Initial commit: HifiPlanet Car-Hifi Shop (Frontend + Backend)

React/Vite Frontend (hifi-src) + PHP/MariaDB Backend (hifi/api) fuer
den Car-Hifi Umbau-Shop HifiPlanet in Amorbach, inkl. Admin-Panel,
2FA, Wartungsmodus, Cookie-Consent und rechtlichen Pflichtseiten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-05 13:37:06 +02:00
commit f3ab7d0816
134 changed files with 12018 additions and 0 deletions

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# Dieses Repo lebt im XAMPP htdocs-Ordner zusammen mit anderen, projektfremden
# Verzeichnissen (Standard-XAMPP-Dateien, andere lokale Seiten). Nur die beiden
# HifiPlanet-Ordner (hifi/ und hifi-src/) sollen versioniert werden.
/*
!/hifi/
!/hifi-src/
!/.gitignore
# Von Composer/NPM regenerierbare Abhängigkeiten
/hifi/api/vendor/
/hifi-src/node_modules/
# Vite-Build-Output (wird per "npm run build" in hifi-src erzeugt und landet laut
# vite.config.js direkt in hifi/). Da emptyOutDir:false ist, sammeln sich hier über
# viele Builds hinweg tausende alte, umbenannte Chunk-Dateien an (u.a. durch das
# Code-Splitting der ~1600 Lucide-Icons im IconPicker) - das gehört nicht ins Repo.
/hifi/assets/
/hifi/index.html
/hifi/favicon.png
/hifi/robots.txt
# Umgebungsspezifische DB-Zugangsdaten (wird vom Admin-Panel automatisch neu geschrieben,
# unterscheidet sich zwischen Dev-/Live-Umgebung und gehört daher nicht ins Repo)
/hifi/api/config/db.php
# Claude-Code-Tooling (Skills/lokale Einstellungen) - gehört nicht zum Website-Projekt
/hifi/.claude/
/hifi/.agents/
/hifi/skills-lock.json
/hifi-src/.claude/
/hifi-src/.agents/
/hifi-src/skills-lock.json
# Von XAMPP/Editoren erzeugte Laufzeit-/Cache-Dateien
*.log
Thumbs.db
.DS_Store

46
hifi-src/index.html Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>HifiPlanet | Car-Hifi Umbauten nach Maß</title>
<meta name="description" content="HifiPlanet dein Car-Hifi Spezialist. Marke und Modell wählen, passende Sound-Pakete entdecken und unverbindlich anfragen." />
<link rel="canonical" href="https://hifi-planet.de/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="HifiPlanet" />
<meta property="og:title" content="HifiPlanet | Car-Hifi Umbauten nach Maß" />
<meta property="og:description" content="Dein Car-Hifi Spezialist: Marke und Modell wählen, passende Sound-Pakete entdecken und unverbindlich anfragen." />
<meta property="og:locale" content="de_DE" />
<meta name="twitter:card" content="summary" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "AutoRepair",
"name": "HifiPlanet",
"description": "Car-Hifi Spezialist für individuelle Sound-Umbauten vom unauffälligen Upgrade bis zum Komplettumbau.",
"url": "https://hifi-planet.de/",
"telephone": "+4993732062390",
"email": "info@hifi-planet-amorbach.de",
"address": {
"@type": "PostalAddress",
"streetAddress": "Boxbrunner Str. 20a",
"postalCode": "63916",
"addressLocality": "Amorbach",
"addressCountry": "DE"
},
"openingHoursSpecification": [
{ "@type": "OpeningHoursSpecification", "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], "opens": "09:00", "closes": "18:00" },
{ "@type": "OpeningHoursSpecification", "dayOfWeek": ["Saturday"], "opens": "10:00", "closes": "13:00" }
],
"sameAs": ["https://www.youtube.com/@hifiplanet2812"]
}
</script>
</head>
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3035
hifi-src/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
hifi-src/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "hifi-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.42.2",
"lucide-react": "^1.23.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"vite": "^5.4.6"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
hifi-src/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,6 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api
Sitemap: https://hifi-planet.de/sitemap.xml

83
hifi-src/src/App.jsx Normal file
View file

@ -0,0 +1,83 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import ScrollToTop from './components/ScrollToTop.jsx';
import PublicLayout from './components/PublicLayout.jsx';
import AdminLayout from './components/AdminLayout.jsx';
import ProtectedRoute from './components/ProtectedRoute.jsx';
import RequirePermission from './components/RequirePermission.jsx';
import Home from './pages/public/Home.jsx';
import VehicleSelect from './pages/public/VehicleSelect.jsx';
import BrandPage from './pages/public/BrandPage.jsx';
import ModelPage from './pages/public/ModelPage.jsx';
import Leistungen from './pages/public/Leistungen.jsx';
import ContactPage from './pages/public/ContactPage.jsx';
import Impressum from './pages/public/Impressum.jsx';
import Datenschutz from './pages/public/Datenschutz.jsx';
import AGB from './pages/public/AGB.jsx';
import Login from './pages/admin/Login.jsx';
import Dashboard from './pages/admin/Dashboard.jsx';
import Brands from './pages/admin/Brands.jsx';
import Models from './pages/admin/Models.jsx';
import Packages from './pages/admin/Packages.jsx';
import Services from './pages/admin/Services.jsx';
import PackageProducts from './pages/admin/PackageProducts.jsx';
import PackageUpgrades from './pages/admin/PackageUpgrades.jsx';
import ContactRequests from './pages/admin/ContactRequests.jsx';
import AccountSettings from './pages/admin/AccountSettings.jsx';
import AdminUsers from './pages/admin/AdminUsers.jsx';
import PermissionGroups from './pages/admin/PermissionGroups.jsx';
import SettingsLayout from './pages/admin/settings/SettingsLayout.jsx';
import DatabaseSettings from './pages/admin/settings/DatabaseSettings.jsx';
import ExportImportSettings from './pages/admin/settings/ExportImportSettings.jsx';
import MaintenanceSettings from './pages/admin/settings/MaintenanceSettings.jsx';
import ResetSettings from './pages/admin/settings/ResetSettings.jsx';
export default function App() {
return (
<>
<ScrollToTop />
<Routes>
<Route element={<PublicLayout />}>
<Route path="/" element={<Home />} />
<Route path="/fahrzeuge" element={<VehicleSelect />} />
<Route path="/fahrzeuge/:brandSlug" element={<BrandPage />} />
<Route path="/fahrzeuge/:brandSlug/:modelSlug" element={<ModelPage />} />
<Route path="/leistungen" element={<Leistungen />} />
<Route path="/kontakt" element={<ContactPage />} />
<Route path="/impressum" element={<Impressum />} />
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="/agb" element={<AGB />} />
</Route>
<Route path="/admin/login" element={<Login />} />
<Route path="/admin" element={<ProtectedRoute />}>
<Route element={<AdminLayout />}>
<Route index element={<Dashboard />} />
<Route path="account" element={<AccountSettings />} />
<Route path="brands" element={<RequirePermission permission="brands.manage"><Brands /></RequirePermission>} />
<Route path="models" element={<RequirePermission permission="models.manage"><Models /></RequirePermission>} />
<Route path="packages" element={<RequirePermission permission="packages.manage"><Packages /></RequirePermission>} />
<Route path="packages/:packageId/products" element={<RequirePermission permission="packages.manage"><PackageProducts /></RequirePermission>} />
<Route path="packages/:packageId/upgrades" element={<RequirePermission permission="packages.manage"><PackageUpgrades /></RequirePermission>} />
<Route path="services" element={<RequirePermission permission="services.manage"><Services /></RequirePermission>} />
<Route path="contact-requests" element={<RequirePermission permission="contact.manage"><ContactRequests /></RequirePermission>} />
<Route path="users" element={<RequirePermission permission="users.manage"><AdminUsers /></RequirePermission>} />
<Route path="permission-groups" element={<RequirePermission permission="permission_groups.manage"><PermissionGroups /></RequirePermission>} />
<Route
path="settings"
element={<RequirePermission permission="settings.manage"><SettingsLayout /></RequirePermission>}
>
<Route index element={<Navigate to="database" replace />} />
<Route path="database" element={<DatabaseSettings />} />
<Route path="export-import" element={<ExportImportSettings />} />
<Route path="maintenance" element={<MaintenanceSettings />} />
<Route path="reset" element={<ResetSettings />} />
</Route>
</Route>
</Route>
</Routes>
</>
);
}

View file

@ -0,0 +1,26 @@
const API_BASE = '/hifi/api';
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
headers: options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' },
...options,
});
const isJson = res.headers.get('content-type')?.includes('application/json');
const data = isJson ? await res.json() : null;
if (!res.ok) {
throw new Error(data?.error || `Fehler ${res.status}`);
}
return data;
}
export const api = {
get: (path) => request(path),
post: (path, body) => request(path, { method: 'POST', body: body instanceof FormData ? body : JSON.stringify(body) }),
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
patch: (path, body) => request(path, { method: 'PATCH', body: JSON.stringify(body) }),
delete: (path) => request(path, { method: 'DELETE' }),
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

View file

@ -0,0 +1,50 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
export default function Accordion({ items }) {
const [openIndex, setOpenIndex] = useState(null);
return (
<div className="divide-y divide-slate-200 rounded-xl border border-slate-200 bg-white dark:divide-slate-800 dark:border-slate-800 dark:bg-slate-900">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div key={item.question}>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left"
aria-expanded={isOpen}
>
<span className="font-medium text-slate-900 dark:text-white">{item.question}</span>
<motion.svg
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="h-5 w-5 shrink-0 text-slate-500"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m6 9 6 6 6-6" />
</motion.svg>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="overflow-hidden"
>
<p className="px-5 pb-4 text-sm text-slate-600 dark:text-slate-300">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,171 @@
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { api } from '../api/client.js';
import ThemeToggle from './ThemeToggle.jsx';
import logo from '../assets/logo.png';
const ICONS = {
home: 'M3 11.5 12 4l9 7.5M5 10v9a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1v-9',
tag: 'M4 4h7l9 9-7 7-9-9V4Zm3.5 3.5h.01',
car: 'M3 13.5 4.5 9A2 2 0 0 1 6.4 7.5h11.2A2 2 0 0 1 19.5 9L21 13.5M3 13.5v4a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-4M3 13.5h18M6.5 16.5h.01M17.5 16.5h.01',
box: 'M12 3 4 7.5v9L12 21l8-4.5v-9L12 3Zm0 9 8-4.5M12 12v9M12 12 4 7.5',
mail: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Zm0 0 8 7 8-7',
users: 'M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0Zm-4 6c-4 0-7 2-7 4.5V19h14v-1.5C19 15 16 13 12 13Z',
shield: 'M12 3 4 6v6c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V6l-8-3Zm-1.5 9.5 4-4 1 1-5 5-2.5-2.5 1-1 1.5 1.5Z',
lock: 'M6 10V8a6 6 0 1 1 12 0v2m-13 0h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1Zm7 5v2',
briefcase: 'M4 7h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1Zm4 0V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18',
sliders: 'M4 6h16M4 6a2 2 0 1 0 4 0 2 2 0 0 0-4 0Zm16 6H4m10 0a2 2 0 1 0 4 0 2 2 0 0 0-4 0ZM4 18h16m-12 0a2 2 0 1 0 4 0 2 2 0 0 0-4 0Z',
};
const Icon = ({ path }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" className="h-5 w-5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d={path} />
</svg>
);
export default function AdminLayout() {
const { logout, user, hasPermission } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [newContactCount, setNewContactCount] = useState(0);
useEffect(() => {
if (!hasPermission('contact.manage')) return;
api.get('/contact').then((rows) => setNewContactCount(rows.filter((r) => r.status === 'new').length)).catch(() => {});
}, []);
const navGroups = [
{
title: null,
links: [
{ to: '/admin', label: 'Dashboard', icon: 'home', end: true },
{ to: '/admin/account', label: 'Mein Konto', icon: 'lock' },
],
},
{
title: 'Fahrzeug-Katalog',
links: [
{ to: '/admin/brands', label: 'Marken', icon: 'tag', permission: 'brands.manage' },
{ to: '/admin/models', label: 'Modelle', icon: 'car', permission: 'models.manage' },
{ to: '/admin/packages', label: 'Pakete', icon: 'box', permission: 'packages.manage' },
],
},
{
title: 'Website',
links: [
{ to: '/admin/services', label: 'Leistungen', icon: 'briefcase', permission: 'services.manage' },
],
},
{
title: 'Kunden',
links: [
{ to: '/admin/contact-requests', label: 'Kontaktanfragen', icon: 'mail', permission: 'contact.manage', badge: newContactCount },
],
},
{
title: 'Verwaltung',
links: [
{ to: '/admin/users', label: 'Benutzer', icon: 'users', permission: 'users.manage' },
{ to: '/admin/permission-groups', label: 'Berechtigungsgruppen', icon: 'shield', permission: 'permission_groups.manage' },
{ to: '/admin/settings', label: 'Einstellungen', icon: 'sliders', permission: 'settings.manage' },
],
},
]
.map((group) => ({ ...group, links: group.links.filter((l) => !l.permission || hasPermission(l.permission)) }))
.filter((group) => group.links.length > 0);
const handleLogout = async () => {
await logout();
navigate('/admin/login');
};
const linkClass = ({ isActive }) =>
`flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium transition ${
isActive
? 'bg-brand-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`;
const NavLinks = ({ onNavigate }) => (
<nav className="space-y-5">
{navGroups.map((group, i) => (
<div key={i}>
{group.title && (
<p className="mb-1.5 px-3 text-xs font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
{group.title}
</p>
)}
<div className="space-y-1">
{group.links.map((link) => (
<NavLink key={link.to} to={link.to} end={link.end} className={linkClass} onClick={onNavigate}>
<span className="flex items-center gap-2.5">
<Icon path={ICONS[link.icon]} />
{link.label}
</span>
{!!link.badge && (
<span className="rounded-full bg-red-500 px-2 py-0.5 text-xs font-bold text-white">{link.badge}</span>
)}
</NavLink>
))}
</div>
</div>
))}
</nav>
);
return (
<div className="flex min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<aside className="hidden w-60 shrink-0 border-r border-slate-200 p-4 dark:border-slate-800 sm:block">
<div className="mb-6 flex items-center gap-2 px-3">
<span className="rounded-lg px-2 py-1 dark:bg-white">
<img src={logo} alt="HifiPlanet" className="h-7 w-auto" />
</span>
<span className="text-sm font-semibold text-slate-500 dark:text-slate-400">Admin</span>
</div>
<NavLinks />
</aside>
<div className="flex-1">
<header className="border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center justify-between px-4 py-3 sm:px-6">
<div className="flex items-center gap-2 sm:hidden">
<button
onClick={() => setMobileOpen((o) => !o)}
aria-label="Menü öffnen"
className="rounded-md p-2 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-6 w-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span className="rounded-lg px-1.5 py-0.5 dark:bg-white">
<img src={logo} alt="HifiPlanet" className="h-6 w-auto" />
</span>
</div>
<p className="hidden text-sm text-slate-500 dark:text-slate-400 sm:block">
Angemeldet als {user?.username}{user?.is_super_admin ? ' · Super-Admin' : ''}
</p>
<div className="flex items-center gap-2">
<ThemeToggle />
<button
onClick={handleLogout}
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
Abmelden
</button>
</div>
</div>
{mobileOpen && (
<div className="border-t border-slate-200 px-4 py-3 dark:border-slate-800 sm:hidden">
<NavLinks onNavigate={() => setMobileOpen(false)} />
</div>
)}
</header>
<main className="p-4 sm:p-6">
<Outlet />
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useCookieConsent } from '../context/CookieConsentContext.jsx';
function SettingsModal({ onClose }) {
const { consent, saveCustom } = useCookieConsent();
const [external, setExternal] = useState(consent.external);
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl bg-white p-6 dark:bg-slate-900"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-1 text-lg font-bold text-slate-900 dark:text-white">Cookie-Einstellungen</h2>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
Lege fest, welche Kategorien du zulassen möchtest. Mehr dazu in unserer{' '}
<Link to="/datenschutz" className="underline hover:text-brand-500" onClick={onClose}>
Datenschutzerklärung
</Link>
.
</p>
<div className="space-y-3">
<div className="flex items-start justify-between gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">Notwendig</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Für den Betrieb der Website erforderlich (z. B. Login-Session, Theme- und Cookie-Einstellung).
Kann nicht deaktiviert werden.
</p>
</div>
<input type="checkbox" checked disabled className="mt-1 h-4 w-4 shrink-0" />
</div>
<div className="flex items-start justify-between gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">Externe Medien</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Google Maps und YouTube-Videos. Beim Laden werden Daten (u. a. deine IP-Adresse) an Google
übertragen.
</p>
</div>
<input
type="checkbox"
checked={external}
onChange={(e) => setExternal(e.target.checked)}
className="mt-1 h-4 w-4 shrink-0"
/>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700"
>
Abbrechen
</button>
<button
type="button"
onClick={() => saveCustom({ external })}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Auswahl speichern
</button>
</div>
</div>
</div>
);
}
export default function CookieConsentBanner() {
const { decided, settingsOpen, acceptAll, rejectNonEssential, openSettings, closeSettings } = useCookieConsent();
return (
<>
{!decided && (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-slate-200 bg-white/95 p-4 shadow-lg backdrop-blur dark:border-slate-800 dark:bg-slate-900/95 sm:p-5">
<div className="mx-auto flex max-w-6xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-600 dark:text-slate-300">
Wir verwenden nur technisch notwendige Cookies. Für Google Maps und YouTube-Videos benötigen wir
zusätzlich deine Zustimmung, da dabei Daten an Google übertragen werden. Mehr dazu in unserer{' '}
<Link to="/datenschutz" className="underline hover:text-brand-500">
Datenschutzerklärung
</Link>
.
</p>
<div className="flex flex-wrap gap-2 sm:shrink-0">
<button
type="button"
onClick={openSettings}
className="rounded-md border border-slate-300 px-3 py-2 text-sm dark:border-slate-700"
>
Einstellungen
</button>
<button
type="button"
onClick={rejectNonEssential}
className="rounded-md border border-slate-300 px-3 py-2 text-sm dark:border-slate-700"
>
Nur notwendige
</button>
<button
type="button"
onClick={acceptAll}
className="rounded-md bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Alle akzeptieren
</button>
</div>
</div>
</div>
)}
{settingsOpen && <SettingsModal onClose={closeSettings} />}
</>
);
}

View file

@ -0,0 +1,8 @@
import { DynamicIcon as LucideDynamicIcon } from 'lucide-react/dynamic';
// Lädt pro Icon-Name nur den einzelnen benötigten Icon-Chunk nach, statt die
// komplette ~2000-Icon-Bibliothek ins Bundle zu ziehen (siehe lucide-react/dynamic).
export default function DynamicIcon({ name, className = 'h-6 w-6', ...props }) {
if (!name) return null;
return <LucideDynamicIcon name={name} className={className} {...props} />;
}

View file

@ -0,0 +1,35 @@
import { useCookieConsent } from '../context/CookieConsentContext.jsx';
export default function ExternalEmbed({ name, description, className = '', children }) {
const { consent, acceptAll, openSettings } = useCookieConsent();
if (consent.external) {
return children;
}
return (
<div
className={`flex flex-col items-center justify-center gap-3 rounded-xl border border-slate-200 bg-slate-50 p-6 text-center dark:border-slate-800 dark:bg-slate-900/40 ${className}`}
>
<p className="text-sm text-slate-600 dark:text-slate-300">
{description || `${name} wird erst nach deiner Zustimmung geladen, da dabei Daten an Google übertragen werden.`}
</p>
<div className="flex flex-wrap justify-center gap-2">
<button
type="button"
onClick={acceptAll}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Laden erlauben
</button>
<button
type="button"
onClick={openSettings}
className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700"
>
Cookie-Einstellungen
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,110 @@
import { Link } from 'react-router-dom';
import DynamicIcon from './DynamicIcon.jsx';
import { useCookieConsent } from '../context/CookieConsentContext.jsx';
const SHOP_ADDRESS_ENCODED = encodeURIComponent('Boxbrunner Str. 20a, 63916 Amorbach');
const DIRECTIONS_URL = `https://www.google.com/maps/dir/?api=1&destination=${SHOP_ADDRESS_ENCODED}`;
const HOURS = [
{ days: 'Montag Freitag', from: 9 * 60, to: 18 * 60, label: '9:0018:00 Uhr' },
{ days: 'Samstag', from: 10 * 60, to: 13 * 60, label: '10:0013:00 Uhr' },
{ days: 'Sonntag', from: null, to: null, label: 'Geschlossen' },
];
function isOpenNow() {
const now = new Date();
const minutes = now.getHours() * 60 + now.getMinutes();
const day = now.getDay(); // 0 = Sonntag, 1-5 = Mo-Fr, 6 = Sa
const todayHours = day === 0 ? HOURS[2] : day === 6 ? HOURS[1] : HOURS[0];
return todayHours.from !== null && minutes >= todayHours.from && minutes < todayHours.to;
}
export default function Footer() {
const open = isOpenNow();
const { openSettings } = useCookieConsent();
return (
<footer className="border-t border-slate-200 bg-slate-50 py-10 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300">
<div className="mx-auto grid max-w-6xl gap-4 px-4 sm:grid-cols-3 sm:px-6">
<div className="rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex items-center gap-2">
<DynamicIcon name="map-pin" className="h-4 w-4 text-brand-500" />
<p className="font-semibold text-slate-900 dark:text-white">HifiPlanet</p>
</div>
<p>Boxbrunner Str. 20a</p>
<p>63916 Amorbach</p>
<a
href={DIRECTIONS_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-4 flex items-center justify-center gap-1.5 rounded-md bg-brand-500 px-3 py-2 text-xs font-semibold text-white hover:bg-brand-600"
>
<DynamicIcon name="navigation" className="h-3.5 w-3.5" />
Route planen
</a>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex items-center gap-2">
<DynamicIcon name="phone" className="h-4 w-4 text-brand-500" />
<p className="font-semibold text-slate-900 dark:text-white">Kontakt</p>
</div>
<p>
<a href="tel:+4993732062390" className="hover:text-brand-500">09373 20 62 390</a>
</p>
<p>
<a href="mailto:info@hifi-planet-amorbach.de" className="hover:text-brand-500">info@hifi-planet-amorbach.de</a>
</p>
<Link
to="/kontakt"
className="mt-4 flex items-center justify-center gap-1.5 rounded-md border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:border-brand-500 hover:text-brand-600 dark:border-slate-700 dark:text-slate-200"
>
Kontaktformular
</Link>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<DynamicIcon name="clock" className="h-4 w-4 text-brand-500" />
<p className="font-semibold text-slate-900 dark:text-white">Öffnungszeiten</p>
</div>
<span
className={`flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
open
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400'
}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${open ? 'bg-green-500' : 'bg-slate-400'}`} />
{open ? 'Geöffnet' : 'Geschlossen'}
</span>
</div>
<dl className="space-y-1.5">
{HOURS.map((row) => (
<div key={row.days} className="flex items-center justify-between gap-3">
<dt className="text-slate-500 dark:text-slate-400">{row.days}</dt>
<dd className="font-medium text-slate-700 dark:text-slate-200">{row.label}</dd>
</div>
))}
</dl>
</div>
</div>
<div className="mx-auto mt-8 max-w-6xl border-t border-slate-200 px-4 pt-6 text-center text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400 sm:px-6">
<p className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<span>© {new Date().getFullYear()} HifiPlanet. Alle Angaben ohne Gewähr.</span>
<span>·</span>
<Link to="/impressum" className="hover:text-brand-500">Impressum</Link>
<span>·</span>
<Link to="/datenschutz" className="hover:text-brand-500">Datenschutz</Link>
<span>·</span>
<Link to="/agb" className="hover:text-brand-500">AGB</Link>
<span>·</span>
<button type="button" onClick={openSettings} className="hover:text-brand-500">
Cookie-Einstellungen
</button>
</p>
</div>
</footer>
);
}

View file

@ -0,0 +1,63 @@
import { useMemo, useState } from 'react';
import { iconNames } from 'lucide-react/dynamic';
import DynamicIcon from './DynamicIcon.jsx';
const ALL_ICON_NAMES = [...iconNames].sort();
const DEFAULT_LIMIT = 120;
const SEARCH_LIMIT = 200;
export default function IconPicker({ value, onChange }) {
const [search, setSearch] = useState('');
const results = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return ALL_ICON_NAMES.slice(0, DEFAULT_LIMIT);
return ALL_ICON_NAMES.filter((name) => name.includes(q)).slice(0, SEARCH_LIMIT);
}, [search]);
return (
<div>
<div className="mb-2 flex items-center gap-3 rounded-md border border-slate-300 p-2 dark:border-slate-700">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400">
{value ? <DynamicIcon name={value} className="h-6 w-6" /> : <span className="text-xs text-slate-400">?</span>}
</div>
<div className="text-sm">
<p className="font-medium text-slate-800 dark:text-slate-100">{value || 'Kein Icon gewählt'}</p>
<p className="text-xs text-slate-400">{ALL_ICON_NAMES.length} Icons verfügbar</p>
</div>
</div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Icon suchen (z. B. car, shield, zap)…"
className="mb-2 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
<div className="grid max-h-64 grid-cols-6 gap-1 overflow-y-auto rounded-md border border-slate-200 p-2 sm:grid-cols-8 dark:border-slate-700">
{results.map((name) => (
<button
type="button"
key={name}
title={name}
onClick={() => onChange(name)}
className={`flex h-10 w-10 items-center justify-center rounded-md transition ${
value === name
? 'bg-brand-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
<DynamicIcon name={name} className="h-5 w-5" />
</button>
))}
{results.length === 0 && (
<p className="col-span-full py-4 text-center text-sm text-slate-400">Keine Icons gefunden.</p>
)}
</div>
{!search && (
<p className="mt-1 text-xs text-slate-400">Tipp: Tippe einen Suchbegriff ein, um alle {ALL_ICON_NAMES.length} Icons zu durchsuchen.</p>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
import { useState } from 'react';
import { api } from '../api/client.js';
export default function ImageUploadField({ value, onChange, label = 'Bild' }) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const handleFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', file);
const result = await api.post('/uploads', formData);
onChange(result.path);
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>
<div className="flex items-center gap-3">
{value && <img src={value} alt="" className="h-12 w-12 rounded-md object-cover" />}
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFile} className="text-sm" />
{uploading && <span className="text-sm text-slate-500">Lädt hoch</span>}
</div>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,22 @@
const TEXT = (
<>
Wartungsmodus aktiv Besucher sehen aktuell eine Wartungsmeldung. Du siehst diese Seite nur, weil
du die Berechtigung "Wartungsmodus umgehen" hast.
</>
);
export default function MaintenanceBypassBanner({ inline = false }) {
if (inline) {
return (
<div className="mb-8 rounded-lg bg-amber-500 px-4 py-2 text-center text-xs font-semibold text-white">
{TEXT}
</div>
);
}
return (
<div className="bg-amber-500 px-4 py-2 text-center text-xs font-semibold text-white">
{TEXT}
</div>
);
}

View file

@ -0,0 +1,33 @@
const WrenchIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-10 w-10">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.7 6.3a4 4 0 0 0-5.5 4.5L3 17l2 2 6.2-6.2a4 4 0 0 0 4.5-5.5l-2.6 2.6-2-2 2.6-2.6Z" />
</svg>
);
export default function MaintenanceNotice({ message, fullScreen = false }) {
const content = (
<div className="mx-auto max-w-md text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400">
<WrenchIcon />
</div>
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Wartungsarbeiten</h1>
<p className="text-slate-600 dark:text-slate-300">
{message || 'Dieser Bereich wird gerade aktualisiert. Bitte schau in Kürze wieder vorbei.'}
</p>
</div>
);
if (fullScreen) {
return (
<div className="flex min-h-screen items-center justify-center bg-white px-4 dark:bg-slate-950">
{content}
</div>
);
}
return (
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6">
{content}
</div>
);
}

View file

@ -0,0 +1,46 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import ThemeToggle from './ThemeToggle.jsx';
import logo from '../assets/logo.png';
export default function Navbar() {
const [open, setOpen] = useState(false);
return (
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3 sm:px-6">
<Link to="/" className="flex items-center rounded-lg px-2 py-1 dark:bg-white">
<img src={logo} alt="HifiPlanet" className="h-8 w-auto sm:h-10" />
</Link>
<nav className="hidden items-center gap-6 text-sm font-medium text-slate-600 dark:text-slate-300 md:flex">
<Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</Link>
<Link to="/leistungen" className="hover:text-brand-500">Leistungen</Link>
<Link to="/kontakt" className="hover:text-brand-500">Kontakt</Link>
<ThemeToggle />
</nav>
<div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<button
onClick={() => setOpen((o) => !o)}
aria-label="Menü öffnen"
className="rounded-md p-2 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-6 w-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
{open && (
<nav className="flex flex-col gap-1 border-t border-slate-200 px-4 py-3 text-sm font-medium text-slate-600 dark:border-slate-800 dark:text-slate-300 md:hidden">
<Link to="/fahrzeuge" onClick={() => setOpen(false)} className="rounded-md px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-800">Fahrzeuge</Link>
<Link to="/leistungen" onClick={() => setOpen(false)} className="rounded-md px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-800">Leistungen</Link>
<Link to="/kontakt" onClick={() => setOpen(false)} className="rounded-md px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-800">Kontakt</Link>
</nav>
)}
</header>
);
}

View file

@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
export default function ProtectedRoute() {
const { user, loading } = useAuth();
if (loading) {
return <div className="flex min-h-screen items-center justify-center text-slate-500">Lädt</div>;
}
if (!user) {
return <Navigate to="/admin/login" replace />;
}
return <Outlet />;
}

View file

@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import Navbar from './Navbar.jsx';
import Footer from './Footer.jsx';
import ScrollProgress from './ScrollProgress.jsx';
import MaintenanceNotice from './MaintenanceNotice.jsx';
import MaintenanceBypassBanner from './MaintenanceBypassBanner.jsx';
import CookieConsentBanner from './CookieConsentBanner.jsx';
import { MaintenanceProvider } from '../context/MaintenanceContext.jsx';
import { useAuth } from '../context/AuthContext.jsx';
import { api } from '../api/client.js';
const FALLBACK_STATUS = {
global: { enabled: false, message: null },
services: { enabled: false, message: null },
vehicles: { enabled: false, message: null },
};
// Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG
// "leicht erkennbar und unmittelbar erreichbar" bleiben auch während der
// generellen Wartung darf diese Erreichbarkeit nicht durch die Wartungsseite
// verdeckt werden.
const LEGAL_PATHS = ['/impressum', '/datenschutz', '/agb'];
export default function PublicLayout() {
const [status, setStatus] = useState(null);
const { loading: authLoading, hasPermission } = useAuth();
const location = useLocation();
useEffect(() => {
api.get('/maintenance').then(setStatus).catch(() => setStatus(FALLBACK_STATUS));
}, []);
if (status === null || authLoading) {
return <div className="min-h-screen bg-white dark:bg-slate-950" />;
}
const bypass = hasPermission('maintenance.bypass');
const isLegalPage = LEGAL_PATHS.includes(location.pathname);
if (status.global.enabled && !bypass && !isLegalPage) {
return <MaintenanceNotice message={status.global.message} fullScreen />;
}
return (
<MaintenanceProvider value={{ ...status, bypass }}>
<div className="flex min-h-screen flex-col bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
{bypass && status.global.enabled && <MaintenanceBypassBanner />}
<ScrollProgress />
<Navbar />
<main className="flex-1">
<Outlet />
</main>
<Footer />
<CookieConsentBanner />
</div>
</MaintenanceProvider>
);
}

View file

@ -0,0 +1,22 @@
import { useAuth } from '../context/AuthContext.jsx';
export default function RequirePermission({ permission, children }) {
const { hasPermission } = useAuth();
const permissions = Array.isArray(permission) ? permission : [permission];
const allowed = permissions.some((p) => hasPermission(p));
if (!allowed) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center dark:border-slate-800 dark:bg-slate-900">
<h1 className="mb-2 text-lg font-bold text-slate-900 dark:text-white">Keine Berechtigung</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Du hast keinen Zugriff auf diesen Bereich. Bitte wende dich an einen Administrator, falls du hier
Zugriff brauchst.
</p>
</div>
);
}
return children;
}

View file

@ -0,0 +1,50 @@
import { motion } from 'framer-motion';
const EASE = [0.16, 1, 0.3, 1];
const offsetFor = {
up: { y: 40 },
left: { x: -56 },
right: { x: 56 },
};
function buildVariants(direction) {
const offset = offsetFor[direction] || offsetFor.up;
return {
hidden: { opacity: 0, scale: 0.96, filter: 'blur(8px)', ...offset },
visible: { opacity: 1, scale: 1, x: 0, y: 0, filter: 'blur(0px)' },
};
}
const shineVariants = {
hidden: { x: '-130%' },
visible: { x: '230%', transition: { duration: 1.1, ease: 'easeInOut', delay: 0.4 } },
};
// Trigger-based reveal: each element fades/slides/un-blurs into place the
// first time it enters the viewport, then stays put. Calm and predictable
// on repeated scrolling in either direction (no re-triggering, no jitter).
export default function Reveal({ children, direction = 'up', className = '', index = 0, shine = false }) {
const delay = Math.min(index * 0.09, 0.45);
return (
<motion.div
className={className}
style={{ position: 'relative', overflow: 'hidden' }}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.25, margin: '-100px' }}
variants={buildVariants(direction)}
transition={{ duration: 0.75, ease: EASE, delay }}
>
{children}
{shine && (
<motion.div
aria-hidden
variants={shineVariants}
className="pointer-events-none absolute inset-y-0 -left-1/3 w-1/3 rotate-12 bg-gradient-to-r from-transparent via-white/40 to-transparent"
/>
)}
</motion.div>
);
}

View file

@ -0,0 +1,13 @@
import { motion, useScroll, useSpring } from 'framer-motion';
export default function ScrollProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, { stiffness: 200, damping: 30, restDelta: 0.001 });
return (
<motion.div
style={{ scaleX }}
className="fixed left-0 top-0 z-40 h-0.5 w-full origin-left bg-brand-500"
/>
);
}

View file

@ -0,0 +1,14 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export default function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
// 'instant' statt 'auto', da 'auto' die global gesetzte CSS-Regel scroll-behavior: smooth
// respektiert und dadurch animiert statt sofort zu springen
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [pathname]);
return null;
}

View file

@ -0,0 +1,11 @@
export default function StarRating({ count = 5, className = 'h-4 w-4' }) {
return (
<div className="flex gap-0.5 text-amber-400">
{Array.from({ length: count }).map((_, i) => (
<svg key={i} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
<path d="M10 1.5l2.6 5.6 6.1.6-4.6 4.1 1.3 6-5.4-3.1-5.4 3.1 1.3-6-4.6-4.1 6.1-.6L10 1.5z" />
</svg>
))}
</div>
);
}

View file

@ -0,0 +1,136 @@
import { useLayoutEffect, useRef } from 'react';
import StarRating from './StarRating.jsx';
const SPEED_PX_PER_SEC = 70;
// Läuft als Endlos-Marquee (links -> rechts): der Track enthält die Liste doppelt
// hintereinander. Die Position wird per rAF selbst verwaltet (statt CSS-Animation),
// damit sie sich beim Hovern anhalten UND per Drag manuell verschieben lässt.
export default function TestimonialSlider({ testimonials }) {
const viewportRef = useRef(null);
const trackRef = useRef(null);
const state = useRef({
pos: 0,
half: 0,
hovering: false,
dragging: false,
dragStartX: 0,
dragStartPos: 0,
lastTime: null,
rafId: null,
}).current;
useLayoutEffect(() => {
const track = trackRef.current;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const applyPos = () => {
track.style.transform = `translateX(${state.pos}px)`;
};
const normalize = (pos) => {
if (state.half <= 0) return pos;
while (pos > 0) pos -= state.half;
while (pos <= -state.half) pos += state.half;
return pos;
};
const measure = () => {
state.half = track.scrollWidth / 2;
if (state.pos === 0) {
state.pos = -state.half;
} else {
state.pos = normalize(state.pos);
}
applyPos();
};
measure();
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(track);
const tick = (time) => {
if (state.lastTime === null) state.lastTime = time;
const dt = (time - state.lastTime) / 1000;
state.lastTime = time;
if (!state.dragging && !state.hovering && !reduceMotion) {
state.pos = normalize(state.pos + SPEED_PX_PER_SEC * dt);
applyPos();
}
state.rafId = requestAnimationFrame(tick);
};
state.rafId = requestAnimationFrame(tick);
const viewport = viewportRef.current;
const onPointerEnter = () => {
state.hovering = true;
};
const onPointerLeave = () => {
state.hovering = false;
};
const onPointerDown = (e) => {
state.dragging = true;
state.dragStartX = e.clientX;
state.dragStartPos = state.pos;
viewport.setPointerCapture(e.pointerId);
viewport.classList.add('cursor-grabbing');
viewport.classList.remove('cursor-grab');
};
const onPointerMove = (e) => {
if (!state.dragging) return;
state.pos = normalize(state.dragStartPos + (e.clientX - state.dragStartX));
applyPos();
};
const endDrag = (e) => {
if (!state.dragging) return;
state.dragging = false;
if (viewport.hasPointerCapture(e.pointerId)) {
viewport.releasePointerCapture(e.pointerId);
}
viewport.classList.remove('cursor-grabbing');
viewport.classList.add('cursor-grab');
};
viewport.addEventListener('pointerenter', onPointerEnter);
viewport.addEventListener('pointerleave', onPointerLeave);
viewport.addEventListener('pointerdown', onPointerDown);
viewport.addEventListener('pointermove', onPointerMove);
viewport.addEventListener('pointerup', endDrag);
viewport.addEventListener('pointercancel', endDrag);
return () => {
cancelAnimationFrame(state.rafId);
resizeObserver.disconnect();
viewport.removeEventListener('pointerenter', onPointerEnter);
viewport.removeEventListener('pointerleave', onPointerLeave);
viewport.removeEventListener('pointerdown', onPointerDown);
viewport.removeEventListener('pointermove', onPointerMove);
viewport.removeEventListener('pointerup', endDrag);
viewport.removeEventListener('pointercancel', endDrag);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testimonials.length]);
const track = [...testimonials, ...testimonials];
return (
<div
ref={viewportRef}
className="marquee-viewport cursor-grab touch-pan-y select-none overflow-hidden"
>
<div ref={trackRef} className="marquee-track flex w-max gap-5">
{track.map((t, i) => (
<div key={`${t.name}-${i}`} className="w-64 shrink-0 sm:w-72 lg:w-80">
<div className="flex h-full flex-col rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<StarRating className="mb-3 h-4 w-4" />
<p className="mb-4 flex-1 text-sm text-slate-600 dark:text-slate-300">{t.text}"</p>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{t.name}</p>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
import { useTheme } from '../context/ThemeContext.jsx';
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
aria-label="Theme umschalten"
className="rounded-full p-2 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 transition"
>
{theme === 'dark' ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="M12 3a1 1 0 011 1v1a1 1 0 11-2 0V4a1 1 0 011-1zm0 15a5 5 0 100-10 5 5 0 000 10zm9-6a1 1 0 010 2h-1a1 1 0 110-2h1zM4 12a1 1 0 010 2H3a1 1 0 110-2h1zm14.36-7.36a1 1 0 011.42 1.42l-.71.7a1 1 0 11-1.41-1.4l.7-.72zM5.64 18.36a1 1 0 011.41 1.41l-.7.71a1 1 0 11-1.42-1.41l.71-.71zm12.72 0l.71.7a1 1 0 11-1.41 1.42l-.71-.71a1 1 0 011.41-1.41zM6.34 5.64l-.7-.7A1 1 0 117.05 3.5l.7.71A1 1 0 016.34 5.64zM12 6a1 1 0 011-1v0a1 1 0 01-1 1zm0 12a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path fillRule="evenodd" d="M9.53 2.25a.75.75 0 01.19.82 8.25 8.25 0 0011.03 11.03.75.75 0 011.01.99A10.5 10.5 0 1112.9 2.1a.75.75 0 011.63-.85z" clipRule="evenodd" />
</svg>
)}
</button>
);
}

View file

@ -0,0 +1,54 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { api } from '../api/client.js';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.get('/auth/me')
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
const login = async (username, password) => {
const data = await api.post('/auth/login', { username, password });
if (data.requires_2fa) {
return { requires2fa: true };
}
setUser(data);
return { requires2fa: false };
};
const verifyTwoFactor = async (code) => {
const data = await api.post('/auth/verify-2fa', { code });
setUser(data);
};
const logout = async () => {
await api.post('/auth/logout', {});
setUser(null);
};
const refreshUser = async () => {
const data = await api.get('/auth/me');
setUser(data);
};
const hasPermission = (permission) =>
!!user && (user.is_super_admin || user.permissions?.includes(permission));
return (
<AuthContext.Provider value={{ user, loading, login, verifyTwoFactor, logout, refreshUser, hasPermission }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View file

@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
const STORAGE_KEY = 'hifiplanet-cookie-consent';
const defaultConsent = { necessary: true, external: false };
const CookieConsentContext = createContext({
consent: defaultConsent,
decided: false,
settingsOpen: false,
acceptAll: () => {},
rejectNonEssential: () => {},
saveCustom: () => {},
openSettings: () => {},
closeSettings: () => {},
});
export function CookieConsentProvider({ children }) {
const [consent, setConsent] = useState(defaultConsent);
const [decided, setDecided] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
setConsent({ necessary: true, external: !!parsed.external });
setDecided(true);
}
} catch {
// beschädigter Storage-Eintrag -> Banner wird erneut angezeigt
}
}, []);
const persist = useCallback((next) => {
setConsent(next);
setDecided(true);
setSettingsOpen(false);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...next, decidedAt: new Date().toISOString() }));
}, []);
const acceptAll = useCallback(() => persist({ necessary: true, external: true }), [persist]);
const rejectNonEssential = useCallback(() => persist({ necessary: true, external: false }), [persist]);
const saveCustom = useCallback((partial) => persist({ necessary: true, external: !!partial.external }), [persist]);
const openSettings = useCallback(() => setSettingsOpen(true), []);
const closeSettings = useCallback(() => setSettingsOpen(false), []);
return (
<CookieConsentContext.Provider
value={{ consent, decided, settingsOpen, acceptAll, rejectNonEssential, saveCustom, openSettings, closeSettings }}
>
{children}
</CookieConsentContext.Provider>
);
}
export function useCookieConsent() {
return useContext(CookieConsentContext);
}

View file

@ -0,0 +1,14 @@
import { createContext, useContext } from 'react';
const MaintenanceContext = createContext({
global: { enabled: false, message: null },
services: { enabled: false, message: null },
vehicles: { enabled: false, message: null },
bypass: false,
});
export const MaintenanceProvider = MaintenanceContext.Provider;
export function useMaintenance() {
return useContext(MaintenanceContext);
}

View file

@ -0,0 +1,24 @@
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
const stored = localStorage.getItem('hifi-theme');
if (stored) return stored;
return 'light';
});
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('hifi-theme', theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
return useContext(ThemeContext);
}

View file

@ -0,0 +1,38 @@
import { useEffect } from 'react';
function setMetaTag(name, content, attr = 'name') {
if (!content) return;
let tag = document.head.querySelector(`meta[${attr}="${name}"]`);
if (!tag) {
tag = document.createElement('meta');
tag.setAttribute(attr, name);
document.head.appendChild(tag);
}
tag.setAttribute('content', content);
}
function setCanonical(path) {
let link = document.head.querySelector('link[rel="canonical"]');
if (!link) {
link = document.createElement('link');
link.setAttribute('rel', 'canonical');
document.head.appendChild(link);
}
link.setAttribute('href', `https://hifi-planet.de${path}`);
}
export default function usePageMeta({ title, description, path }) {
useEffect(() => {
if (title) {
document.title = `${title} | HifiPlanet`;
setMetaTag('og:title', `${title} | HifiPlanet`, 'property');
}
if (description) {
setMetaTag('description', description);
setMetaTag('og:description', description, 'property');
}
if (path) {
setCanonical(path);
}
}, [title, description, path]);
}

12
hifi-src/src/index.css Normal file
View file

@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
}
.marquee-viewport {
-webkit-mask-image: linear-gradient(to right, transparent, black 6%, black 94%, transparent);
mask-image: linear-gradient(to right, transparent, black 6%, black 94%, transparent);
}

22
hifi-src/src/main.jsx Normal file
View file

@ -0,0 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import { ThemeProvider } from './context/ThemeContext.jsx';
import { AuthProvider } from './context/AuthContext.jsx';
import { CookieConsentProvider } from './context/CookieConsentContext.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter basename="/hifi">
<ThemeProvider>
<AuthProvider>
<CookieConsentProvider>
<App />
</CookieConsentProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
);

View file

@ -0,0 +1,358 @@
import { useState } from 'react';
import QRCode from 'qrcode';
import { api } from '../../api/client.js';
import { useAuth } from '../../context/AuthContext.jsx';
const emptyPasswordForm = {
current_password: '',
new_password: '',
new_password_confirmation: '',
two_factor_code: '',
};
export default function AccountSettings() {
const { user, refreshUser } = useAuth();
const [phase, setPhase] = useState('idle'); // idle | setup | recovery-codes | disable | regenerate
const [qrDataUrl, setQrDataUrl] = useState('');
const [secret, setSecret] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
const [passwordBusy, setPasswordBusy] = useState(false);
const handleChangePassword = async (e) => {
e.preventDefault();
setPasswordError('');
setPasswordSuccess('');
if (passwordForm.new_password !== passwordForm.new_password_confirmation) {
setPasswordError('Die neuen Passwörter stimmen nicht überein');
return;
}
setPasswordBusy(true);
try {
await api.post('/auth/change-password', passwordForm);
setPasswordForm(emptyPasswordForm);
setPasswordSuccess('Passwort erfolgreich geändert.');
} catch (err) {
setPasswordError(err.message);
} finally {
setPasswordBusy(false);
}
};
const startSetup = async () => {
setError('');
setBusy(true);
try {
const data = await api.get('/auth/2fa/setup');
setSecret(data.secret);
setQrDataUrl(await QRCode.toDataURL(data.otpauth_url));
setCode('');
setPhase('setup');
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const confirmSetup = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
const data = await api.post('/auth/2fa/enable', { code });
setRecoveryCodes(data.recovery_codes);
setPhase('recovery-codes');
await refreshUser();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const finishRecoveryCodes = () => {
setPhase('idle');
setRecoveryCodes([]);
setPassword('');
};
const handleDisable = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await api.post('/auth/2fa/disable', { password });
setPassword('');
setPhase('idle');
await refreshUser();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const handleRegenerate = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
const data = await api.post('/auth/2fa/recovery-codes', { password });
setRecoveryCodes(data.recovery_codes);
setPhase('recovery-codes');
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div className="max-w-xl">
<h1 className="mb-1 text-xl font-bold text-slate-900 dark:text-white">Mein Konto</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Angemeldet als <strong>{user?.username}</strong>{user?.is_super_admin ? ' (Super-Admin)' : ''}.
</p>
<div className="mb-6 rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-1 font-semibold text-slate-900 dark:text-white">Passwort ändern</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Vergib ein neues Passwort für dein Konto.
{user?.two_factor_enabled && ' Da 2FA aktiv ist, musst du die Änderung zusätzlich mit deinem Code bestätigen.'}
</p>
<form onSubmit={handleChangePassword}>
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Aktuelles Passwort
</label>
<input
type="password"
value={passwordForm.current_password}
onChange={(e) => setPasswordForm({ ...passwordForm, current_password: e.target.value })}
required
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Neues Passwort
</label>
<input
type="password"
value={passwordForm.new_password}
onChange={(e) => setPasswordForm({ ...passwordForm, new_password: e.target.value })}
required
minLength={8}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Neues Passwort wiederholen
</label>
<input
type="password"
value={passwordForm.new_password_confirmation}
onChange={(e) => setPasswordForm({ ...passwordForm, new_password_confirmation: e.target.value })}
required
minLength={8}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{user?.two_factor_enabled && (
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
2FA-Code zur Bestätigung
</label>
<input
value={passwordForm.two_factor_code}
onChange={(e) => setPasswordForm({ ...passwordForm, two_factor_code: e.target.value })}
required
inputMode="numeric"
placeholder="123456 oder Wiederherstellungscode"
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
)}
{passwordError && <p className="mb-4 text-sm text-red-600">{passwordError}</p>}
{passwordSuccess && <p className="mb-4 text-sm text-green-600 dark:text-green-400">{passwordSuccess}</p>}
<button
type="submit"
disabled={passwordBusy}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{passwordBusy ? 'Wird geändert…' : 'Passwort ändern'}
</button>
</form>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-1 font-semibold text-slate-900 dark:text-white">Zwei-Faktor-Authentifizierung</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Schützt dein Konto zusätzlich mit einem Code aus der Google Authenticator App.
</p>
{phase === 'idle' && (
<>
<p className="mb-4 text-sm">
Status:{' '}
{user?.two_factor_enabled ? (
<span className="font-semibold text-green-600 dark:text-green-400">Aktiv</span>
) : (
<span className="font-semibold text-slate-500 dark:text-slate-400">Nicht aktiv</span>
)}
</p>
{user?.two_factor_enabled ? (
<div className="flex flex-wrap gap-2">
<button
onClick={() => { setPhase('regenerate'); setError(''); }}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800"
>
Neue Wiederherstellungscodes erzeugen
</button>
<button
onClick={() => { setPhase('disable'); setError(''); }}
className="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-900 dark:hover:bg-red-900/20"
>
2FA deaktivieren
</button>
</div>
) : (
<button
onClick={startSetup}
disabled={busy}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
2FA aktivieren
</button>
)}
</>
)}
{phase === 'setup' && (
<form onSubmit={confirmSetup}>
<ol className="mb-4 list-decimal space-y-2 pl-5 text-sm text-slate-700 dark:text-slate-300">
<li>Öffne die Google Authenticator App (oder eine kompatible App).</li>
<li>Scanne den folgenden QR-Code:</li>
</ol>
<div className="mb-4 flex justify-center">
{qrDataUrl && <img src={qrDataUrl} alt="QR-Code für Zwei-Faktor-Authentifizierung" className="h-48 w-48 rounded-lg border border-slate-200 dark:border-slate-700" />}
</div>
<p className="mb-4 text-center text-xs text-slate-500 dark:text-slate-400">
Geht das Scannen nicht? Gib diesen Code manuell ein: <br />
<code className="mt-1 inline-block rounded bg-slate-100 px-2 py-1 font-mono tracking-wider dark:bg-slate-800">{secret}</code>
</p>
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Bestätigungscode aus der App
</label>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
required
autoFocus
inputMode="numeric"
placeholder="123456"
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-center text-lg tracking-widest dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={busy} className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60">
Bestätigen & aktivieren
</button>
<button type="button" onClick={() => setPhase('idle')} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
</div>
</form>
)}
{phase === 'recovery-codes' && (
<div>
<p className="mb-3 text-sm font-semibold text-slate-900 dark:text-white">
Wiederherstellungscodes jetzt sicher speichern!
</p>
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
Jeder Code funktioniert einmalig, falls du keinen Zugriff mehr auf die Authenticator-App hast.
Diese Codes werden nur jetzt angezeigt.
</p>
<div className="mb-4 grid grid-cols-2 gap-2 rounded-md bg-slate-100 p-4 font-mono text-sm dark:bg-slate-800">
{recoveryCodes.map((c) => <span key={c}>{c}</span>)}
</div>
<button onClick={finishRecoveryCodes} className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
Ich habe die Codes gespeichert
</button>
</div>
)}
{phase === 'disable' && (
<form onSubmit={handleDisable}>
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Passwort zur Bestätigung
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={busy} className="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-60">
2FA jetzt deaktivieren
</button>
<button type="button" onClick={() => { setPhase('idle'); setPassword(''); }} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
</div>
</form>
)}
{phase === 'regenerate' && (
<form onSubmit={handleRegenerate}>
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
Die bisherigen Wiederherstellungscodes werden ungültig.
</p>
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Passwort zur Bestätigung
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={busy} className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60">
Neue Codes erzeugen
</button>
<button type="button" onClick={() => { setPhase('idle'); setPassword(''); }} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
</div>
</form>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,235 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client.js';
import { useAuth } from '../../context/AuthContext.jsx';
const emptyForm = { username: '', password: '', is_super_admin: false, permissions: [], group_ids: [] };
export default function AdminUsers() {
const { user: currentUser, hasPermission } = useAuth();
const [users, setUsers] = useState([]);
const [catalog, setCatalog] = useState([]);
const [groups, setGroups] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState(null);
const [error, setError] = useState('');
const load = () => {
api.get('/admin-users').then(setUsers);
api.get('/permissions/catalog').then(setCatalog);
if (hasPermission('permission_groups.manage') || hasPermission('users.manage')) {
api.get('/permission-groups').then(setGroups);
}
};
useEffect(load, []);
const startEdit = (u) => {
setEditingId(u.id);
setForm({
username: u.username,
password: '',
is_super_admin: u.is_super_admin,
permissions: u.permissions,
group_ids: u.groups.map((g) => g.id),
});
};
const resetForm = () => {
setEditingId(null);
setForm(emptyForm);
};
const togglePermission = (key) => {
setForm((f) => ({
...f,
permissions: f.permissions.includes(key) ? f.permissions.filter((p) => p !== key) : [...f.permissions, key],
}));
};
const toggleGroup = (id) => {
setForm((f) => ({
...f,
group_ids: f.group_ids.includes(id) ? f.group_ids.filter((g) => g !== id) : [...f.group_ids, id],
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
if (editingId) {
await api.put(`/admin-users/${editingId}`, form);
} else {
if (form.password.length < 8) {
setError('Passwort muss mindestens 8 Zeichen lang sein');
return;
}
await api.post('/admin-users', form);
}
resetForm();
load();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (id) => {
if (!confirm('Diesen Benutzer wirklich löschen?')) return;
try {
await api.delete(`/admin-users/${id}`);
load();
} catch (err) {
alert(err.message);
}
};
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-1 text-xl font-bold text-slate-900 dark:text-white">Benutzer</h1>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Jeder Mitarbeiter bekommt ein eigenes Konto mit genau den Rechten, die er braucht.
</p>
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-4 py-2">Benutzername</th>
<th className="px-4 py-2">Rolle</th>
<th className="px-4 py-2">Berechtigungsgruppen</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{users.map((u) => (
<tr key={u.id} className="bg-white dark:bg-slate-950">
<td className="px-4 py-2 font-medium text-slate-800 dark:text-slate-100">
{u.username}
{u.id === currentUser?.id && <span className="ml-1 text-xs text-slate-400">(du)</span>}
</td>
<td className="px-4 py-2">
{u.is_super_admin ? (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/40 dark:text-brand-300">
Super-Admin
</span>
) : (
<span className="text-slate-500 dark:text-slate-400">{u.permissions.length} Einzelrechte</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex flex-wrap gap-1">
{u.groups.map((g) => (
<span key={g.id} className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{g.name}
</span>
))}
{u.groups.length === 0 && ''}
</div>
</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(u)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
{u.id !== currentUser?.id && (
<button onClick={() => handleDelete(u.id)} className="text-red-600 hover:underline">Löschen</button>
)}
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-slate-400">Noch keine Benutzer angelegt.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="font-bold text-slate-900 dark:text-white">{editingId ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Benutzername *</label>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Passwort {editingId ? '(leer lassen = unverändert)' : '*'}
</label>
<input
type="password"
required={!editingId}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{currentUser?.is_super_admin && (
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={form.is_super_admin}
onChange={(e) => setForm({ ...form, is_super_admin: e.target.checked })}
className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
Super-Admin (hat automatisch alle Rechte)
</label>
)}
{!form.is_super_admin && (
<>
<div>
<p className="mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">Einzelne Berechtigungen</p>
<div className="space-y-1.5 rounded-md border border-slate-200 p-3 dark:border-slate-700">
{catalog.map((p) => (
<label key={p.key} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={form.permissions.includes(p.key)}
onChange={() => togglePermission(p.key)}
className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
{p.label}
</label>
))}
</div>
</div>
<div>
<p className="mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">Berechtigungsgruppen</p>
<div className="space-y-1.5 rounded-md border border-slate-200 p-3 dark:border-slate-700">
{groups.map((g) => (
<label key={g.id} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={form.group_ids.includes(g.id)}
onChange={() => toggleGroup(g.id)}
className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
{g.name}
</label>
))}
{groups.length === 0 && <p className="text-sm text-slate-400">Noch keine Berechtigungsgruppen angelegt.</p>}
</div>
</div>
</>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client.js';
const emptyForm = { name: '', sort_order: 0 };
export default function Brands() {
const [brands, setBrands] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState(null);
const [error, setError] = useState('');
const load = () => api.get('/brands').then(setBrands);
useEffect(() => {
load();
}, []);
const startEdit = (brand) => {
setEditingId(brand.id);
setForm({ name: brand.name, sort_order: brand.sort_order });
};
const resetForm = () => {
setEditingId(null);
setForm(emptyForm);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
if (editingId) {
await api.put(`/brands/${editingId}`, form);
} else {
await api.post('/brands', form);
}
resetForm();
load();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (id) => {
if (!confirm('Marke inkl. aller Modelle/Pakete/Produkte wirklich löschen?')) return;
await api.delete(`/brands/${id}`);
load();
};
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">Marken</h1>
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Sortierung</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{brands.map((brand) => (
<tr key={brand.id} className="bg-white dark:bg-slate-950">
<td className="px-4 py-2 font-medium text-slate-800 dark:text-slate-100">
<Link to={`/marken/${brand.slug}`} target="_blank" className="hover:text-brand-500">
{brand.name}
</Link>
</td>
<td className="px-4 py-2">{brand.sort_order}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(brand)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(brand.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{brands.length === 0 && (
<tr><td colSpan={3} className="px-4 py-6 text-center text-slate-400">Noch keine Marken angelegt.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="font-bold text-slate-900 dark:text-white">{editingId ? 'Marke bearbeiten' : 'Neue Marke'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name *</label>
<input
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Sortierung</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client.js';
import { useAuth } from '../../context/AuthContext.jsx';
const formatPrice = (value) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
const statusLabel = { new: 'Neu', in_progress: 'In Bearbeitung', done: 'Erledigt' };
const statusBadge = {
new: 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300',
in_progress: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
done: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
};
const TrashIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m3 0-.8 12.1a1 1 0 0 1-1 .9H7.8a1 1 0 0 1-1-.9L6 7h12ZM10 11v6M14 11v6" />
</svg>
);
export default function ContactRequests() {
const { hasPermission } = useAuth();
const [requests, setRequests] = useState([]);
const [filter, setFilter] = useState('all');
const load = () => api.get('/contact').then(setRequests);
useEffect(() => {
load();
}, []);
const handleStatusChange = async (id, status) => {
await api.patch(`/contact/${id}`, { status });
load();
};
const handleDelete = async (id) => {
if (!confirm('Diese Kontaktanfrage wirklich unwiderruflich löschen?')) return;
await api.delete(`/contact/${id}`);
load();
};
const counts = useMemo(
() => ({
all: requests.length,
new: requests.filter((r) => r.status === 'new').length,
in_progress: requests.filter((r) => r.status === 'in_progress').length,
done: requests.filter((r) => r.status === 'done').length,
}),
[requests]
);
const filtered = filter === 'all' ? requests : requests.filter((r) => r.status === filter);
const tabs = [
{ value: 'all', label: 'Alle' },
{ value: 'new', label: 'Neu' },
{ value: 'in_progress', label: 'In Bearbeitung' },
{ value: 'done', label: 'Erledigt' },
];
return (
<div>
<h1 className="mb-1 text-xl font-bold text-slate-900 dark:text-white">Kontaktanfragen</h1>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Alle Anfragen deiner Kunden. Wähle oben einen Reiter, um nur bestimmte Anfragen zu sehen.
</p>
<div className="mb-5 flex flex-wrap gap-2">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => setFilter(tab.value)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
filter === tab.value
? 'bg-brand-500 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
}`}
>
{tab.label} ({counts[tab.value]})
</button>
))}
</div>
<div className="space-y-3">
{filtered.map((r) => (
<div key={r.id} className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
<div>
<p className="font-semibold text-slate-800 dark:text-slate-100">{r.name} · {r.email}{r.phone ? ` · ${r.phone}` : ''}</p>
<p className="text-xs text-slate-400">{new Date(r.created_at.replace(' ', 'T')).toLocaleString('de-DE')}</p>
</div>
<div className="flex items-center gap-2">
<select
value={r.status}
onChange={(e) => handleStatusChange(r.id, e.target.value)}
className={`rounded-full border-0 px-3 py-1 text-xs font-medium ${statusBadge[r.status]}`}
>
{Object.entries(statusLabel).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
{hasPermission('contact.delete') && (
<button
onClick={() => handleDelete(r.id)}
aria-label="Anfrage löschen"
title="Anfrage löschen"
className="rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<TrashIcon />
</button>
)}
</div>
</div>
{(r.brand_name || r.model_name || r.package_name || r.product_name) && (
<p className="mb-2 text-sm text-slate-500 dark:text-slate-400">
{[r.brand_name, r.model_name, r.package_name, r.product_name].filter(Boolean).join(' · ')}
</p>
)}
{r.selected_upgrades?.length > 0 && (
<div className="mb-2 rounded-md bg-brand-50 p-2 text-sm text-brand-800 dark:bg-brand-900/20 dark:text-brand-200">
<p className="font-semibold">Gewünschte Upgrades:</p>
<ul className="list-disc pl-5">
{r.selected_upgrades.map((u) => (
<li key={u.id}>{u.name} {formatPrice(u.price)}</li>
))}
</ul>
</div>
)}
{r.message && <p className="text-sm text-slate-700 dark:text-slate-300">{r.message}</p>}
</div>
))}
{filtered.length === 0 && (
<p className="text-slate-400">
{filter === 'all' ? 'Noch keine Kontaktanfragen eingegangen.' : 'Keine Anfragen mit diesem Status.'}
</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client.js';
import { useAuth } from '../../context/AuthContext.jsx';
const ICONS = {
tag: 'M4 4h7l9 9-7 7-9-9V4Zm3.5 3.5h.01',
car: 'M3 13.5 4.5 9A2 2 0 0 1 6.4 7.5h11.2A2 2 0 0 1 19.5 9L21 13.5M3 13.5v4a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-4M3 13.5h18M6.5 16.5h.01M17.5 16.5h.01',
box: 'M12 3 4 7.5v9L12 21l8-4.5v-9L12 3Zm0 9 8-4.5M12 12v9M12 12 4 7.5',
mail: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Zm0 0 8 7 8-7',
};
const Icon = ({ path, className = 'h-6 w-6' }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d={path} />
</svg>
);
export default function Dashboard() {
const { user, hasPermission } = useAuth();
const [stats, setStats] = useState({});
useEffect(() => {
if (hasPermission('brands.manage')) api.get('/brands').then((rows) => setStats((s) => ({ ...s, brands: rows.length })));
if (hasPermission('models.manage') || hasPermission('packages.manage')) {
api.get('/models').then((rows) => setStats((s) => ({ ...s, models: rows.length })));
}
if (hasPermission('packages.manage')) api.get('/packages').then((rows) => setStats((s) => ({ ...s, packages: rows.length })));
if (hasPermission('contact.manage')) {
api.get('/contact').then((rows) => setStats((s) => ({ ...s, newContacts: rows.filter((c) => c.status === 'new').length })));
}
}, []);
const cards = [
{ key: 'brands', label: 'Marken', icon: 'tag', hint: 'Die Hersteller, z. B. Audi oder BMW.', value: stats.brands, to: '/admin/brands', show: hasPermission('brands.manage') },
{ key: 'models', label: 'Modelle', icon: 'car', hint: 'Die Fahrzeuge einer Marke, z. B. A4.', value: stats.models, to: '/admin/models', show: hasPermission('models.manage') },
{ key: 'packages', label: 'Pakete', icon: 'box', hint: 'Die Sound-Pakete für ein Modell.', value: stats.packages, to: '/admin/packages', show: hasPermission('packages.manage') },
{ key: 'contacts', label: 'Neue Anfragen', icon: 'mail', hint: 'Kunden, die noch auf Antwort warten.', value: stats.newContacts, to: '/admin/contact-requests', show: hasPermission('contact.manage'), highlight: true },
].filter((c) => c.show);
return (
<div>
<h1 className="mb-1 text-xl font-bold text-slate-900 dark:text-white">Willkommen, {user?.username}!</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Hier siehst du auf einen Blick, wie viel in deinem Shop hinterlegt ist. Klicke auf eine Kachel, um dorthin zu gelangen.
</p>
{cards.length === 0 ? (
<p className="text-slate-500 dark:text-slate-400">
Für deinen Account sind noch keine Bereiche freigeschaltet. Bitte wende dich an einen Administrator.
</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<Link
key={card.key}
to={card.to}
className={`rounded-xl border p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md ${
card.highlight && card.value > 0
? 'border-brand-300 bg-brand-50 dark:border-brand-900 dark:bg-brand-900/20'
: 'border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900'
}`}
>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400">
<Icon path={ICONS[card.icon]} />
</div>
<p className="text-sm font-semibold text-slate-700 dark:text-slate-200">{card.label}</p>
<p className="mt-1 text-3xl font-extrabold text-brand-600 dark:text-brand-400">{card.value ?? '…'}</p>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{card.hint}</p>
</Link>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,131 @@
import { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext.jsx';
import logo from '../../assets/logo.png';
export default function Login() {
const { user, login, verifyTwoFactor } = useAuth();
const navigate = useNavigate();
const [step, setStep] = useState('credentials');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
if (user) {
return <Navigate to="/admin" replace />;
}
const handleCredentials = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const result = await login(username, password);
if (result.requires2fa) {
setStep('code');
} else {
navigate('/admin');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleCode = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await verifyTwoFactor(code);
navigate('/admin');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-slate-50 px-4 dark:bg-slate-950">
<img src={logo} alt="HifiPlanet" className="h-16 w-auto sm:h-20" />
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h1 className="mb-6 text-xl font-bold text-slate-900 dark:text-white">Admin-Login</h1>
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Benutzername</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="mb-6">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{loading ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
) : (
<form onSubmit={handleCode} className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h1 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Bestätigungscode</h1>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
Öffne die Google Authenticator App und gib den 6-stelligen Code ein. Alternativ kannst du auch
einen deiner Wiederherstellungscodes verwenden.
</p>
<div className="mb-6">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Code</label>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
required
autoFocus
inputMode="numeric"
placeholder="123456"
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-center text-lg tracking-widest focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{loading ? 'Prüfe…' : 'Bestätigen'}
</button>
<button
type="button"
onClick={() => { setStep('credentials'); setCode(''); setError(''); }}
className="mt-3 w-full text-sm text-slate-500 hover:underline dark:text-slate-400"
>
Zurück zum Login
</button>
</form>
)}
</div>
);
}

View file

@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client.js';
const emptyForm = { name: '', brand_id: '', sort_order: 0 };
export default function Models() {
const [models, setModels] = useState([]);
const [brands, setBrands] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState(null);
const [error, setError] = useState('');
const [brandFilter, setBrandFilter] = useState('all');
const filteredModels = useMemo(
() => (brandFilter === 'all' ? models : models.filter((m) => String(m.brand_id) === brandFilter)),
[models, brandFilter]
);
const load = () => {
api.get('/models').then(setModels);
api.get('/brands').then(setBrands);
};
useEffect(load, []);
const startEdit = (model) => {
setEditingId(model.id);
setForm({ name: model.name, brand_id: model.brand_id, sort_order: model.sort_order });
};
const resetForm = () => {
setEditingId(null);
setForm(emptyForm);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const payload = { ...form, brand_id: Number(form.brand_id) };
try {
if (editingId) {
await api.put(`/models/${editingId}`, payload);
} else {
await api.post('/models', payload);
}
resetForm();
load();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (id) => {
if (!confirm('Modell inkl. aller Pakete/Produkte wirklich löschen?')) return;
await api.delete(`/models/${id}`);
load();
};
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">Modelle</h1>
<div className="mb-3 flex flex-wrap items-center gap-2">
<label className="text-sm font-medium text-slate-600 dark:text-slate-300">Nach Marke filtern:</label>
<select
value={brandFilter}
onChange={(e) => setBrandFilter(e.target.value)}
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="all">Alle Marken</option>
{brands.map((b) => <option key={b.id} value={String(b.id)}>{b.name}</option>)}
</select>
{brandFilter !== 'all' && (
<button onClick={() => setBrandFilter('all')} className="text-sm text-brand-600 hover:underline">
Filter zurücksetzen
</button>
)}
<span className="text-sm text-slate-400">{filteredModels.length} von {models.length}</span>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-4 py-2">Marke</th>
<th className="px-4 py-2">Modell</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{filteredModels.map((model) => (
<tr key={model.id} className="bg-white dark:bg-slate-950">
<td className="px-4 py-2">{model.brand_name}</td>
<td className="px-4 py-2 font-medium text-slate-800 dark:text-slate-100">{model.name}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(model)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(model.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{filteredModels.length === 0 && (
<tr><td colSpan={3} className="px-4 py-6 text-center text-slate-400">
{models.length === 0 ? 'Noch keine Modelle angelegt.' : 'Keine Modelle für diese Marke.'}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="font-bold text-slate-900 dark:text-white">{editingId ? 'Modell bearbeiten' : 'Neues Modell'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Marke *</label>
<select
required
value={form.brand_id}
onChange={(e) => setForm({ ...form, brand_id: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="">Bitte wählen</option>
{brands.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Modellname *</label>
<input
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Sortierung</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../../api/client.js';
const formatPrice = (value) =>
value == null ? '' : new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
const statusBadge = {
ok: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
};
export default function PackageProducts() {
const { packageId } = useParams();
const [products, setProducts] = useState([]);
const [sourceUrl, setSourceUrl] = useState('');
const [nameOverride, setNameOverride] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const load = () => api.get(`/packages/${packageId}/products`).then(setProducts);
useEffect(() => {
load();
}, [packageId]);
const handleAdd = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await api.post('/products', { package_id: Number(packageId), source_url: sourceUrl, name_override: nameOverride || null });
setSourceUrl('');
setNameOverride('');
load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const handleRefresh = async (id) => {
setBusy(true);
try {
await api.post(`/products/${id}/refresh-price`, {});
load();
} finally {
setBusy(false);
}
};
const handleDelete = async (id) => {
if (!confirm('Produkt wirklich entfernen?')) return;
await api.delete(`/products/${id}`);
load();
};
return (
<div>
<p className="mb-2 text-sm">
<Link to="/admin/packages" className="text-brand-600 hover:underline"> Zurück zu den Paketen</Link>
</p>
<h1 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">Produkte im Paket</h1>
<form onSubmit={handleAdd} className="mb-6 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex-1 min-w-[240px]">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">audio4cars.de Produkt-URL *</label>
<input
required
type="url"
placeholder="https://www.audio4cars.de/produkt/..."
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="w-56">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name überschreiben (optional)</label>
<input
value={nameOverride}
onChange={(e) => setNameOverride(e.target.value)}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<button disabled={busy} type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60">
Hinzufügen
</button>
</form>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-4 py-2">Produkt</th>
<th className="px-4 py-2">Preis</th>
<th className="px-4 py-2">Status</th>
<th className="px-4 py-2">Zuletzt aktualisiert</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{products.map((p) => (
<tr key={p.id} className="bg-white dark:bg-slate-950">
<td className="px-4 py-2">
<a href={p.source_url} target="_blank" rel="noreferrer" className="font-medium text-slate-800 hover:underline dark:text-slate-100">
{p.name_override || p.scraped_name || p.source_url}
</a>
{p.scrape_status === 'error' && <p className="text-xs text-red-500">{p.scrape_error}</p>}
</td>
<td className="px-4 py-2">{formatPrice(p.scraped_price)}</td>
<td className="px-4 py-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusBadge[p.scrape_status]}`}>
{p.scrape_status}
</span>
</td>
<td className="px-4 py-2 text-slate-500 dark:text-slate-400">
{p.price_updated_at ? new Date(p.price_updated_at.replace(' ', 'T')).toLocaleString('de-DE') : ''}
</td>
<td className="px-4 py-2 text-right">
<button disabled={busy} onClick={() => handleRefresh(p.id)} className="mr-3 text-brand-600 hover:underline disabled:opacity-60">
Preis aktualisieren
</button>
<button onClick={() => handleDelete(p.id)} className="text-red-600 hover:underline">Entfernen</button>
</td>
</tr>
))}
{products.length === 0 && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-slate-400">Noch keine Produkte im Paket.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../../api/client.js';
const formatPrice = (value) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
const emptyForm = { name: '', description: '', price: 0, sort_order: 0 };
export default function PackageUpgrades() {
const { packageId } = useParams();
const [upgrades, setUpgrades] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState(null);
const [error, setError] = useState('');
const load = () => api.get(`/packages/${packageId}/upgrades`).then(setUpgrades);
useEffect(() => {
load();
}, [packageId]);
const startEdit = (u) => {
setEditingId(u.id);
setForm({ name: u.name, description: u.description || '', price: u.price, sort_order: u.sort_order });
};
const resetForm = () => {
setEditingId(null);
setForm(emptyForm);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const payload = { ...form, package_id: Number(packageId), price: Number(form.price) };
try {
if (editingId) {
await api.put(`/package-upgrades/${editingId}`, payload);
} else {
await api.post('/package-upgrades', payload);
}
resetForm();
load();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (id) => {
if (!confirm('Upgrade wirklich löschen?')) return;
await api.delete(`/package-upgrades/${id}`);
load();
};
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<p className="mb-2 text-sm">
<Link to="/admin/packages" className="text-brand-600 hover:underline"> Zurück zu den Paketen</Link>
</p>
<h1 className="mb-1 text-xl font-bold text-slate-900 dark:text-white">Optionale Upgrades</h1>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Diese Upgrades erscheinen nicht in der öffentlichen Paketübersicht, sondern erst als Auswahl im Kontaktformular.
</p>
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Preis</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{upgrades.map((u) => (
<tr key={u.id} className="bg-white dark:bg-slate-950">
<td className="px-4 py-2">
<p className="font-medium text-slate-800 dark:text-slate-100">{u.name}</p>
{u.description && <p className="text-xs text-slate-500 dark:text-slate-400">{u.description}</p>}
</td>
<td className="px-4 py-2">{formatPrice(u.price)}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(u)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(u.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{upgrades.length === 0 && (
<tr><td colSpan={3} className="px-4 py-6 text-center text-slate-400">Noch keine Upgrades hinterlegt.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="font-bold text-slate-900 dark:text-white">{editingId ? 'Upgrade bearbeiten' : 'Neues Upgrade'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name *</label>
<input
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Beschreibung</label>
<textarea
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Preis () *</label>
<input
required
type="number"
step="0.01"
min="0"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Sortierung</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,248 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client.js';
const emptyForm = { name: '', car_model_id: '', description: '', markup_type: 'none', markup_value: 0, sort_order: 0 };
export default function Packages() {
const [packages, setPackages] = useState([]);
const [models, setModels] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState(null);
const [error, setError] = useState('');
const [brandFilter, setBrandFilter] = useState('all');
const [modelFilter, setModelFilter] = useState('all');
const load = () => {
api.get('/packages').then(setPackages);
api.get('/models').then(setModels);
};
useEffect(load, []);
const modelById = useMemo(() => new Map(models.map((m) => [m.id, m])), [models]);
const brandOptions = useMemo(() => {
const seen = new Map();
models.forEach((m) => {
if (!seen.has(m.brand_id)) seen.set(m.brand_id, m.brand_name);
});
return Array.from(seen, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
}, [models]);
const modelOptions = useMemo(
() =>
models
.filter((m) => brandFilter === 'all' || String(m.brand_id) === brandFilter)
.sort((a, b) => a.name.localeCompare(b.name)),
[models, brandFilter]
);
const handleBrandFilterChange = (value) => {
setBrandFilter(value);
setModelFilter('all');
};
const filteredPackages = useMemo(
() =>
packages.filter((pkg) => {
const model = modelById.get(pkg.car_model_id);
if (brandFilter !== 'all' && String(model?.brand_id) !== brandFilter) return false;
if (modelFilter !== 'all' && String(pkg.car_model_id) !== modelFilter) return false;
return true;
}),
[packages, modelById, brandFilter, modelFilter]
);
const startEdit = (pkg) => {
setEditingId(pkg.id);
setForm({
name: pkg.name,
car_model_id: pkg.car_model_id,
description: pkg.description || '',
markup_type: pkg.markup_type || 'none',
markup_value: pkg.markup_value || 0,
sort_order: pkg.sort_order,
});
};
const resetForm = () => {
setEditingId(null);
setForm(emptyForm);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const payload = { ...form, car_model_id: Number(form.car_model_id), markup_value: Number(form.markup_value) };
try {
if (editingId) {
await api.put(`/packages/${editingId}`, payload);
} else {
await api.post('/packages', payload);
}
resetForm();
load();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (id) => {
if (!confirm('Paket inkl. aller Produkte wirklich löschen?')) return;
await api.delete(`/packages/${id}`);
load();
};
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">Pakete</h1>
<div className="mb-3 flex flex-wrap items-center gap-2">
<label className="text-sm font-medium text-slate-600 dark:text-slate-300">Filtern:</label>
<select
value={brandFilter}
onChange={(e) => handleBrandFilterChange(e.target.value)}
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="all">Alle Marken</option>
{brandOptions.map((b) => <option key={b.id} value={String(b.id)}>{b.name}</option>)}
</select>
<select
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="all">Alle Modelle</option>
{modelOptions.map((m) => <option key={m.id} value={String(m.id)}>{m.brand_name} {m.name}</option>)}
</select>
{(brandFilter !== 'all' || modelFilter !== 'all') && (
<button onClick={() => handleBrandFilterChange('all')} className="text-sm text-brand-600 hover:underline">
Filter zurücksetzen
</button>
)}
<span className="text-sm text-slate-400">{filteredPackages.length} von {packages.length}</span>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-4 py-2">Marke / Modell</th>
<th className="px-4 py-2">Paket</th>
<th className="px-4 py-2">Aufschlag</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{filteredPackages.map((pkg) => (
<tr key={pkg.id} className="bg-white dark:bg-slate-950">
<td className="px-4 py-2">{pkg.brand_name} {pkg.model_name}</td>
<td className="px-4 py-2 font-medium text-slate-800 dark:text-slate-100">{pkg.name}</td>
<td className="px-4 py-2 text-slate-500 dark:text-slate-400">
{pkg.markup_type === 'fixed' && `+${pkg.markup_value}`}
{pkg.markup_type === 'percent' && `+${pkg.markup_value} %`}
{(!pkg.markup_type || pkg.markup_type === 'none') && ''}
</td>
<td className="px-4 py-2 text-right">
<Link to={`/admin/packages/${pkg.id}/products`} className="mr-3 text-brand-600 hover:underline">Produkte</Link>
<Link to={`/admin/packages/${pkg.id}/upgrades`} className="mr-3 text-brand-600 hover:underline">Upgrades</Link>
<button onClick={() => startEdit(pkg)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(pkg.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{filteredPackages.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-slate-400">
{packages.length === 0 ? 'Noch keine Pakete angelegt.' : 'Keine Pakete für diesen Filter.'}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="font-bold text-slate-900 dark:text-white">{editingId ? 'Paket bearbeiten' : 'Neues Paket'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Modell *</label>
<select
required
value={form.car_model_id}
onChange={(e) => setForm({ ...form, car_model_id: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="">Bitte wählen</option>
{models.map((m) => <option key={m.id} value={m.id}>{m.brand_name} {m.name}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Paketname *</label>
<input
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Beschreibung</label>
<p className="mb-1 text-xs text-slate-500 dark:text-slate-400">Jede Zeile erscheint als eigener Stichpunkt zusammen mit den Bauteilen.</p>
<textarea
rows={3}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Aufschlag</label>
<div className="flex gap-2">
<select
value={form.markup_type}
onChange={(e) => setForm({ ...form, markup_type: e.target.value })}
className="w-40 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="none">Kein Aufschlag</option>
<option value="fixed">Fester Betrag ()</option>
<option value="percent">Prozent (%)</option>
</select>
<input
type="number"
step="0.01"
min="0"
disabled={form.markup_type === 'none'}
value={form.markup_value}
onChange={(e) => setForm({ ...form, markup_value: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">Wird auf die Summe der Bauteilpreise aufgeschlagen und ergibt den Gesamtpreis für den Kunden.</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Sortierung</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,150 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client.js';
const emptyForm = { name: '', description: '', permissions: [] };
export default function PermissionGroups() {
const [groups, setGroups] = useState([]);
const [catalog, setCatalog] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState(null);
const [error, setError] = useState('');
const load = () => {
api.get('/permission-groups').then(setGroups);
api.get('/permissions/catalog').then(setCatalog);
};
useEffect(load, []);
const startEdit = (g) => {
setEditingId(g.id);
setForm({ name: g.name, description: g.description || '', permissions: g.permissions });
};
const resetForm = () => {
setEditingId(null);
setForm(emptyForm);
};
const togglePermission = (key) => {
setForm((f) => ({
...f,
permissions: f.permissions.includes(key) ? f.permissions.filter((p) => p !== key) : [...f.permissions, key],
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
if (editingId) {
await api.put(`/permission-groups/${editingId}`, form);
} else {
await api.post('/permission-groups', form);
}
resetForm();
load();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (id) => {
if (!confirm('Diese Berechtigungsgruppe wirklich löschen? Benutzer behalten ihre sonstigen Rechte.')) return;
await api.delete(`/permission-groups/${id}`);
load();
};
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-1 text-xl font-bold text-slate-900 dark:text-white">Berechtigungsgruppen</h1>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Fasse mehrere Berechtigungen zu einer Gruppe zusammen (z. B. Werkstatt"), die du dann bequem einem
oder mehreren Benutzern zuweisen kannst.
</p>
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Berechtigungen</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{groups.map((g) => (
<tr key={g.id} className="bg-white dark:bg-slate-950">
<td className="px-4 py-2">
<p className="font-medium text-slate-800 dark:text-slate-100">{g.name}</p>
{g.description && <p className="text-xs text-slate-500 dark:text-slate-400">{g.description}</p>}
</td>
<td className="px-4 py-2 text-slate-500 dark:text-slate-400">{g.permissions.length} von {catalog.length}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(g)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(g.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{groups.length === 0 && (
<tr><td colSpan={3} className="px-4 py-6 text-center text-slate-400">Noch keine Berechtigungsgruppen angelegt.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="font-bold text-slate-900 dark:text-white">{editingId ? 'Gruppe bearbeiten' : 'Neue Gruppe'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name *</label>
<input
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Beschreibung</label>
<textarea
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<p className="mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">Berechtigungen</p>
<div className="space-y-1.5 rounded-md border border-slate-200 p-3 dark:border-slate-700">
{catalog.map((p) => (
<label key={p.key} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={form.permissions.includes(p.key)}
onChange={() => togglePermission(p.key)}
className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
{p.label}
</label>
))}
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,189 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client.js';
import ImageUploadField from '../../components/ImageUploadField.jsx';
import IconPicker from '../../components/IconPicker.jsx';
import DynamicIcon from '../../components/DynamicIcon.jsx';
const emptyForm = {
icon_name: '',
title: '',
description: '',
image_path: '',
cta_label: '',
cta_url: '',
sort_order: 0,
};
export default function Services() {
const [services, setServices] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState(null);
const [error, setError] = useState('');
const load = () => api.get('/services').then(setServices);
useEffect(load, []);
const startEdit = (s) => {
setEditingId(s.id);
setForm({
icon_name: s.icon_name,
title: s.title,
description: s.description,
image_path: s.image_path || '',
cta_label: s.cta_label || '',
cta_url: s.cta_url || '',
sort_order: s.sort_order,
});
};
const resetForm = () => {
setEditingId(null);
setForm(emptyForm);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!form.icon_name) {
setError('Bitte ein Icon auswählen');
return;
}
const payload = { ...form, sort_order: Number(form.sort_order) };
try {
if (editingId) {
await api.put(`/services/${editingId}`, payload);
} else {
await api.post('/services', payload);
}
resetForm();
load();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (id) => {
if (!confirm('Diese Leistung wirklich löschen?')) return;
await api.delete(`/services/${id}`);
load();
};
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-1 text-xl font-bold text-slate-900 dark:text-white">Leistungen</h1>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Diese Karten erscheinen auf der öffentlichen Leistungen-Seite. Die 8 mitgelieferten Einträge sind die
Standard-Einstellung du kannst sie bearbeiten, löschen und beliebig neue hinzufügen.
</p>
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th className="px-4 py-2">Icon</th>
<th className="px-4 py-2">Titel</th>
<th className="px-4 py-2">Sortierung</th>
<th className="px-4 py-2 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{services.map((s) => (
<tr key={s.id} className="bg-white dark:bg-slate-950">
<td className="px-4 py-2">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400">
<DynamicIcon name={s.icon_name} className="h-5 w-5" />
</div>
</td>
<td className="px-4 py-2 font-medium text-slate-800 dark:text-slate-100">{s.title}</td>
<td className="px-4 py-2">{s.sort_order}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(s)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(s.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{services.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-slate-400">Noch keine Leistungen angelegt.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="font-bold text-slate-900 dark:text-white">{editingId ? 'Leistung bearbeiten' : 'Neue Leistung'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Icon *</label>
<IconPicker value={form.icon_name} onChange={(name) => setForm({ ...form, icon_name: name })} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name *</label>
<input
required
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Beschreibung *</label>
<textarea
required
rows={4}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<ImageUploadField value={form.image_path} onChange={(path) => setForm({ ...form, image_path: path })} label="Bild" />
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Button-Text (optional)</label>
<input
value={form.cta_label}
onChange={(e) => setForm({ ...form, cta_label: e.target.value })}
placeholder="z. B. Fahrzeug konfigurieren"
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Button-Link (optional)</label>
<input
value={form.cta_url}
onChange={(e) => setForm({ ...form, cta_url: e.target.value })}
placeholder="z. B. /fahrzeuge"
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Sortierung</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-slate-300 px-4 py-2 text-sm dark:border-slate-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,218 @@
import { useEffect, useState } from 'react';
import { api } from '../../../api/client.js';
export default function DatabaseSettings() {
const [form, setForm] = useState({ host: '', name: '', user: '', password: '', charset: 'utf8mb4' });
const [hasPassword, setHasPassword] = useState(false);
const [loading, setLoading] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [saveBusy, setSaveBusy] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [schemaReport, setSchemaReport] = useState(null);
const [schemaChecking, setSchemaChecking] = useState(true);
const [schemaMigrating, setSchemaMigrating] = useState(false);
const [schemaActions, setSchemaActions] = useState(null);
const loadDbConfig = () => {
setLoading(true);
api.get('/settings/database').then((res) => {
setForm({ ...res.config, password: '' });
setHasPassword(res.has_password);
}).finally(() => setLoading(false));
};
const checkSchema = () => {
setSchemaChecking(true);
api.get('/settings/schema-check')
.then(setSchemaReport)
.catch((err) => setSchemaReport({ ok: false, error: err.message, tables: [] }))
.finally(() => setSchemaChecking(false));
};
useEffect(() => {
loadDbConfig();
checkSchema();
}, []);
const handleMigrateSchema = async () => {
setSchemaMigrating(true);
setSchemaActions(null);
try {
const result = await api.post('/settings/schema-migrate', {});
setSchemaActions(result.actions);
setSchemaReport(result.report);
} catch (err) {
setSchemaActions(['Fehler: ' + err.message]);
} finally {
setSchemaMigrating(false);
}
};
const handleSave = async (e) => {
e.preventDefault();
setSaveBusy(true);
setSaveError('');
setSaveSuccess(false);
try {
await api.post('/settings/database', form);
setSaveSuccess(true);
setForm((f) => ({ ...f, password: '' }));
setHasPassword(true);
checkSchema();
} catch (err) {
setSaveError(err.message);
} finally {
setSaveBusy(false);
}
};
return (
<div className="max-w-2xl space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-1 font-semibold text-slate-900 dark:text-white">Datenbank-Zugangsdaten</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Wichtig für einen Umzug auf einen anderen Server/Webspace: Trage hier die Zugangsdaten der dortigen
Datenbank ein. Die Verbindung wird vor dem Speichern getestet, damit du dich nicht aussperrst.
</p>
{loading ? (
<p className="text-sm text-slate-400">Lädt</p>
) : (
<form onSubmit={handleSave} className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Host *</label>
<input
required
value={form.host}
onChange={(e) => setForm({ ...form, host: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Datenbankname *</label>
<input
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Benutzer *</label>
<input
required
value={form.user}
onChange={(e) => setForm({ ...form, user: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Passwort {hasPassword ? '(leer lassen = unverändert)' : ''}
</label>
<div className="flex gap-2">
<input
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
placeholder={hasPassword ? '••••••••' : ''}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="shrink-0 rounded-md border border-slate-300 px-3 text-sm dark:border-slate-700"
>
{showPassword ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Zeichensatz</label>
<input
value={form.charset}
onChange={(e) => setForm({ ...form, charset: e.target.value })}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{saveError && <p className="text-sm text-red-600">{saveError}</p>}
{saveSuccess && <p className="text-sm text-green-600 dark:text-green-400">Verbindung erfolgreich getestet und gespeichert.</p>}
<button
type="submit"
disabled={saveBusy}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
>
{saveBusy ? 'Teste & speichere…' : 'Verbindung testen & speichern'}
</button>
</form>
)}
</section>
<section className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-1 flex items-center justify-between gap-3">
<h2 className="font-semibold text-slate-900 dark:text-white">Datenbankstruktur</h2>
{!schemaChecking && schemaReport && (
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${
schemaReport.ok
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
}`}
>
{schemaReport.ok ? 'Datenbank OK' : 'Datenbank nicht OK'}
</span>
)}
</div>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Prüft, ob diese Datenbank alle Tabellen und Spalten hat, die der aktuelle Code-Stand braucht wichtig,
wenn deine Live-Version auf einem anderen Server einen älteren Stand hat.
</p>
{schemaChecking && <p className="text-sm text-slate-400">Prüfe</p>}
{!schemaChecking && schemaReport && !schemaReport.ok && (
<ul className="mb-4 space-y-1 rounded-md bg-red-50 p-3 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-300">
{schemaReport.tables.filter((t) => !t.ok).map((t) => (
<li key={t.table}>
{!t.exists ? (
<>Tabelle <code className="font-mono">{t.table}</code> fehlt komplett</>
) : (
<>Tabelle <code className="font-mono">{t.table}</code>: Spalte(n) fehlen {t.missing_columns.join(', ')}</>
)}
</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-2">
<button
onClick={checkSchema}
disabled={schemaChecking}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium hover:bg-slate-50 disabled:opacity-50 dark:border-slate-700 dark:hover:bg-slate-800"
>
Erneut prüfen
</button>
{schemaReport && !schemaReport.ok && (
<button
onClick={handleMigrateSchema}
disabled={schemaMigrating}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
>
{schemaMigrating ? 'Aktualisiere…' : 'Datenbankstruktur aktualisieren'}
</button>
)}
</div>
{schemaActions && (
<ul className="mt-3 space-y-1 rounded-md bg-slate-50 p-3 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{schemaActions.map((a, i) => <li key={i}>{a}</li>)}
</ul>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,98 @@
import { useState } from 'react';
import { api } from '../../../api/client.js';
export default function ExportImportSettings() {
const [importFile, setImportFile] = useState(null);
const [importConfirmed, setImportConfirmed] = useState(false);
const [importBusy, setImportBusy] = useState(false);
const [importResult, setImportResult] = useState(null);
const [importError, setImportError] = useState('');
const handleImport = async (e) => {
e.preventDefault();
if (!importFile || !importConfirmed) return;
setImportBusy(true);
setImportError('');
setImportResult(null);
try {
const formData = new FormData();
formData.append('file', importFile);
const result = await api.post('/settings/import', formData);
setImportResult(result);
setImportFile(null);
setImportConfirmed(false);
e.target.reset();
} catch (err) {
setImportError(err.message);
} finally {
setImportBusy(false);
}
};
return (
<div className="max-w-2xl space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-1 font-semibold text-slate-900 dark:text-white">Daten exportieren</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Lädt eine Datei mit allen Marken, Modellen, Paketen, Produkten, Upgrades und Leistungen (inkl. Bilder)
herunter. Benutzerkonten und Kontaktanfragen sind bewusst <strong>nicht</strong> enthalten.
</p>
<a
href="/hifi/api/settings/export"
className="inline-block rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Jetzt exportieren
</a>
</section>
<section className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-1 font-semibold text-slate-900 dark:text-white">Daten importieren</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Lädt eine zuvor exportierte Datei auf diesen Server. <strong>Ersetzt dabei alle</strong> aktuellen
Marken, Modelle, Pakete, Produkte, Upgrades und Leistungen auf diesem Server das kann nicht
rückgängig gemacht werden. Kontaktanfragen und Benutzerkonten bleiben unangetastet.
</p>
<form onSubmit={handleImport} className="space-y-3">
<input
type="file"
accept="application/json,.json"
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
className="text-sm"
/>
<label className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={importConfirmed}
onChange={(e) => setImportConfirmed(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
Mir ist bewusst, dass dies alle aktuellen Marken, Modelle, Pakete, Produkte, Upgrades und
Leistungen auf diesem Server unwiderruflich ersetzt.
</label>
<button
type="submit"
disabled={!importFile || !importConfirmed || importBusy}
className="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
>
{importBusy ? 'Importiere…' : 'Importieren'}
</button>
</form>
{importError && <p className="mt-3 text-sm text-red-600">{importError}</p>}
{importResult && (
<div className="mt-3 rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300">
<p className="mb-1 font-semibold">Import erfolgreich:</p>
<ul className="list-disc pl-5">
<li>{importResult.counts.brands} Marken</li>
<li>{importResult.counts.car_models} Modelle</li>
<li>{importResult.counts.packages} Pakete</li>
<li>{importResult.counts.package_products} Produkte</li>
<li>{importResult.counts.package_upgrades} Upgrades</li>
<li>{importResult.counts.services} Leistungen</li>
<li>{importResult.images_restored} Bilder wiederhergestellt</li>
</ul>
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { api } from '../../../api/client.js';
const emptyStatus = {
global: { enabled: false, message: '' },
services: { enabled: false, message: '' },
vehicles: { enabled: false, message: '' },
};
export default function MaintenanceSettings() {
const [status, setStatus] = useState(emptyStatus);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
api.get('/maintenance').then((res) => {
setStatus({
global: { enabled: res.global.enabled, message: res.global.message || '' },
services: { enabled: res.services.enabled, message: res.services.message || '' },
vehicles: { enabled: res.vehicles.enabled, message: res.vehicles.message || '' },
});
}).finally(() => setLoading(false));
}, []);
const update = (scope, field, value) => {
setStatus((s) => ({ ...s, [scope]: { ...s[scope], [field]: value } }));
setSaved(false);
};
const handleSave = async (e) => {
e.preventDefault();
setBusy(true);
setError('');
setSaved(false);
try {
const result = await api.post('/maintenance', status);
setStatus({
global: { enabled: result.global.enabled, message: result.global.message || '' },
services: { enabled: result.services.enabled, message: result.services.message || '' },
vehicles: { enabled: result.vehicles.enabled, message: result.vehicles.message || '' },
});
setSaved(true);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const sections = [
{
key: 'global',
title: 'Gesamte Seite',
hint: 'Besucher sehen nur noch eine Wartungsmeldung egal welche Seite sie aufrufen. Das Admin-Panel bleibt für dich erreichbar.',
},
{
key: 'services',
title: 'Nur Leistungen',
hint: 'Nur die Leistungen-Seite zeigt eine Wartungsmeldung, der Rest der Website funktioniert normal.',
},
{
key: 'vehicles',
title: 'Nur Fahrzeugkatalog',
hint: 'Nur die Fahrzeugauswahl (Marke → Modell → Pakete) zeigt eine Wartungsmeldung, der Rest der Website funktioniert normal.',
},
];
if (loading) {
return <p className="text-sm text-slate-400">Lädt</p>;
}
return (
<form onSubmit={handleSave} className="max-w-2xl space-y-6">
<p className="text-sm text-slate-500 dark:text-slate-400">
Ist "Gesamte Seite" aktiv, gewinnt das immer die beiden anderen Schalter spielen dann keine Rolle mehr.
</p>
{sections.map((section) => (
<section key={section.key} className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-1 flex items-center justify-between gap-3">
<h2 className="font-semibold text-slate-900 dark:text-white">{section.title}</h2>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={status[section.key].enabled}
onChange={(e) => update(section.key, 'enabled', e.target.checked)}
className="peer sr-only"
/>
<div className="h-6 w-11 rounded-full bg-slate-200 transition peer-checked:bg-brand-500 dark:bg-slate-700" />
<div className="absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-5" />
</label>
</div>
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400">{section.hint}</p>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Eigene Nachricht (optional)
</label>
<textarea
rows={2}
value={status[section.key].message}
onChange={(e) => update(section.key, 'message', e.target.value)}
placeholder="z. B. Wir aktualisieren gerade unser Angebot. Schau bald wieder vorbei!"
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
/>
</section>
))}
{error && <p className="text-sm text-red-600">{error}</p>}
{saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
<button
type="submit"
disabled={busy}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
>
{busy ? 'Speichere…' : 'Speichern'}
</button>
</form>
);
}

View file

@ -0,0 +1,107 @@
import { useState } from 'react';
import { api } from '../../../api/client.js';
export default function ResetSettings() {
const [servicesBusy, setServicesBusy] = useState(false);
const [servicesMessage, setServicesMessage] = useState('');
const [catalogBusy, setCatalogBusy] = useState(false);
const [catalogMessage, setCatalogMessage] = useState('');
const [allBusy, setAllBusy] = useState(false);
const [allMessage, setAllMessage] = useState('');
const handleResetServices = async () => {
if (!confirm('Alle aktuellen Leistungen werden gelöscht und durch die 8 Standard-Leistungen ersetzt. Fortfahren?')) return;
setServicesBusy(true);
setServicesMessage('');
try {
const result = await api.post('/settings/reset-services', {});
setServicesMessage(`${result.count} Standard-Leistungen wiederhergestellt.`);
} catch (err) {
setServicesMessage('Fehler: ' + err.message);
} finally {
setServicesBusy(false);
}
};
const handleResetCatalog = async () => {
if (!confirm('Alle Marken, Modelle, Pakete, Produkte und Upgrades werden unwiderruflich gelöscht. Leistungen und Kontaktanfragen bleiben erhalten. Fortfahren?')) return;
setCatalogBusy(true);
setCatalogMessage('');
try {
const result = await api.post('/settings/reset-catalog', {});
setCatalogMessage(`Fahrzeugkatalog geleert (${result.brands_removed} Marken entfernt).`);
} catch (err) {
setCatalogMessage('Fehler: ' + err.message);
} finally {
setCatalogBusy(false);
}
};
const handleResetAll = async () => {
if (!confirm('WIRKLICH ALLES zurücksetzen? Marken, Modelle, Pakete, Produkte und Upgrades werden gelöscht, Leistungen auf Standard zurückgesetzt. Kontaktanfragen und Benutzerkonten bleiben erhalten. Das kann nicht rückgängig gemacht werden!')) return;
setAllBusy(true);
setAllMessage('');
try {
const result = await api.post('/settings/reset-all', {});
setAllMessage(`Erledigt: ${result.brands_removed} Marken entfernt, ${result.services_reset} Standard-Leistungen wiederhergestellt.`);
} catch (err) {
setAllMessage('Fehler: ' + err.message);
} finally {
setAllBusy(false);
}
};
return (
<div className="max-w-2xl space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-1 font-semibold text-slate-900 dark:text-white">Nur Leistungen zurücksetzen</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Löscht alle aktuellen Leistungen-Karten und stellt die 8 mitgelieferten Standard-Leistungen wieder her.
Marken, Modelle und Pakete bleiben unangetastet.
</p>
<button
onClick={handleResetServices}
disabled={servicesBusy}
className="rounded-md border border-red-300 px-4 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-900 dark:hover:bg-red-900/20"
>
{servicesBusy ? 'Setze zurück…' : 'Leistungen auf Standard zurücksetzen'}
</button>
{servicesMessage && <p className="mt-3 text-sm text-slate-600 dark:text-slate-300">{servicesMessage}</p>}
</section>
<section className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-1 font-semibold text-slate-900 dark:text-white">Nur Fahrzeugkatalog leeren</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Löscht alle Marken, Modelle, Pakete, Produkte und Upgrades unwiderruflich. Leistungen, Kontaktanfragen
und Benutzerkonten bleiben erhalten.
</p>
<button
onClick={handleResetCatalog}
disabled={catalogBusy}
className="rounded-md border border-red-300 px-4 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-900 dark:hover:bg-red-900/20"
>
{catalogBusy ? 'Leere…' : 'Fahrzeugkatalog leeren'}
</button>
{catalogMessage && <p className="mt-3 text-sm text-slate-600 dark:text-slate-300">{catalogMessage}</p>}
</section>
<section className="rounded-xl border border-red-300 bg-red-50/40 p-6 dark:border-red-900 dark:bg-red-900/10">
<h2 className="mb-1 font-semibold text-red-700 dark:text-red-300">Alles zurücksetzen</h2>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
Kombiniert beides: Fahrzeugkatalog wird komplett geleert <strong>und</strong> Leistungen werden auf
Standard zurückgesetzt. Kontaktanfragen und Benutzerkonten bleiben erhalten. Nicht rückgängig zu machen.
</p>
<button
onClick={handleResetAll}
disabled={allBusy}
className="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
>
{allBusy ? 'Setze zurück…' : 'Wirklich alles zurücksetzen'}
</button>
{allMessage && <p className="mt-3 text-sm text-slate-700 dark:text-slate-300">{allMessage}</p>}
</section>
</div>
);
}

View file

@ -0,0 +1,34 @@
import { NavLink, Outlet } from 'react-router-dom';
const tabs = [
{ to: '/admin/settings/database', label: 'Datenbank' },
{ to: '/admin/settings/export-import', label: 'Export & Import' },
{ to: '/admin/settings/maintenance', label: 'Wartungsmodus' },
{ to: '/admin/settings/reset', label: 'Zurücksetzen' },
];
export default function SettingsLayout() {
const tabClass = ({ isActive }) =>
`rounded-md px-4 py-2 text-sm font-medium transition ${
isActive
? 'bg-brand-500 text-white'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`;
return (
<div>
<h1 className="mb-1 text-xl font-bold text-slate-900 dark:text-white">Einstellungen</h1>
<p className="mb-5 text-sm text-slate-500 dark:text-slate-400">
Werkzeuge rund um Datenbank, Server-Umzug und Zurücksetzen dieser Seite.
</p>
<nav className="mb-6 flex flex-wrap gap-2">
{tabs.map((tab) => (
<NavLink key={tab.to} to={tab.to} className={tabClass}>
{tab.label}
</NavLink>
))}
</nav>
<Outlet />
</div>
);
}

View file

@ -0,0 +1,124 @@
import usePageMeta from '../../hooks/usePageMeta.js';
export default function AGB() {
usePageMeta({
title: 'AGB',
description: 'Allgemeine Geschäftsbedingungen von HifiPlanet Amorbach.',
path: '/agb',
});
return (
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
<h1 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">
Allgemeine Geschäftsbedingungen
</h1>
<div className="space-y-8 text-sm text-slate-600 dark:text-slate-300">
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">1. Geltungsbereich</h2>
<p>
Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Beratungs-, Werkstatt- und
Umbauleistungen, die die Hifi Planet e.K., Boxbrunner Str. 20a, 63916 Amorbach (wir", „HifiPlanet")
gegenüber ihren Kunden erbringt, sowie für die Nutzung des Kontaktformulars auf dieser Website.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">2. Anfragen über die Website</h2>
<p>
Über das Kontaktformular können Sie unverbindlich eine Beratung oder ein Angebot zu einem einzelnen
Produkt oder einem ganzen Paket anfragen. Die auf der Website angezeigten Preise sind, soweit sie von
Drittanbietern automatisiert übernommen werden, unverbindliche Circa-Preise ohne Einbau und können sich
bis zur Erstellung eines konkreten Angebots ändern. Mit dem Absenden einer Anfrage kommt noch kein
Vertrag zustande.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">3. Vertragsschluss</h2>
<p>
Ein verbindlicher Vertrag über Beratung, Material und Einbauleistungen kommt erst zustande, wenn wir
Ihnen nach persönlicher oder schriftlicher Abstimmung ein konkretes Angebot unterbreitet haben und Sie
dieses angenommen haben, spätestens jedoch mit der Übergabe Ihres Fahrzeugs zur vereinbarten
Auftragsdurchführung in unserer Werkstatt in Amorbach.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">4. Preise und Zahlung</h2>
<p>
Es gelten die im jeweiligen Einzelangebot genannten Preise inklusive der zum Zeitpunkt der
Rechnungsstellung gesetzlich geltenden Umsatzsteuer. Sofern nichts anderes vereinbart ist, ist die
Rechnung nach Abnahme der Leistung bzw. Übergabe des Fahrzeugs ohne Abzug fällig.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">5. Termine und Fahrzeugübergabe</h2>
<p>
Vereinbarte Termine zur Fahrzeugübergabe sind von beiden Seiten einzuhalten. Verschiebungen sind
möglichst frühzeitig mitzuteilen. Für Gegenstände, die sich zum Zeitpunkt der Übergabe im Fahrzeug
befinden und nicht Gegenstand des Auftrags sind, übernehmen wir keine Haftung.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">6. Gewährleistung</h2>
<p>
Es gelten die gesetzlichen Gewährleistungsrechte. Mängel sind uns unverzüglich nach Feststellung
anzuzeigen, damit wir Ihnen Nacherfüllung anbieten können. Für Verschleißteile sowie für Schäden, die
auf unsachgemäße Behandlung, Fremdeingriffe Dritter oder normalen Verschleiß zurückzuführen sind,
besteht kein Gewährleistungsanspruch.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">7. Eigentumsvorbehalt</h2>
<p>
Verbautes Material und gelieferte Ware bleiben bis zur vollständigen Bezahlung unser Eigentum.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">8. Haftung</h2>
<p>
Wir haften unbeschränkt bei Vorsatz und grober Fahrlässigkeit sowie nach Maßgabe des
Produkthaftungsgesetzes. Bei leichter Fahrlässigkeit haften wir nur bei Verletzung einer wesentlichen
Vertragspflicht (Kardinalpflicht) und begrenzt auf den vertragstypisch vorhersehbaren Schaden. Die
Haftung für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit bleibt hiervon
unberührt.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">9. Widerrufsrecht</h2>
<p>
Ein gesetzliches Widerrufsrecht für Verbraucher besteht bei Verträgen, die außerhalb von
Geschäftsräumen oder im Fernabsatz geschlossen werden. Da der eigentliche Werkvertrag über Beratung und
Einbauleistungen erst bei persönlicher Fahrzeugübergabe in unseren Geschäftsräumen in Amorbach
geschlossen wird, handelt es sich hierbei nicht um einen Fernabsatzvertrag; ein gesetzliches
Widerrufsrecht besteht in diesem Fall nicht. Sollte im Einzelfall ausnahmsweise ein Vertrag im
Fernabsatz zustande kommen, informieren wir Sie gesondert über Ihr gesetzliches Widerrufsrecht.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">10. Anwendbares Recht und Gerichtsstand</h2>
<p>
Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts. Ist der Kunde
Kaufmann, juristische Person des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen, ist
Gerichtsstand für alle Streitigkeiten aus diesem Vertragsverhältnis unser Geschäftssitz in Amorbach.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">11. Schlussbestimmungen</h2>
<p>
Sollte eine Bestimmung dieser AGB unwirksam sein oder werden, bleibt die Wirksamkeit der übrigen
Bestimmungen hiervon unberührt.
</p>
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
export default function BrandPage() {
const { brandSlug } = useParams();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const maintenance = useMaintenance();
useEffect(() => {
if (maintenance.vehicles.enabled && !maintenance.bypass) return;
api
.get(`/brands/${brandSlug}/models`)
.then(setData)
.catch((e) => setError(e.message));
}, [brandSlug, maintenance.vehicles.enabled, maintenance.bypass]);
usePageMeta({
title: data ? `${data.brand.name} Modelle` : 'Modell auswählen',
description: data
? `Wähle dein ${data.brand.name} Modell und entdecke passende Car-Hifi Sound-Pakete von HifiPlanet.`
: undefined,
path: `/fahrzeuge/${brandSlug}`,
});
if (maintenance.vehicles.enabled && !maintenance.bypass) {
return <MaintenanceNotice message={maintenance.vehicles.message} />;
}
if (error) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-red-600 sm:px-6">{error}</p>;
}
if (!data) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-slate-500 sm:px-6">Lädt</p>;
}
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
{maintenance.vehicles.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
<p className="mb-1 text-sm text-slate-500 dark:text-slate-400">
<Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</Link> / {data.brand.name}
</p>
<h1 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">
{data.brand.name} Modell wählen
</h1>
{data.models.length === 0 && (
<p className="text-slate-500 dark:text-slate-400">Für diese Marke sind noch keine Modelle hinterlegt.</p>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{data.models.map((model) => (
<Link
key={model.id}
to={`/fahrzeuge/${brandSlug}/${model.slug}`}
className="flex items-center justify-center rounded-xl border border-slate-200 bg-white px-6 py-8 text-center font-semibold text-slate-800 shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"
>
{model.name}
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,240 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import ExternalEmbed from '../../components/ExternalEmbed.jsx';
const formatPrice = (value) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
const SHOP_ADDRESS_ENCODED = encodeURIComponent('Boxbrunner Str. 20a, 63916 Amorbach');
export default function ContactPage() {
const [params] = useSearchParams();
const [form, setForm] = useState({ name: '', email: '', phone: '', message: '' });
const [status, setStatus] = useState('idle');
const [error, setError] = useState('');
const [upgrades, setUpgrades] = useState([]);
const [selectedUpgradeIds, setSelectedUpgradeIds] = useState([]);
const packageId = params.get('packageId');
const packageTotal = params.get('total') ? Number(params.get('total')) : null;
useEffect(() => {
if (!packageId) {
setUpgrades([]);
return;
}
api.get(`/packages/${packageId}/upgrades`).then(setUpgrades).catch(() => setUpgrades([]));
}, [packageId]);
const toggleUpgrade = (id) => {
setSelectedUpgradeIds((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]));
};
const upgradesTotal = upgrades
.filter((u) => selectedUpgradeIds.includes(u.id))
.reduce((sum, u) => sum + Number(u.price), 0);
usePageMeta({
title: 'Kontakt',
description: 'Kontaktiere HifiPlanet in Amorbach für dein individuelles Car-Hifi Projekt wir beraten dich gerne unverbindlich.',
path: '/kontakt',
});
const context = {
brand: params.get('brand') || '',
model: params.get('model') || '',
package: params.get('package') || '',
product: params.get('product') || '',
};
const hasContext = context.brand || context.model || context.package || context.product;
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('sending');
setError('');
try {
await api.post('/contact', {
...form,
brand_name: context.brand || null,
model_name: context.model || null,
package_name: context.package || null,
product_name: context.product || null,
package_id: packageId || null,
package_product_id: params.get('productId') || null,
upgrade_ids: selectedUpgradeIds,
});
setStatus('sent');
} catch (e) {
setError(e.message);
setStatus('idle');
}
};
return (
<div className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<h1 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">Kontakt aufnehmen</h1>
<div className="grid gap-10 md:grid-cols-5">
<div className="md:col-span-3">
{status === 'sent' ? (
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Danke für deine Anfrage!</h2>
<p className="text-slate-600 dark:text-slate-300">Wir melden uns so schnell wie möglich bei dir.</p>
</div>
) : (
<>
{hasContext && (
<div className="mb-6 rounded-lg border border-brand-200 bg-brand-50 p-4 text-sm text-brand-800 dark:border-brand-900 dark:bg-brand-900/20 dark:text-brand-200">
<p className="font-semibold">Deine Anfrage bezieht sich auf:</p>
<ul className="mt-1 space-y-0.5">
{context.brand && <li>Marke: {context.brand}</li>}
{context.model && <li>Modell: {context.model}</li>}
{context.package && <li>Paket: {context.package}</li>}
{context.product && <li>Produkt: {context.product}</li>}
</ul>
{packageTotal != null && (
<p className="mt-2 font-semibold">
Paketpreis: {formatPrice(packageTotal)}
{upgradesTotal > 0 && <> + {formatPrice(upgradesTotal)} Upgrades = {formatPrice(packageTotal + upgradesTotal)}</>}
</p>
)}
</div>
)}
{upgrades.length > 0 && (
<div className="mb-6 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<p className="mb-3 text-sm font-semibold text-slate-900 dark:text-white">Optionale Upgrades</p>
<div className="space-y-2">
{upgrades.map((u) => (
<label key={u.id} className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">
<input
type="checkbox"
checked={selectedUpgradeIds.includes(u.id)}
onChange={() => toggleUpgrade(u.id)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
<span className="flex-1">
<span className="block font-medium text-slate-800 dark:text-slate-100">{u.name}</span>
{u.description && <span className="block text-slate-500 dark:text-slate-400">{u.description}</span>}
</span>
<span className="font-semibold text-brand-600 dark:text-brand-400">+{formatPrice(u.price)}</span>
</label>
))}
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name *</label>
<input
name="name"
required
value={form.name}
onChange={handleChange}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">E-Mail *</label>
<input
type="email"
name="email"
required
value={form.email}
onChange={handleChange}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Telefon</label>
<input
name="phone"
value={form.phone}
onChange={handleChange}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Nachricht</label>
<textarea
name="message"
rows={4}
value={form.message}
onChange={handleChange}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={status === 'sending'}
className="w-full rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-60"
>
{status === 'sending' ? 'Wird gesendet…' : 'Anfrage senden'}
</button>
</form>
</>
)}
</div>
<div className="md:col-span-2">
<div className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-4 font-semibold text-slate-900 dark:text-white">HifiPlanet Amorbach</h2>
<dl className="space-y-3 text-sm text-slate-600 dark:text-slate-300">
<div>
<dt className="font-medium text-slate-800 dark:text-slate-100">Adresse</dt>
<dd>Boxbrunner Str. 20a, 63916 Amorbach</dd>
</div>
<div>
<dt className="font-medium text-slate-800 dark:text-slate-100">Telefon</dt>
<dd><a href="tel:+4993732062390" className="hover:text-brand-500">09373 20 62 390</a></dd>
</div>
<div>
<dt className="font-medium text-slate-800 dark:text-slate-100">E-Mail</dt>
<dd><a href="mailto:info@hifi-planet-amorbach.de" className="hover:text-brand-500">info@hifi-planet-amorbach.de</a></dd>
</div>
<div>
<dt className="font-medium text-slate-800 dark:text-slate-100">Öffnungszeiten</dt>
<dd>MoFr: 9:0018:00 Uhr</dd>
<dd>Sa: 10:0013:00 Uhr</dd>
</div>
</dl>
</div>
<div className="mt-6 rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
<h2 className="mb-4 font-semibold text-slate-900 dark:text-white">Anfahrt</h2>
<div className="overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
<ExternalEmbed name="Die Google-Maps-Karte" className="h-[220px] w-full">
<iframe
title="HifiPlanet Amorbach Standort"
src={`https://www.google.com/maps?q=${SHOP_ADDRESS_ENCODED}&output=embed`}
width="100%"
height="220"
style={{ border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</ExternalEmbed>
</div>
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${SHOP_ADDRESS_ENCODED}`}
target="_blank"
rel="noopener noreferrer"
className="mt-4 flex w-full items-center justify-center gap-2 rounded-md bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600"
>
Route planen
</a>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,192 @@
import { Link } from 'react-router-dom';
import usePageMeta from '../../hooks/usePageMeta.js';
export default function Datenschutz() {
usePageMeta({
title: 'Datenschutzerklärung',
description: 'Datenschutzerklärung von HifiPlanet Amorbach gemäß Art. 13 DSGVO.',
path: '/datenschutz',
});
return (
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
<h1 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">Datenschutzerklärung</h1>
<div className="space-y-8 text-sm text-slate-600 dark:text-slate-300">
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">1. Verantwortlicher</h2>
<p>
Verantwortlicher im Sinne der Datenschutz-Grundverordnung (DSGVO) ist:
<br />
Hifi Planet e.K., Christopher Horndasch
<br />
Boxbrunner Str. 20a, 63916 Amorbach
<br />
Telefon: 09373 20 62 390 · E-Mail:{' '}
<a href="mailto:info@hifi-planet-amorbach.de" className="hover:text-brand-500">info@hifi-planet-amorbach.de</a>
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">2. Ihre Rechte als betroffene Person</h2>
<p className="mb-2">Sie haben jederzeit das Recht,</p>
<ul className="list-disc space-y-1 pl-5">
<li>Auskunft über Ihre bei uns gespeicherten personenbezogenen Daten zu erhalten (Art. 15 DSGVO),</li>
<li>die Berichtigung unrichtiger Daten zu verlangen (Art. 16 DSGVO),</li>
<li>die Löschung Ihrer Daten zu verlangen (Art. 17 DSGVO),</li>
<li>die Verarbeitung Ihrer Daten einschränken zu lassen (Art. 18 DSGVO),</li>
<li>der Verarbeitung Ihrer Daten zu widersprechen (Art. 21 DSGVO),</li>
<li>Ihre Daten in einem gängigen Format zu erhalten (Datenübertragbarkeit, Art. 20 DSGVO), und</li>
<li>eine erteilte Einwilligung jederzeit mit Wirkung für die Zukunft zu widerrufen (Art. 7 Abs. 3 DSGVO).</li>
</ul>
<p className="mt-2">
Außerdem haben Sie das Recht, sich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer
personenbezogenen Daten durch uns zu beschweren, zum Beispiel beim Bayerischen Landesamt für
Datenschutzaufsicht (BayLDA).
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">3. Hosting und Server-Logfiles</h2>
<p>
Beim Aufruf dieser Website erhebt der Webserver automatisch Daten in sogenannten Server-Logfiles, die
Ihr Browser übermittelt. Dazu gehören: IP-Adresse, Datum und Uhrzeit der Anfrage, aufgerufene Seite,
verwendeter Browser und Betriebssystem sowie die zuvor besuchte Seite (Referrer-URL). Diese Daten
dienen ausschließlich der technisch fehlerfreien Auslieferung der Website sowie der IT-Sicherheit
(z. B. zur Abwehr von Angriffen) und werden nicht mit anderen Datenquellen zusammengeführt. Rechtsgrundlage
ist unser berechtigtes Interesse an einem sicheren und stabilen Betrieb der Website (Art. 6 Abs. 1
lit. f DSGVO).
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">4. Kontaktformular</h2>
<p>
Wenn Sie uns über das Kontaktformular eine Anfrage zukommen lassen, werden Ihre Angaben aus dem
Formular (Name, E-Mail-Adresse, optional Telefonnummer, Nachricht sowie ggf. der Kontext Ihrer Anfrage
wie Marke, Modell oder Paket) inklusive der von Ihnen dort angegebenen Kontaktdaten bei uns
gespeichert, um Ihre Anfrage zu bearbeiten und zu beantworten. Die Verarbeitung erfolgt auf Grundlage
Ihrer Einwilligung durch Absenden des Formulars (Art. 6 Abs. 1 lit. a DSGVO) bzw., sofern Ihre Anfrage
der Vorbereitung eines Vertrags dient, auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Wir löschen die
Anfrage inklusive der Kontaktdaten, sobald der jeweilige Sachverhalt abschließend geklärt ist und keine
gesetzlichen Aufbewahrungspflichten entgegenstehen. Diese Einwilligung können Sie jederzeit
widerrufen, indem Sie uns formlos per E-Mail kontaktieren.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">5. Cookies und lokale Speicherung</h2>
<p className="mb-2">
Diese Website verwendet technisch notwendige Cookies bzw. Speichertechnologien, die für den Betrieb der
Seite erforderlich sind und ohne die einzelne Funktionen nicht funktionieren würden. Dazu gehören:
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
ein Session-Cookie (<code>hifi_admin_session</code>), das für den Anmeldestatus im Mitarbeiter-Login
benötigt wird und mit Ende der Browsersitzung abläuft,
</li>
<li>
eine lokale Speicherung (<code>localStorage</code>) Ihrer Hell-/Dunkelmodus-Einstellung sowie Ihrer
Cookie-Auswahl, damit wir Sie nicht bei jedem Besuch erneut danach fragen müssen.
</li>
</ul>
<p className="mt-2">
Für diese technisch notwendigen Cookies ist gemäß § 25 Abs. 2 Nr. 2 TTDSG keine Einwilligung
erforderlich. Rechtsgrundlage ist unser berechtigtes Interesse am technischen Betrieb der Website (Art.
6 Abs. 1 lit. f DSGVO). Für alle darüber hinausgehenden Inhalte aktuell die eingebundenen Google
Maps- und YouTube-Elemente fragen wir Sie beim ersten Besuch aktiv nach Ihrer Einwilligung. Sie
können Ihre Auswahl jederzeit über den Link Cookie-Einstellungen" im Footer ändern.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">6. Google Maps</h2>
<p>
Auf unserer Kontaktseite und im Footer binden wir Kartenmaterial des Dienstes Google Maps" ein, um
Ihnen die Anfahrt zu unserem Standort zu erleichtern. Anbieter ist Google Ireland Limited, Gordon
House, Barrow Street, Dublin 4, Irland. Die eingebettete Karte wird erst geladen, nachdem Sie dem im
Cookie-Banner unter Externe Medien" zugestimmt haben. Beim Laden der Karte wird Ihre IP-Adresse an
Google übertragen; eine Verarbeitung kann dabei auch auf Servern außerhalb der EU/des EWR erfolgen. Die
Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die Sie jederzeit
über die Cookie-Einstellungen widerrufen können. Weitere Informationen finden Sie in der{' '}
<a
href="https://policies.google.com/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand-500"
>
Datenschutzerklärung von Google
</a>
.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">7. YouTube</h2>
<p>
Auf unserer Startseite binden wir ein Video des Anbieters YouTube (Google Ireland Limited, Gordon
House, Barrow Street, Dublin 4, Irland) im erweiterten Datenschutzmodus" ein (Domain
youtube-nocookie.com). Auch dieses Video wird erst geladen, nachdem Sie unter Externe Medien"
zugestimmt haben. Nach Angaben von Google werden dabei bis zum Start der Wiedergabe keine Cookies
gesetzt; beim Laden des Players wird jedoch bereits eine Verbindung zu Google-Servern aufgebaut und
Ihre IP-Adresse übertragen. Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1
lit. a DSGVO), die Sie jederzeit über die Cookie-Einstellungen widerrufen können. Weitere Informationen
finden Sie in der{' '}
<a
href="https://policies.google.com/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-brand-500"
>
Datenschutzerklärung von Google
</a>
.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">8. Empfänger der Daten</h2>
<p>
Ihre Daten werden grundsätzlich nur innerhalb unseres Unternehmens verarbeitet. Eine Weitergabe an
Dritte erfolgt nur, soweit dies zur Bearbeitung Ihrer Anfrage erforderlich ist (z. B. an unseren
Hosting-Provider zum Betrieb dieser Website und an unseren E-Mail-Anbieter zur Zustellung Ihrer
Kontaktanfrage) oder soweit wir gesetzlich dazu verpflichtet sind.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">9. Speicherdauer</h2>
<p>
Sofern in dieser Erklärung keine speziellere Speicherdauer genannt wurde, verbleiben Ihre
personenbezogenen Daten bei uns, bis der Zweck für die Datenspeicherung entfällt. Gesetzliche
Aufbewahrungsfristen (z. B. aus dem Handels- oder Steuerrecht) bleiben hiervon unberührt und können eine
längere Speicherung erforderlich machen.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">10. SSL-/TLS-Verschlüsselung</h2>
<p>
Diese Seite nutzt aus Sicherheitsgründen eine SSL-/TLS-Verschlüsselung zur Übertragung Ihrer Daten. Eine
verschlüsselte Verbindung erkennen Sie an dem Schloss-Symbol in der Adresszeile Ihres Browsers.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">11. Änderung dieser Datenschutzerklärung</h2>
<p>
Wir passen diese Datenschutzerklärung an, sobald sich die von uns eingesetzten Datenverarbeitungen
ändern. Es gilt jeweils die zum Zeitpunkt Ihres Besuchs aktuelle, auf dieser Seite veröffentlichte
Fassung.
</p>
</section>
<p>
Fragen zum Datenschutz beantworten wir Ihnen gerne über unser{' '}
<Link to="/kontakt" className="underline hover:text-brand-500">Kontaktformular</Link> oder direkt per
E-Mail.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,377 @@
import { useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { motion, useScroll, useTransform } from 'framer-motion';
import usePageMeta from '../../hooks/usePageMeta.js';
import Reveal from '../../components/Reveal.jsx';
import Accordion from '../../components/Accordion.jsx';
import StarRating from '../../components/StarRating.jsx';
import TestimonialSlider from '../../components/TestimonialSlider.jsx';
import ExternalEmbed from '../../components/ExternalEmbed.jsx';
import heroImage from '../../assets/photos/hero-trunk.jpg';
import galleryAmp from '../../assets/photos/gallery-amp-purple.jpg';
import gallerySpeaker from '../../assets/photos/gallery-speaker.jpg';
import gallerySubs from '../../assets/photos/gallery-subs.jpg';
import galleryDash from '../../assets/photos/gallery-dash.jpg';
import galleryDash2 from '../../assets/photos/gallery-dash2.jpg';
import galleryRear from '../../assets/photos/gallery-rear.jpg';
import heroHighlight from '../../assets/leistungen/hero-highlight.jpg';
const YOUTUBE_URL = 'https://www.youtube.com/@hifiplanet2812';
const YOUTUBE_VIDEO_ID = 'ostj2mKDVUc';
const stats = [
{ value: 'Seit 2010', label: 'in Amorbach' },
{ value: '20+', label: '3D-Drucker im Haus' },
{ value: 'Eigene', label: 'CNC- & Laserwerkstatt' },
{ value: 'Bundesweit', label: 'anerkannter Car-Hifi-Spezialist' },
];
const brands = ['Focal', 'Helix', 'Ground Zero', 'Mosconi', 'Hifonics', 'Audison'];
const steps = [
{ title: 'Marke & Modell wählen', text: 'Finde dein Fahrzeug in wenigen Klicks.' },
{ title: 'Sound-Paket entdecken', text: 'Passende Pakete inkl. aktueller Preise auf einen Blick.' },
{ title: 'Unverbindlich anfragen', text: 'Wir melden uns zeitnah mit allen Details bei dir.' },
];
const gallery = [
{ src: gallerySubs, alt: 'Individueller Subwoofer-Bau mit Ground Zero Bässen' },
{ src: galleryAmp, alt: 'Endstufen-Einbau mit violetter Ambiente-Beleuchtung' },
{ src: gallerySpeaker, alt: 'Hochtöner- und Mitteltöner-Einbau von Focal Utopia' },
{ src: galleryDash, alt: 'Sony Navigationssystem mit Apple CarPlay im Cockpit' },
{ src: galleryRear, alt: 'Verbauter Hochtöner im Kofferraum' },
{ src: galleryDash2, alt: 'Individuell beleuchtetes Cockpit mit Soundsystem' },
];
const testimonials = [
{ name: 'Thomas K.', text: 'Über YouTube auf dieses Klang-Juwel gestoßen. Beratung und Empfehlungen sind erstklassig der Tesla-Vorführwagen hat meine Leidenschaft für guten Sound neu entfacht.' },
{ name: 'Andreas I.', text: 'Sehr professionelle Beratung und Service. Obwohl ich von VAG zu Mercedes gewechselt bin, wurde alles reibungslos angepasst. Die 3-Wege-Lautsprecher und zwei Subwoofer liefern außergewöhnliche Leistung.' },
{ name: 'Thomas F.', text: 'Kompletter Audi A4 B9 Umbau hat meine Erwartungen übertroffen. Von Anfang bis Ende professionell, mit tadelloser Verarbeitung und Klangqualität.' },
{ name: 'Drago G.', text: 'Beeindruckt von der CNC-Arbeit, die auf YouTube gezeigt wird. Angefangen mit 3D-gedruckten Lautsprecherringen für meinen Ford Mustang daraus wurde ein viel größeres Projekt.' },
{ name: 'Leonardo B.', text: 'Toller Service und meisterhafte Handwerkskunst. Saubere Installation, Leidenschaft in jedem Detail erkennbar.' },
{ name: 'Marcel S.', text: 'Unschlagbares Preis-Leistungs-Verhältnis. Das Team hat sich viel Zeit genommen, um genau die Lösung für mein Budget zu finden.' },
{ name: 'Julia W.', text: 'Endlich ein Betrieb, der auch Wohnmobile ernst nimmt. Die Soundanlage in unserem Camper klingt jetzt wie im Wohnzimmer.' },
{ name: 'Kevin R.', text: 'Dashcam und Alarmanlage in einem Termin sauber verbaut, keine sichtbaren Kabel. Absolute Empfehlung für alle, die Wert auf Verarbeitung legen.' },
{ name: 'Sabrina H.', text: 'Mein Oldtimer hat jetzt modernen Sound, ohne dass es dem Original-Look geschadet hätte. Genau das habe ich gesucht.' },
{ name: 'Niklas P.', text: 'Von der ersten Anfrage bis zum fertigen Umbau lief alles reibungslos. Ehrliche Beratung ohne Verkaufsdruck.' },
];
const faqs = [
{ question: 'Was kostet eine Beratung?', answer: 'Beratung und Preisanfrage sind für dich komplett kostenlos und unverbindlich.' },
{ question: 'Muss ich mein Fahrzeug vorbeibringen?', answer: 'Für eine erste Einschätzung reicht oft deine Anfrage über die Website. Für den Einbau selbst vereinbaren wir gemeinsam einen Termin bei uns in Amorbach.' },
{ question: 'Wie lange dauert ein Umbau?', answer: 'Das hängt vom Umfang ab von einem einfachen Lautsprecher-Tausch in wenigen Stunden bis zum aufwendigen Komplettumbau über mehrere Tage.' },
{ question: 'Bietet ihr auch Lösungen für Leasingfahrzeuge?', answer: 'Ja, auf Wunsch bauen wir reversibel um, sodass dein Fahrzeug bei Rückgabe wieder in den Originalzustand versetzt werden kann.' },
{ question: 'Arbeitet ihr nur mit bestimmten Marken?', answer: 'Nein, wir sind herstellerunabhängig und wählen die Komponenten, die am besten zu deinem Anspruch und Budget passen.' },
{ question: 'Was ist, wenn mein Fahrzeug nicht gelistet ist?', answer: 'Kein Problem schreib uns einfach über das Kontaktformular, wir finden für jedes Fahrzeug eine passende Lösung.' },
];
export default function Home() {
usePageMeta({
title: 'Car-Hifi Umbauten nach Maß',
description:
'HifiPlanet dein Car-Hifi Spezialist in Amorbach. Marke und Modell wählen, passende Sound-Pakete entdecken und unverbindlich anfragen.',
path: '/',
});
useEffect(() => {
const script = document.createElement('script');
script.type = 'application/ld+json';
script.text = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((f) => ({
'@type': 'Question',
name: f.question,
acceptedAnswer: { '@type': 'Answer', text: f.answer },
})),
});
document.head.appendChild(script);
return () => document.head.removeChild(script);
}, []);
const heroRef = useRef(null);
const { scrollYProgress: heroProgress } = useScroll({ target: heroRef, offset: ['start start', 'end start'] });
const heroImageY = useTransform(heroProgress, [0, 1], ['-6%', '6%']);
const heroTextY = useTransform(heroProgress, [0, 1], ['0%', '60%']);
const heroOpacity = useTransform(heroProgress, [0, 0.8], [1, 0]);
const ctaOpacity = useTransform(heroProgress, [0.5, 1], [0, 1]);
const ctaPointerEvents = useTransform(ctaOpacity, (v) => (v > 0.05 ? 'auto' : 'none'));
const ctaY = useTransform(heroProgress, [0.5, 1], [16, 0]);
return (
<div>
<motion.div
style={{ opacity: ctaOpacity, pointerEvents: ctaPointerEvents, y: ctaY }}
className="fixed inset-x-0 bottom-0 z-20 border-t border-slate-200 bg-white/95 px-4 py-3 shadow-[0_-8px_24px_rgba(0,0,0,0.08)] backdrop-blur dark:border-slate-800 dark:bg-slate-950/95 sm:px-6"
>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3">
<p className="hidden text-sm font-medium text-slate-600 dark:text-slate-300 sm:block">
Bereit für deinen Sound-Umbau?
</p>
<div className="flex w-full gap-3 sm:w-auto">
<Link
to="/fahrzeuge"
className="flex-1 rounded-md bg-brand-500 px-5 py-2.5 text-center text-sm font-semibold text-white shadow-sm transition hover:-translate-y-0.5 hover:bg-brand-600 sm:flex-none"
>
Fahrzeug auswählen
</Link>
<Link
to="/kontakt"
className="flex-1 rounded-md border border-slate-300 px-5 py-2.5 text-center text-sm font-semibold text-slate-700 transition hover:-translate-y-0.5 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 sm:flex-none"
>
Kontakt aufnehmen
</Link>
</div>
</div>
</motion.div>
<section ref={heroRef} className="relative h-[85vh] min-h-[560px] overflow-hidden">
<motion.img
style={{ y: heroImageY }}
src={heroImage}
alt="Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum"
className="absolute inset-0 h-full w-full scale-110 object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/80 to-slate-950/40" />
<motion.div
style={{ y: heroTextY, opacity: heroOpacity }}
className="relative flex h-full max-w-6xl flex-col items-center justify-center px-4 mx-auto text-center sm:px-6"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: 'easeOut' }}
>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-brand-400">
Willkommen bei HifiPlanet
</p>
<h1 className="text-3xl font-extrabold tracking-tight text-white sm:text-5xl">
Audiophil aus Prinzip
</h1>
<p className="mx-auto mt-4 max-w-2xl text-base text-slate-200 sm:text-lg">
Dein Car-Hifi Spezialist für individuelle Sound-Umbauten. Wähle dein Fahrzeug,
entdecke passende Pakete und frag unverbindlich an wir kümmern uns um den Rest.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link
to="/fahrzeuge"
className="rounded-md bg-brand-500 px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:-translate-y-0.5 hover:bg-brand-600 hover:shadow-lg"
>
Fahrzeug auswählen
</Link>
<Link
to="/kontakt"
className="rounded-md border border-white/30 bg-white/10 px-6 py-3 text-sm font-semibold text-white backdrop-blur transition hover:-translate-y-0.5 hover:bg-white/20"
>
Kontakt aufnehmen
</Link>
</div>
</motion.div>
</motion.div>
</section>
<section className="border-b border-slate-200 bg-brand-500 py-8 dark:border-slate-800">
<div className="mx-auto grid max-w-6xl grid-cols-2 gap-6 px-4 text-center sm:grid-cols-4 sm:px-6">
{stats.map((stat, i) => (
<Reveal key={stat.label} index={i}>
<p className="text-2xl font-extrabold text-white sm:text-3xl">{stat.value}</p>
<p className="text-sm text-brand-50">{stat.label}</p>
</Reveal>
))}
</div>
</section>
<section className="border-b border-slate-200 py-8 dark:border-slate-800">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<p className="mb-4 text-center text-xs font-semibold uppercase tracking-wide text-slate-400">
Marken, mit denen wir arbeiten
</p>
<div className="flex flex-wrap items-center justify-center gap-x-8 gap-y-3">
{brands.map((brand, i) => (
<Reveal key={brand} index={i} className="text-lg font-bold text-slate-400 dark:text-slate-600">
{brand}
</Reveal>
))}
</div>
</div>
</section>
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<Reveal>
<h2 className="mb-8 text-center text-2xl font-bold text-slate-900 dark:text-white">So funktioniert's</h2>
</Reveal>
<div className="grid gap-6 sm:grid-cols-3">
{steps.map((step, i) => (
<Reveal key={step.title} index={i} className="rounded-xl border border-slate-200 bg-white p-6 text-center dark:border-slate-800 dark:bg-slate-900">
<div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-brand-500 text-lg font-bold text-white">
{i + 1}
</div>
<h3 className="mb-1 font-semibold text-slate-900 dark:text-white">{step.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-300">{step.text}</p>
</Reveal>
))}
</div>
</section>
<section className="relative overflow-hidden bg-gradient-to-br from-red-700 via-red-800 to-slate-950 py-20 sm:py-28">
<div className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-red-500/30 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-red-500/20 blur-3xl" />
<div className="relative mx-auto grid max-w-6xl gap-12 px-4 sm:px-6 lg:grid-cols-2 lg:items-center">
<Reveal direction="left" className="text-center lg:text-left">
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-1.5 text-sm font-semibold text-white backdrop-blur">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5 text-white">
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31.4 31.4 0 0 0 0 12a31.4 31.4 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31.4 31.4 0 0 0 24 12a31.4 31.4 0 0 0-.5-5.8ZM9.6 15.6V8.4l6.3 3.6-6.3 3.6Z" />
</svg>
HifiPlanet auf YouTube
</div>
<h2 className="text-3xl font-extrabold text-white sm:text-4xl">Sieh dir unsere Umbauten in Aktion an</h2>
<p className="mx-auto mt-4 max-w-xl text-lg text-red-50/90 lg:mx-0">
Umbauten, Soundchecks und Verstärker-Tests aus unserer Werkstatt Den besten Sound gibt es nicht ab Werk!"
</p>
<ul className="mx-auto mt-6 flex max-w-xl flex-col items-center gap-2 text-sm text-red-50/90 sm:flex-row sm:flex-wrap sm:justify-center lg:mx-0 lg:justify-start">
{['Umbauten Schritt für Schritt', 'Soundchecks & Vergleiche', 'Verstärker- & Komponenten-Tests'].map((item) => (
<li key={item} className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-3.5 w-3.5 flex-none">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{item}
</li>
))}
</ul>
<a
href={YOUTUBE_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex items-center gap-2 rounded-md bg-white px-6 py-3 text-sm font-bold text-red-700 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:bg-red-50"
>
Zum YouTube-Kanal
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0-4 4m4-4H3" />
</svg>
</a>
</Reveal>
<Reveal direction="right" index={1} className="overflow-hidden rounded-xl border border-white/10 shadow-2xl shadow-black/40">
<div className="aspect-video w-full">
<ExternalEmbed name="Das YouTube-Video" className="h-full w-full">
<iframe
className="h-full w-full"
src={`https://www.youtube-nocookie.com/embed/${YOUTUBE_VIDEO_ID}`}
title="HifiPlanet Amorbach Car-Hifi Umbauten"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</ExternalEmbed>
</div>
</Reveal>
</div>
</section>
<section className="border-y border-slate-200 bg-slate-50 py-16 dark:border-slate-800 dark:bg-slate-900/40">
<div className="mx-auto max-w-6xl space-y-16 overflow-hidden px-4 sm:px-6">
<div className="grid items-center gap-8 md:grid-cols-2">
<Reveal direction="left" shine className="rounded-xl shadow-lg">
<img src={gallerySubs} alt="Individueller Subwoofer-Bau" className="w-full rounded-xl object-cover" style={{ maxHeight: 380 }} />
</Reveal>
<Reveal direction="right" index={1}>
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">Handwerk</p>
<h2 className="mb-3 text-2xl font-bold text-slate-900 dark:text-white">Präzisionsarbeit in jedem Detail</h2>
<p className="text-slate-600 dark:text-slate-300">
Ob eigens gefertigte Subwoofer-Gehäuse, integrierte Beleuchtung oder unsichtbar verlegte Kabelwege
dank eigener CNC-, Laser- und 3D-Druck-Fertigung entstehen bei uns Lösungen, die es von der Stange nicht gibt.
</p>
</Reveal>
</div>
<div className="grid items-center gap-8 md:grid-cols-2">
<Reveal direction="left" className="order-2 md:order-1">
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-brand-600 dark:text-brand-400">Beratung</p>
<h2 className="mb-3 text-2xl font-bold text-slate-900 dark:text-white">Von der ersten Idee bis zum letzten Schliff</h2>
<p className="text-slate-600 dark:text-slate-300">
Jedes Fahrzeug und jeder Anspruch ist anders. Deshalb beraten wir dich persönlich und unverbindlich
vom dezenten Upgrade bis zum kompromisslosen High-End-System.
</p>
</Reveal>
<Reveal direction="right" index={1} shine className="order-1 md:order-2 rounded-xl shadow-lg">
<img src={galleryAmp} alt="Endstufen-Einbau mit Ambiente-Beleuchtung" className="w-full rounded-xl object-cover" style={{ maxHeight: 380 }} />
</Reveal>
</div>
</div>
</section>
<section className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<Reveal className="mb-8 text-center">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Einblicke in unsere Umbauten</h2>
<p className="mt-2 text-slate-600 dark:text-slate-300">
Vom unauffälligen Sound-Upgrade bis zum aufwendigen Komplettumbau Handarbeit aus unserer Werkstatt in Amorbach.
</p>
</Reveal>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{gallery.map((img, i) => (
<Reveal key={img.src} index={i % 3} shine className="aspect-square overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
<img src={img.src} alt={img.alt} loading="lazy" className="h-full w-full object-cover transition duration-300 hover:scale-105" />
</Reveal>
))}
</div>
</section>
<section className="border-y border-slate-200 bg-slate-50 py-16 dark:border-slate-800 dark:bg-slate-900/40">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<Reveal className="mb-10 text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<StarRating className="h-6 w-6" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">5,0 von 181 Kunden bewertet</h2>
<p className="mt-1 text-slate-600 dark:text-slate-300">Das sagen unsere Kunden über uns.</p>
<a
href="https://www.google.com/maps/search/?api=1&query=Hifi+Planet+Amorbach+Boxbrunner+Stra%C3%9Fe+20a"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-sm font-medium text-brand-600 hover:underline dark:text-brand-400"
>
Alle Bewertungen auf Google ansehen
</a>
</Reveal>
<Reveal>
<TestimonialSlider testimonials={testimonials} />
</Reveal>
</div>
</section>
<section className="mx-auto max-w-6xl overflow-hidden px-4 py-16 sm:px-6">
<div className="grid items-center gap-10 md:grid-cols-2">
<Reveal direction="left" shine className="overflow-hidden rounded-xl shadow-lg">
<img src={heroHighlight} alt="Hochwertige Endstufe und Subwoofer, Studioaufnahme" className="h-full w-full object-cover" loading="lazy" />
</Reveal>
<Reveal direction="right" index={1} className="text-center md:text-left">
<h2 className="mb-3 text-2xl font-bold text-slate-900 dark:text-white">Mehr als nur Car-Hifi</h2>
<p className="mx-auto mb-8 max-w-2xl text-slate-600 dark:text-slate-300 md:mx-0">
Neben individuellen Sound-Umbauten bieten wir CNC-Zerspanung, Lasertechnik, 3D-Druck und mehr
alles aus einer Hand in unserer eigenen Werkstatt.
</p>
<Link
to="/leistungen"
className="inline-block rounded-md border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 transition hover:-translate-y-0.5 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
Alle Leistungen entdecken
</Link>
</Reveal>
</div>
</section>
<section className="border-t border-slate-200 py-16 dark:border-slate-800">
<div className="mx-auto max-w-3xl px-4 sm:px-6">
<Reveal className="mb-8 text-center">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Häufig gestellte Fragen</h2>
</Reveal>
<Reveal index={1}>
<Accordion items={faqs} />
</Reveal>
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,108 @@
import usePageMeta from '../../hooks/usePageMeta.js';
export default function Impressum() {
usePageMeta({
title: 'Impressum',
description: 'Impressum von HifiPlanet Amorbach gemäß § 5 DDG.',
path: '/impressum',
});
return (
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
<h1 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">Impressum</h1>
<div className="space-y-8 text-sm text-slate-600 dark:text-slate-300">
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">Angaben gemäß § 5 DDG</h2>
<p>
Hifi Planet e.K.
<br />
Inhaber: Christopher Horndasch
<br />
Boxbrunner Str. 20a
<br />
63916 Amorbach
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">Kontakt</h2>
<p>
Telefon: <a href="tel:+4993732062390" className="hover:text-brand-500">09373 20 62 390</a>
<br />
E-Mail: <a href="mailto:info@hifi-planet-amorbach.de" className="hover:text-brand-500">info@hifi-planet-amorbach.de</a>
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">Umsatzsteuer-ID</h2>
<p>
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: DE 275084766
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">Zuständige Kammer</h2>
<p>IHK Aschaffenburg</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<p>
Christopher Horndasch
<br />
Anschrift wie oben
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">Verbraucherstreitbeilegung</h2>
<p>
Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle im Sinne des Verbraucherstreitbeilegungsgesetzes (VSBG) teilzunehmen.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">Haftung für Inhalte</h2>
<p>
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den
allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG 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. 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. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend
entfernen.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">Haftung für Links</h2>
<p>
Unser Angebot enthält 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. Eine
permanente inhaltliche Kontrolle der verlinkten Seiten ist ohne konkrete Anhaltspunkte einer
Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links
umgehend entfernen.
</p>
</section>
<section>
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">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.
Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.
Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte
Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem
auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei
Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.
</p>
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import usePageMeta from '../../hooks/usePageMeta.js';
import Reveal from '../../components/Reveal.jsx';
import DynamicIcon from '../../components/DynamicIcon.jsx';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
import { api } from '../../api/client.js';
export default function Leistungen() {
const [services, setServices] = useState([]);
const maintenance = useMaintenance();
useEffect(() => {
api.get('/services').then(setServices).catch(() => setServices([]));
}, []);
usePageMeta({
title: 'Leistungen',
description:
'Car-Hifi, Wohnmobil & Caravan, Oldtimer, CNC-Zerspanung, Lasertechnik, 3D-Druck, Alarmanlagen und Dash Cams alles aus einer Hand bei HifiPlanet in Amorbach.',
path: '/leistungen',
});
if (maintenance.services.enabled && !maintenance.bypass) {
return <MaintenanceNotice message={maintenance.services.message} />;
}
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
{maintenance.services.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
<h1 className="mb-2 text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">Unsere Leistungen</h1>
<p className="mb-10 max-w-2xl text-slate-600 dark:text-slate-300">
Von individuellen Sound-Umbauten bis zur eigenen CNC- und 3D-Druck-Fertigung alles aus einer Hand
in unserer Werkstatt in Amorbach.
</p>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{services.map((service, i) => (
<Reveal
key={service.id}
index={i % 3}
shine
className="overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
<div className="relative aspect-[4/3]">
{service.image_path && (
<img src={service.image_path} alt={service.title} className="h-full w-full object-cover" loading="lazy" />
)}
<div className="absolute -bottom-5 left-5 flex h-12 w-12 items-center justify-center rounded-full bg-brand-500 text-white shadow-lg ring-4 ring-white dark:ring-slate-900">
<DynamicIcon name={service.icon_name} className="h-6 w-6" />
</div>
</div>
<div className="p-6 pt-8">
<h2 className="mb-2 font-semibold text-slate-900 dark:text-white">{service.title}</h2>
<p className="mb-3 text-sm text-slate-600 dark:text-slate-300">{service.description}</p>
{service.cta_label && service.cta_url && (
<Link to={service.cta_url} className="text-sm font-semibold text-brand-600 hover:underline dark:text-brand-400">
{service.cta_label}
</Link>
)}
</div>
</Reveal>
))}
</div>
<div className="mt-12 rounded-xl border border-slate-200 bg-slate-50 p-8 text-center dark:border-slate-800 dark:bg-slate-900/40">
<h2 className="mb-2 text-xl font-bold text-slate-900 dark:text-white">Dein Projekt ist nicht dabei?</h2>
<p className="mb-5 text-slate-600 dark:text-slate-300">Sprich uns einfach an wir finden gemeinsam die passende Lösung.</p>
<Link to="/kontakt" className="inline-block rounded-md bg-brand-500 px-6 py-3 text-sm font-semibold text-white hover:bg-brand-600">
Kontakt aufnehmen
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
const formatPrice = (value) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
export default function ModelPage() {
const { brandSlug, modelSlug } = useParams();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const maintenance = useMaintenance();
useEffect(() => {
if (maintenance.vehicles.enabled && !maintenance.bypass) return;
api
.get(`/models/${brandSlug}/${modelSlug}/packages`)
.then(setData)
.catch((e) => setError(e.message));
}, [brandSlug, modelSlug, maintenance.vehicles.enabled, maintenance.bypass]);
usePageMeta({
title: data ? `${data.model.brand_name} ${data.model.name} Sound-Pakete` : 'Sound-Pakete',
description: data
? `Car-Hifi Sound-Pakete für ${data.model.brand_name} ${data.model.name} inkl. aktueller Preise von HifiPlanet unverbindlich anfragen.`
: undefined,
path: `/fahrzeuge/${brandSlug}/${modelSlug}`,
});
if (maintenance.vehicles.enabled && !maintenance.bypass) {
return <MaintenanceNotice message={maintenance.vehicles.message} />;
}
if (error) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-red-600 sm:px-6">{error}</p>;
}
if (!data) {
return <p className="mx-auto max-w-6xl px-4 py-12 text-slate-500 sm:px-6">Lädt</p>;
}
const { model, packages } = data;
const contactUrl = (pkg) => {
const params = new URLSearchParams({
brand: model.brand_name,
model: model.name,
package: pkg.name,
packageId: pkg.id,
total: pkg.total_price,
});
return `/kontakt?${params.toString()}`;
};
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
{maintenance.vehicles.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
<p className="mb-1 text-sm text-slate-500 dark:text-slate-400">
<Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</Link> /{' '}
<Link to={`/fahrzeuge/${brandSlug}`} className="hover:text-brand-500">{model.brand_name}</Link> / {model.name}
</p>
<h1 className="mb-8 text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">
{model.brand_name} {model.name} Sound-Pakete
</h1>
{packages.length === 0 && (
<p className="text-slate-500 dark:text-slate-400">Für dieses Modell sind noch keine Pakete hinterlegt.</p>
)}
<div className="space-y-6">
{packages.map((pkg) => (
<div key={pkg.id} className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex flex-wrap items-start justify-between gap-4">
<h2 className="text-lg font-bold text-slate-900 dark:text-white">{pkg.name}</h2>
<div className="text-right">
<p className="text-xs uppercase tracking-wide text-slate-400">Gesamtpreis (ca.)</p>
<p className="text-xl font-extrabold text-brand-600 dark:text-brand-400">{formatPrice(pkg.total_price)}</p>
</div>
</div>
<ul className="mb-5 list-disc space-y-1 pl-5 text-sm text-slate-700 dark:text-slate-300">
{pkg.products.map((product) => (
<li key={product.id}>
{product.name_override || product.scraped_name || 'Produkt wird geladen…'}
</li>
))}
{pkg.description
?.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, i) => <li key={`desc-${i}`}>{line}</li>)}
</ul>
<Link
to={contactUrl(pkg)}
className="inline-block rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Kontakt anfragen
</Link>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js';
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
export default function VehicleSelect() {
const [brands, setBrands] = useState([]);
const [error, setError] = useState('');
const maintenance = useMaintenance();
usePageMeta({
title: 'Fahrzeug auswählen',
description: 'Wähle deine Fahrzeugmarke und dein Modell und entdecke passende Car-Hifi Sound-Pakete von HifiPlanet.',
path: '/fahrzeuge',
});
useEffect(() => {
if (maintenance.vehicles.enabled && !maintenance.bypass) return;
api.get('/brands').then(setBrands).catch((e) => setError(e.message));
}, [maintenance.vehicles.enabled, maintenance.bypass]);
if (maintenance.vehicles.enabled && !maintenance.bypass) {
return <MaintenanceNotice message={maintenance.vehicles.message} />;
}
return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
{maintenance.vehicles.enabled && maintenance.bypass && <MaintenanceBypassBanner inline />}
<h1 className="mb-2 text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">Fahrzeug auswählen</h1>
<p className="mb-8 text-slate-600 dark:text-slate-300">
Wähle zuerst deine Marke, dann dein Modell wir zeigen dir direkt die passenden Sound-Pakete.
</p>
{error && <p className="text-red-600">{error}</p>}
{brands.length === 0 && !error && (
<p className="text-slate-500 dark:text-slate-400">Es sind noch keine Marken hinterlegt.</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{brands.map((brand) => (
<Link
key={brand.id}
to={`/fahrzeuge/${brand.slug}`}
className="flex items-center justify-center rounded-xl border border-slate-200 bg-white px-6 py-8 text-center font-semibold text-slate-800 shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"
>
{brand.name}
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#f5fbee',
100: '#e9f6da',
200: '#d3eeb4',
300: '#b6e382',
400: '#92d444',
500: '#6ba626',
600: '#58891f',
700: '#48701a',
800: '#3b5b15',
900: '#304b11',
},
},
},
},
plugins: [],
};

19
hifi-src/vite.config.js Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: '/hifi/',
build: {
outDir: '../hifi',
emptyOutDir: false,
},
server: {
proxy: {
'/hifi/api': {
target: 'http://localhost',
changeOrigin: true,
},
},
},
});

15
hifi/.htaccess Normal file
View file

@ -0,0 +1,15 @@
RewriteEngine On
RewriteBase /hifi/
# API-Requests an den PHP-Front-Controller weiterleiten
RewriteCond %{REQUEST_URI} ^/hifi/api/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^api/(.*)$ api/public/index.php [QSA,L]
# Dynamische sitemap.xml (DB-generiert)
RewriteRule ^sitemap\.xml$ api/public/sitemap.php [L]
# Alles andere: SPA-Fallback auf index.html (React Router übernimmt das Routing)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [QSA,L]

10
hifi/api/composer.json Normal file
View file

@ -0,0 +1,10 @@
{
"require": {
"phpmailer/phpmailer": "^6.9"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}

100
hifi/api/composer.lock generated Normal file
View file

@ -0,0 +1,100 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "baf0b1b659e688a64051c5ee9742a77e",
"packages": [
{
"name": "phpmailer/phpmailer",
"version": "v6.12.0",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12",
"reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*",
"php": ">=5.5.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^9.3.5",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7.2",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-only"
],
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0"
},
"funding": [
{
"url": "https://github.com/Synchro",
"type": "github"
}
],
"time": "2025-10-15T16:49:08+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View file

@ -0,0 +1,30 @@
<?php
return [
// Ausgelagert, damit das Admin-Panel (Einstellungen -> Datenbank) diese Datei
// gezielt neu schreiben kann, ohne den Rest dieser Konfiguration anzufassen.
'db' => require __DIR__ . '/db.php',
'mail' => [
// Vom Betreiber auszufüllen (z.B. eigenes Postfach oder Transactional-Mail-Anbieter).
// Solange 'host' leer ist, wird der Mailversand übersprungen (Anfrage wird trotzdem in der DB gespeichert).
'host' => '',
'port' => 587,
'username' => '',
'password' => '',
'encryption' => 'tls', // 'tls' oder 'ssl'
'from_email' => 'kontakt@example.com',
'from_name' => 'HifiPlanet',
'to_email' => 'kontakt@example.com',
],
'app' => [
// Muss zum Vite "base" passen und wird für Session-Cookie-Pfad genutzt.
'base_path' => '/hifi',
'session_name' => 'hifi_admin_session',
// Öffentliche Domain der Seite (für sitemap.xml, robots.txt, Canonical-URLs). Ohne abschließenden Slash.
'site_url' => 'https://hifi-planet.de',
],
'scraper' => [
'timeout' => 12,
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
],
];

View file

@ -0,0 +1,12 @@
<?php
// Kopiere diese Datei zu db.php und trage die Zugangsdaten der jeweiligen Umgebung ein.
// db.php selbst ist bewusst nicht Teil des Repos (siehe .gitignore) und wird zusätzlich
// vom Admin-Panel (Einstellungen -> Datenbank) automatisch neu geschrieben.
return array (
'host' => '127.0.0.1',
'name' => 'hifi_shop',
'user' => 'root',
'pass' => '',
'charset' => 'utf8mb4',
);

View file

@ -0,0 +1,6 @@
USE hifi_shop;
ALTER TABLE admin_users
ADD COLUMN two_factor_secret VARCHAR(64) NULL AFTER is_super_admin,
ADD COLUMN two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER two_factor_secret,
ADD COLUMN two_factor_recovery_codes TEXT NULL AFTER two_factor_enabled;

View file

@ -0,0 +1,14 @@
USE hifi_shop;
CREATE TABLE IF NOT EXISTS app_settings (
id INT PRIMARY KEY DEFAULT 1,
maintenance_global TINYINT(1) NOT NULL DEFAULT 0,
maintenance_global_message VARCHAR(500) NULL,
maintenance_services TINYINT(1) NOT NULL DEFAULT 0,
maintenance_services_message VARCHAR(500) NULL,
maintenance_vehicles TINYINT(1) NOT NULL DEFAULT 0,
maintenance_vehicles_message VARCHAR(500) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
INSERT IGNORE INTO app_settings (id) VALUES (1);

View file

@ -0,0 +1,37 @@
USE hifi_shop;
ALTER TABLE admin_users
ADD COLUMN is_super_admin TINYINT(1) NOT NULL DEFAULT 0 AFTER password_hash;
-- Bestehende Admin-Konten bleiben uneingeschränkt (Super-Admin), damit niemand ausgesperrt wird.
UPDATE admin_users SET is_super_admin = 1;
CREATE TABLE IF NOT EXISTS permission_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS permission_group_permissions (
permission_group_id INT NOT NULL,
permission VARCHAR(64) NOT NULL,
PRIMARY KEY (permission_group_id, permission),
CONSTRAINT fk_pgp_group FOREIGN KEY (permission_group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS admin_user_permissions (
admin_user_id INT NOT NULL,
permission VARCHAR(64) NOT NULL,
PRIMARY KEY (admin_user_id, permission),
CONSTRAINT fk_aup_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS admin_user_groups (
admin_user_id INT NOT NULL,
permission_group_id INT NOT NULL,
PRIMARY KEY (admin_user_id, permission_group_id),
CONSTRAINT fk_aug_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE,
CONSTRAINT fk_aug_group FOREIGN KEY (permission_group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
) ENGINE=InnoDB;

View file

@ -0,0 +1,27 @@
USE hifi_shop;
CREATE TABLE IF NOT EXISTS services (
id INT AUTO_INCREMENT PRIMARY KEY,
icon_name VARCHAR(100) NOT NULL,
title VARCHAR(150) NOT NULL,
description TEXT NOT NULL,
image_path VARCHAR(255) NULL,
cta_label VARCHAR(100) NULL,
cta_url VARCHAR(255) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- icon_name nutzt lucide-react's kebab-case Namenskonvention (z.B. "shield-alert"),
-- damit sich die Icons per lucide-react/dynamic einzeln nachladen lassen statt die
-- komplette ~2000-Icon-Bibliothek in den Public-Bundle zu ziehen.
INSERT INTO services (icon_name, title, description, image_path, cta_label, cta_url, sort_order) VALUES
('car', 'Car-Hifi', 'Individuelle Sound-Umbauten vom dezenten Upgrade bis zum kompromisslosen High-End-System konfigurierbar direkt auf unserer Seite.', '/hifi/api/uploads/seed-leistung-car-hifi.jpg', 'Fahrzeug konfigurieren', '/fahrzeuge', 0),
('caravan', 'Wohnmobil & Caravan', 'Sound- und Elektronik-Lösungen speziell für Reisemobile und Caravans reversibel oder fest verbaut.', '/hifi/api/uploads/seed-leistung-wohnmobil.jpg', NULL, NULL, 1),
('car-front', 'Oldtimer', 'Zeitgemäßer Klang für Klassiker wir modernisieren die Anlage, ohne den Charakter deines Oldtimers zu verlieren.', '/hifi/api/uploads/seed-leistung-oldtimer.jpg', NULL, NULL, 2),
('cog', 'CNC Zerspanen', 'Fräsen, Bohren, Schneiden und Schleifen auf einer Fläche von 1500 × 3000 mm für Prototypen, Kleinserien und Großauflagen. Massivholz, Verbundwerkstoffe, Plattenmaterial und mehr.', '/hifi/api/uploads/seed-leistung-cnc-zerspanen.jpg', NULL, NULL, 3),
('zap', 'CNC Lasertechnik', 'Präzises Laserschneiden und -gravieren für Leder, Glas, Acryl, Holz, Karton und mehr für Einzelstücke ebenso wie Serienfertigung.', '/hifi/api/uploads/seed-leistung-cnc-laser.jpg', NULL, NULL, 4),
('boxes', '3D-Druck', 'Über 20 Drucker (FDM & SLA) für individuelle Halterungen, Blenden und Kleinserien Fertigung meist in 13 Tagen, auf Wunsch auch farbig.', '/hifi/api/uploads/seed-leistung-3d-druck.jpg', NULL, NULL, 5),
('shield-alert', 'Alarmanlagen', 'Zuverlässiger Diebstahlschutz für dein Fahrzeug, abgestimmt auf deine Ansprüche und dein Budget.', '/hifi/api/uploads/seed-leistung-alarmanlagen.jpg', NULL, NULL, 6),
('video', 'Dash Cams', 'Beweissichere Aufzeichnung fürs Auto wir beraten dich zur passenden Kamera und übernehmen den unauffälligen Einbau.', '/hifi/api/uploads/seed-leistung-dashcam.jpg', NULL, NULL, 7);

View file

@ -0,0 +1,20 @@
USE hifi_shop;
ALTER TABLE packages
ADD COLUMN markup_type ENUM('none','fixed','percent') NOT NULL DEFAULT 'none' AFTER description,
ADD COLUMN markup_value DECIMAL(10,2) NOT NULL DEFAULT 0 AFTER markup_type;
CREATE TABLE IF NOT EXISTS package_upgrades (
id INT AUTO_INCREMENT PRIMARY KEY,
package_id INT NOT NULL,
name VARCHAR(150) NOT NULL,
description VARCHAR(255) NULL,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_upgrades_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE
) ENGINE=InnoDB;
ALTER TABLE contact_requests
ADD COLUMN selected_upgrades TEXT NULL AFTER package_product_id;

View file

@ -0,0 +1,154 @@
CREATE DATABASE IF NOT EXISTS hifi_shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hifi_shop;
CREATE TABLE admin_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_super_admin TINYINT(1) NOT NULL DEFAULT 0,
two_factor_secret VARCHAR(64) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
two_factor_recovery_codes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE app_settings (
id INT PRIMARY KEY DEFAULT 1,
maintenance_global TINYINT(1) NOT NULL DEFAULT 0,
maintenance_global_message VARCHAR(500) NULL,
maintenance_services TINYINT(1) NOT NULL DEFAULT 0,
maintenance_services_message VARCHAR(500) NULL,
maintenance_vehicles TINYINT(1) NOT NULL DEFAULT 0,
maintenance_vehicles_message VARCHAR(500) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE services (
id INT AUTO_INCREMENT PRIMARY KEY,
icon_name VARCHAR(100) NOT NULL,
title VARCHAR(150) NOT NULL,
description TEXT NOT NULL,
image_path VARCHAR(255) NULL,
cta_label VARCHAR(100) NULL,
cta_url VARCHAR(255) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE permission_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE permission_group_permissions (
permission_group_id INT NOT NULL,
permission VARCHAR(64) NOT NULL,
PRIMARY KEY (permission_group_id, permission),
CONSTRAINT fk_pgp_group FOREIGN KEY (permission_group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE admin_user_permissions (
admin_user_id INT NOT NULL,
permission VARCHAR(64) NOT NULL,
PRIMARY KEY (admin_user_id, permission),
CONSTRAINT fk_aup_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE admin_user_groups (
admin_user_id INT NOT NULL,
permission_group_id INT NOT NULL,
PRIMARY KEY (admin_user_id, permission_group_id),
CONSTRAINT fk_aug_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE,
CONSTRAINT fk_aug_group FOREIGN KEY (permission_group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE brands (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE car_models (
id INT AUTO_INCREMENT PRIMARY KEY,
brand_id INT NOT NULL,
name VARCHAR(100) NOT NULL,
slug VARCHAR(150) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_brand_slug (brand_id, slug),
CONSTRAINT fk_car_models_brand FOREIGN KEY (brand_id) REFERENCES brands(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE packages (
id INT AUTO_INCREMENT PRIMARY KEY,
car_model_id INT NOT NULL,
name VARCHAR(150) NOT NULL,
slug VARCHAR(170) NOT NULL,
description TEXT NULL,
markup_type ENUM('none','fixed','percent') NOT NULL DEFAULT 'none',
markup_value DECIMAL(10,2) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_model_slug (car_model_id, slug),
CONSTRAINT fk_packages_model FOREIGN KEY (car_model_id) REFERENCES car_models(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE package_upgrades (
id INT AUTO_INCREMENT PRIMARY KEY,
package_id INT NOT NULL,
name VARCHAR(150) NOT NULL,
description VARCHAR(255) NULL,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_upgrades_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE package_products (
id INT AUTO_INCREMENT PRIMARY KEY,
package_id INT NOT NULL,
source_url VARCHAR(500) NOT NULL,
name_override VARCHAR(255) NULL,
scraped_name VARCHAR(255) NULL,
scraped_price DECIMAL(10,2) NULL,
scraped_currency VARCHAR(8) NOT NULL DEFAULT 'EUR',
scraped_image_url VARCHAR(500) NULL,
price_updated_at DATETIME NULL,
scrape_status ENUM('pending','ok','error') NOT NULL DEFAULT 'pending',
scrape_error VARCHAR(255) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_products_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE contact_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL,
phone VARCHAR(50) NULL,
message TEXT NULL,
brand_name VARCHAR(100) NULL,
model_name VARCHAR(100) NULL,
package_name VARCHAR(150) NULL,
product_name VARCHAR(255) NULL,
package_id INT NULL,
package_product_id INT NULL,
selected_upgrades TEXT NULL,
status ENUM('new','in_progress','done') NOT NULL DEFAULT 'new',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_contact_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE SET NULL,
CONSTRAINT fk_contact_product FOREIGN KEY (package_product_id) REFERENCES package_products(id) ON DELETE SET NULL
) ENGINE=InnoDB;
INSERT IGNORE INTO app_settings (id) VALUES (1);

162
hifi/api/public/index.php Normal file
View file

@ -0,0 +1,162 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
use App\Controllers\AdminUserController;
use App\Controllers\AuthController;
use App\Controllers\BrandController;
use App\Controllers\ContactController;
use App\Controllers\DatabaseConfigController;
use App\Controllers\MaintenanceController;
use App\Controllers\ModelController;
use App\Controllers\PackageController;
use App\Controllers\PackageUpgradeController;
use App\Controllers\PermissionCatalogController;
use App\Controllers\PermissionGroupController;
use App\Controllers\ProductController;
use App\Controllers\SchemaController;
use App\Controllers\ServiceController;
use App\Controllers\SettingsController;
use App\Controllers\TwoFactorController;
use App\Controllers\UploadController;
use App\Middleware\AuthMiddleware;
use App\Router;
use App\Support\Http;
$config = require __DIR__ . '/../config/config.php';
// Erlaubt lokale Vite-Dev-Server-Ports, Cookies inklusive (nur relevant während der Entwicklung).
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (preg_match('#^http://localhost:\d+$#', $origin)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
}
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
session_name($config['app']['session_name']);
session_set_cookie_params([
'path' => $config['app']['base_path'] . '/',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$router = new Router();
// Nur eingeloggt sein muss man hierfür.
$admin = fn(callable $handler) => function (array $params = []) use ($handler) {
AuthMiddleware::requireAdmin();
$handler($params);
};
// Braucht zusätzlich eine bestimmte Permission (direkt oder über eine Berechtigungsgruppe).
$perm = fn(string $permission, callable $handler) => function (array $params = []) use ($handler, $permission) {
AuthMiddleware::requirePermission($permission);
$handler($params);
};
// Reicht, wenn irgendeine der genannten Permissions vorhanden ist.
$anyPerm = fn(array $permissions, callable $handler) => function (array $params = []) use ($handler, $permissions) {
AuthMiddleware::requireAnyPermission($permissions);
$handler($params);
};
// Blockiert öffentliche Besucher (nicht eingeloggte Admins), wenn der globale oder
// der übergebene Wartungsmodus aktiv ist.
$maint = fn(?string $scope, callable $handler) => function (array $params = []) use ($handler, $scope) {
MaintenanceController::guardPublic($scope);
$handler($params);
};
$router->post('/auth/login', fn($p) => AuthController::login());
$router->post('/auth/verify-2fa', fn($p) => AuthController::verifyTwoFactor());
$router->post('/auth/logout', fn($p) => AuthController::logout());
$router->get('/auth/me', fn($p) => AuthController::me());
$router->post('/auth/change-password', $admin(fn($p) => AuthController::changePassword()));
$router->get('/auth/2fa/setup', $admin(fn($p) => TwoFactorController::setup()));
$router->post('/auth/2fa/enable', $admin(fn($p) => TwoFactorController::enable()));
$router->post('/auth/2fa/disable', $admin(fn($p) => TwoFactorController::disable()));
$router->post('/auth/2fa/recovery-codes', $admin(fn($p) => TwoFactorController::regenerateRecoveryCodes()));
$router->get('/permissions/catalog', $admin(fn($p) => PermissionCatalogController::index()));
$router->get('/brands', $maint('vehicles', fn($p) => BrandController::index()));
$router->post('/brands', $perm('brands.manage', fn($p) => BrandController::store()));
$router->put('/brands/{id}', $perm('brands.manage', fn($p) => BrandController::update($p)));
$router->delete('/brands/{id}', $perm('brands.manage', fn($p) => BrandController::destroy($p)));
$router->get('/brands/{slug}/models', $maint('vehicles', fn($p) => BrandController::modelsForBrand($p)));
// Wird auch von der Pakete-Verwaltung für die Marke/Modell-Filter & -Auswahl benötigt.
$router->get('/models', $anyPerm(['models.manage', 'packages.manage'], fn($p) => ModelController::index()));
$router->post('/models', $perm('models.manage', fn($p) => ModelController::store()));
$router->put('/models/{id}', $perm('models.manage', fn($p) => ModelController::update($p)));
$router->delete('/models/{id}', $perm('models.manage', fn($p) => ModelController::destroy($p)));
$router->get('/models/{brand_slug}/{model_slug}/packages', $maint('vehicles', fn($p) => ModelController::packagesForModel($p)));
$router->get('/packages', $perm('packages.manage', fn($p) => PackageController::index()));
$router->post('/packages', $perm('packages.manage', fn($p) => PackageController::store()));
$router->put('/packages/{id}', $perm('packages.manage', fn($p) => PackageController::update($p)));
$router->delete('/packages/{id}', $perm('packages.manage', fn($p) => PackageController::destroy($p)));
$router->get('/packages/{id}/products', $perm('packages.manage', fn($p) => PackageController::products($p)));
$router->get('/packages/{id}/upgrades', $maint('vehicles', fn($p) => PackageUpgradeController::index($p)));
$router->post('/package-upgrades', $perm('packages.manage', fn($p) => PackageUpgradeController::store()));
$router->put('/package-upgrades/{id}', $perm('packages.manage', fn($p) => PackageUpgradeController::update($p)));
$router->delete('/package-upgrades/{id}', $perm('packages.manage', fn($p) => PackageUpgradeController::destroy($p)));
$router->post('/products', $perm('packages.manage', fn($p) => ProductController::store()));
$router->put('/products/{id}', $perm('packages.manage', fn($p) => ProductController::update($p)));
$router->delete('/products/{id}', $perm('packages.manage', fn($p) => ProductController::destroy($p)));
$router->post('/products/{id}/refresh-price', $perm('packages.manage', fn($p) => ProductController::refreshPrice($p)));
$router->post('/contact', $maint(null, fn($p) => ContactController::store()));
$router->get('/contact', $perm('contact.manage', fn($p) => ContactController::index()));
$router->patch('/contact/{id}', $perm('contact.manage', fn($p) => ContactController::updateStatus($p)));
$router->delete('/contact/{id}', $perm('contact.delete', fn($p) => ContactController::destroy($p)));
$router->post('/uploads', $anyPerm(['brands.manage', 'models.manage', 'services.manage'], fn($p) => UploadController::store()));
$router->get('/services', $maint('services', fn($p) => ServiceController::index()));
$router->post('/services', $perm('services.manage', fn($p) => ServiceController::store()));
$router->put('/services/{id}', $perm('services.manage', fn($p) => ServiceController::update($p)));
$router->delete('/services/{id}', $perm('services.manage', fn($p) => ServiceController::destroy($p)));
$router->get('/admin-users', $perm('users.manage', fn($p) => AdminUserController::index()));
$router->post('/admin-users', $perm('users.manage', fn($p) => AdminUserController::store()));
$router->put('/admin-users/{id}', $perm('users.manage', fn($p) => AdminUserController::update($p)));
$router->delete('/admin-users/{id}', $perm('users.manage', fn($p) => AdminUserController::destroy($p)));
$router->get('/permission-groups', $anyPerm(['users.manage', 'permission_groups.manage'], fn($p) => PermissionGroupController::index()));
$router->post('/permission-groups', $perm('permission_groups.manage', fn($p) => PermissionGroupController::store()));
$router->put('/permission-groups/{id}', $perm('permission_groups.manage', fn($p) => PermissionGroupController::update($p)));
$router->delete('/permission-groups/{id}', $perm('permission_groups.manage', fn($p) => PermissionGroupController::destroy($p)));
$router->get('/settings/export', $perm('settings.manage', fn($p) => SettingsController::exportData()));
$router->post('/settings/import', $perm('settings.manage', fn($p) => SettingsController::importData()));
$router->post('/settings/reset-services', $perm('settings.manage', fn($p) => SettingsController::resetServicesToDefaults()));
$router->post('/settings/reset-catalog', $perm('settings.manage', fn($p) => SettingsController::resetCatalog()));
$router->post('/settings/reset-all', $perm('settings.manage', fn($p) => SettingsController::resetAll()));
$router->get('/settings/schema-check', $perm('settings.manage', fn($p) => SchemaController::check()));
$router->post('/settings/schema-migrate', $perm('settings.manage', fn($p) => SchemaController::migrate()));
$router->get('/settings/database', $perm('settings.manage', fn($p) => DatabaseConfigController::show()));
$router->post('/settings/database', $perm('settings.manage', fn($p) => DatabaseConfigController::update()));
$router->get('/maintenance', fn($p) => MaintenanceController::status());
$router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update()));
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$apiBase = $config['app']['base_path'] . '/api';
$route = '/' . ltrim(substr($path, strlen($apiBase)), '/');
header('Content-Type: application/json; charset=utf-8');
try {
$router->dispatch($_SERVER['REQUEST_METHOD'], rtrim($route, '/') ?: '/');
} catch (\Throwable $e) {
Http::error('Serverfehler: ' . $e->getMessage(), 500);
}

View file

@ -0,0 +1,46 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
use App\Config\Database;
$config = require __DIR__ . '/../config/config.php';
$siteUrl = rtrim($config['app']['site_url'], '/');
$db = Database::connection();
$models = $db->query(
'SELECT b.slug AS brand_slug, m.slug AS model_slug, m.updated_at
FROM car_models m JOIN brands b ON b.id = m.brand_id'
)->fetchAll();
$brands = $db->query('SELECT slug, updated_at FROM brands')->fetchAll();
$urls = [
['loc' => '/', 'priority' => '1.0'],
['loc' => '/fahrzeuge', 'priority' => '0.9'],
['loc' => '/kontakt', 'priority' => '0.5'],
];
foreach ($brands as $brand) {
$urls[] = ['loc' => "/fahrzeuge/{$brand['slug']}", 'priority' => '0.8', 'lastmod' => $brand['updated_at']];
}
foreach ($models as $model) {
$urls[] = [
'loc' => "/fahrzeuge/{$model['brand_slug']}/{$model['model_slug']}",
'priority' => '0.7',
'lastmod' => $model['updated_at'],
];
}
header('Content-Type: application/xml; charset=utf-8');
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ($urls as $url) {
echo " <url>\n";
echo ' <loc>' . htmlspecialchars($siteUrl . $url['loc']) . "</loc>\n";
if (!empty($url['lastmod'])) {
echo ' <lastmod>' . date('Y-m-d', strtotime($url['lastmod'])) . "</lastmod>\n";
}
echo ' <priority>' . $url['priority'] . "</priority>\n";
echo " </url>\n";
}
echo '</urlset>';

View file

@ -0,0 +1,28 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
use App\Config\Database;
if ($argc < 3) {
fwrite(STDERR, "Verwendung: php create_admin.php <benutzername> <passwort>\n");
exit(1);
}
[$script, $username, $password] = $argv;
if (strlen($password) < 8) {
fwrite(STDERR, "Passwort muss mindestens 8 Zeichen lang sein.\n");
exit(1);
}
$db = Database::connection();
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $db->prepare(
'INSERT INTO admin_users (username, password_hash, is_super_admin) VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), is_super_admin = 1'
);
$stmt->execute([$username, $hash]);
echo "Super-Admin '{$username}' angelegt/aktualisiert.\n";

View file

@ -0,0 +1,2 @@
@echo off
"C:\xamppHIFI\php\php.exe" "C:\xamppHIFI\htdocs\hifi\api\scripts\refresh_prices.php" >> "C:\xamppHIFI\htdocs\hifi\api\scripts\refresh_prices.log" 2>&1

View file

@ -0,0 +1,36 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
use App\Config\Database;
use App\Services\Audio4CarsScraper;
$config = require __DIR__ . '/../config/config.php';
$scraper = new Audio4CarsScraper($config['scraper']['timeout'], $config['scraper']['user_agent']);
$db = Database::connection();
$products = $db->query('SELECT id, source_url FROM package_products')->fetchAll();
$ok = 0;
$failed = 0;
foreach ($products as $product) {
$result = $scraper->scrape($product['source_url']);
if ($result['ok']) {
$stmt = $db->prepare(
'UPDATE package_products
SET scraped_name = ?, scraped_price = ?, scraped_currency = ?, scraped_image_url = ?,
price_updated_at = NOW(), scrape_status = "ok", scrape_error = NULL
WHERE id = ?'
);
$stmt->execute([$result['name'], $result['price'], $result['currency'], $result['image'], $product['id']]);
$ok++;
} else {
$stmt = $db->prepare('UPDATE package_products SET scrape_status = "error", scrape_error = ? WHERE id = ?');
$stmt->execute([$result['error'], $product['id']]);
$failed++;
}
}
echo "Preis-Refresh abgeschlossen: {$ok} OK, {$failed} Fehler (von " . count($products) . " Produkten).\n";

View file

@ -0,0 +1,34 @@
<?php
namespace App\Config;
use PDO;
use PDOException;
class Database
{
private static ?PDO $instance = null;
public static function connection(): PDO
{
if (self::$instance === null) {
$config = require __DIR__ . '/../../config/config.php';
$db = $config['db'];
$dsn = "mysql:host={$db['host']};dbname={$db['name']};charset={$db['charset']}";
try {
self::$instance = new PDO($dsn, $db['user'], $db['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Datenbankverbindung fehlgeschlagen']);
exit;
}
}
return self::$instance;
}
}

View file

@ -0,0 +1,171 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Middleware\AuthMiddleware;
use App\Support\Http;
use App\Support\Permissions;
class AdminUserController
{
public static function index(): void
{
$db = Database::connection();
$users = $db->query(
'SELECT id, username, is_super_admin, created_at FROM admin_users ORDER BY username'
)->fetchAll();
$permStmt = $db->prepare('SELECT permission FROM admin_user_permissions WHERE admin_user_id = ?');
$groupStmt = $db->prepare(
'SELECT pg.id, pg.name FROM admin_user_groups aug
JOIN permission_groups pg ON pg.id = aug.permission_group_id
WHERE aug.admin_user_id = ?'
);
foreach ($users as &$user) {
$user['is_super_admin'] = (bool) $user['is_super_admin'];
$permStmt->execute([$user['id']]);
$user['permissions'] = $permStmt->fetchAll(\PDO::FETCH_COLUMN);
$groupStmt->execute([$user['id']]);
$user['groups'] = $groupStmt->fetchAll();
}
unset($user);
Http::send($users);
}
public static function store(): void
{
$actorId = AuthMiddleware::requireAdmin();
$body = Http::jsonBody();
$username = trim($body['username'] ?? '');
$password = (string) ($body['password'] ?? '');
if ($username === '' || strlen($password) < 8) {
Http::error('Benutzername erforderlich, Passwort mindestens 8 Zeichen', 422);
}
$wantsSuperAdmin = !empty($body['is_super_admin']);
if ($wantsSuperAdmin && !AuthMiddleware::isSuperAdmin($actorId)) {
Http::error('Nur Super-Admins dürfen Super-Admin-Rechte vergeben', 403);
}
$db = Database::connection();
$exists = $db->prepare('SELECT 1 FROM admin_users WHERE username = ?');
$exists->execute([$username]);
if ($exists->fetch()) {
Http::error('Benutzername bereits vergeben', 422);
}
$stmt = $db->prepare(
'INSERT INTO admin_users (username, password_hash, is_super_admin) VALUES (?, ?, ?)'
);
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT), $wantsSuperAdmin ? 1 : 0]);
$id = (int) $db->lastInsertId();
self::saveAssignments($db, $id, $body);
Http::send(['id' => $id], 201);
}
public static function update(array $params): void
{
$actorId = AuthMiddleware::requireAdmin();
$id = (int) $params['id'];
$body = Http::jsonBody();
$username = trim($body['username'] ?? '');
if ($username === '') {
Http::error('Benutzername erforderlich', 422);
}
$db = Database::connection();
$exists = $db->prepare('SELECT 1 FROM admin_users WHERE username = ? AND id != ?');
$exists->execute([$username, $id]);
if ($exists->fetch()) {
Http::error('Benutzername bereits vergeben', 422);
}
$wantsSuperAdmin = !empty($body['is_super_admin']);
$actorIsSuperAdmin = AuthMiddleware::isSuperAdmin($actorId);
if ($wantsSuperAdmin !== AuthMiddleware::isSuperAdmin($id) && !$actorIsSuperAdmin) {
Http::error('Nur Super-Admins dürfen Super-Admin-Rechte ändern', 403);
}
if (!$wantsSuperAdmin && AuthMiddleware::isSuperAdmin($id)) {
self::guardLastSuperAdmin($db, $id, 'Der letzte Super-Admin kann nicht degradiert werden');
}
$password = (string) ($body['password'] ?? '');
if ($password !== '') {
if (strlen($password) < 8) {
Http::error('Passwort muss mindestens 8 Zeichen lang sein', 422);
}
$stmt = $db->prepare(
'UPDATE admin_users SET username = ?, password_hash = ?, is_super_admin = ? WHERE id = ?'
);
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT), $wantsSuperAdmin ? 1 : 0, $id]);
} else {
$stmt = $db->prepare('UPDATE admin_users SET username = ?, is_super_admin = ? WHERE id = ?');
$stmt->execute([$username, $wantsSuperAdmin ? 1 : 0, $id]);
}
self::saveAssignments($db, $id, $body);
Http::send(['ok' => true]);
}
public static function destroy(array $params): void
{
$actorId = AuthMiddleware::requireAdmin();
$id = (int) $params['id'];
if ($id === $actorId) {
Http::error('Du kannst dein eigenes Konto nicht löschen', 422);
}
$db = Database::connection();
if (AuthMiddleware::isSuperAdmin($id)) {
self::guardLastSuperAdmin($db, $id, 'Der letzte Super-Admin kann nicht gelöscht werden');
}
$stmt = $db->prepare('DELETE FROM admin_users WHERE id = ?');
$stmt->execute([$id]);
Http::send(['ok' => true]);
}
private static function guardLastSuperAdmin(\PDO $db, int $excludingId, string $message): void
{
$stmt = $db->prepare('SELECT COUNT(*) FROM admin_users WHERE is_super_admin = 1 AND id != ?');
$stmt->execute([$excludingId]);
if ((int) $stmt->fetchColumn() === 0) {
Http::error($message, 422);
}
}
private static function saveAssignments(\PDO $db, int $userId, array $body): void
{
if (array_key_exists('permissions', $body)) {
$permissions = Permissions::sanitizeList($body['permissions'] ?? []);
$db->prepare('DELETE FROM admin_user_permissions WHERE admin_user_id = ?')->execute([$userId]);
if ($permissions) {
$stmt = $db->prepare('INSERT INTO admin_user_permissions (admin_user_id, permission) VALUES (?, ?)');
foreach ($permissions as $permission) {
$stmt->execute([$userId, $permission]);
}
}
}
if (array_key_exists('group_ids', $body)) {
$groupIds = array_values(array_unique(array_map('intval', $body['group_ids'] ?? [])));
$db->prepare('DELETE FROM admin_user_groups WHERE admin_user_id = ?')->execute([$userId]);
if ($groupIds) {
$stmt = $db->prepare('INSERT INTO admin_user_groups (admin_user_id, permission_group_id) VALUES (?, ?)');
foreach ($groupIds as $groupId) {
$stmt->execute([$userId, $groupId]);
}
}
}
}
}

View file

@ -0,0 +1,187 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Middleware\AuthMiddleware;
use App\Support\Http;
use App\Support\RecoveryCodes;
use App\Support\Totp;
class AuthController
{
public static function login(): void
{
$body = Http::jsonBody();
$username = trim($body['username'] ?? '');
$password = (string) ($body['password'] ?? '');
if ($username === '' || $password === '') {
Http::error('Benutzername und Passwort erforderlich', 422);
}
$stmt = Database::connection()->prepare(
'SELECT id, username, password_hash, two_factor_enabled FROM admin_users WHERE username = ?'
);
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
Http::error('Benutzername oder Passwort falsch', 401);
}
if ((bool) $user['two_factor_enabled']) {
session_regenerate_id(true);
$_SESSION['pending_2fa_user_id'] = (int) $user['id'];
unset($_SESSION['admin_id'], $_SESSION['admin_username']);
Http::send(['requires_2fa' => true]);
return;
}
self::completeLogin((int) $user['id'], $user['username']);
}
public static function verifyTwoFactor(): void
{
if (empty($_SESSION['pending_2fa_user_id'])) {
Http::error('Keine ausstehende Anmeldung. Bitte erneut einloggen.', 400);
}
$userId = (int) $_SESSION['pending_2fa_user_id'];
$body = Http::jsonBody();
$code = trim($body['code'] ?? '');
if ($code === '') {
Http::error('Code erforderlich', 422);
}
$stmt = Database::connection()->prepare(
'SELECT username, two_factor_secret, two_factor_recovery_codes FROM admin_users WHERE id = ?'
);
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user) {
Http::error('Benutzer nicht gefunden', 404);
}
$valid = Totp::verifyCode($user['two_factor_secret'] ?? '', $code);
if (!$valid) {
$hashes = $user['two_factor_recovery_codes'] ? json_decode($user['two_factor_recovery_codes'], true) : [];
$remaining = RecoveryCodes::consume($hashes, $code);
if ($remaining !== null) {
$valid = true;
$update = Database::connection()->prepare(
'UPDATE admin_users SET two_factor_recovery_codes = ? WHERE id = ?'
);
$update->execute([json_encode($remaining), $userId]);
}
}
if (!$valid) {
Http::error('Code ungültig oder abgelaufen', 401);
}
self::completeLogin($userId, $user['username']);
}
public static function changePassword(): void
{
$userId = AuthMiddleware::requireAdmin();
$body = Http::jsonBody();
$currentPassword = (string) ($body['current_password'] ?? '');
$newPassword = (string) ($body['new_password'] ?? '');
$newPasswordConfirmation = (string) ($body['new_password_confirmation'] ?? '');
$twoFactorCode = trim((string) ($body['two_factor_code'] ?? ''));
if ($currentPassword === '' || $newPassword === '' || $newPasswordConfirmation === '') {
Http::error('Bitte alle Felder ausfüllen', 422);
}
if (strlen($newPassword) < 8) {
Http::error('Das neue Passwort muss mindestens 8 Zeichen lang sein', 422);
}
if ($newPassword !== $newPasswordConfirmation) {
Http::error('Die neuen Passwörter stimmen nicht überein', 422);
}
$stmt = Database::connection()->prepare(
'SELECT password_hash, two_factor_enabled, two_factor_secret, two_factor_recovery_codes FROM admin_users WHERE id = ?'
);
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user || !password_verify($currentPassword, $user['password_hash'])) {
Http::error('Aktuelles Passwort ist falsch', 401);
}
if ((bool) $user['two_factor_enabled']) {
if ($twoFactorCode === '') {
Http::error('Bitte deinen 2FA-Code eingeben', 422);
}
$valid = Totp::verifyCode($user['two_factor_secret'] ?? '', $twoFactorCode);
if (!$valid) {
$hashes = $user['two_factor_recovery_codes'] ? json_decode($user['two_factor_recovery_codes'], true) : [];
$remaining = RecoveryCodes::consume($hashes, $twoFactorCode);
if ($remaining !== null) {
$valid = true;
$update = Database::connection()->prepare(
'UPDATE admin_users SET two_factor_recovery_codes = ? WHERE id = ?'
);
$update->execute([json_encode($remaining), $userId]);
}
}
if (!$valid) {
Http::error('2FA-Code ungültig oder abgelaufen', 401);
}
}
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
$update = Database::connection()->prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?');
$update->execute([$newHash, $userId]);
Http::send(['ok' => true]);
}
public static function logout(): void
{
$_SESSION = [];
session_destroy();
Http::send(['ok' => true]);
}
public static function me(): void
{
if (empty($_SESSION['admin_id'])) {
Http::error('Nicht angemeldet', 401);
}
Http::send(self::userPayload((int) $_SESSION['admin_id'], $_SESSION['admin_username']));
}
private static function completeLogin(int $id, string $username): void
{
session_regenerate_id(true);
$_SESSION['admin_id'] = $id;
$_SESSION['admin_username'] = $username;
unset($_SESSION['pending_2fa_user_id']);
Http::send(self::userPayload($id, $username));
}
private static function userPayload(int $id, string $username): array
{
$stmt = Database::connection()->prepare('SELECT two_factor_enabled FROM admin_users WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
return [
'id' => $id,
'username' => $username,
'is_super_admin' => AuthMiddleware::isSuperAdmin($id),
'two_factor_enabled' => (bool) ($row['two_factor_enabled'] ?? false),
'permissions' => AuthMiddleware::effectivePermissions($id),
];
}
}

Some files were not shown because too many files have changed in this diff Show more