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 PermissionGroups from './pages/admin/PermissionGroups.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 ExportImportSettings from './pages/admin/settings/ExportImportSettings.jsx';
import MaintenanceSettings from './pages/admin/settings/MaintenanceSettings.jsx';
@ -74,7 +75,8 @@ export default function App() {
path="settings"
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="export-import" element={<ExportImportSettings />} />
<Route path="maintenance" element={<MaintenanceSettings />} />

View file

@ -1,6 +1,9 @@
import { Link, useLocation } from 'react-router-dom';
import DynamicIcon from './DynamicIcon.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 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.
const { pathname } = useLocation();
const isHome = pathname === '/';
const { phone, contact_email: contactEmail } = useSiteSettings();
return (
<footer
@ -59,10 +63,10 @@ export default function Footer() {
<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>
<a href={`tel:${digitsOnly(phone)}`} className="hover:text-brand-500">{phone}</a>
</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>
<Link
to="/kontakt"

View file

@ -3,58 +3,86 @@ import { Link } from 'react-router-dom';
import ThemeToggle from './ThemeToggle.jsx';
import DynamicIcon from './DynamicIcon.jsx';
import { useAuth } from '../context/AuthContext.jsx';
import { useSiteSettings } from '../context/SiteSettingsContext.jsx';
import logo from '../assets/logo.png';
const SHOP_URL = 'https://www.audio4cars.de/';
const digitsOnly = (value) => (value || '').replace(/[^\d+]/g, '');
export default function Navbar() {
const [open, setOpen] = useState(false);
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 (
<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>
{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>
{/* Schmale Kontakt-Leiste (Telefon/WhatsApp/Shop) - auf kleinen Screens ausgeblendet,
steht dort stattdessen im aufklappbaren mobilen Menü. */}
<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">
<div className="mx-auto flex max-w-6xl items-center justify-end gap-5">
{phone && (
<a href={`tel:${digitsOnly(phone)}`} className="flex items-center gap-1.5 hover:text-brand-500">
<DynamicIcon name="phone" className="h-3.5 w-3.5" />
{phone}
</a>
)}
<ThemeToggle />
</nav>
<div className="flex items-center gap-2 md:hidden">
{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"
{whatsapp && (
<a
href={`https://wa.me/${digitsOnly(whatsapp).replace(/^0/, '49').replace('+', '')}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 hover:text-brand-500"
>
<DynamicIcon name="layout-dashboard" className="h-5 w-5" />
</Link>
<DynamicIcon name="message-circle" className="h-3.5 w-3.5" />
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
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"
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">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</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>
{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="/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>
<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>
)}
</header>

View file

@ -7,6 +7,7 @@ import MaintenanceNotice from './MaintenanceNotice.jsx';
import MaintenanceBypassBanner from './MaintenanceBypassBanner.jsx';
import CookieConsentBanner from './CookieConsentBanner.jsx';
import { MaintenanceProvider } from '../context/MaintenanceContext.jsx';
import { SiteSettingsProvider } from '../context/SiteSettingsContext.jsx';
import { useAuth } from '../context/AuthContext.jsx';
import { api } from '../api/client.js';
@ -16,6 +17,13 @@ const FALLBACK_STATUS = {
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
// "leicht erkennbar und unmittelbar erreichbar" bleiben auch während der
// generellen Wartung darf diese Erreichbarkeit nicht durch die Wartungsseite
@ -24,11 +32,13 @@ const LEGAL_PATHS = ['/impressum', '/datenschutz', '/agb'];
export default function PublicLayout() {
const [status, setStatus] = useState(null);
const [siteSettings, setSiteSettings] = useState(DEFAULT_SITE_SETTINGS);
const { loading: authLoading, hasPermission } = useAuth();
const location = useLocation();
useEffect(() => {
api.get('/maintenance').then(setStatus).catch(() => setStatus(FALLBACK_STATUS));
api.get('/site-settings').then(setSiteSettings).catch(() => {});
}, []);
if (status === null || authLoading) {
@ -44,16 +54,18 @@ export default function PublicLayout() {
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>
<SiteSettingsProvider value={siteSettings}>
<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>
</SiteSettingsProvider>
</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>
<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>
</div>
<div className="flex items-center gap-2">

View file

@ -1,6 +1,7 @@
import { NavLink, Outlet } from 'react-router-dom';
const tabs = [
{ to: '/admin/settings/website', label: 'Website' },
{ to: '/admin/settings/database', label: 'Datenbank' },
{ to: '/admin/settings/export-import', label: 'Export & Import' },
{ 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 usePageMeta from '../../hooks/usePageMeta.js';
import ExternalEmbed from '../../components/ExternalEmbed.jsx';
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
const formatPrice = (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');
export default function ContactPage() {
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 [error, setError] = 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"
/>
</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>
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Nachricht</label>
<textarea
@ -195,11 +211,11 @@ export default function ContactPage() {
</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>
<dd><a href={`tel:${digitsOnly(phone)}`} className="hover:text-brand-500">{phone}</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>
<dd><a href={`mailto:${contactEmail}`} className="hover:text-brand-500">{contactEmail}</a></dd>
</div>
<div>
<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 TestimonialSlider from '../../components/TestimonialSlider.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 gallerySpeaker from '../../assets/photos/gallery-speaker.jpg';
import gallerySubs from '../../assets/photos/gallery-subs.jpg';
@ -66,6 +67,9 @@ const faqs = [
];
export default function Home() {
const { hero_image_path: heroImagePath } = useSiteSettings();
const heroImage = heroImagePath || defaultHeroImage;
usePageMeta({
title: 'Car-Hifi Umbauten nach Maß',
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_vehicles TINYINT(1) NOT NULL DEFAULT 0,
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
) ENGINE=InnoDB;
@ -137,6 +141,7 @@ CREATE TABLE contact_requests (
name VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL,
phone VARCHAR(50) NULL,
vin VARCHAR(50) NULL,
message TEXT NULL,
brand_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\SettingsController;
use App\Controllers\SetupController;
use App\Controllers\SiteSettingsController;
use App\Controllers\TwoFactorController;
use App\Controllers\UploadController;
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->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->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->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
// Admin gibt. Jeder schreibende Endpunkt prüft selbst, ob wirklich noch kein
// Admin-Account existiert (siehe SetupController::requireUnlocked).

View file

@ -24,13 +24,14 @@ class ContactController
$stmt = $db->prepare(
'INSERT INTO contact_requests
(name, email, phone, message, brand_name, model_name, package_name, product_name, package_id, package_product_id, selected_upgrades)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
(name, email, phone, vin, message, brand_name, model_name, package_name, product_name, package_id, package_product_id, selected_upgrades)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$name,
$email,
$body['phone'] ?? null,
$body['vin'] ?? null,
$body['message'] ?? null,
$body['brand_name'] ?? null,
$body['model_name'] ?? null,
@ -71,7 +72,7 @@ class ContactController
public static function index(): void
{
$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
FROM contact_requests ORDER BY created_at DESC'
);
@ -145,7 +146,8 @@ class ContactController
}
$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'] ?? '-');
$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_vehicles TINYINT(1) NOT NULL DEFAULT 0,
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
) ENGINE=InnoDB",
@ -148,6 +152,7 @@ class Schema
name VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL,
phone VARCHAR(50) NULL,
vin VARCHAR(50) NULL,
message TEXT NULL,
brand_name VARCHAR(100) NULL,
model_name VARCHAR(100) NULL,
@ -187,6 +192,10 @@ class Schema
'maintenance_services_message' => 'VARCHAR(500) NULL',
'maintenance_vehicles' => 'TINYINT(1) NOT NULL DEFAULT 0',
'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',
],
'services' => [
@ -280,6 +289,7 @@ class Schema
'name' => 'VARCHAR(150) NOT NULL',
'email' => 'VARCHAR(150) NOT NULL',
'phone' => 'VARCHAR(50) NULL',
'vin' => 'VARCHAR(50) NULL',
'message' => 'TEXT NULL',
'brand_name' => 'VARCHAR(100) NULL',
'model_name' => 'VARCHAR(100) NULL',