Add admin-configurable website settings and VIN field to contact form

Adds a Website settings tab (hero image, phone, WhatsApp, email) that
feeds the navbar, footer and contact page via a new SiteSettingsContext,
redesigns the navbar with a bigger centered logo plus phone/WhatsApp/shop
links, and adds a VIN/chassis-number field to the contact form, admin
inbox and notification email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-06 15:28:11 +02:00
parent febe294129
commit 95764039c7
16 changed files with 376 additions and 54 deletions

View file

@ -30,6 +30,7 @@ import AccountSettings from './pages/admin/AccountSettings.jsx';
import AdminUsers from './pages/admin/AdminUsers.jsx'; import AdminUsers from './pages/admin/AdminUsers.jsx';
import PermissionGroups from './pages/admin/PermissionGroups.jsx'; import PermissionGroups from './pages/admin/PermissionGroups.jsx';
import SettingsLayout from './pages/admin/settings/SettingsLayout.jsx'; import SettingsLayout from './pages/admin/settings/SettingsLayout.jsx';
import WebsiteSettings from './pages/admin/settings/WebsiteSettings.jsx';
import DatabaseSettings from './pages/admin/settings/DatabaseSettings.jsx'; import DatabaseSettings from './pages/admin/settings/DatabaseSettings.jsx';
import ExportImportSettings from './pages/admin/settings/ExportImportSettings.jsx'; import ExportImportSettings from './pages/admin/settings/ExportImportSettings.jsx';
import MaintenanceSettings from './pages/admin/settings/MaintenanceSettings.jsx'; import MaintenanceSettings from './pages/admin/settings/MaintenanceSettings.jsx';
@ -74,7 +75,8 @@ export default function App() {
path="settings" path="settings"
element={<RequirePermission permission="settings.manage"><SettingsLayout /></RequirePermission>} element={<RequirePermission permission="settings.manage"><SettingsLayout /></RequirePermission>}
> >
<Route index element={<Navigate to="database" replace />} /> <Route index element={<Navigate to="website" replace />} />
<Route path="website" element={<WebsiteSettings />} />
<Route path="database" element={<DatabaseSettings />} /> <Route path="database" element={<DatabaseSettings />} />
<Route path="export-import" element={<ExportImportSettings />} /> <Route path="export-import" element={<ExportImportSettings />} />
<Route path="maintenance" element={<MaintenanceSettings />} /> <Route path="maintenance" element={<MaintenanceSettings />} />

View file

@ -1,6 +1,9 @@
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import DynamicIcon from './DynamicIcon.jsx'; import DynamicIcon from './DynamicIcon.jsx';
import { useCookieConsent } from '../context/CookieConsentContext.jsx'; import { useCookieConsent } from '../context/CookieConsentContext.jsx';
import { useSiteSettings } from '../context/SiteSettingsContext.jsx';
const digitsOnly = (value) => (value || '').replace(/[^\d+]/g, '');
const SHOP_ADDRESS_ENCODED = encodeURIComponent('Boxbrunner Str. 20a, 63916 Amorbach'); 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 DIRECTIONS_URL = `https://www.google.com/maps/dir/?api=1&destination=${SHOP_ADDRESS_ENCODED}`;
@ -27,6 +30,7 @@ export default function Footer() {
// zusätzlichen Platz für sie freihalten. // zusätzlichen Platz für sie freihalten.
const { pathname } = useLocation(); const { pathname } = useLocation();
const isHome = pathname === '/'; const isHome = pathname === '/';
const { phone, contact_email: contactEmail } = useSiteSettings();
return ( return (
<footer <footer
@ -59,10 +63,10 @@ export default function Footer() {
<p className="font-semibold text-slate-900 dark:text-white">Kontakt</p> <p className="font-semibold text-slate-900 dark:text-white">Kontakt</p>
</div> </div>
<p> <p>
<a href="tel:+4993732062390" className="hover:text-brand-500">09373 20 62 390</a> <a href={`tel:${digitsOnly(phone)}`} className="hover:text-brand-500">{phone}</a>
</p> </p>
<p> <p>
<a href="mailto:info@hifi-planet-amorbach.de" className="hover:text-brand-500">info@hifi-planet-amorbach.de</a> <a href={`mailto:${contactEmail}`} className="hover:text-brand-500">{contactEmail}</a>
</p> </p>
<Link <Link
to="/kontakt" to="/kontakt"

View file

@ -3,58 +3,86 @@ import { Link } from 'react-router-dom';
import ThemeToggle from './ThemeToggle.jsx'; import ThemeToggle from './ThemeToggle.jsx';
import DynamicIcon from './DynamicIcon.jsx'; import DynamicIcon from './DynamicIcon.jsx';
import { useAuth } from '../context/AuthContext.jsx'; import { useAuth } from '../context/AuthContext.jsx';
import { useSiteSettings } from '../context/SiteSettingsContext.jsx';
import logo from '../assets/logo.png'; import logo from '../assets/logo.png';
const SHOP_URL = 'https://www.audio4cars.de/';
const digitsOnly = (value) => (value || '').replace(/[^\d+]/g, '');
export default function Navbar() { export default function Navbar() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
const { phone, whatsapp } = useSiteSettings();
const AdminIcon = user && (
<Link
to="/admin"
aria-label="Zum Admin-Panel"
title="Zum Admin-Panel"
className="rounded-full p-2 text-slate-600 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
<DynamicIcon name="layout-dashboard" className="h-5 w-5" />
</Link>
);
return ( 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"> <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"> {/* Schmale Kontakt-Leiste (Telefon/WhatsApp/Shop) - auf kleinen Screens ausgeblendet,
<Link to="/" className="flex items-center rounded-lg px-2 py-1 dark:bg-white"> steht dort stattdessen im aufklappbaren mobilen Menü. */}
<img src={logo} alt="HifiPlanet" className="h-8 w-auto sm:h-10" /> <div className="hidden border-b border-slate-100 bg-slate-50 px-4 py-1.5 text-xs text-slate-600 dark:border-slate-900 dark:bg-slate-900/40 dark:text-slate-400 sm:px-6 md:block">
</Link> <div className="mx-auto flex max-w-6xl items-center justify-end gap-5">
{phone && (
<nav className="hidden items-center gap-6 text-sm font-medium text-slate-600 dark:text-slate-300 md:flex"> <a href={`tel:${digitsOnly(phone)}`} className="flex items-center gap-1.5 hover:text-brand-500">
<Link to="/fahrzeuge" className="hover:text-brand-500">Fahrzeuge</Link> <DynamicIcon name="phone" className="h-3.5 w-3.5" />
<Link to="/leistungen" className="hover:text-brand-500">Leistungen</Link> {phone}
<Link to="/kontakt" className="hover:text-brand-500">Kontakt</Link> </a>
{user && (
<Link
to="/admin"
aria-label="Zum Admin-Panel"
title="Zum Admin-Panel"
className="rounded-full p-2 text-slate-600 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
<DynamicIcon name="layout-dashboard" className="h-5 w-5" />
</Link>
)} )}
<ThemeToggle /> {whatsapp && (
</nav> <a
href={`https://wa.me/${digitsOnly(whatsapp).replace(/^0/, '49').replace('+', '')}`}
<div className="flex items-center gap-2 md:hidden"> target="_blank"
{user && ( rel="noopener noreferrer"
<Link className="flex items-center gap-1.5 hover:text-brand-500"
to="/admin"
aria-label="Zum Admin-Panel"
title="Zum Admin-Panel"
className="rounded-full p-2 text-slate-600 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
> >
<DynamicIcon name="layout-dashboard" className="h-5 w-5" /> <DynamicIcon name="message-circle" className="h-3.5 w-3.5" />
</Link> WhatsApp
</a>
)} )}
<ThemeToggle /> <a href={SHOP_URL} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-brand-500">
<DynamicIcon name="shopping-bag" className="h-3.5 w-3.5" />
Zum Shop
</a>
</div>
</div>
<div className="mx-auto grid max-w-6xl grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 py-3 sm:px-6">
<div className="flex items-center justify-self-start">
<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>
</nav>
<button <button
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
aria-label="Menü öffnen" 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" className="rounded-md p-2 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 md:hidden"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-6 w-6"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
</button> </button>
</div> </div>
<Link to="/" className="flex items-center justify-self-center rounded-lg px-2 py-1 dark:bg-white">
<img src={logo} alt="HifiPlanet" className="h-11 w-auto sm:h-14" />
</Link>
<div className="flex items-center justify-self-end gap-1">
{AdminIcon}
<ThemeToggle />
</div>
</div> </div>
{open && ( {open && (
@ -62,6 +90,33 @@ export default function Navbar() {
<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="/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="/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> <Link to="/kontakt" onClick={() => setOpen(false)} className="rounded-md px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-800">Kontakt</Link>
<div className="my-1 border-t border-slate-200 dark:border-slate-800" />
{phone && (
<a href={`tel:${digitsOnly(phone)}`} className="flex items-center gap-2 rounded-md px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-800">
<DynamicIcon name="phone" className="h-4 w-4" />
{phone}
</a>
)}
{whatsapp && (
<a
href={`https://wa.me/${digitsOnly(whatsapp).replace(/^0/, '49').replace('+', '')}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-md px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<DynamicIcon name="message-circle" className="h-4 w-4" />
WhatsApp
</a>
)}
<a
href={SHOP_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-md px-3 py-2 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<DynamicIcon name="shopping-bag" className="h-4 w-4" />
Zum Shop
</a>
</nav> </nav>
)} )}
</header> </header>

View file

@ -7,6 +7,7 @@ import MaintenanceNotice from './MaintenanceNotice.jsx';
import MaintenanceBypassBanner from './MaintenanceBypassBanner.jsx'; import MaintenanceBypassBanner from './MaintenanceBypassBanner.jsx';
import CookieConsentBanner from './CookieConsentBanner.jsx'; import CookieConsentBanner from './CookieConsentBanner.jsx';
import { MaintenanceProvider } from '../context/MaintenanceContext.jsx'; import { MaintenanceProvider } from '../context/MaintenanceContext.jsx';
import { SiteSettingsProvider } from '../context/SiteSettingsContext.jsx';
import { useAuth } from '../context/AuthContext.jsx'; import { useAuth } from '../context/AuthContext.jsx';
import { api } from '../api/client.js'; import { api } from '../api/client.js';
@ -16,6 +17,13 @@ const FALLBACK_STATUS = {
vehicles: { enabled: false, message: null }, vehicles: { enabled: false, message: null },
}; };
const DEFAULT_SITE_SETTINGS = {
phone: '09373 20 62 390',
whatsapp: null,
contact_email: 'info@hifi-planet-amorbach.de',
hero_image_path: null,
};
// Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG // Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG
// "leicht erkennbar und unmittelbar erreichbar" bleiben auch während der // "leicht erkennbar und unmittelbar erreichbar" bleiben auch während der
// generellen Wartung darf diese Erreichbarkeit nicht durch die Wartungsseite // generellen Wartung darf diese Erreichbarkeit nicht durch die Wartungsseite
@ -24,11 +32,13 @@ const LEGAL_PATHS = ['/impressum', '/datenschutz', '/agb'];
export default function PublicLayout() { export default function PublicLayout() {
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [siteSettings, setSiteSettings] = useState(DEFAULT_SITE_SETTINGS);
const { loading: authLoading, hasPermission } = useAuth(); const { loading: authLoading, hasPermission } = useAuth();
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
api.get('/maintenance').then(setStatus).catch(() => setStatus(FALLBACK_STATUS)); api.get('/maintenance').then(setStatus).catch(() => setStatus(FALLBACK_STATUS));
api.get('/site-settings').then(setSiteSettings).catch(() => {});
}, []); }, []);
if (status === null || authLoading) { if (status === null || authLoading) {
@ -44,16 +54,18 @@ export default function PublicLayout() {
return ( return (
<MaintenanceProvider value={{ ...status, bypass }}> <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"> <SiteSettingsProvider value={siteSettings}>
{bypass && status.global.enabled && <MaintenanceBypassBanner />} <div className="flex min-h-screen flex-col bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<ScrollProgress /> {bypass && status.global.enabled && <MaintenanceBypassBanner />}
<Navbar /> <ScrollProgress />
<main className="flex-1"> <Navbar />
<Outlet /> <main className="flex-1">
</main> <Outlet />
<Footer /> </main>
<CookieConsentBanner /> <Footer />
</div> <CookieConsentBanner />
</div>
</SiteSettingsProvider>
</MaintenanceProvider> </MaintenanceProvider>
); );
} }

View file

@ -0,0 +1,16 @@
import { createContext, useContext } from 'react';
// Defaults entsprechen dem, was früher fest im Code stand - greifen, solange
// der eigentliche /site-settings-Request noch lädt oder fehlschlägt.
const SiteSettingsContext = createContext({
phone: '09373 20 62 390',
whatsapp: null,
contact_email: 'info@hifi-planet-amorbach.de',
hero_image_path: null,
});
export const SiteSettingsProvider = SiteSettingsContext.Provider;
export function useSiteSettings() {
return useContext(SiteSettingsContext);
}

View file

@ -88,6 +88,9 @@ export default function ContactRequests() {
<div className="mb-2 flex flex-wrap items-start justify-between gap-2"> <div className="mb-2 flex flex-wrap items-start justify-between gap-2">
<div> <div>
<p className="font-semibold text-slate-800 dark:text-slate-100">{r.name} · {r.email}{r.phone ? ` · ${r.phone}` : ''}</p> <p className="font-semibold text-slate-800 dark:text-slate-100">{r.name} · {r.email}{r.phone ? ` · ${r.phone}` : ''}</p>
{r.vin && (
<p className="text-xs text-slate-500 dark:text-slate-400">FIN: <span className="font-mono">{r.vin}</span></p>
)}
<p className="text-xs text-slate-400">{new Date(r.created_at.replace(' ', 'T')).toLocaleString('de-DE')}</p> <p className="text-xs text-slate-400">{new Date(r.created_at.replace(' ', 'T')).toLocaleString('de-DE')}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -1,6 +1,7 @@
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
const tabs = [ const tabs = [
{ to: '/admin/settings/website', label: 'Website' },
{ to: '/admin/settings/database', label: 'Datenbank' }, { to: '/admin/settings/database', label: 'Datenbank' },
{ to: '/admin/settings/export-import', label: 'Export & Import' }, { to: '/admin/settings/export-import', label: 'Export & Import' },
{ to: '/admin/settings/maintenance', label: 'Wartungsmodus' }, { to: '/admin/settings/maintenance', label: 'Wartungsmodus' },

View file

@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { api } from '../../../api/client.js';
import ImageUploadField from '../../../components/ImageUploadField.jsx';
const emptyForm = { phone: '', whatsapp: '', contact_email: '', hero_image_path: '' };
export default function WebsiteSettings() {
const [form, setForm] = useState(emptyForm);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
api
.get('/site-settings')
.then((res) =>
setForm({
phone: res.phone || '',
whatsapp: res.whatsapp || '',
contact_email: res.contact_email || '',
hero_image_path: res.hero_image_path || '',
})
)
.finally(() => setLoading(false));
}, []);
const update = (field, value) => {
setForm((f) => ({ ...f, [field]: value }));
setSaved(false);
};
const handleSave = async (e) => {
e.preventDefault();
setBusy(true);
setError('');
setSaved(false);
try {
await api.post('/site-settings', form);
setSaved(true);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
if (loading) {
return <p className="text-sm text-slate-400">Lädt</p>;
}
return (
<form onSubmit={handleSave} 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">Startseiten-Bild</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Das große Hero-Bild ganz oben auf der Startseite. Ohne eigenes Bild wird das Standardbild verwendet.
</p>
<ImageUploadField
value={form.hero_image_path}
onChange={(path) => update('hero_image_path', path)}
label="Bild"
/>
</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">Kontaktdaten</h2>
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Wird im Header, Footer und auf der Kontaktseite angezeigt.
</p>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Telefon</label>
<input
value={form.phone}
onChange={(e) => update('phone', e.target.value)}
placeholder="09373 20 62 390"
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">
WhatsApp Business Nummer
</label>
<input
value={form.whatsapp}
onChange={(e) => update('whatsapp', e.target.value)}
placeholder="z. B. 09373 20 62 390"
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"
/>
<p className="mt-1 text-xs text-slate-400">Leer lassen, um den WhatsApp-Link im Header auszublenden.</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">E-Mail</label>
<input
type="email"
value={form.contact_email}
onChange={(e) => update('contact_email', e.target.value)}
placeholder="info@hifi-planet-amorbach.de"
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>
</section>
{error && <p className="text-sm text-red-600">{error}</p>}
{saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
<button
type="submit"
disabled={busy}
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
>
{busy ? 'Speichere…' : 'Speichern'}
</button>
</form>
);
}

View file

@ -3,15 +3,19 @@ import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client.js'; import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js'; import usePageMeta from '../../hooks/usePageMeta.js';
import ExternalEmbed from '../../components/ExternalEmbed.jsx'; import ExternalEmbed from '../../components/ExternalEmbed.jsx';
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
const formatPrice = (value) => const formatPrice = (value) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value); new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
const digitsOnly = (value) => (value || '').replace(/[^\d+]/g, '');
const SHOP_ADDRESS_ENCODED = encodeURIComponent('Boxbrunner Str. 20a, 63916 Amorbach'); const SHOP_ADDRESS_ENCODED = encodeURIComponent('Boxbrunner Str. 20a, 63916 Amorbach');
export default function ContactPage() { export default function ContactPage() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const [form, setForm] = useState({ name: '', email: '', phone: '', message: '' }); const { phone, contact_email: contactEmail } = useSiteSettings();
const [form, setForm] = useState({ name: '', email: '', phone: '', vin: '', message: '' });
const [status, setStatus] = useState('idle'); const [status, setStatus] = useState('idle');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [upgrades, setUpgrades] = useState([]); const [upgrades, setUpgrades] = useState([]);
@ -160,6 +164,18 @@ export default function ContactPage() {
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" 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>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
Fahrgestellnummer (FIN)
</label>
<input
name="vin"
value={form.vin}
onChange={handleChange}
placeholder="Optional hilft uns bei der genauen Einschätzung deines Fahrzeugs"
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> <div>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Nachricht</label> <label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Nachricht</label>
<textarea <textarea
@ -195,11 +211,11 @@ export default function ContactPage() {
</div> </div>
<div> <div>
<dt className="font-medium text-slate-800 dark:text-slate-100">Telefon</dt> <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> <dd><a href={`tel:${digitsOnly(phone)}`} className="hover:text-brand-500">{phone}</a></dd>
</div> </div>
<div> <div>
<dt className="font-medium text-slate-800 dark:text-slate-100">E-Mail</dt> <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> <dd><a href={`mailto:${contactEmail}`} className="hover:text-brand-500">{contactEmail}</a></dd>
</div> </div>
<div> <div>
<dt className="font-medium text-slate-800 dark:text-slate-100">Öffnungszeiten</dt> <dt className="font-medium text-slate-800 dark:text-slate-100">Öffnungszeiten</dt>

View file

@ -7,7 +7,8 @@ import Accordion from '../../components/Accordion.jsx';
import StarRating from '../../components/StarRating.jsx'; import StarRating from '../../components/StarRating.jsx';
import TestimonialSlider from '../../components/TestimonialSlider.jsx'; import TestimonialSlider from '../../components/TestimonialSlider.jsx';
import ExternalEmbed from '../../components/ExternalEmbed.jsx'; import ExternalEmbed from '../../components/ExternalEmbed.jsx';
import heroImage from '../../assets/photos/hero-trunk.jpg'; import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
import defaultHeroImage from '../../assets/photos/hero-trunk.jpg';
import galleryAmp from '../../assets/photos/gallery-amp-purple.jpg'; import galleryAmp from '../../assets/photos/gallery-amp-purple.jpg';
import gallerySpeaker from '../../assets/photos/gallery-speaker.jpg'; import gallerySpeaker from '../../assets/photos/gallery-speaker.jpg';
import gallerySubs from '../../assets/photos/gallery-subs.jpg'; import gallerySubs from '../../assets/photos/gallery-subs.jpg';
@ -66,6 +67,9 @@ const faqs = [
]; ];
export default function Home() { export default function Home() {
const { hero_image_path: heroImagePath } = useSiteSettings();
const heroImage = heroImagePath || defaultHeroImage;
usePageMeta({ usePageMeta({
title: 'Car-Hifi Umbauten nach Maß', title: 'Car-Hifi Umbauten nach Maß',
description: description:

View file

@ -0,0 +1,13 @@
-- Kontaktdaten (Telefon/WhatsApp/E-Mail) und Startseiten-Hero-Bild admin-konfigurierbar machen,
-- sowie ein Fahrgestellnummer/FIN-Feld fuer Kontaktanfragen. Additiv, nichts wird geloescht.
-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren"
-- unter Admin-Panel -> Einstellungen -> Datenbank.
ALTER TABLE app_settings
ADD COLUMN IF NOT EXISTS phone VARCHAR(50) NULL,
ADD COLUMN IF NOT EXISTS whatsapp VARCHAR(50) NULL,
ADD COLUMN IF NOT EXISTS contact_email VARCHAR(150) NULL,
ADD COLUMN IF NOT EXISTS hero_image_path VARCHAR(255) NULL;
ALTER TABLE contact_requests
ADD COLUMN IF NOT EXISTS vin VARCHAR(50) NULL AFTER phone;

View file

@ -20,6 +20,10 @@ CREATE TABLE app_settings (
maintenance_services_message VARCHAR(500) NULL, maintenance_services_message VARCHAR(500) NULL,
maintenance_vehicles TINYINT(1) NOT NULL DEFAULT 0, maintenance_vehicles TINYINT(1) NOT NULL DEFAULT 0,
maintenance_vehicles_message VARCHAR(500) NULL, maintenance_vehicles_message VARCHAR(500) NULL,
phone VARCHAR(50) NULL,
whatsapp VARCHAR(50) NULL,
contact_email VARCHAR(150) NULL,
hero_image_path VARCHAR(255) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB; ) ENGINE=InnoDB;
@ -137,6 +141,7 @@ CREATE TABLE contact_requests (
name VARCHAR(150) NOT NULL, name VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL, email VARCHAR(150) NOT NULL,
phone VARCHAR(50) NULL, phone VARCHAR(50) NULL,
vin VARCHAR(50) NULL,
message TEXT NULL, message TEXT NULL,
brand_name VARCHAR(100) NULL, brand_name VARCHAR(100) NULL,
model_name VARCHAR(100) NULL, model_name VARCHAR(100) NULL,

View file

@ -24,6 +24,7 @@ use App\Controllers\SchemaController;
use App\Controllers\ServiceController; use App\Controllers\ServiceController;
use App\Controllers\SettingsController; use App\Controllers\SettingsController;
use App\Controllers\SetupController; use App\Controllers\SetupController;
use App\Controllers\SiteSettingsController;
use App\Controllers\TwoFactorController; use App\Controllers\TwoFactorController;
use App\Controllers\UploadController; use App\Controllers\UploadController;
use App\Middleware\AuthMiddleware; use App\Middleware\AuthMiddleware;
@ -124,7 +125,7 @@ $router->get('/contact', $perm('contact.manage', fn($p) => ContactController::in
$router->patch('/contact/{id}', $perm('contact.manage', fn($p) => ContactController::updateStatus($p))); $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->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->post('/uploads', $anyPerm(['brands.manage', 'models.manage', 'services.manage', 'settings.manage'], fn($p) => UploadController::store()));
$router->get('/services', $maint('services', fn($p) => ServiceController::index())); $router->get('/services', $maint('services', fn($p) => ServiceController::index()));
$router->post('/services', $perm('services.manage', fn($p) => ServiceController::store())); $router->post('/services', $perm('services.manage', fn($p) => ServiceController::store()));
@ -156,6 +157,9 @@ $router->post('/settings/database', $perm('settings.manage', fn($p) => DatabaseC
$router->get('/maintenance', fn($p) => MaintenanceController::status()); $router->get('/maintenance', fn($p) => MaintenanceController::status());
$router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update())); $router->post('/maintenance', $perm('settings.manage', fn($p) => MaintenanceController::update()));
$router->get('/site-settings', fn($p) => SiteSettingsController::show());
$router->post('/site-settings', $perm('settings.manage', fn($p) => SiteSettingsController::update()));
// Ersteinrichtungs-Assistent (/setup) - bewusst ohne Login, da es ja noch keinen // Ersteinrichtungs-Assistent (/setup) - bewusst ohne Login, da es ja noch keinen
// Admin gibt. Jeder schreibende Endpunkt prüft selbst, ob wirklich noch kein // Admin gibt. Jeder schreibende Endpunkt prüft selbst, ob wirklich noch kein
// Admin-Account existiert (siehe SetupController::requireUnlocked). // Admin-Account existiert (siehe SetupController::requireUnlocked).

View file

@ -24,13 +24,14 @@ class ContactController
$stmt = $db->prepare( $stmt = $db->prepare(
'INSERT INTO contact_requests 'INSERT INTO contact_requests
(name, email, phone, message, brand_name, model_name, package_name, product_name, package_id, package_product_id, selected_upgrades) (name, email, phone, vin, message, brand_name, model_name, package_name, product_name, package_id, package_product_id, selected_upgrades)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
); );
$stmt->execute([ $stmt->execute([
$name, $name,
$email, $email,
$body['phone'] ?? null, $body['phone'] ?? null,
$body['vin'] ?? null,
$body['message'] ?? null, $body['message'] ?? null,
$body['brand_name'] ?? null, $body['brand_name'] ?? null,
$body['model_name'] ?? null, $body['model_name'] ?? null,
@ -71,7 +72,7 @@ class ContactController
public static function index(): void public static function index(): void
{ {
$stmt = Database::connection()->query( $stmt = Database::connection()->query(
'SELECT id, name, email, phone, message, brand_name, model_name, package_name, product_name, 'SELECT id, name, email, phone, vin, message, brand_name, model_name, package_name, product_name,
selected_upgrades, status, created_at selected_upgrades, status, created_at
FROM contact_requests ORDER BY created_at DESC' FROM contact_requests ORDER BY created_at DESC'
); );
@ -145,7 +146,8 @@ class ContactController
} }
$mail->Subject = 'Neue Kontaktanfrage von ' . $name; $mail->Subject = 'Neue Kontaktanfrage von ' . $name;
$mail->Body = "Name: {$name}\nE-Mail: {$email}\nTelefon: " . ($body['phone'] ?? '-') . "\n\n" $mail->Body = "Name: {$name}\nE-Mail: {$email}\nTelefon: " . ($body['phone'] ?? '-')
. "\nFahrgestellnummer (FIN): " . ($body['vin'] ?? '-') . "\n\n"
. $context . "\n\nGewünschte Upgrades:\n" . $upgradesText . "\n\nNachricht:\n" . ($body['message'] ?? '-'); . $context . "\n\nGewünschte Upgrades:\n" . $upgradesText . "\n\nNachricht:\n" . ($body['message'] ?? '-');
$mail->send(); $mail->send();

View file

@ -0,0 +1,57 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Support\Http;
class SiteSettingsController
{
// Greifen, solange in den Einstellungen noch nichts eingetragen wurde -
// entsprechen dem, was bisher fest im Code stand.
private const DEFAULTS = [
'phone' => '09373 20 62 390',
'whatsapp' => null,
'contact_email' => 'info@hifi-planet-amorbach.de',
'hero_image_path' => null,
];
public static function show(): void
{
try {
$stmt = Database::connection()->query(
'SELECT phone, whatsapp, contact_email, hero_image_path FROM app_settings WHERE id = 1'
);
$row = $stmt->fetch();
} catch (\Throwable $e) {
$row = false;
}
$row = $row ?: [];
$result = [];
foreach (self::DEFAULTS as $key => $default) {
$result[$key] = ($row[$key] ?? null) !== null && $row[$key] !== '' ? $row[$key] : $default;
}
Http::send($result);
}
public static function update(): void
{
$body = Http::jsonBody();
$db = Database::connection();
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
$stmt = $db->prepare(
'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ? WHERE id = 1'
);
$stmt->execute([
trim($body['phone'] ?? '') ?: null,
trim($body['whatsapp'] ?? '') ?: null,
trim($body['contact_email'] ?? '') ?: null,
trim($body['hero_image_path'] ?? '') ?: null,
]);
self::show();
}
}

View file

@ -31,6 +31,10 @@ class Schema
maintenance_services_message VARCHAR(500) NULL, maintenance_services_message VARCHAR(500) NULL,
maintenance_vehicles TINYINT(1) NOT NULL DEFAULT 0, maintenance_vehicles TINYINT(1) NOT NULL DEFAULT 0,
maintenance_vehicles_message VARCHAR(500) NULL, maintenance_vehicles_message VARCHAR(500) NULL,
phone VARCHAR(50) NULL,
whatsapp VARCHAR(50) NULL,
contact_email VARCHAR(150) NULL,
hero_image_path VARCHAR(255) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB", ) ENGINE=InnoDB",
@ -148,6 +152,7 @@ class Schema
name VARCHAR(150) NOT NULL, name VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL, email VARCHAR(150) NOT NULL,
phone VARCHAR(50) NULL, phone VARCHAR(50) NULL,
vin VARCHAR(50) NULL,
message TEXT NULL, message TEXT NULL,
brand_name VARCHAR(100) NULL, brand_name VARCHAR(100) NULL,
model_name VARCHAR(100) NULL, model_name VARCHAR(100) NULL,
@ -187,6 +192,10 @@ class Schema
'maintenance_services_message' => 'VARCHAR(500) NULL', 'maintenance_services_message' => 'VARCHAR(500) NULL',
'maintenance_vehicles' => 'TINYINT(1) NOT NULL DEFAULT 0', 'maintenance_vehicles' => 'TINYINT(1) NOT NULL DEFAULT 0',
'maintenance_vehicles_message' => 'VARCHAR(500) NULL', 'maintenance_vehicles_message' => 'VARCHAR(500) NULL',
'phone' => 'VARCHAR(50) NULL',
'whatsapp' => 'VARCHAR(50) NULL',
'contact_email' => 'VARCHAR(150) NULL',
'hero_image_path' => 'VARCHAR(255) NULL',
'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', 'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
], ],
'services' => [ 'services' => [
@ -280,6 +289,7 @@ class Schema
'name' => 'VARCHAR(150) NOT NULL', 'name' => 'VARCHAR(150) NOT NULL',
'email' => 'VARCHAR(150) NOT NULL', 'email' => 'VARCHAR(150) NOT NULL',
'phone' => 'VARCHAR(50) NULL', 'phone' => 'VARCHAR(50) NULL',
'vin' => 'VARCHAR(50) NULL',
'message' => 'TEXT NULL', 'message' => 'TEXT NULL',
'brand_name' => 'VARCHAR(100) NULL', 'brand_name' => 'VARCHAR(100) NULL',
'model_name' => 'VARCHAR(100) NULL', 'model_name' => 'VARCHAR(100) NULL',