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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. 15–20 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user