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>
37
.gitignore
vendored
Normal 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
|
|
@ -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
26
hifi-src/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
hifi-src/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
hifi-src/public/favicon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
6
hifi-src/public/robots.txt
Normal 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
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
hifi-src/src/api/client.js
Normal 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' }),
|
||||||
|
};
|
||||||
BIN
hifi-src/src/assets/leistungen/3d-druck.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
hifi-src/src/assets/leistungen/alarmanlagen.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
hifi-src/src/assets/leistungen/car-hifi.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
hifi-src/src/assets/leistungen/cnc-laser.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
hifi-src/src/assets/leistungen/cnc-zerspanen.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
hifi-src/src/assets/leistungen/dashcam.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
hifi-src/src/assets/leistungen/hero-highlight.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
hifi-src/src/assets/leistungen/oldtimer.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
hifi-src/src/assets/leistungen/wohnmobil.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
hifi-src/src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
hifi-src/src/assets/photos/gallery-amp-purple.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
hifi-src/src/assets/photos/gallery-dash.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
hifi-src/src/assets/photos/gallery-dash2.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
hifi-src/src/assets/photos/gallery-rear.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
hifi-src/src/assets/photos/gallery-speaker.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
hifi-src/src/assets/photos/gallery-subs.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
hifi-src/src/assets/photos/hero-trunk.jpg
Normal file
|
After Width: | Height: | Size: 341 KiB |
50
hifi-src/src/components/Accordion.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
hifi-src/src/components/AdminLayout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
hifi-src/src/components/CookieConsentBanner.jsx
Normal 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} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
hifi-src/src/components/DynamicIcon.jsx
Normal 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} />;
|
||||||
|
}
|
||||||
35
hifi-src/src/components/ExternalEmbed.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
hifi-src/src/components/Footer.jsx
Normal 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:00–18:00 Uhr' },
|
||||||
|
{ days: 'Samstag', from: 10 * 60, to: 13 * 60, label: '10:00–13: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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
hifi-src/src/components/IconPicker.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
hifi-src/src/components/ImageUploadField.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
hifi-src/src/components/MaintenanceBypassBanner.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
hifi-src/src/components/MaintenanceNotice.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
hifi-src/src/components/Navbar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
hifi-src/src/components/ProtectedRoute.jsx
Normal 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 />;
|
||||||
|
}
|
||||||
59
hifi-src/src/components/PublicLayout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
hifi-src/src/components/RequirePermission.jsx
Normal 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;
|
||||||
|
}
|
||||||
50
hifi-src/src/components/Reveal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
hifi-src/src/components/ScrollProgress.jsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
hifi-src/src/components/ScrollToTop.jsx
Normal 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;
|
||||||
|
}
|
||||||
11
hifi-src/src/components/StarRating.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
hifi-src/src/components/TestimonialSlider.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
hifi-src/src/components/ThemeToggle.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
hifi-src/src/context/AuthContext.jsx
Normal 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);
|
||||||
|
}
|
||||||
60
hifi-src/src/context/CookieConsentContext.jsx
Normal 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);
|
||||||
|
}
|
||||||
14
hifi-src/src/context/MaintenanceContext.jsx
Normal 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);
|
||||||
|
}
|
||||||
24
hifi-src/src/context/ThemeContext.jsx
Normal 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);
|
||||||
|
}
|
||||||
38
hifi-src/src/hooks/usePageMeta.js
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
358
hifi-src/src/pages/admin/AccountSettings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
hifi-src/src/pages/admin/AdminUsers.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
hifi-src/src/pages/admin/Brands.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
hifi-src/src/pages/admin/ContactRequests.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
hifi-src/src/pages/admin/Dashboard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
hifi-src/src/pages/admin/Login.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
hifi-src/src/pages/admin/Models.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
hifi-src/src/pages/admin/PackageProducts.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
hifi-src/src/pages/admin/PackageUpgrades.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
hifi-src/src/pages/admin/Packages.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
hifi-src/src/pages/admin/PermissionGroups.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
hifi-src/src/pages/admin/Services.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
hifi-src/src/pages/admin/settings/DatabaseSettings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
hifi-src/src/pages/admin/settings/ExportImportSettings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
hifi-src/src/pages/admin/settings/MaintenanceSettings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
hifi-src/src/pages/admin/settings/ResetSettings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
hifi-src/src/pages/admin/settings/SettingsLayout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
hifi-src/src/pages/public/AGB.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
hifi-src/src/pages/public/BrandPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
hifi-src/src/pages/public/ContactPage.jsx
Normal 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>Mo–Fr: 9:00–18:00 Uhr</dd>
|
||||||
|
<dd>Sa: 10:00–13: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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
hifi-src/src/pages/public/Datenschutz.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
hifi-src/src/pages/public/Home.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
hifi-src/src/pages/public/Impressum.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
hifi-src/src/pages/public/Leistungen.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
hifi-src/src/pages/public/ModelPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
hifi-src/src/pages/public/VehicleSelect.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
hifi-src/tailwind.config.js
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"phpmailer/phpmailer": "^6.9"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
hifi/api/composer.lock
generated
Normal 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"
|
||||||
|
}
|
||||||
30
hifi/api/config/config.php
Normal 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',
|
||||||
|
],
|
||||||
|
];
|
||||||
12
hifi/api/config/db.php.example
Normal 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',
|
||||||
|
);
|
||||||
6
hifi/api/database/migration_2fa.sql
Normal 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;
|
||||||
14
hifi/api/database/migration_maintenance.sql
Normal 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);
|
||||||
37
hifi/api/database/migration_permissions.sql
Normal 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;
|
||||||
27
hifi/api/database/migration_services.sql
Normal 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 1–3 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);
|
||||||
20
hifi/api/database/migration_upgrades.sql
Normal 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;
|
||||||
154
hifi/api/database/schema.sql
Normal 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
|
|
@ -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);
|
||||||
|
}
|
||||||
46
hifi/api/public/sitemap.php
Normal 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>';
|
||||||
28
hifi/api/scripts/create_admin.php
Normal 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";
|
||||||
2
hifi/api/scripts/refresh_prices.bat
Normal 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
|
||||||
36
hifi/api/scripts/refresh_prices.php
Normal 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";
|
||||||
34
hifi/api/src/Config/Database.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
hifi/api/src/Controllers/AdminUserController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
hifi/api/src/Controllers/AuthController.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||