feat: add MCP server with resources, tools, auth, and Traefik routing

- Add backend/app/mcp_server.py with FastMCP server (hms-cms)
- Resources: design-rules, page-structure, sync-status
- Tools: trigger_sync, get_sync_log, set_rentman_token, read_file,
  write_file, create_page, deploy, git_commit, git_status
- Bearer token auth via MCP_AUTH_TOKEN env var
- Mount MCP at /mcp with streamable HTTP transport
- Integrate session manager in FastAPI lifespan
- Add Traefik labels for external /mcp route on hms.media-on.de
- Add git, docker.io, curl to backend Dockerfile
- Mount repo root + docker socket in backend container
- Add mcp>=1.0.0 to requirements.txt
This commit is contained in:
Agent Zero
2026-07-12 15:58:22 +02:00
parent 00c23bc066
commit cd465e0564
124 changed files with 3309 additions and 225 deletions
+33
View File
@@ -0,0 +1,33 @@
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Index from './pages/Index';
import Mietkatalog from './pages/Mietkatalog';
import EquipmentDetail from './pages/EquipmentDetail';
import Kontakt from './pages/Kontakt';
import Warenkorb from './pages/Warenkorb';
import Admin from './pages/Admin';
import Referenzen from './pages/Referenzen';
import Impressum from './pages/Impressum';
import Datenschutz from './pages/Datenschutz';
import AGB from './pages/AGB';
import ErrorPage from './pages/ErrorPage';
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Index />} />
<Route path="/mietkatalog" element={<Mietkatalog />} />
<Route path="/mietkatalog/:id" element={<EquipmentDetail />} />
<Route path="/kontakt" element={<Kontakt />} />
<Route path="/warenkorb" element={<Warenkorb />} />
<Route path="/admin" element={<Admin />} />
<Route path="/referenzen" element={<Referenzen />} />
<Route path="/impressum" element={<Impressum />} />
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb-vermietung" element={<AGB />} />
<Route path="*" element={<ErrorPage />} />
</Route>
</Routes>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { apiPost } from './client';
export async function adminLogin(username: string, password: string): Promise<{ token: string }> {
return apiPost<{ token: string }>('/admin/login', { username, password });
}
export async function adminSync(token: string): Promise<{ status: string; message: string }> {
return apiPost<{ status: string; message: string }>('/admin/sync', undefined, token);
}
+54
View File
@@ -0,0 +1,54 @@
const API_BASE = '/api';
export async function apiGet<T>(url: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const query = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value));
}
}
}
const qs = query.toString();
const fullUrl = `${API_BASE}${url}${qs ? '?' + qs : ''}`;
const res = await fetch(fullUrl, {
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
export async function apiPost<T>(url: string, body?: unknown, token?: string): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${url}`, {
method: 'POST',
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
export async function apiPut<T>(url: string, body?: unknown, token?: string): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${url}`, {
method: 'PUT',
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
export async function apiDelete<T>(url: string, token?: string): Promise<T> {
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${url}`, {
method: 'DELETE',
headers,
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
+30
View File
@@ -0,0 +1,30 @@
import { apiGet } from './client';
import type { EquipmentItem, EquipmentDetail, PaginatedEquipment, SortOption } from '../types';
export async function fetchEquipment(params: {
search?: string;
category?: string;
sort?: SortOption;
page?: number;
page_size?: number;
}): Promise<PaginatedEquipment> {
return apiGet<PaginatedEquipment>('/equipment', {
search: params.search || undefined,
category: params.category || undefined,
sort: params.sort || 'name_asc',
page: params.page || 1,
page_size: params.page_size || 24,
});
}
export async function fetchEquipmentDetail(id: number | string): Promise<EquipmentDetail> {
return apiGet<EquipmentDetail>(`/equipment/${id}`);
}
export async function fetchCategories(): Promise<string[]> {
return apiGet<string[]>('/equipment/categories');
}
export async function fetchEquipmentById(id: number | string): Promise<EquipmentItem> {
return apiGet<EquipmentItem>(`/equipment/${id}`);
}
+18
View File
@@ -0,0 +1,18 @@
interface Props {
icon?: string;
title: string;
message: string;
actionLabel?: string;
onAction?: () => void;
}
export default function EmptyState({ icon = '🔍', title, message, actionLabel, onAction }: Props) {
return (
<div className="text-center py-16 px-4" role="status">
<div className="text-6xl mb-4" aria-hidden="true">{icon}</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
<p className="max-w-md mx-auto mb-6" style={{ color: 'var(--secondary)' }}>{message}</p>
{actionLabel && <button onClick={onAction} className="hms-btn hms-btn-primary">{actionLabel}</button>}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { EquipmentItem } from '../types';
interface Props {
item: EquipmentItem & { code?: string; image?: string };
onClick: (item: any) => void;
onAddToCart: (item: any) => void;
}
export default function EquipmentCard({ item, onClick, onAddToCart }: Props) {
return (
<div className="hms-eq-card" onClick={() => onClick(item)} role="article" tabIndex={0} onKeyDown={e => e.key === 'Enter' && onClick(item)}>
<div className="aspect-[4/3] flex items-center justify-center relative overflow-hidden" style={{ background: 'var(--surface)' }}>
{item.image ? (
<img src={item.image} alt={item.name} loading="lazy" className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="text-4xl" style={{ color: 'var(--secondary)' }}>📦</div>
)}
{item.category && <span className="hms-badge hms-badge-primary absolute top-3 left-3">{item.category}</span>}
</div>
<div className="p-4">
<h3 className="font-semibold text-sm mb-1 truncate" style={{ color: 'var(--text)' }}>{item.name}</h3>
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--secondary)' }}>{item.description}</p>
<div className="flex items-center justify-between">
<span className="text-xs" style={{ color: 'var(--secondary)' }}>{item.code}</span>
<button onClick={e => { e.stopPropagation(); onAddToCart(item); }} className="hms-btn hms-btn-ghost text-xs px-3 py-1.5" style={{ color: 'var(--color-accent)' }} aria-label={item.name + ' zum Warenkorb hinzufügen'}>+ Hinzufügen</button>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
interface Props {
title?: string;
message: string;
onRetry: () => void;
}
export default function ErrorState({ title = 'Ein Fehler ist aufgetreten', message, onRetry }: Props) {
return (
<div className="text-center py-16 px-4" role="alert">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ background: 'var(--color-error-bg)', color: 'var(--color-error)' }}>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
<p className="max-w-md mx-auto mb-6" style={{ color: 'var(--secondary)' }}>{message}</p>
<button onClick={onRetry} className="hms-btn hms-btn-primary">Erneut versuchen</button>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Link } from 'react-router-dom';
import Logo from './Logo';
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="hms-footer mt-16" role="contentinfo">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
<div>
<div className="flex items-center gap-2 mb-4" style={{ color: 'var(--text)' }}>
<Logo size={36} />
<div><div className="font-bold text-sm">HMS Licht & Ton</div><div className="text-xs" style={{ color: 'var(--secondary)' }}>Veranstaltungstechnik</div></div>
</div>
<p className="text-sm leading-relaxed" style={{ color: 'var(--secondary)' }}>Hammerschmidt u. Mössle GbR seit über 20 Jahren Ihr Partner für professionelle Veranstaltungstechnik in der Region Leipheim / Ellzee.</p>
</div>
<div>
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Navigation</h3>
<ul className="space-y-2 text-sm">
<li><Link to="/" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Home</Link></li>
<li><Link to="/referenzen" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Referenzen</Link></li>
<li><Link to="/mietkatalog" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Mietkatalog</Link></li>
<li><Link to="/kontakt" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Kontakt</Link></li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Kontakt</h3>
<ul className="space-y-2 text-sm" style={{ color: 'var(--secondary)' }}>
<li>Grockelhofen 10, 89340 Leipheim</li>
<li><a href="tel:+498221204433" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">+49 (0) 8221 / 204433</a></li>
<li><a href="mailto:info@hms-licht-ton.de" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">info@hms-licht-ton.de</a></li>
<li className="flex gap-3 pt-2">
<a href="https://facebook.com" target="_blank" rel="noopener" aria-label="Facebook" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]"><svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
<a href="https://instagram.com" target="_blank" rel="noopener" aria-label="Instagram" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]"><svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg></a>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Rechtliches</h3>
<ul className="space-y-2 text-sm">
<li><Link to="/impressum" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">Impressum</Link></li>
<li><Link to="/datenschutz" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">DSGVO</Link></li>
<li><Link to="/agb-vermietung" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)]">AGB Vermietung</Link></li>
<li><Link to="/admin" style={{ color: 'var(--secondary)' }} className="hover:text-[var(--color-accent)] text-xs">Admin-Login</Link></li>
</ul>
</div>
</div>
<div className="border-t mt-8 pt-6 text-center text-xs" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
© {year} Hammerschmidt u. Mössle GbR · Veranstaltungstechnik · Leipheim / Ellzee
</div>
</div>
</footer>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import Logo from './Logo';
const navItems = [
{ label: 'Home', to: '/' },
{ label: 'Referenzen', to: '/referenzen' },
{ label: 'Mietkatalog', to: '/mietkatalog' },
{ label: 'Kontakt', to: '/kontakt' }
];
export default function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
function isActive(to: string): boolean {
if (to === '/') return location.pathname === '/';
return location.pathname.startsWith(to);
}
return (
<header className="hms-header" role="banner">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between" style={{ height: 'var(--header-height)' }}>
<Link to="/" className="flex items-center gap-2" style={{ color: 'var(--text)' }} aria-label="HMS Licht & Ton Startseite">
<Logo size={40} />
<div className="hidden sm:block leading-tight">
<div className="font-bold text-base" style={{ color: 'var(--text)' }}>HMS Licht & Ton</div>
<div className="text-xs" style={{ color: 'var(--secondary)' }}>Veranstaltungstechnik</div>
</div>
</Link>
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="Hauptnavigation">
{navItems.map(item => (
<Link key={item.to} to={item.to}
className="hms-nav-btn px-4 py-2 rounded-lg text-sm font-medium"
style={isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }}
aria-current={isActive(item.to) ? 'page' : undefined}>
{item.label}
</Link>
))}
<Link to="/warenkorb" className="hms-btn hms-btn-primary text-sm ml-2 px-4 py-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
Warenkorb
</Link>
</nav>
<button onClick={() => setMobileMenuOpen(!mobileMenuOpen)} className="md:hidden p-2 rounded-lg" style={{ color: 'var(--text-muted)' }} aria-expanded={mobileMenuOpen} aria-label="Menü öffnen/schließen">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{!mobileMenuOpen ?
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"/> :
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/>
}
</svg>
</button>
</div>
</div>
{mobileMenuOpen && (
<nav id="mobile-menu" className="md:hidden border-t" style={{ borderColor: 'var(--border)', background: 'var(--panel)' }} role="navigation" aria-label="Mobile Navigation">
<div className="px-4 py-3 space-y-1">
{navItems.map(item => (
<Link key={item.to} to={item.to} onClick={() => setMobileMenuOpen(false)}
className="hms-nav-btn block w-full text-left px-4 py-3 rounded-lg text-sm font-medium"
style={isActive(item.to) ? { color: 'var(--color-accent)', background: 'var(--color-accent-light)' } : { color: 'var(--text-muted)' }}>
{item.label}
</Link>
))}
<Link to="/warenkorb" onClick={() => setMobileMenuOpen(false)} className="block w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-white" style={{ background: 'var(--color-accent)' }}>🛒 Warenkorb</Link>
</div>
</nav>
)}
</header>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Outlet } from 'react-router-dom';
import Header from './Header';
import Footer from './Footer';
export default function Layout() {
return (
<div className="min-h-screen flex flex-col">
<a href="#main-content" className="skip-link">Zum Hauptinhalt springen</a>
<Header />
<main id="main-content" className="flex-1" role="main">
<Outlet />
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
interface Props {
title: string;
content?: string;
loading?: boolean;
}
export default function LegalContentPage({ title, content = '', loading = false }: Props) {
useEffect(() => {
document.title = `${title} HMS Licht & Ton`;
}, [title]);
if (loading) {
return (
<div className="max-w-3xl mx-auto">
<div className="space-y-4">
<div className="hms-skeleton rounded-md" style={{ height: 32, width: '60%' }} />
<div className="hms-skeleton rounded-md" style={{ height: 20 }} />
<div className="hms-skeleton rounded-md" style={{ height: 20, width: '90%' }} />
<div className="hms-skeleton rounded-md" style={{ height: 20 }} />
<div className="hms-skeleton rounded-md" style={{ height: 20, width: '80%' }} />
</div>
</div>
);
}
return (
<div className="max-w-3xl mx-auto">
<article>
<h1 className="text-3xl font-bold text-text mb-6">{title}</h1>
<div className="prose prose-invert max-w-none text-text-muted" dangerouslySetInnerHTML={{ __html: content }} />
</article>
</div>
);
}
@@ -0,0 +1,24 @@
interface Props {
count?: number;
}
export default function LoadingSkeleton({ count = 6 }: Props) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" aria-live="polite" aria-busy="true">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="hms-card overflow-hidden">
<div className="hms-skeleton aspect-[4/3]" style={{ borderRadius: 0 }}></div>
<div className="p-4 space-y-3">
<div className="hms-skeleton h-4 w-3/4"></div>
<div className="hms-skeleton h-3 w-full"></div>
<div className="hms-skeleton h-3 w-1/2"></div>
<div className="flex justify-between items-center pt-2">
<div className="hms-skeleton h-3 w-20"></div>
<div className="hms-skeleton h-6 w-24" style={{ borderRadius: 'var(--radius-md)' }}></div>
</div>
</div>
</div>
))}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
interface LogoProps {
size?: number;
}
export default function Logo({ size = 40 }: LogoProps) {
return (
<svg className="hms-logo-svg" viewBox="0 0 200 200" style={{ height: `${size}px`, width: 'auto' }} aria-label="HMS Licht & Ton Logo" role="img">
<path fill="currentColor" d="M166.266,172.872h-37.687v-60.718H74.226v60.718H36.539V26.956h37.687v56.335h54.354V26.956h37.687V172.872z" />
<rect x="23.598" y="15.343" fill="none" stroke="#FA5C01" strokeWidth="15.1525" strokeMiterlimit="10" width="155.612" height="168.874" />
</svg>
);
}
+15
View File
@@ -0,0 +1,15 @@
interface Props {
icon: string;
title: string;
description: string;
}
export default function ServiceCard({ icon, title, description }: Props) {
return (
<div className="hms-card p-6">
<div className="hms-icon-circle mb-4"><span aria-hidden="true">{icon}</span></div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>{title}</h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>{description}</p>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useMemo } from 'react';
interface Props {
type?: string;
}
const icons: Record<string, string> = {
linearray: '<rect x="8" y="2" width="16" height="40" rx="2"/><circle cx="16" cy="10" r="3"/><circle cx="16" cy="22" r="4"/><circle cx="16" cy="34" r="3"/>',
subwoofer: '<rect x="4" y="8" width="24" height="20" rx="3"/><circle cx="16" cy="18" r="7"/><circle cx="16" cy="18" r="3"/>',
monitor: '<path d="M4 6h20v14H4z"/><path d="M8 24h12"/><circle cx="14" cy="13" r="3"/>',
pointsource: '<circle cx="16" cy="16" r="12"/><circle cx="16" cy="16" r="6"/><circle cx="16" cy="16" r="2"/>',
column: '<rect x="10" y="2" width="12" height="44" rx="2"/><circle cx="16" cy="8" r="2"/><circle cx="16" cy="16" r="2"/><circle cx="16" cy="24" r="2"/><circle cx="16" cy="32" r="2"/><circle cx="16" cy="40" r="2"/>',
movinghead: '<rect x="10" y="4" width="12" height="16" rx="2"/><path d="M16 20v8"/><rect x="8" y="28" width="16" height="4" rx="1"/>'
};
export default function SpeakerIcon({ type = 'linearray' }: Props) {
const svgStr = useMemo(() =>
'<svg class="hms-speaker-icon" viewBox="0 0 32 48" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">' +
(icons[type] || icons.linearray) +
'</svg>', [type]);
return <span className="hms-speaker-icon-wrap" dangerouslySetInnerHTML={{ __html: svgStr }} />;
}
+432
View File
@@ -0,0 +1,432 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================
HMS Licht & Ton Design Tokens & Custom CSS
Dark Theme + Subtle Orange Accent
============================================ */
:root {
/* Brand Accent - used sparingly */
--color-accent: #FA5C01;
--color-accent-hover: #d4581a;
--color-accent-light: rgba(236, 105, 37, 0.08);
--color-accent-border: rgba(236, 105, 37, 0.25);
--color-accent-dark: #b8461a;
/* Agent Zero Dark Theme Grays */
--bg: #131313;
--panel: #1a1a1a;
--surface: #212121;
--row: #272727;
--card: #2d2d2d;
--border: #444444a8;
--secondary: #656565;
--primary: #737a81;
--text: #ffffff;
--text-muted: #d4d4d4;
/* Aliases */
--color-bg: var(--bg);
--color-surface: var(--panel);
--color-surface-alt: var(--surface);
--color-card: var(--card);
--color-text: var(--text);
--color-text-muted: var(--text-muted);
--color-text-light: var(--secondary);
--color-border: var(--border);
--color-border-strong: #555555;
--border-strong: #555555;
/* Status Colors */
--color-success: #4ade80;
--color-success-bg: rgba(74, 222, 128, 0.08);
--color-error: #f87171;
--color-error-bg: rgba(248, 113, 113, 0.08);
--color-warning: #fbbf24;
--color-warning-bg: rgba(251, 191, 36, 0.08);
--color-info: #60a5fa;
--color-info-bg: rgba(96, 165, 250, 0.08);
/* Typography */
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-heading: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
--space-4xl: 6rem;
/* Radius */
--radius-sm: 2px;
--radius-md: 3px;
--radius-lg: 4px;
--radius-xl: 4px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 400ms ease;
/* Layout */
--header-height: 4rem;
--max-width: 1280px;
}
/* Base Reset */
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body {
font-family: var(--font-sans);
color: var(--text);
background-color: var(--bg);
line-height: 1.6;
font-size: var(--text-base);
-webkit-font-smoothing: antialiased;
}
/* Tailwind dark overrides */
.text-gray-900 { color: var(--text) !important; }
.text-gray-800 { color: var(--text-muted) !important; }
.text-gray-700 { color: var(--text-muted) !important; }
.text-gray-600 { color: var(--text-muted) !important; }
.text-gray-500 { color: var(--secondary) !important; }
.text-gray-400 { color: var(--secondary) !important; }
.text-white { color: var(--text) !important; }
.bg-white { background-color: var(--panel) !important; }
.bg-gray-100 { background-color: var(--surface) !important; }
.bg-gray-200 { background-color: var(--row) !important; }
.bg-gray-800 { background-color: var(--bg) !important; }
.bg-gray-900 { background-color: var(--bg) !important; }
.border-gray-200 { border-color: var(--border) !important; }
.border-gray-700 { border-color: var(--border) !important; }
.border-gray-100 { border-color: var(--border) !important; }
.divide-gray-100 > * { border-color: var(--border) !important; }
/* Skip Link */
.skip-link {
position: absolute; top: -100px; left: 0;
background: var(--color-accent); color: white;
padding: var(--space-md) var(--space-lg);
z-index: 9999; border-radius: 0 0 var(--radius-md) 0;
font-weight: 600;
}
.skip-link:focus { top: 0; }
/* Focus visible */
*:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--secondary); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--primary); }
/* Component: Header */
.hms-header {
background: var(--panel);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}
.hms-logo-svg { height: 40px; width: auto; }
.hms-logo-svg rect { stroke: var(--color-accent) !important; }
/* Component: Hero */
.hms-hero {
position: relative;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
.hms-hero-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
opacity: 0.35;
}
.hms-hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(19,19,19,0.6) 0%, rgba(19,19,19,0.85) 50%, var(--bg) 100%);
}
/* Component: Card */
.hms-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition-base), border-color var(--transition-base);
}
.hms-card:hover {
box-shadow: var(--shadow-lg);
border-color: var(--secondary);
}
/* Component: Button */
.hms-btn {
display: inline-flex; align-items: center; justify-content: center;
gap: var(--space-sm);
font-family: var(--font-sans); font-weight: 600;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
cursor: pointer; border: none; text-decoration: none;
white-space: nowrap;
}
.hms-btn-primary {
background: var(--color-accent); color: white;
padding: var(--space-md) var(--space-xl);
box-shadow: 0 0 0 0 rgba(236, 105, 37, 0);
}
.hms-btn-primary:hover {
background: var(--color-accent-hover);
box-shadow: 0 0 12px 2px rgba(236, 105, 37, 0.3);
transform: translateY(-1px);
}
.hms-btn-primary:active { transform: translateY(0); box-shadow: 0 0 6px 0px rgba(236, 105, 37, 0.2); }
.hms-btn-primary:disabled { background: var(--secondary); cursor: not-allowed; box-shadow: none; transform: none; }
.hms-btn-secondary {
background: var(--surface); color: var(--text-muted);
border: 1px solid var(--border-strong);
padding: var(--space-md) var(--space-xl);
}
.hms-btn-secondary:hover {
background: var(--row);
border-color: var(--color-accent);
color: var(--text);
box-shadow: 0 0 8px 0 rgba(236, 105, 37, 0.1);
}
.hms-btn-secondary:active { background: var(--surface); box-shadow: none; }
.hms-btn-ghost {
background: transparent; color: var(--text-muted);
padding: var(--space-sm) var(--space-md);
}
.hms-btn-ghost:hover { background: var(--surface); color: var(--color-accent); border-color: var(--color-accent-border); }
/* Component: Nav Button */
.hms-nav-btn {
position: relative;
transition: color var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast) !important;
}
.hms-nav-btn:hover {
color: var(--color-accent) !important;
background: var(--color-accent-light) !important;
}
.hms-nav-btn:hover::after {
content: '';
position: absolute;
bottom: 2px; left: 50%;
transform: translateX(-50%);
width: 60%; height: 2px;
background: var(--color-accent);
border-radius: var(--radius-full);
}
.hms-nav-btn[aria-current="page"]:hover {
color: var(--color-accent) !important;
background: var(--color-accent-light) !important;
}
/* Component: Badge */
.hms-badge {
display: inline-flex; align-items: center;
padding: 2px var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--text-xs); font-weight: 600;
}
.hms-badge-primary {
background: rgba(236, 105, 37, 0.12);
color: var(--color-accent);
border: 1px solid rgba(236, 105, 37, 0.2);
}
.hms-badge-gray { background: var(--surface); color: var(--text-muted); }
.hms-badge-success { background: var(--color-success-bg); color: var(--color-success); }
.hms-badge-error { background: var(--color-error-bg); color: var(--color-error); }
/* Component: Input */
.hms-input {
width: 100%;
padding: var(--space-md) var(--space-lg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
font-size: var(--text-base);
background: var(--surface); color: var(--text);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.hms-input::placeholder { color: var(--secondary); }
.hms-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(236, 105, 37, 0.1);
}
.hms-input:disabled { background: var(--row); color: var(--secondary); cursor: not-allowed; }
.hms-input-error { border-color: var(--color-error); }
.hms-input-error:focus { box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.1); }
select.hms-input { background: var(--surface); color: var(--text); }
select.hms-input option { background: var(--panel); color: var(--text); }
/* Component: Loading Skeleton */
.hms-skeleton {
background: linear-gradient(90deg, var(--surface) 25%, var(--row) 50%, var(--surface) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Component: Spinner */
.hms-spinner {
width: 24px; height: 24px;
border: 3px solid var(--surface);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Component: Service Icon Circle */
.hms-icon-circle {
width: 48px; height: 48px;
border-radius: var(--radius-md);
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; flex-shrink: 0;
}
/* Component: Speaker Grid */
.hms-speaker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-md);
}
.hms-speaker-item {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
text-align: center;
transition: all var(--transition-base);
}
.hms-speaker-item:hover {
border-color: var(--color-accent-border);
background: var(--surface);
}
.hms-speaker-icon-wrap { display: inline-flex; align-items: center; justify-content: center; margin: 0 auto var(--space-sm); }
.hms-speaker-icon-wrap svg { width: 48px; height: 48px; color: var(--text-muted); transition: color var(--transition-base); }
.hms-speaker-item:hover .hms-speaker-icon-wrap svg { color: var(--color-accent); }
.hms-speaker-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text);
margin-bottom: 2px;
}
.hms-speaker-type {
font-size: var(--text-xs);
color: var(--secondary);
}
/* Component: Footer */
.hms-footer {
background: var(--bg);
color: var(--text-muted);
border-top: 1px solid var(--border);
}
/* Gallery Grid */
.hms-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-lg);
}
/* Equipment Card */
.hms-eq-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all var(--transition-base);
cursor: pointer;
}
.hms-eq-card:hover {
border-color: var(--color-accent-border);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
/* Filter Chip */
.hms-chip {
display: inline-flex; align-items: center; gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-full);
font-size: var(--text-sm); font-weight: 500;
cursor: pointer;
border: 1px solid var(--border-strong);
background: var(--surface); color: var(--text-muted);
transition: all var(--transition-fast);
}
.hms-chip:hover { border-color: var(--secondary); }
.hms-chip.active {
background: var(--color-accent); color: white;
border-color: var(--color-accent);
}
/* Section divider */
.hms-section-divider {
height: 1px;
background: linear-gradient(90deg, transparent 0%, var(--border) 50%, transparent 100%);
}
/* Mobile Menu Animation */
.mobile-menu-enter-active, .mobile-menu-leave-active { transition: all var(--transition-base); }
.mobile-menu-enter-from, .mobile-menu-leave-to { opacity: 0; transform: translateY(-10px); }
/* Fade Transition */
.fade-enter-active, .fade-leave-active { transition: opacity var(--transition-base); }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* Link colors */
a { color: var(--color-accent); }
a:hover { color: var(--color-accent-hover); }
/* Responsive */
@media (max-width: 768px) {
:root { --header-height: 3.5rem; }
.hms-speaker-grid { grid-template-columns: repeat(2, 1fr); }
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
import { CartProvider } from './store/CartContext';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<CartProvider>
<App />
</CartProvider>
</BrowserRouter>
</React.StrictMode>
);
+103
View File
@@ -0,0 +1,103 @@
import LegalContentPage from '../components/LegalContentPage';
const content = `
<h2>§ 1 Geltungsbereich</h2>
<p>
Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Mietverträge über
Veranstaltungstechnik zwischen der Hammerschmidt u. Mössle GbR (nachfolgend „HMS
Licht & Ton“ oder „Vermieter“) und dem Mieter. Sie gelten ausschließlich;
entgegenstehende oder von unseren AGB abweichende Bedingungen des Mieters
erkennen wir nicht an, es sei denn, wir hätten ausdrücklich schriftlich ihrer
Geltung zugestimmt.
</p>
<h2>§ 2 Mietgegenstand</h2>
<p>
Gegenstand des Mietvertrages ist die Überlassung von Veranstaltungstechnik
(Lautsprecher, Mischpulte, Lichtanlagen, Rigging-Material, Traversen,
Zubehör etc.) gemäß der im Mietvertrag bzw. in der Auftragsbestätigung
angegebenen Spezifikation. Der Vermieter schuldet nicht die Durchführung der
Veranstaltung, sondern nur die Überlassung der gemieteten Technik in
vertragsgemäßem Zustand.
</p>
<h2>§ 3 Preise</h2>
<p>
Es gelten die zum Zeitpunkt der Auftragsbestätigung im Mietkatalog bzw. im
Angebot angegebenen Preise. Alle Preise verstehen sich zzgl. der gesetzlich
geltenden Mehrwertsteuer. Miettage berechnen sich ab Abholung bzw. Anlieferung
bis zur Rückgabe bzw. Abholung durch den Vermieter. Ein Miettag entspricht
24 Stunden. Bei Überschreitung der vereinbarten Mietzeit werden zusätzliche
Miettage berechnet.
</p>
<h2>§ 4 Zahlungsbedingungen</h2>
<p>
Die Zahlung ist innerhalb von 14 Tagen nach Rechnungsstellung ohne Abzug
fällig, sofern nichts anderes vereinbart wurde. Bei Neukunden kann eine
Anzahlung in Höhe von 50 % des Gesamtmietpreises vor Übergabe der Technik
verlangt werden. Die Aufrechnung mit Gegenansprüchen ist nur zulässig,
soweit diese unbestritten oder rechtskräftig festgestellt sind.
</p>
<h2>§ 5 Haftung des Mieters</h2>
<p>
Der Mieter hat das Mietmaterial pfleglich zu behandeln und vor Beschädigung,
Diebstahl und unsachgemäßer Nutzung zu schützen. Der Mieter haftet für alle
Schäden am Mietmaterial, die während der Mietzeit entstehen, soweit sie nicht
auf normaler Abnutzung beruhen. Bei Verlust oder Diebstahl trägt der Mieter
den Wiederbeschaffungswert. Bei Beschädigung trägt der Mieter die
Reparaturkosten oder den Zeitwert bei Totalschaden.
</p>
<p>
Eine Haftung des Vermieters für indirekte Schäden, insbesondere für
Gewinnverluste, Ausfallkosten oder Folgeschäden der Veranstaltung, ist
ausgeschlossen, soweit diese nicht auf Vorsatz oder grober Fahrlässigkeit
beruhen.
</p>
<h2>§ 6 Übergabe und Rückgabe</h2>
<p>
Die Übergabe des Mietmaterials erfolgt am Lager des Vermieters oder an dem
vereinbarten Veranstaltungsort. Der Mieter hat das Mietmaterial bei
Übergabe auf Vollständigkeit und Schäden zu prüfen. Mängel sind sofort
schriftlich zu melden. Bei der Rückgabe wird das Mietmaterial erneut
geprüft. Fehlbestände oder Schäden, die erst bei der Rückgabe festgestellt
werden, gelten als während der Mietzeit entstanden, sofern der Mieter nicht
den Beweis erbringt, dass der Schaden bereits bei Übergabe vorlag.
</p>
<p>
Das Mietmaterial ist nach Ende der Mietzeit in gereinigtem und
sortiertem Zustand zurückzugeben. Verpackungsmaterial ist vollständig
zurückzugeben. Nicht rechtzeitig zurückgegebenes Material wird bis zur
Rückgabe weiterberechnet.
</p>
<h2>§ 7 Kündigung und Rücktritt</h2>
<p>
Der Mieter kann den Mietvertrag jederzeit vor Übergabe des Mietmaterials
schriftlich kündigen. Bei Kündigung bis 14 Tage vor Mietbeginn werden 25 %,
bis 7 Tage 50 %, bis 3 Tage 75 % und ab 3 Tagen 100 % des Mietpreises als
Stornokosten berechnet.
</p>
<p>
Der Vermieter behält sich vor, vom Vertrag zurückzutreten, wenn die
Verfügung über das Mietmaterial aus Gründen, die der Vermieter nicht zu
vertreten hat, unmöglich wird (z. B. höhere Gewalt, höhere Gewalt,
unvorhersehbare Ereignisse). In diesem Fall werden bereits gezahlte
Mietkosten erstattet.
</p>
<h2>§ 8 Schlussbestimmungen</h2>
<p>
Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist
Günzburg. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, so
bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. An die Stelle
der unwirksamen Bestimmung tritt eine Regelung, die dem wirtschaftlichen
Zweck der unwirksamen Bestimmung am nächsten kommt.
</p>
`;
export default function AGB() {
return <LegalContentPage title="AGB Vermietung" content={content} />;
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react';
import Logo from '../components/Logo';
export default function Admin() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
function login(e: React.FormEvent) {
e.preventDefault();
setError('');
if (!username || !password) {
setError('Bitte Benutzername und Passwort eingeben');
return;
}
setLoading(true);
setTimeout(() => {
setLoading(false);
setLoggedIn(true);
}, 1200);
}
return (
<div className="max-w-md mx-auto px-4 py-16">
<div className="hms-card p-8">
<div className="text-center mb-6">
<Logo size={48} />
<h1 className="text-xl font-bold mt-4" style={{ color: 'var(--text)' }}>Admin-Login</h1>
<p className="text-sm mt-1" style={{ color: 'var(--secondary)' }}>Equipment-Sync Verwaltungsbereich</p>
</div>
{loggedIn ? (
<div className="text-center py-8" role="status">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full mb-3" style={{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }}>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"/></svg>
</div>
<p className="font-medium" style={{ color: 'var(--text)' }}>Eingeloggt</p>
<p className="text-xs mt-1" style={{ color: 'var(--secondary)' }}>(Prototyp keine echte Session)</p>
</div>
) : (
<form onSubmit={login} noValidate>
<div className="space-y-4">
<div>
<label htmlFor="admin-user" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Benutzername</label>
<input id="admin-user" value={username} onChange={e => setUsername(e.target.value)} type="text" className="hms-input" placeholder="admin" aria-invalid={!!error} />
</div>
<div>
<label htmlFor="admin-pass" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Passwort</label>
<input id="admin-pass" value={password} onChange={e => setPassword(e.target.value)} type="password" className="hms-input" placeholder="••••••••" aria-invalid={!!error} />
</div>
{error && <p className="text-xs" style={{ color: 'var(--color-error)' }} role="alert">{error}</p>}
<button type="submit" disabled={loading} className="hms-btn hms-btn-primary w-full py-3">
{loading ? <span className="hms-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} /> : <span>Einloggen</span>}
</button>
</div>
</form>
)}
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import LegalContentPage from '../components/LegalContentPage';
const content = `
<h2>1. Datenschutz auf einen Blick</h2>
<p>
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
personenbezogenen Daten passiert, wenn Sie diese Website besuchen oder unser
Kontaktformular nutzen. Personenbezogene Daten sind alle Daten, mit denen Sie
persönlich identifiziert werden können.
</p>
<h3>Datenerhebung</h3>
<p>
Die Datenerhebung auf dieser Website erfolgt technisch notwendig zur Bereitstellung
der Website und zur Gewährleistung der Funktionstüchtigkeit und Sicherheit der
Systeme. Darüber hinaus werden Daten nur erhoben, wenn Sie uns diese im Rahmen
des Kontaktformulars freiwillig zur Verfügung stellen.
</p>
<h3>Cookies</h3>
<p>
Diese Website verwendet keine Tracking-Cookies. Technisch notwendige Cookies,
die für den Betrieb der Seite erforderlich sind, werden nur verwendet, soweit
dies für die Bereitstellung der Funktionalität der Website zwingend notwendig
ist. Sie können die Speicherung solcher Cookies durch entsprechende Einstellungen
in Ihrem Browser verhindern.
</p>
<h2>2. Verantwortliche Stelle</h2>
<p>
Verantwortlich für die Datenverarbeitung auf dieser Website ist:<br/>
Hammerschmidt u. Mössle GbR<br/>
Grockelhofen 10<br/>
89340 Leipheim<br/>
Telefon: +49 (0) 8221 / 204433<br/>
E-Mail: info@hms-licht-ton.de
</p>
<h2>3. Erhebung von Daten beim Kontaktformular</h2>
<p>
Wenn Sie uns über das Kontaktformular Anfragen zukommen lassen, werden Ihre
Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen
Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen
bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.
</p>
<p>
Die Verarbeitung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b
DSGVO, sofern Ihre Anfrage mit der Erfüllung eines Vertrags zusammenhängt oder
zur Durchführung vorvertraglicher Maßnahmen erforderlich ist. In allen
übrigen Fällen ist die Verarbeitung auf Grundlage Ihres berechtigten
Interesses (Art. 6 Abs. 1 lit. f DSGVO) oder auf Grundlage Ihrer Einwilligung
(Art. 6 Abs. 1 lit. a DSGVO) erfolgt.
</p>
<p>
Die Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer
Erhebung nicht mehr erforderlich sind. Für die personenbezogenen Daten aus
dem Eingabeformular der Kontaktanfrage ist dies der Fall, wenn die jeweilige
Konversation mit Ihnen beendet ist. Der Gesprächsbeginn endet, wenn aus den
Umständen zu entnehmen ist, dass der betroffene Sachverhalt abschließend
geklärt ist.
</p>
<h2>4. Server-Log-Dateien</h2>
<p>
Der Provider der Seiten erhebt und speichert automatisch Informationen in
sogenannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt.
Diese sind:
</p>
<ul>
<li>Browsertyp und Browserversion</li>
<li>verwendetes Betriebssystem</li>
<li>Referrer URL</li>
<li>Hostname des zugreifenden Rechners</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse (anonymisiert)</li>
</ul>
<p>
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht
vorgenommen. Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f
DSGVO zum Zweck der Gewährleistung eines reibungslosen Betriebs der Website.
</p>
<h2>5. Weitergabe an Drittanbieter</h2>
<p>
Eine Übermittlung Ihrer persönlichen Daten an Dritte zu anderen als den im
Folgenden aufgeführten Zwecken findet nicht statt. Eine Weitergabe erfolgt nur,
wenn Sie eine ausdrückliche Einwilligung dazu erteilt haben oder die
Weitergabe gesetzlich zulässig ist.
</p>
<h2>6. Ihre Rechte</h2>
<p>
Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der
Verarbeitung, Widerspruch gegen die Verarbeitung und das Recht auf
Datenübertragbarkeit (Art. 1520 DSGVO). Zudem haben Sie das Recht, sich bei
einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer
personenbezogenen Daten zu beschweren.
</p>
`;
export default function Datenschutz() {
return <LegalContentPage title="Datenschutz (DSGVO)" content={content} />;
}
+132
View File
@@ -0,0 +1,132 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { fetchEquipmentDetail } from '../api/equipment';
import type { EquipmentDetail } from '../types';
import { useCart } from '../store/CartContext';
import ErrorState from '../components/ErrorState';
export default function EquipmentDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { addItemByFields } = useCart();
const [item, setItem] = useState<EquipmentDetail | null>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState(false);
const [quantity, setQuantity] = useState(1);
const [rentalStart, setRentalStart] = useState('');
const [rentalEnd, setRentalEnd] = useState('');
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
setPending(true);
setError(false);
if (id) {
fetchEquipmentDetail(id).then(data => {
if (mountedRef.current) { setItem(data); setPending(false); }
}).catch(() => {
if (mountedRef.current) { setError(true); setPending(false); }
});
}
return () => { mountedRef.current = false; };
}, [id]);
function navigateTo(route: string) {
navigate(route);
window.scrollTo(0, 0);
}
function addToCart() {
if (!item) return;
addItemByFields({
equipment_id: item.id,
name: item.name,
rental_price: null,
image_url: item.image_url || null,
});
navigateTo('/warenkorb');
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<nav className="text-sm mb-6" aria-label="Breadcrumb">
<Link to="/mietkatalog" style={{ color: 'var(--secondary)' }}> Zurück zum Katalog</Link>
</nav>
{pending && (
<div className="grid lg:grid-cols-2 gap-8" aria-live="polite" aria-busy="true">
<div className="hms-skeleton aspect-square rounded-lg"></div>
<div className="space-y-4">
<div className="hms-skeleton h-8 w-3/4"></div>
<div className="hms-skeleton h-4 w-1/4"></div>
<div className="hms-skeleton h-24 w-full"></div>
<div className="hms-skeleton h-32 w-full"></div>
<div className="hms-skeleton h-12 w-full" style={{ borderRadius: 'var(--radius-md)' }}></div>
</div>
</div>
)}
{error && <ErrorState title="Gerät nicht gefunden" message="Das angeforderte Gerät konnte nicht gefunden werden." onRetry={() => navigateTo('/mietkatalog')} />}
{!pending && !error && item && (
<div>
<div className="grid lg:grid-cols-2 gap-8 lg:gap-12">
<div className="aspect-square rounded-lg flex items-center justify-center relative overflow-hidden border" style={{ background: 'var(--surface)', borderColor: 'var(--border)' }}>
{item.image_url ? (
<img src={item.image_url} alt={item.name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="text-center">
<div className="text-8xl mb-2" style={{ color: 'var(--secondary)' }}>📦</div>
<div className="text-sm" style={{ color: 'var(--secondary)' }}>Kein Bild verfügbar</div>
</div>
)}
{item.category && <span className="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{item.category}</span>}
</div>
<div>
<div className="text-sm mb-1" style={{ color: 'var(--secondary)' }}>Artikelnummer: {item.number || item.rentman_id}</div>
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text)' }}>{item.name}</h1>
{item.brand && <div className="text-sm mb-4" style={{ color: 'var(--text-muted)' }}>Marke: <span className="font-medium" style={{ color: 'var(--text)' }}>{item.brand}</span></div>}
{item.description && <p className="leading-relaxed mb-6" style={{ color: 'var(--text-muted)' }}>{item.description}</p>}
{item.specifications && (
<div className="hms-card p-4 mb-6">
<h2 className="text-sm font-semibold mb-3" style={{ color: 'var(--text)' }}>Technische Daten</h2>
<dl className="divide-y" style={{ borderColor: 'var(--border)' }}>
{Object.entries(item.specifications).map(([key, value]) => (
<div key={key} className="flex justify-between py-2 text-sm" style={{ borderColor: 'var(--border)' }}>
<dt className="capitalize" style={{ color: 'var(--secondary)' }}>{key.replace(/_/g, ' ')}</dt>
<dd className="font-medium text-right" style={{ color: 'var(--text)' }}>{String(value)}</dd>
</div>
))}
</dl>
</div>
)}
<div className="hms-card p-6">
<h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>Mietanfrage</h2>
<div className="grid grid-cols-2 gap-3 mb-4">
<div>
<label htmlFor="rental-start" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietbeginn</label>
<input id="rental-start" value={rentalStart} onChange={e => setRentalStart(e.target.value)} type="date" className="hms-input text-sm" />
</div>
<div>
<label htmlFor="rental-end" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Mietende</label>
<input id="rental-end" value={rentalEnd} onChange={e => setRentalEnd(e.target.value)} type="date" className="hms-input text-sm" />
</div>
</div>
<div className="mb-4">
<label htmlFor="quantity" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Anzahl</label>
<div className="flex items-center gap-3">
<button onClick={() => setQuantity(q => Math.max(1, q - 1))} className="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl verringern"></button>
<input id="quantity" value={quantity} onChange={e => setQuantity(Math.max(1, Number(e.target.value)))} type="number" min={1} className="hms-input text-center w-20" aria-label="Anzahl" />
<button onClick={() => setQuantity(q => q + 1)} className="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl erhöhen">+</button>
</div>
</div>
<button onClick={addToCart} className="hms-btn hms-btn-primary w-full py-3 text-base">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
Zur Mietanfrage hinzufügen
</button>
<p className="text-xs mt-3 text-center" style={{ color: 'var(--secondary)' }}>Preise auf Anfrage unverbindliche Mietanfrage</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Link } from 'react-router-dom';
import Header from '../components/Header';
import Footer from '../components/Footer';
export default function ErrorPage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main id="main-content" className="flex-1" role="main">
<div className="max-w-md mx-auto px-4 py-24 text-center">
<div className="text-8xl font-bold mb-4" style={{ color: 'var(--secondary)' }}>404</div>
<h1 className="text-2xl font-semibold mb-2" style={{ color: 'var(--text)' }}>Seite nicht gefunden</h1>
<p className="mb-8" style={{ color: 'var(--secondary)' }}>Die angeforderte Seite existiert nicht.</p>
<Link to="/" className="hms-btn hms-btn-primary">Zur Startseite</Link>
</div>
</main>
<Footer />
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import LegalContentPage from '../components/LegalContentPage';
const impressumContent = `
<h2>Angaben gemäß § 5 TMG</h2>
<p>
Hammerschmidt u. Mössle GbR<br/>
Grockelhofen 10<br/>
89340 Leipheim
</p>
<h2>Kontakt</h2>
<p>
Telefon: +49 (0) 8221 / 204433<br/>
E-Mail: <a href="mailto:info@hms-licht-ton.de">info@hms-licht-ton.de</a>
</p>
<h2>Vertretungsberechtigte</h2>
<p>
Markus Hammerschmidt<br/>
Thomas Mössle
</p>
<h2>Registereintrag</h2>
<p>
Eintragung im Registergericht: Amtsgericht Günzburg<br/>
Registernummer: HRA 12345 (Beispiel wird nachgeliefert)
</p>
<h2>Umsatzsteuer-ID</h2>
<p>
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:<br/>
DE 123456789 (Beispiel wird nachgeliefert)
</p>
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
<p>
Markus Hammerschmidt<br/>
Grockelhofen 10<br/>
89340 Leipheim
</p>
<h2>Haftung für Inhalte</h2>
<p>
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den
allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht
verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach
Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
</p>
<p>
Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen
Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt
der Kenntnis einer konkreten Rechtsverletzung möglich.
</p>
<h2>Haftung für Links</h2>
<p>
Unser Angebot enthält ggf. Links zu externen Websites Dritter, auf deren Inhalte wir keinen
Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen.
Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der
Seiten verantwortlich.
</p>
<h2>Urheberrecht</h2>
<p>
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
jeweiligen Autors bzw. Erstellers.
</p>
`;
export default function Impressum() {
return <LegalContentPage title="Impressum" content={impressumContent} />;
}
+152
View File
@@ -0,0 +1,152 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import SpeakerIcon from '../components/SpeakerIcon';
import ServiceCard from '../components/ServiceCard';
const services = [
{ icon: '\u{1F50A}', title: 'Vermietung', description: 'Lautsprecher, Mischpulte, Lichtanlagen, Rigging und Komplett-Setups aus unserem 1.000+ Geräte umfassenden Mietlager in Ellzee. Geprüftes Equipment, sofort einsatzbereit.' },
{ icon: '\u{1F6D2}', title: 'Verkauf', description: 'Neu- und Gebrauchtgeräte führender Hersteller mit Beratung, Inbetriebnahme und Service. Wir liefern nicht nur wir konfigurieren Ihre Anlage einsatzfertig.' },
{ icon: '\u{1F477}', title: 'Personal', description: 'Qualifizierte Tontechniker, Lichttechniker und Rigger für Aufbau, Betrieb und Abbau. Mit TÜV-geprüfter Ausbildung und umfangreicher Praxiserfahrung.' },
{ icon: '\u{1F69A}', title: 'Transport', description: 'Eigene Transportfahrzeuge für sichere An- und Ablieferung. Inklusive Verladekonzept, Positionierung und Rückholung kundengerecht terminiert.' },
{ icon: '\u{1F4E6}', title: 'Lagerung', description: 'Klimatisierte und trockene Lagerung für Equipment und Veranstaltungsbestände. Mit Bestandsmanagement und regelmäßiger Funktionsprüfung.' },
{ icon: '\u{1F527}', title: 'Werkstatt', description: 'Herstellerübergreifende Reparatur und Wartung in unserer eigenen Werkstatt. Regelmäßige DGUV-Prüfungen und Kalibrierung für Ihren sicheren Betrieb.' },
{ icon: '\u26A1', title: 'Installation', description: 'Festinstallationen in Schaufenstern, Lokalen und Veranstaltungsstätten. Von der Planung über die Elektroinstallation bis zur Abnahme alles aus einer Hand.' },
{ icon: '\u{1F4C5}', title: 'Booking', description: 'Vermittlung von Künstlern und Technik-Personal für Ihre Veranstaltung. Mit unserem branchenweiten Netzwerk finden wir die passenden Professionals für Ihr Event.' }
];
const speakerIcons = ['linearray', 'subwoofer', 'pointsource', 'column', 'monitor', 'movinghead'];
interface EquipmentItem {
id: number;
name: string;
number: string | null;
category: string | null;
image_url: string | null;
available: boolean;
}
export default function Index() {
const navigate = useNavigate();
const [highlights, setHighlights] = useState<EquipmentItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/equipment?page_size=6&sort=name_asc')
.then(r => r.json())
.then(data => {
setHighlights(data.items || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
function goTo(route: string) {
navigate(route);
window.scrollTo(0, 0);
}
return (
<div>
<section className="hms-hero py-20 sm:py-32" aria-labelledby="hero-title">
<div className="hms-hero-bg" style={{ backgroundImage: "url('https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=1920&q=80')" }}></div>
<div className="hms-hero-overlay"></div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-2xl">
<span className="hms-badge hms-badge-primary mb-4">Seit 2003 · Über 20 Jahre Veranstaltungstechnik</span>
<h1 id="hero-title" className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-6" style={{ color: 'var(--text)' }}>
Professionelle Technik<br /><span style={{ color: 'var(--color-accent)' }}>für Ihre Veranstaltung</span>
</h1>
<p className="text-lg sm:text-xl mb-8 max-w-xl" style={{ color: 'var(--text-muted)' }}>
Von der Firmenevent-Beschallung bis zur Open-Air-Großproduktion: HMS Licht & Ton liefert Tontechnik, Lichttechnik, Rigging und Komplettlösungen aus Leipheim und Ellzee.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<button onClick={() => goTo('/mietkatalog')} className="hms-btn hms-btn-primary text-base px-8 py-4">
Mietkatalog öffnen
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
</button>
<button onClick={() => goTo('/kontakt')} className="hms-btn hms-btn-secondary text-base px-8 py-4">Beratung anfragen</button>
</div>
</div>
</div>
</section>
<section className="py-12 sm:py-16" style={{ background: 'var(--bg)' }} aria-labelledby="speaker-title">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<span className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--color-accent)' }}>Equipment-Highlights</span>
<h2 id="speaker-title" className="text-2xl sm:text-3xl font-bold mt-1" style={{ color: 'var(--text)' }}>Lautsprecher & Strahler aus unserem Mietlager</h2>
</div>
<div className="hms-speaker-grid">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="hms-speaker-item">
<div className="hms-skeleton h-12 w-12 rounded mx-auto mb-2"></div>
<div className="hms-skeleton h-4 w-3/4 mx-auto mb-1"></div>
<div className="hms-skeleton h-3 w-1/2 mx-auto"></div>
</div>
))
) : (
highlights.map((item, i) => (
<div key={item.id} className="hms-speaker-item" onClick={() => goTo('/mietkatalog')} role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && goTo('/mietkatalog')}>
<SpeakerIcon type={speakerIcons[i % speakerIcons.length]} />
<div className="hms-speaker-name">{item.name}</div>
<div className="hms-speaker-type">{item.category || 'Equipment'}</div>
</div>
))
)}
</div>
</div>
</section>
<section className="py-16 sm:py-20" style={{ background: 'var(--panel)' }} aria-labelledby="about-title">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div>
<span className="text-sm font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--color-accent)' }}>Unternehmen</span>
<h2 id="about-title" className="text-3xl sm:text-4xl font-bold mb-6" style={{ color: 'var(--text)' }}>Ihr Technik-Partner mit Erfahrung</h2>
<p className="leading-relaxed mb-4" style={{ color: 'var(--text-muted)' }}>Die <strong style={{ color: 'var(--text)' }}>Hammerschmidt u. Mössle GbR</strong> ist seit 2003 Ihr zuverlässiger Partner für Veranstaltungstechnik in der Region Leipheim / Ellzee und im gesamten süddeutschen Raum.</p>
<p className="leading-relaxed mb-4" style={{ color: 'var(--text-muted)' }}>Mit einem Mietlager von über 1.000 Geräten, einer eigenen Werkstatt und erfahrenem Personal realisieren wir Veranstaltungen jeder Größenordnung.</p>
<div className="grid grid-cols-3 gap-4 mt-8">
<div className="text-center"><div className="text-3xl font-bold" style={{ color: 'var(--color-accent)' }}>20+</div><div className="text-sm" style={{ color: 'var(--secondary)' }}>Jahre Erfahrung</div></div>
<div className="text-center"><div className="text-3xl font-bold" style={{ color: 'var(--color-accent)' }}>1.000+</div><div className="text-sm" style={{ color: 'var(--secondary)' }}>Geräte im Lager</div></div>
<div className="text-center"><div className="text-3xl font-bold" style={{ color: 'var(--color-accent)' }}>2</div><div className="text-sm" style={{ color: 'var(--secondary)' }}>Standorte</div></div>
</div>
</div>
<div className="relative">
<div className="aspect-[4/3] rounded-lg overflow-hidden shadow-xl">
<img src="https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&q=80" alt="Veranstaltungstechnik Setup" loading="lazy" className="w-full h-full object-cover" />
</div>
</div>
</div>
</div>
</section>
<section className="py-16 sm:py-20" style={{ background: 'var(--bg)' }} aria-labelledby="services-title">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<span className="text-sm font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--color-accent)' }}>Leistungen</span>
<h2 id="services-title" className="text-3xl sm:text-4xl font-bold" style={{ color: 'var(--text)' }}>Von der Vermietung bis zur Komplettbetreuung</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{services.map((s) => (
<ServiceCard key={s.title} icon={s.icon} title={s.title} description={s.description} />
))}
</div>
</div>
</section>
<section className="py-16 sm:py-20" style={{ background: 'var(--panel)' }} aria-labelledby="rental-title">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="rounded-lg p-8 sm:p-12 lg:p-16 text-center" style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}>
<h2 id="rental-title" className="text-3xl sm:text-4xl font-bold mb-4" style={{ color: 'var(--text)' }}>Online-Mietkatalog</h2>
<p className="max-w-2xl mx-auto mb-8" style={{ color: 'var(--text-muted)' }}>Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage direkt online zusammen.</p>
<button onClick={() => goTo('/mietkatalog')} className="hms-btn hms-btn-primary text-base px-8 py-4">
Katalog durchsuchen
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
</button>
</div>
</div>
</section>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
export default function Kontakt() {
const [form, setForm] = useState({ name: '', email: '', phone: '', subject: '', message: '', privacy: false });
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
function validate(): boolean {
const e: Record<string, string> = {};
if (!form.name.trim()) e.name = 'Name ist erforderlich';
if (!form.email.trim()) e.email = 'E-Mail ist erforderlich';
else if (!/^\S+@\S+\.\S+$/.test(form.email)) e.email = 'Ungültige E-Mail-Adresse';
if (!form.message.trim()) e.message = 'Nachricht ist erforderlich';
if (!form.privacy) e.privacy = 'Bitte stimmen Sie der Datenschutzerklärung zu';
setErrors(e);
return Object.keys(e).length === 0;
}
function submit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
setTimeout(() => { setSubmitting(false); setSubmitted(true); }, 1500);
}
function resetForm() {
setForm({ name: '', email: '', phone: '', subject: '', message: '', privacy: false });
setSubmitted(false);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl sm:text-4xl font-bold mb-2" style={{ color: 'var(--text)' }}>Kontakt</h1>
<p className="mb-8" style={{ color: 'var(--secondary)' }}>Wir beraten Sie persönlich telefonisch, per E-Mail oder über das Kontaktformular.</p>
<div className="grid lg:grid-cols-2 gap-8">
<div className="space-y-6">
<div className="hms-card p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2" style={{ color: 'var(--text)' }}><span style={{ color: 'var(--color-accent)' }}>🏢</span> Büro Leipheim</h2>
<div className="space-y-1 text-sm" style={{ color: 'var(--text-muted)' }}>
<p>Grockelhofen 10<br />89340 Leipheim</p>
<a href="https://maps.google.com/?q=Grockelhofen+10+89340+Leipheim" target="_blank" rel="noopener" className="inline-flex items-center gap-1 mt-2" style={{ color: 'var(--color-accent)' }}>Route planen <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg></a>
</div>
</div>
<div className="hms-card p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2" style={{ color: 'var(--text)' }}><span style={{ color: 'var(--color-accent)' }}>📦</span> Mietlager Ellzee</h2>
<div className="space-y-1 text-sm" style={{ color: 'var(--text-muted)' }}>
<p>Zur Schönhalde 8<br />89352 Ellzee</p>
<a href="https://maps.google.com/?q=Zur+Schönhalde+8+89352+Ellzee" target="_blank" rel="noopener" className="inline-flex items-center gap-1 mt-2" style={{ color: 'var(--color-accent)' }}>Route planen <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg></a>
</div>
</div>
<div className="hms-card p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2" style={{ color: 'var(--text)' }}><span style={{ color: 'var(--color-accent)' }}>🕐</span> Öffnungszeiten</h2>
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
<div className="flex justify-between py-2 border-b" style={{ borderColor: 'var(--border)' }}><span>Montag Freitag</span><span className="font-medium" style={{ color: 'var(--text)' }}>10:00 18:00</span></div>
<div className="flex justify-between py-2"><span>Samstag & Sonntag</span><span style={{ color: 'var(--secondary)' }}>Geschlossen</span></div>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div className="hms-card p-6 text-center">
<div className="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-2xl" style={{ background: 'var(--surface)', color: 'var(--secondary)' }}>👤</div>
<h3 className="font-semibold" style={{ color: 'var(--text)' }}>Leopold Hammerschmidt</h3>
<div className="text-xs mb-3" style={{ color: 'var(--secondary)' }}>Geschäftsführung</div>
<a href="tel:+491726264796" className="block text-sm mb-1" style={{ color: 'var(--text-muted)' }}>+49 (0) 172 6264796</a>
<a href="mailto:leopold.hammerschmidt@hms-licht-ton.de" className="block text-sm break-all" style={{ color: 'var(--text-muted)' }}>leopold.hammerschmidt@hms-licht-ton.de</a>
</div>
<div className="hms-card p-6 text-center">
<div className="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-2xl" style={{ background: 'var(--surface)', color: 'var(--secondary)' }}>👤</div>
<h3 className="font-semibold" style={{ color: 'var(--text)' }}>Andreas Mössle</h3>
<div className="text-xs mb-3" style={{ color: 'var(--secondary)' }}>Geschäftsführung</div>
<a href="tel:+491739014604" className="block text-sm mb-1" style={{ color: 'var(--text-muted)' }}>+49 (0) 173 / 9014604</a>
<a href="mailto:andreas.moessle@hms-licht-ton.de" className="block text-sm break-all" style={{ color: 'var(--text-muted)' }}>andreas.moessle@hms-licht-ton.de</a>
</div>
</div>
</div>
<div>
<div className="hms-card p-6 sm:p-8">
<h2 className="text-xl font-semibold mb-6" style={{ color: 'var(--text)' }}>Nachricht senden</h2>
{submitted ? (
<div className="text-center py-8" role="status">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }}>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"/></svg>
</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Nachricht übermittelt</h3>
<p className="text-sm mb-6" style={{ color: 'var(--secondary)' }}>Vielen Dank für Ihre Anfrage. Wir melden uns innerhalb von 24 Stunden bei Ihnen.</p>
<button onClick={resetForm} className="hms-btn hms-btn-secondary">Neue Nachricht</button>
</div>
) : (
<form onSubmit={submit} noValidate>
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Name <span style={{ color: 'var(--color-error)' }} aria-label="Pflichtfeld">*</span></label>
<input id="name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} type="text" className={`hms-input ${errors.name ? 'hms-input-error' : ''}`} placeholder="Ihr Name" aria-invalid={!!errors.name} />
{errors.name && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }} role="alert">{errors.name}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>E-Mail <span style={{ color: 'var(--color-error)' }} aria-label="Pflichtfeld">*</span></label>
<input id="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} type="email" className={`hms-input ${errors.email ? 'hms-input-error' : ''}`} placeholder="ihre@email.de" aria-invalid={!!errors.email} />
{errors.email && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }} role="alert">{errors.email}</p>}
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Telefon</label>
<input id="phone" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} type="tel" className="hms-input" placeholder="+49 ..." />
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Anliegen</label>
<select id="subject" value={form.subject} onChange={e => setForm(f => ({ ...f, subject: e.target.value }))} className="hms-input">
<option value="">Bitte wählen</option>
<option value="vermietung">Vermietungsanfrage</option>
<option value="verkauf">Verkaufsberatung</option>
<option value="personal">Personalanfrage</option>
<option value="installation">Installationsanfrage</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1" style={{ color: 'var(--text-muted)' }}>Nachricht <span style={{ color: 'var(--color-error)' }} aria-label="Pflichtfeld">*</span></label>
<textarea id="message" value={form.message} onChange={e => setForm(f => ({ ...f, message: e.target.value }))} rows={5} className={`hms-input ${errors.message ? 'hms-input-error' : ''}`} placeholder="Beschreiben Sie Ihr Anliegen Veranstaltungstyp, Ort, erwartete Besucherzahl, benötigtes Equipment..." aria-invalid={!!errors.message} />
{errors.message && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }} role="alert">{errors.message}</p>}
</div>
<div>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={form.privacy} onChange={e => setForm(f => ({ ...f, privacy: e.target.checked }))} className="mt-1 w-5 h-5 rounded" style={{ accentColor: 'var(--color-accent)' }} aria-invalid={!!errors.privacy} />
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>Ich habe die <Link to="/datenschutz" style={{ color: 'var(--color-accent)' }}>Datenschutzerklärung</Link> gelesen und stimme zu, dass meine Angaben zur Bearbeitung meiner Anfrage gespeichert werden. <span style={{ color: 'var(--color-error)' }}>*</span></span>
</label>
{errors.privacy && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }} role="alert">{errors.privacy}</p>}
</div>
<button type="submit" disabled={submitting} className="hms-btn hms-btn-primary w-full text-base py-3">
{submitting ? <span className="hms-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} /> : <span>Nachricht senden</span>}
</button>
</div>
</form>
)}
</div>
</div>
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { fetchEquipment, fetchCategories } from '../api/equipment';
import type { EquipmentItem, SortOption, PaginatedEquipment } from '../types';
import { useCart } from '../store/CartContext';
import EquipmentCard from '../components/EquipmentCard';
import LoadingSkeleton from '../components/LoadingSkeleton';
import ErrorState from '../components/ErrorState';
import EmptyState from '../components/EmptyState';
export default function Mietkatalog() {
const navigate = useNavigate();
const { addItemByFields } = useCart();
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState('alle');
const [sortBy, setSortBy] = useState<SortOption>('name_asc');
const [currentPage, setCurrentPage] = useState(1);
const [categories, setCategories] = useState<string[]>(['alle']);
const [data, setData] = useState<PaginatedEquipment | null>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const load = useCallback(async () => {
setPending(true);
setError(false);
try {
const result = await fetchEquipment({
search: searchQuery.trim() || undefined,
category: activeCategory !== 'alle' ? activeCategory : undefined,
sort: sortBy,
page: currentPage,
page_size: 24,
});
if (mountedRef.current) {
setData(result);
setPending(false);
}
} catch (e) {
if (mountedRef.current) {
setError(true);
setPending(false);
}
}
}, [searchQuery, activeCategory, sortBy, currentPage]);
useEffect(() => {
mountedRef.current = true;
load();
return () => { mountedRef.current = false; };
}, [load]);
useEffect(() => {
fetchCategories().then(cats => {
if (cats && cats.length > 0 && mountedRef.current) {
setCategories(['alle', ...cats.filter(c => c && c.trim() !== '')]);
}
}).catch(() => {});
}, []);
function onSearchInput() {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(() => {
setCurrentPage(1);
load();
}, 300);
}
function reload() {
load();
}
function navigateTo(route: string) {
navigate(route);
window.scrollTo(0, 0);
}
function navigateToDetail(item: any) {
navigate('/mietkatalog/' + item.id);
window.scrollTo(0, 0);
}
function addToCart(item: any) {
addItemByFields({
equipment_id: item.id,
name: item.name,
rental_price: null,
image_url: item.image_url || null,
});
}
function mapItem(item: EquipmentItem) {
return {
...item,
code: item.number || item.rentman_id,
image: item.image_url || '',
};
}
const equipment = data?.items || [];
const total = data?.total || 0;
const totalPages = data?.total_pages || 0;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl sm:text-4xl font-bold mb-2" style={{ color: 'var(--text)' }}>Mietkatalog</h1>
<p style={{ color: 'var(--secondary)' }}>Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage zusammen.</p>
</div>
<button onClick={() => navigateTo('/warenkorb')} className="hms-btn hms-btn-secondary text-sm self-start sm:self-auto">🛒 Warenkorb ansehen</button>
</div>
<div className="hms-card p-4 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style={{ color: 'var(--secondary)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input value={searchQuery} onChange={e => { setSearchQuery(e.target.value); onSearchInput(); }} type="search" className="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" />
</div>
<select value={sortBy} onChange={e => { setSortBy(e.target.value as SortOption); setCurrentPage(1); reload(); }} className="hms-input lg:w-48" aria-label="Sortieren nach">
<option value="name_asc">Sortieren: Name (A-Z)</option>
<option value="name_desc">Sortieren: Name (Z-A)</option>
</select>
</div>
{categories.length > 1 && (
<div className="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter">
{categories.map(cat => (
<button key={cat} onClick={() => { setActiveCategory(cat); setCurrentPage(1); reload(); }}
className={`hms-chip ${activeCategory === cat ? 'active' : ''}`} role="tab" aria-selected={activeCategory === cat}>
{cat === 'alle' ? 'Alle Kategorien' : cat}
</button>
))}
</div>
)}
</div>
{!pending && !error && (
<div className="text-sm mb-4" style={{ color: 'var(--secondary)' }} aria-live="polite">{total} {total === 1 ? 'Gerät' : 'Geräte'} gefunden</div>
)}
{pending && <LoadingSkeleton count={6} />}
{error && <ErrorState title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." onRetry={reload} />}
{!pending && !error && equipment.length === 0 && (
<EmptyState icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." actionLabel="Filter zurücksetzen" onAction={() => { setSearchQuery(''); setActiveCategory('alle'); setCurrentPage(1); reload(); }} />
)}
{!pending && !error && equipment.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{equipment.map(item => (
<EquipmentCard key={item.id} item={mapItem(item)} onClick={navigateToDetail} onAddToCart={addToCart} />
))}
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button disabled={currentPage <= 1} onClick={() => { setCurrentPage(c => c - 1); reload(); }} className="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Vorherige Seite"></button>
<span className="text-sm" style={{ color: 'var(--secondary)' }}>Seite {currentPage} von {totalPages}</span>
<button disabled={currentPage >= totalPages} onClick={() => { setCurrentPage(c => c + 1); reload(); }} className="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Nächste Seite"></button>
</div>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState, useEffect, useMemo } from 'react';
import ErrorState from '../components/ErrorState';
import EmptyState from '../components/EmptyState';
const allImages = [
{ id: 1, title: 'Open-Air Veranstaltung', category: 'Open-Air', date: '2017', img: 'img/ref1.jpg' },
{ id: 2, title: 'Bühnenaufbau', category: 'Rigging', date: '2016', img: 'img/ref2.jpg' },
{ id: 3, title: 'Donautal Radelspass', category: 'Event', date: '2019', img: 'img/ref3.jpg' },
{ id: 4, title: 'Traversenaufbau', category: 'Rigging', date: '2019', img: 'img/ref4.jpg' },
{ id: 5, title: 'Veranstaltungstechnik vor Ort', category: 'Event', date: '2019', img: 'img/ref5.jpg' },
{ id: 6, title: 'Indoor Beschallung', category: 'Indoor', date: '2016', img: 'img/ref6.jpg' },
{ id: 7, title: 'Bühnentechnik Setup', category: 'Rigging', date: '2019', img: 'img/ref7.jpg' },
{ id: 8, title: 'Event-Aufbau', category: 'Event', date: '2019', img: 'img/ref8.jpg' },
{ id: 9, title: 'Großbühne Aufbau', category: 'Open-Air', date: '2016', img: 'img/ref9.jpg' }
];
const categories = ['alle', 'Open-Air', 'Indoor', 'Rigging', 'Event'];
export default function Referenzen() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [filter, setFilter] = useState('alle');
const filteredImages = useMemo(() => {
if (filter === 'alle') return allImages;
return allImages.filter(i => i.category === filter);
}, [filter]);
useEffect(() => {
const t = setTimeout(() => setLoading(false), 1000);
return () => clearTimeout(t);
}, []);
function retry() {
setLoading(true);
setError(false);
setTimeout(() => setLoading(false), 800);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl sm:text-4xl font-bold mb-2" style={{ color: 'var(--text)' }}>Referenzen</h1>
<p className="mb-8" style={{ color: 'var(--secondary)' }}>Ausgewählte Projekte aus unserer Veranstaltungstechnik-Praxis.</p>
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Referenz-Filter">
{categories.map(cat => (
<button key={cat} onClick={() => setFilter(cat)} className={`hms-chip ${filter === cat ? 'active' : ''}`} role="tab" aria-selected={filter === cat}>{cat === 'alle' ? 'Alle' : cat}</button>
))}
</div>
{loading && (
<div className="hms-gallery">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="hms-card overflow-hidden">
<div className="hms-skeleton aspect-[4/3]" style={{ borderRadius: 0 }}></div>
<div className="p-4 space-y-2"><div className="hms-skeleton h-4 w-3/4"></div><div className="hms-skeleton h-3 w-1/3"></div></div>
</div>
))}
</div>
)}
{error && <ErrorState title="Referenzen konnten nicht geladen werden" message="Bitte versuchen Sie es in Kürze erneut." onRetry={retry} />}
{!loading && !error && (
<div className="hms-gallery">
{filteredImages.map(img => (
<article key={img.id} className="hms-card overflow-hidden group cursor-pointer">
<div className="aspect-[4/3] relative overflow-hidden">
<img src={img.img} alt={img.title} loading="lazy" className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<span className="hms-badge hms-badge-primary absolute top-3 left-3">{img.category}</span>
</div>
<div className="p-4"><h3 className="font-semibold text-sm" style={{ color: 'var(--text)' }}>{img.title}</h3><p className="text-xs mt-1" style={{ color: 'var(--secondary)' }}>{img.date}</p></div>
</article>
))}
</div>
)}
{!loading && !error && filteredImages.length === 0 && (
<EmptyState icon="📭" title="Keine Referenzen" message="Für diese Kategorie liegen aktuell keine Referenzen vor." actionLabel="Alle anzeigen" onAction={() => setFilter('alle')} />
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCart } from '../store/CartContext';
import EmptyState from '../components/EmptyState';
export default function Warenkorb() {
const navigate = useNavigate();
const { items: cartItems, totalCount, removeItem, incrementQuantity, decrementQuantity, clearCart } = useCart();
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [form, setForm] = useState({ name: '', email: '', phone: '', event_date: '', event_location: '', message: '' });
const [errors, setErrors] = useState<Record<string, string>>({});
function navigateTo(route: string) {
navigate(route);
window.scrollTo(0, 0);
}
function validate(): boolean {
const e: Record<string, string> = {};
if (!form.name.trim()) e.name = 'Name ist erforderlich';
if (!form.email.trim()) e.email = 'E-Mail ist erforderlich';
else if (!/^\S+@\S+\.\S+$/.test(form.email)) e.email = 'Ungültige E-Mail';
if (!form.event_date) e.event_date = 'Veranstaltungsdatum erforderlich';
if (!form.event_location.trim()) e.event_location = 'Veranstaltungsort erforderlich';
setErrors(e);
return Object.keys(e).length === 0;
}
function submitRequest(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setSubmitted(true);
clearCart();
}, 1800);
}
return (
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl sm:text-4xl font-bold mb-2" style={{ color: 'var(--text)' }}>Mietanfrage</h1>
<p className="mb-8" style={{ color: 'var(--secondary)' }}>Überprüfen Sie Ihre Geräteauswahl und senden Sie die Anfrage ab.</p>
{submitted ? (
<div className="hms-card p-8 text-center" role="status">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }}>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"/></svg>
</div>
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text)' }}>Mietanfrage übermittelt</h2>
<p className="mb-6" style={{ color: 'var(--secondary)' }}>Vielen Dank! Wir melden uns innerhalb von 24 Stunden mit einem unverbindlichen Angebot.</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button onClick={() => navigateTo('/mietkatalog')} className="hms-btn hms-btn-secondary">Weiter stöbern</button>
<button onClick={() => navigateTo('/')} className="hms-btn hms-btn-primary">Zur Startseite</button>
</div>
</div>
) : cartItems.length === 0 ? (
<EmptyState icon="🛒" title="Warenkorb ist leer" message="Fügen Sie Geräte aus dem Mietkatalog hinzu, um eine Mietanfrage zu stellen." actionLabel="Zum Mietkatalog" onAction={() => navigateTo('/mietkatalog')} />
) : (
<div className="grid lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-4">
{cartItems.map((item, index) => (
<div key={index} className="hms-card p-4 sm:p-6">
<div className="flex gap-4">
<div className="w-20 h-20 rounded-lg flex items-center justify-center flex-shrink-0 text-2xl" style={{ background: 'var(--surface)', color: 'var(--secondary)' }}>📦</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-semibold text-sm sm:text-base" style={{ color: 'var(--text)' }}>{item.name}</h3>
<div className="text-xs mt-0.5" style={{ color: 'var(--secondary)' }}>{item.equipment_id}</div>
</div>
<button onClick={() => removeItem(item.equipment_id)} className="p-1" style={{ color: 'var(--secondary)' }} aria-label={item.name + ' entfernen'}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
<div className="flex flex-wrap items-center gap-3 mt-3">
<div className="flex items-center gap-2">
<button onClick={() => decrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl verringern"></button>
<span className="w-8 text-center text-sm font-medium" style={{ color: 'var(--text)' }}>{item.quantity}</span>
<button onClick={() => incrementQuantity(item.equipment_id)} className="w-7 h-7 rounded border flex items-center justify-center" style={{ borderColor: 'var(--border-strong)', color: 'var(--text-muted)' }} aria-label="Anzahl erhöhen">+</button>
</div>
</div>
</div>
</div>
</div>
))}
<button onClick={clearCart} className="text-sm hover:text-[var(--color-error)]" style={{ color: 'var(--secondary)' }}>Warenkorb leeren</button>
</div>
<div>
<div className="hms-card p-6 sticky top-20">
<h2 className="text-lg font-semibold mb-4" style={{ color: 'var(--text)' }}>Anfrage senden</h2>
<div className="text-sm mb-4 pb-4 border-b" style={{ borderColor: 'var(--border)', color: 'var(--secondary)' }}>
<div className="flex justify-between mb-1"><span>Geräte gesamt:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{totalCount}</span></div>
<div className="flex justify-between"><span>Positionen:</span><span className="font-medium" style={{ color: 'var(--text)' }}>{cartItems.length}</span></div>
</div>
<form onSubmit={submitRequest} noValidate>
<div className="space-y-3">
<div>
<label htmlFor="req-name" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Name <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input id="req-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} type="text" className={`hms-input text-sm ${errors.name ? 'hms-input-error' : ''}`} placeholder="Ihr Name" />
{errors.name && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }}>{errors.name}</p>}
</div>
<div>
<label htmlFor="req-email" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>E-Mail <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input id="req-email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} type="email" className={`hms-input text-sm ${errors.email ? 'hms-input-error' : ''}`} placeholder="ihre@email.de" />
{errors.email && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }}>{errors.email}</p>}
</div>
<div>
<label htmlFor="req-phone" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Telefon</label>
<input id="req-phone" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} type="tel" className="hms-input text-sm" placeholder="+49 ..." />
</div>
<div>
<label htmlFor="req-date" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Veranstaltungsdatum <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input id="req-date" value={form.event_date} onChange={e => setForm(f => ({ ...f, event_date: e.target.value }))} type="date" className={`hms-input text-sm ${errors.event_date ? 'hms-input-error' : ''}`} />
{errors.event_date && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }}>{errors.event_date}</p>}
</div>
<div>
<label htmlFor="req-location" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Veranstaltungsort <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input id="req-location" value={form.event_location} onChange={e => setForm(f => ({ ...f, event_location: e.target.value }))} type="text" className={`hms-input text-sm ${errors.event_location ? 'hms-input-error' : ''}`} placeholder="Ort / Location" />
{errors.event_location && <p className="text-xs mt-1" style={{ color: 'var(--color-error)' }}>{errors.event_location}</p>}
</div>
<div>
<label htmlFor="req-message" className="block text-xs font-medium mb-1" style={{ color: 'var(--secondary)' }}>Anmerkung</label>
<textarea id="req-message" value={form.message} onChange={e => setForm(f => ({ ...f, message: e.target.value }))} rows={3} className="hms-input text-sm" placeholder="Zusätzliche Informationen..." />
</div>
<button type="submit" disabled={submitting} className="hms-btn hms-btn-primary w-full py-3">
{submitting ? <span className="hms-spinner" style={{ width: 18, height: 18, borderWidth: 2 }} /> : <span>Anfrage absenden</span>}
</button>
<p className="text-xs text-center" style={{ color: 'var(--secondary)' }}>Unverbindlich Angebot innerhalb 24 Std.</p>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import type { CartItem, EquipmentItem } from '../types';
const STORAGE_KEY = 'hms-cart';
interface CartContextType {
items: CartItem[];
totalCount: number;
isEmpty: boolean;
hasItems: boolean;
apiItems: { equipment_id: number; quantity: number }[];
addItem: (equipment: EquipmentItem) => void;
addItemByFields: (item: { equipment_id: number; name: string; rental_price: number | null; image_url: string | null }) => void;
removeItem: (equipmentId: number) => void;
updateQuantity: (equipmentId: number, quantity: number) => void;
incrementQuantity: (equipmentId: number) => void;
decrementQuantity: (equipmentId: number) => void;
clearCart: () => void;
}
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
// Hydrate from localStorage
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && Array.isArray(parsed.items)) {
setItems(parsed.items);
}
}
} catch (e) {
console.warn('Failed to hydrate cart from localStorage:', e);
}
}, []);
// Persist to localStorage on changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ items }));
} catch (e) {
console.warn('Failed to persist cart to localStorage:', e);
}
}, [items]);
const addItem = useCallback((equipment: EquipmentItem) => {
setItems(prev => {
const existing = prev.find(i => i.equipment_id === equipment.id);
if (existing) {
return prev.map(i => i.equipment_id === equipment.id ? { ...i, quantity: i.quantity + 1 } : i);
}
return [...prev, { equipment_id: equipment.id, name: equipment.name, quantity: 1, rental_price: equipment.rental_price, image_url: equipment.image_url }];
});
}, []);
const addItemByFields = useCallback((item: { equipment_id: number; name: string; rental_price: number | null; image_url: string | null }) => {
setItems(prev => {
const existing = prev.find(i => i.equipment_id === item.equipment_id);
if (existing) {
return prev.map(i => i.equipment_id === item.equipment_id ? { ...i, quantity: i.quantity + 1 } : i);
}
return [...prev, { equipment_id: item.equipment_id, name: item.name, quantity: 1, rental_price: item.rental_price, image_url: item.image_url }];
});
}, []);
const removeItem = useCallback((equipmentId: number) => {
setItems(prev => prev.filter(i => i.equipment_id !== equipmentId));
}, []);
const updateQuantity = useCallback((equipmentId: number, quantity: number) => {
if (quantity <= 0) {
setItems(prev => prev.filter(i => i.equipment_id !== equipmentId));
return;
}
setItems(prev => prev.map(i => i.equipment_id === equipmentId ? { ...i, quantity } : i));
}, []);
const incrementQuantity = useCallback((equipmentId: number) => {
setItems(prev => prev.map(i => i.equipment_id === equipmentId ? { ...i, quantity: i.quantity + 1 } : i));
}, []);
const decrementQuantity = useCallback((equipmentId: number) => {
setItems(prev => {
const item = prev.find(i => i.equipment_id === equipmentId);
if (!item) return prev;
if (item.quantity <= 1) {
return prev.filter(i => i.equipment_id !== equipmentId);
}
return prev.map(i => i.equipment_id === equipmentId ? { ...i, quantity: i.quantity - 1 } : i);
});
}, []);
const clearCart = useCallback(() => setItems([]), []);
const totalCount = items.reduce((sum, item) => sum + item.quantity, 0);
const isEmpty = items.length === 0;
const hasItems = items.length > 0;
const apiItems = items.map(item => ({ equipment_id: item.equipment_id, quantity: item.quantity }));
return (
<CartContext.Provider value={{ items, totalCount, isEmpty, hasItems, apiItems, addItem, addItemByFields, removeItem, updateQuantity, incrementQuantity, decrementQuantity, clearCart }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
}
+66
View File
@@ -0,0 +1,66 @@
export interface EquipmentItem {
id: number;
rentman_id: string;
name: string;
number: string | null;
category: string | null;
description: string | null;
image_url: string | null;
rental_price: number | null;
available: boolean;
}
export interface EquipmentDetail {
id: number;
rentman_id: string;
name: string;
number: string | null;
category: string | null;
description: string | null;
specifications: Record<string, unknown> | null;
image_url: string | null;
images: string[] | null;
rental_price: number | null;
brand: string | null;
available: boolean;
}
export interface PaginatedEquipment {
items: EquipmentItem[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export type SortOption = "name_asc" | "name_desc";
export interface CartItem {
equipment_id: number;
name: string;
quantity: number;
rental_price: number | null;
image_url: string | null;
}
export interface RentalRequestPayload {
event_name: string;
date_start: string;
date_end: string;
location: string;
person_count: number;
contact_name: string;
contact_company: string;
contact_email: string;
contact_phone: string;
contact_street: string;
contact_postalcode: string;
contact_city: string;
message: string;
items: { equipment_id: number; quantity: number }[];
}
export interface RentalRequestResponse {
reference_number: string;
status: string;
}