Move FAQ management into the admin panel and cover it plus the gallery in export/import

FAQ entries were previously hardcoded in the i18n dictionaries; they now live
in a new bilingual `faqs` table with full CRUD in the admin panel, a
reset-to-defaults action seeded with the current 6 questions, and the
homepage (incl. FAQPage JSON-LD) fetches them live per language. Export/
import was also extended to include FAQs and the full image gallery
(brands, projects, photos), which had been missing from that mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-07 19:25:06 +02:00
parent 75525cb0c3
commit 438131fad0
15 changed files with 460 additions and 31 deletions

View file

@ -29,6 +29,7 @@ import GalleryProjects from './pages/admin/GalleryProjects.jsx';
import GalleryPhotos from './pages/admin/GalleryPhotos.jsx'; import GalleryPhotos from './pages/admin/GalleryPhotos.jsx';
import Packages from './pages/admin/Packages.jsx'; import Packages from './pages/admin/Packages.jsx';
import Services from './pages/admin/Services.jsx'; import Services from './pages/admin/Services.jsx';
import Faqs from './pages/admin/Faqs.jsx';
import PackageProducts from './pages/admin/PackageProducts.jsx'; import PackageProducts from './pages/admin/PackageProducts.jsx';
import PackageUpgrades from './pages/admin/PackageUpgrades.jsx'; import PackageUpgrades from './pages/admin/PackageUpgrades.jsx';
import ContactRequests from './pages/admin/ContactRequests.jsx'; import ContactRequests from './pages/admin/ContactRequests.jsx';
@ -81,6 +82,7 @@ export default function App() {
<Route path="packages/:packageId/products" element={<RequirePermission permission="packages.manage"><PackageProducts /></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="packages/:packageId/upgrades" element={<RequirePermission permission="packages.manage"><PackageUpgrades /></RequirePermission>} />
<Route path="services" element={<RequirePermission permission="services.manage"><Services /></RequirePermission>} /> <Route path="services" element={<RequirePermission permission="services.manage"><Services /></RequirePermission>} />
<Route path="faqs" element={<RequirePermission permission="content.manage"><Faqs /></RequirePermission>} />
<Route path="contact-requests" element={<RequirePermission permission="contact.manage"><ContactRequests /></RequirePermission>} /> <Route path="contact-requests" element={<RequirePermission permission="contact.manage"><ContactRequests /></RequirePermission>} />
<Route path="users" element={<RequirePermission permission="users.manage"><AdminUsers /></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="permission-groups" element={<RequirePermission permission="permission_groups.manage"><PermissionGroups /></RequirePermission>} />

View file

@ -17,6 +17,7 @@ const ICONS = {
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', 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', 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',
image: 'M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm2 12 4.5-5.5L14 16l2.5-3L20 17H6ZM8 9.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z', image: 'M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm2 12 4.5-5.5L14 16l2.5-3L20 17H6ZM8 9.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z',
help: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-5.5v.01M9.5 9.25a2.5 2.5 0 1 1 3.5 2.29c-.7.32-1 .77-1 1.46v.3',
}; };
const Icon = ({ path }) => ( const Icon = ({ path }) => (
@ -56,6 +57,7 @@ export default function AdminLayout() {
title: 'Website', title: 'Website',
links: [ links: [
{ to: '/admin/services', label: 'Leistungen', icon: 'briefcase', permission: 'services.manage' }, { to: '/admin/services', label: 'Leistungen', icon: 'briefcase', permission: 'services.manage' },
{ to: '/admin/faqs', label: 'FAQ', icon: 'help', permission: 'content.manage' },
], ],
}, },
{ {

View file

@ -127,14 +127,6 @@ export default {
moreText: 'Neben individuellen Sound-Umbauten bieten wir CNC-Zerspanung, Lasertechnik, 3D-Druck und mehr alles aus einer Hand in unserer eigenen Werkstatt.', moreText: 'Neben individuellen Sound-Umbauten bieten wir CNC-Zerspanung, Lasertechnik, 3D-Druck und mehr alles aus einer Hand in unserer eigenen Werkstatt.',
moreCta: 'Alle Leistungen entdecken', moreCta: 'Alle Leistungen entdecken',
faqHeading: 'Häufig gestellte Fragen', faqHeading: 'Häufig gestellte Fragen',
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.' },
],
}, },
vehicleSelect: { vehicleSelect: {
metaTitle: 'Fahrzeug auswählen', metaTitle: 'Fahrzeug auswählen',

View file

@ -127,14 +127,6 @@ export default {
moreText: 'Besides custom sound installations, we also offer CNC machining, laser technology, 3D printing and more all from a single source in our own workshop.', moreText: 'Besides custom sound installations, we also offer CNC machining, laser technology, 3D printing and more all from a single source in our own workshop.',
moreCta: 'Discover all our services', moreCta: 'Discover all our services',
faqHeading: 'Frequently asked questions', faqHeading: 'Frequently asked questions',
faqs: [
{ question: 'How much does a consultation cost?', answer: 'A consultation and price estimate are completely free and non-binding.' },
{ question: 'Do I need to bring my vehicle in?', answer: 'For an initial assessment, your inquiry through the website is often enough. For the installation itself, we\'ll schedule an appointment together at our workshop in Amorbach.' },
{ question: 'How long does an installation take?', answer: 'It depends on the scope from a simple speaker swap in a few hours to an elaborate full installation over several days.' },
{ question: 'Do you offer solutions for leased vehicles?', answer: 'Yes, on request we install everything reversibly, so your vehicle can be returned to its original condition when handed back.' },
{ question: 'Do you only work with certain brands?', answer: "No, we're brand-independent and choose the components that best fit your needs and budget." },
{ question: "What if my vehicle isn't listed?", answer: "No problem just message us through the contact form, we'll find a suitable solution for any vehicle." },
],
}, },
vehicleSelect: { vehicleSelect: {
metaTitle: 'Select your vehicle', metaTitle: 'Select your vehicle',

View file

@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client.js';
const emptyForm = {
question_de: '',
answer_de: '',
question_en: '',
answer_en: '',
sort_order: 0,
};
export default function Faqs() {
const [faqs, setFaqs] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState(null);
const [error, setError] = useState('');
const load = () => api.get('/faqs').then(setFaqs);
useEffect(load, []);
const startEdit = (f) => {
setEditingId(f.id);
setForm({
question_de: f.question_de,
answer_de: f.answer_de,
question_en: f.question_en,
answer_en: f.answer_en,
sort_order: f.sort_order,
});
};
const resetForm = () => {
setEditingId(null);
setForm(emptyForm);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const payload = { ...form, sort_order: Number(form.sort_order) };
try {
if (editingId) {
await api.put(`/faqs/${editingId}`, payload);
} else {
await api.post('/faqs', payload);
}
resetForm();
load();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (id) => {
if (!confirm('Diese Frage wirklich löschen?')) return;
await api.delete(`/faqs/${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-neutral-900 dark:text-white">Häufig gestellte Fragen</h1>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Diese Fragen erscheinen im FAQ-Bereich der Startseite, jeweils in der aktuell gewählten Sprache. Die 6
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-neutral-200 dark:border-neutral-800">
<table className="w-full text-left text-sm">
<thead className="bg-neutral-50 text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
<tr>
<th className="px-4 py-2">Frage (DE)</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-neutral-100 dark:divide-neutral-800">
{faqs.map((f) => (
<tr key={f.id} className="bg-white dark:bg-neutral-950">
<td className="px-4 py-2 font-medium text-neutral-800 dark:text-neutral-100">{f.question_de}</td>
<td className="px-4 py-2">{f.sort_order}</td>
<td className="px-4 py-2 text-right">
<button onClick={() => startEdit(f)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(f.id)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
{faqs.length === 0 && (
<tr><td colSpan={3} className="px-4 py-6 text-center text-neutral-400">Noch keine Fragen angelegt.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-neutral-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="font-bold text-neutral-900 dark:text-white">{editingId ? 'Frage bearbeiten' : 'Neue Frage'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Frage (Deutsch) *</label>
<input
required
value={form.question_de}
onChange={(e) => setForm({ ...form, question_de: e.target.value })}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Antwort (Deutsch) *</label>
<textarea
required
rows={3}
value={form.answer_de}
onChange={(e) => setForm({ ...form, answer_de: e.target.value })}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Frage (Englisch) *</label>
<input
required
value={form.question_en}
onChange={(e) => setForm({ ...form, question_en: e.target.value })}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Antwort (Englisch) *</label>
<textarea
required
rows={3}
value={form.answer_en}
onChange={(e) => setForm({ ...form, answer_en: e.target.value })}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-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-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-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-neutral-300 px-4 py-2 text-sm dark:border-neutral-700">
Abbrechen
</button>
)}
</div>
</form>
</div>
);
}

View file

@ -34,8 +34,9 @@ export default function ExportImportSettings() {
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"> <section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Daten exportieren</h2> <h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Daten exportieren</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400"> <p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Lädt eine Datei mit allen Marken, Modellen, Paketen, Produkten, Upgrades und Leistungen (inkl. Bilder) Lädt eine Datei mit allen Marken, Modellen, Paketen, Produkten, Upgrades, Leistungen, FAQs und der
herunter. Benutzerkonten und Kontaktanfragen sind bewusst <strong>nicht</strong> enthalten. kompletten Bildergalerie (inkl. Bilder) herunter. Benutzerkonten und Kontaktanfragen sind bewusst
<strong> nicht</strong> enthalten.
</p> </p>
<a <a
href={`${API_BASE}/settings/export`} href={`${API_BASE}/settings/export`}
@ -49,8 +50,9 @@ export default function ExportImportSettings() {
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Daten importieren</h2> <h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Daten importieren</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400"> <p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Lädt eine zuvor exportierte Datei auf diesen Server. <strong>Ersetzt dabei alle</strong> aktuellen 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 Marken, Modelle, Pakete, Produkte, Upgrades, Leistungen, FAQs und die komplette Bildergalerie auf
rückgängig gemacht werden. Kontaktanfragen und Benutzerkonten bleiben unangetastet. diesem Server das kann nicht rückgängig gemacht werden. Kontaktanfragen und Benutzerkonten bleiben
unangetastet.
</p> </p>
<form onSubmit={handleImport} className="space-y-3"> <form onSubmit={handleImport} className="space-y-3">
<input <input
@ -66,8 +68,8 @@ export default function ExportImportSettings() {
onChange={(e) => setImportConfirmed(e.target.checked)} onChange={(e) => setImportConfirmed(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-neutral-300 text-brand-600 focus:ring-brand-500" className="mt-0.5 h-4 w-4 rounded border-neutral-300 text-brand-600 focus:ring-brand-500"
/> />
Mir ist bewusst, dass dies alle aktuellen Marken, Modelle, Pakete, Produkte, Upgrades und Mir ist bewusst, dass dies alle aktuellen Marken, Modelle, Pakete, Produkte, Upgrades, Leistungen,
Leistungen auf diesem Server unwiderruflich ersetzt. FAQs und die Bildergalerie auf diesem Server unwiderruflich ersetzt.
</label> </label>
<button <button
type="submit" type="submit"
@ -88,6 +90,10 @@ export default function ExportImportSettings() {
<li>{importResult.counts.package_products} Produkte</li> <li>{importResult.counts.package_products} Produkte</li>
<li>{importResult.counts.package_upgrades} Upgrades</li> <li>{importResult.counts.package_upgrades} Upgrades</li>
<li>{importResult.counts.services} Leistungen</li> <li>{importResult.counts.services} Leistungen</li>
<li>{importResult.counts.faqs} FAQs</li>
<li>{importResult.counts.gallery_brands} Galerie-Marken</li>
<li>{importResult.counts.gallery_projects} Galerie-Projekte</li>
<li>{importResult.counts.gallery_photos} Galerie-Fotos</li>
<li>{importResult.images_restored} Bilder wiederhergestellt</li> <li>{importResult.images_restored} Bilder wiederhergestellt</li>
</ul> </ul>
</div> </div>

View file

@ -5,6 +5,9 @@ export default function ResetSettings() {
const [servicesBusy, setServicesBusy] = useState(false); const [servicesBusy, setServicesBusy] = useState(false);
const [servicesMessage, setServicesMessage] = useState(''); const [servicesMessage, setServicesMessage] = useState('');
const [faqsBusy, setFaqsBusy] = useState(false);
const [faqsMessage, setFaqsMessage] = useState('');
const [catalogBusy, setCatalogBusy] = useState(false); const [catalogBusy, setCatalogBusy] = useState(false);
const [catalogMessage, setCatalogMessage] = useState(''); const [catalogMessage, setCatalogMessage] = useState('');
@ -25,6 +28,20 @@ export default function ResetSettings() {
} }
}; };
const handleResetFaqs = async () => {
if (!confirm('Alle aktuellen FAQ-Einträge werden gelöscht und durch die 6 Standard-Fragen ersetzt. Fortfahren?')) return;
setFaqsBusy(true);
setFaqsMessage('');
try {
const result = await api.post('/settings/reset-faqs', {});
setFaqsMessage(`${result.count} Standard-Fragen wiederhergestellt.`);
} catch (err) {
setFaqsMessage('Fehler: ' + err.message);
} finally {
setFaqsBusy(false);
}
};
const handleResetCatalog = async () => { const handleResetCatalog = async () => {
if (!confirm('Alle Marken, Modelle, Pakete, Produkte und Upgrades werden unwiderruflich gelöscht. Leistungen und Kontaktanfragen bleiben erhalten. Fortfahren?')) return; if (!confirm('Alle Marken, Modelle, Pakete, Produkte und Upgrades werden unwiderruflich gelöscht. Leistungen und Kontaktanfragen bleiben erhalten. Fortfahren?')) return;
setCatalogBusy(true); setCatalogBusy(true);
@ -40,12 +57,12 @@ export default function ResetSettings() {
}; };
const handleResetAll = async () => { 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; if (!confirm('WIRKLICH ALLES zurücksetzen? Marken, Modelle, Pakete, Produkte und Upgrades werden gelöscht, Leistungen und FAQs auf Standard zurückgesetzt. Kontaktanfragen und Benutzerkonten bleiben erhalten. Das kann nicht rückgängig gemacht werden!')) return;
setAllBusy(true); setAllBusy(true);
setAllMessage(''); setAllMessage('');
try { try {
const result = await api.post('/settings/reset-all', {}); const result = await api.post('/settings/reset-all', {});
setAllMessage(`Erledigt: ${result.brands_removed} Marken entfernt, ${result.services_reset} Standard-Leistungen wiederhergestellt.`); setAllMessage(`Erledigt: ${result.brands_removed} Marken entfernt, ${result.services_reset} Standard-Leistungen und ${result.faqs_reset} Standard-Fragen wiederhergestellt.`);
} catch (err) { } catch (err) {
setAllMessage('Fehler: ' + err.message); setAllMessage('Fehler: ' + err.message);
} finally { } finally {
@ -71,6 +88,22 @@ export default function ResetSettings() {
{servicesMessage && <p className="mt-3 text-sm text-neutral-600 dark:text-neutral-300">{servicesMessage}</p>} {servicesMessage && <p className="mt-3 text-sm text-neutral-600 dark:text-neutral-300">{servicesMessage}</p>}
</section> </section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Nur FAQs zurücksetzen</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Löscht alle aktuellen Fragen und stellt die 6 mitgelieferten Standard-Fragen (Deutsch &amp; Englisch)
wieder her. Leistungen, Marken, Modelle und Pakete bleiben unangetastet.
</p>
<button
onClick={handleResetFaqs}
disabled={faqsBusy}
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"
>
{faqsBusy ? 'Setze zurück…' : 'FAQs auf Standard zurücksetzen'}
</button>
{faqsMessage && <p className="mt-3 text-sm text-neutral-600 dark:text-neutral-300">{faqsMessage}</p>}
</section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"> <section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Nur Fahrzeugkatalog leeren</h2> <h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Nur Fahrzeugkatalog leeren</h2>
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400"> <p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
@ -90,8 +123,8 @@ export default function ResetSettings() {
<section className="rounded-xl border border-red-300 bg-red-50/40 p-6 dark:border-red-900 dark:bg-red-900/10"> <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> <h2 className="mb-1 font-semibold text-red-700 dark:text-red-300">Alles zurücksetzen</h2>
<p className="mb-4 text-sm text-neutral-600 dark:text-neutral-300"> <p className="mb-4 text-sm text-neutral-600 dark:text-neutral-300">
Kombiniert beides: Fahrzeugkatalog wird komplett geleert <strong>und</strong> Leistungen werden auf Kombiniert alles: Fahrzeugkatalog wird komplett geleert <strong>und</strong> Leistungen sowie FAQs werden
Standard zurückgesetzt. Kontaktanfragen und Benutzerkonten bleiben erhalten. Nicht rückgängig zu machen. auf Standard zurückgesetzt. Kontaktanfragen und Benutzerkonten bleiben erhalten. Nicht rückgängig zu machen.
</p> </p>
<button <button
onClick={handleResetAll} onClick={handleResetAll}

View file

@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { motion, useScroll, useTransform } from 'framer-motion'; import { motion, useScroll, useTransform } from 'framer-motion';
import { api } from '../../api/client.js';
import usePageMeta from '../../hooks/usePageMeta.js'; import usePageMeta from '../../hooks/usePageMeta.js';
import Reveal from '../../components/Reveal.jsx'; import Reveal from '../../components/Reveal.jsx';
import Accordion from '../../components/Accordion.jsx'; import Accordion from '../../components/Accordion.jsx';
@ -46,7 +47,21 @@ export default function Home() {
const galleryAlts = t('home.galleryAlts'); const galleryAlts = t('home.galleryAlts');
const gallery = galleryImages.map((img, i) => ({ ...img, alt: galleryAlts[i] })); const gallery = galleryImages.map((img, i) => ({ ...img, alt: galleryAlts[i] }));
const testimonials = t('home.testimonials'); const testimonials = t('home.testimonials');
const faqs = t('home.faqs'); const [faqs, setFaqs] = useState([]);
useEffect(() => {
api
.get('/faqs')
.then((rows) => {
setFaqs(
rows.map((f) => ({
question: language === 'de' ? f.question_de : f.question_en,
answer: language === 'de' ? f.answer_de : f.answer_en,
}))
);
})
.catch(() => {});
}, [language]);
usePageMeta({ usePageMeta({
title: t('home.metaTitle'), title: t('home.metaTitle'),
@ -55,6 +70,8 @@ export default function Home() {
}); });
useEffect(() => { useEffect(() => {
if (faqs.length === 0) return;
const script = document.createElement('script'); const script = document.createElement('script');
script.type = 'application/ld+json'; script.type = 'application/ld+json';
script.text = JSON.stringify({ script.text = JSON.stringify({
@ -68,7 +85,7 @@ export default function Home() {
}); });
document.head.appendChild(script); document.head.appendChild(script);
return () => document.head.removeChild(script); return () => document.head.removeChild(script);
}, [language]); }, [faqs]);
const heroRef = useRef(null); const heroRef = useRef(null);
const { scrollYProgress: heroProgress } = useScroll({ target: heroRef, offset: ['start start', 'end start'] }); const { scrollYProgress: heroProgress } = useScroll({ target: heroRef, offset: ['start start', 'end start'] });

View file

@ -0,0 +1,14 @@
-- FAQ-Verwaltung ins Admin-Panel verschieben (zweisprachig DE/EN). Additiv, nichts wird geloescht.
-- Hinweis: Auf der Live-Seite reicht stattdessen ein Klick auf "Datenbankstruktur aktualisieren"
-- unter Admin-Panel -> Einstellungen -> Datenbank.
CREATE TABLE IF NOT EXISTS faqs (
id INT AUTO_INCREMENT PRIMARY KEY,
question_de TEXT NOT NULL,
answer_de TEXT NOT NULL,
question_en TEXT NOT NULL,
answer_en TEXT 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
) ENGINE=InnoDB;

View file

@ -184,6 +184,17 @@ CREATE TABLE package_products (
CONSTRAINT fk_products_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE CONSTRAINT fk_products_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE
) ENGINE=InnoDB; ) ENGINE=InnoDB;
CREATE TABLE faqs (
id INT AUTO_INCREMENT PRIMARY KEY,
question_de TEXT NOT NULL,
answer_de TEXT NOT NULL,
question_en TEXT NOT NULL,
answer_en TEXT 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
) ENGINE=InnoDB;
CREATE TABLE contact_requests ( CREATE TABLE contact_requests (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL, name VARCHAR(150) NOT NULL,

View file

@ -14,6 +14,7 @@ use App\Controllers\AuthController;
use App\Controllers\BrandController; use App\Controllers\BrandController;
use App\Controllers\ContactController; use App\Controllers\ContactController;
use App\Controllers\DatabaseConfigController; use App\Controllers\DatabaseConfigController;
use App\Controllers\FaqController;
use App\Controllers\GalleryBrandController; use App\Controllers\GalleryBrandController;
use App\Controllers\GalleryPhotoController; use App\Controllers\GalleryPhotoController;
use App\Controllers\GalleryProjectController; use App\Controllers\GalleryProjectController;
@ -154,6 +155,11 @@ $router->post('/services', $perm('services.manage', fn($p) => ServiceController:
$router->put('/services/{id}', $perm('services.manage', fn($p) => ServiceController::update($p))); $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->delete('/services/{id}', $perm('services.manage', fn($p) => ServiceController::destroy($p)));
$router->get('/faqs', fn($p) => FaqController::index());
$router->post('/faqs', $perm('content.manage', fn($p) => FaqController::store()));
$router->put('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::update($p)));
$router->delete('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::destroy($p)));
$router->get('/admin-users', $perm('users.manage', fn($p) => AdminUserController::index())); $router->get('/admin-users', $perm('users.manage', fn($p) => AdminUserController::index()));
$router->post('/admin-users', $perm('users.manage', fn($p) => AdminUserController::store())); $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->put('/admin-users/{id}', $perm('users.manage', fn($p) => AdminUserController::update($p)));
@ -167,6 +173,7 @@ $router->delete('/permission-groups/{id}', $perm('permission_groups.manage', fn(
$router->get('/settings/export', $perm('settings.manage', fn($p) => SettingsController::exportData())); $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/import', $perm('settings.manage', fn($p) => SettingsController::importData()));
$router->post('/settings/reset-services', $perm('settings.manage', fn($p) => SettingsController::resetServicesToDefaults())); $router->post('/settings/reset-services', $perm('settings.manage', fn($p) => SettingsController::resetServicesToDefaults()));
$router->post('/settings/reset-faqs', $perm('settings.manage', fn($p) => SettingsController::resetFaqsToDefaults()));
$router->post('/settings/reset-catalog', $perm('settings.manage', fn($p) => SettingsController::resetCatalog())); $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->post('/settings/reset-all', $perm('settings.manage', fn($p) => SettingsController::resetAll()));

View file

@ -0,0 +1,82 @@
<?php
namespace App\Controllers;
use App\Config\Database;
use App\Support\Http;
class FaqController
{
public static function index(): void
{
$stmt = Database::connection()->query(
'SELECT id, question_de, answer_de, question_en, answer_en, sort_order
FROM faqs ORDER BY sort_order, id'
);
Http::send($stmt->fetchAll());
}
public static function store(): void
{
$body = Http::jsonBody();
[$questionDe, $answerDe, $questionEn, $answerEn] = self::validate($body);
$db = Database::connection();
$stmt = $db->prepare(
'INSERT INTO faqs (question_de, answer_de, question_en, answer_en, sort_order)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([
$questionDe,
$answerDe,
$questionEn,
$answerEn,
(int) ($body['sort_order'] ?? 0),
]);
Http::send(['id' => (int) $db->lastInsertId()], 201);
}
public static function update(array $params): void
{
$id = (int) $params['id'];
$body = Http::jsonBody();
[$questionDe, $answerDe, $questionEn, $answerEn] = self::validate($body);
$stmt = Database::connection()->prepare(
'UPDATE faqs SET question_de = ?, answer_de = ?, question_en = ?, answer_en = ?, sort_order = ?
WHERE id = ?'
);
$stmt->execute([
$questionDe,
$answerDe,
$questionEn,
$answerEn,
(int) ($body['sort_order'] ?? 0),
$id,
]);
Http::send(['ok' => true]);
}
public static function destroy(array $params): void
{
$stmt = Database::connection()->prepare('DELETE FROM faqs WHERE id = ?');
$stmt->execute([(int) $params['id']]);
Http::send(['ok' => true]);
}
private static function validate(array $body): array
{
$questionDe = trim($body['question_de'] ?? '');
$answerDe = trim($body['answer_de'] ?? '');
$questionEn = trim($body['question_en'] ?? '');
$answerEn = trim($body['answer_en'] ?? '');
if ($questionDe === '' || $answerDe === '' || $questionEn === '' || $answerEn === '') {
Http::error('Frage und Antwort sind in beiden Sprachen erforderlich', 422);
}
return [$questionDe, $answerDe, $questionEn, $answerEn];
}
}

View file

@ -19,6 +19,10 @@ class SettingsController
'package_products' => ['id', 'package_id', 'source_url', 'name_override', 'scraped_name', 'scraped_price', 'scraped_currency', 'scraped_image_url', 'price_updated_at', 'scrape_status', 'scrape_error', 'sort_order', 'created_at', 'updated_at'], 'package_products' => ['id', 'package_id', 'source_url', 'name_override', 'scraped_name', 'scraped_price', 'scraped_currency', 'scraped_image_url', 'price_updated_at', 'scrape_status', 'scrape_error', 'sort_order', 'created_at', 'updated_at'],
'package_upgrades' => ['id', 'package_id', 'name', 'description', 'price', 'sort_order', 'created_at', 'updated_at'], 'package_upgrades' => ['id', 'package_id', 'name', 'description', 'price', 'sort_order', 'created_at', 'updated_at'],
'services' => ['id', 'icon_name', 'title', 'description', 'image_path', 'cta_label', 'cta_url', 'sort_order', 'created_at', 'updated_at'], 'services' => ['id', 'icon_name', 'title', 'description', 'image_path', 'cta_label', 'cta_url', 'sort_order', 'created_at', 'updated_at'],
'faqs' => ['id', 'question_de', 'answer_de', 'question_en', 'answer_en', 'sort_order', 'created_at', 'updated_at'],
'gallery_brands' => ['id', 'name', 'slug', 'cover_image_path', 'sort_order', 'created_at', 'updated_at'],
'gallery_projects' => ['id', 'gallery_brand_id', 'name', 'slug', 'cover_image_path', 'sort_order', 'created_at', 'updated_at'],
'gallery_photos' => ['id', 'gallery_project_id', 'image_path', 'caption', 'sort_order', 'created_at', 'updated_at'],
]; ];
// Kein const, da abhängig von der Umgebung (base_path unterscheidet sich // Kein const, da abhängig von der Umgebung (base_path unterscheidet sich
@ -38,7 +42,13 @@ class SettingsController
} }
$images = []; $images = [];
foreach (['services' => 'image_path'] as $table => $column) { $imageColumns = [
'services' => 'image_path',
'gallery_brands' => 'cover_image_path',
'gallery_projects' => 'cover_image_path',
'gallery_photos' => 'image_path',
];
foreach ($imageColumns as $table => $column) {
foreach ($data[$table] as $row) { foreach ($data[$table] as $row) {
self::collectImage($row[$column] ?? null, $images); self::collectImage($row[$column] ?? null, $images);
} }
@ -117,6 +127,22 @@ class SettingsController
Http::send(['ok' => true, 'count' => count($defaults)]); Http::send(['ok' => true, 'count' => count($defaults)]);
} }
public static function resetFaqsToDefaults(): void
{
$db = Database::connection();
$defaults = self::defaultFaqs();
$db->exec('DELETE FROM faqs');
$stmt = $db->prepare(
'INSERT INTO faqs (question_de, answer_de, question_en, answer_en, sort_order) VALUES (?,?,?,?,?)'
);
foreach ($defaults as $row) {
$stmt->execute($row);
}
Http::send(['ok' => true, 'count' => count($defaults)]);
}
/** Löscht Marken (Foreign-Key-Kaskade räumt Modelle, Pakete, Produkte & Upgrades automatisch mit auf). */ /** Löscht Marken (Foreign-Key-Kaskade räumt Modelle, Pakete, Produkte & Upgrades automatisch mit auf). */
public static function resetCatalog(): void public static function resetCatalog(): void
{ {
@ -141,7 +167,16 @@ class SettingsController
$stmt->execute($row); $stmt->execute($row);
} }
Http::send(['ok' => true, 'brands_removed' => $brandCount, 'services_reset' => count($defaults)]); $faqDefaults = self::defaultFaqs();
$db->exec('DELETE FROM faqs');
$faqStmt = $db->prepare(
'INSERT INTO faqs (question_de, answer_de, question_en, answer_en, sort_order) VALUES (?,?,?,?,?)'
);
foreach ($faqDefaults as $row) {
$faqStmt->execute($row);
}
Http::send(['ok' => true, 'brands_removed' => $brandCount, 'services_reset' => count($defaults), 'faqs_reset' => count($faqDefaults)]);
} }
/** @param string[] $allowedColumns @param array<int,array<string,mixed>> $rows */ /** @param string[] $allowedColumns @param array<int,array<string,mixed>> $rows */
@ -206,4 +241,52 @@ class SettingsController
['video', 'Dash Cams', 'Beweissichere Aufzeichnung fürs Auto wir beraten dich zur passenden Kamera und übernehmen den unauffälligen Einbau.', self::uploadsUrlPrefix() . 'seed-leistung-dashcam.jpg', null, null, 7], ['video', 'Dash Cams', 'Beweissichere Aufzeichnung fürs Auto wir beraten dich zur passenden Kamera und übernehmen den unauffälligen Einbau.', self::uploadsUrlPrefix() . 'seed-leistung-dashcam.jpg', null, null, 7],
]; ];
} }
private static function defaultFaqs(): array
{
return [
[
'Was kostet eine Beratung?',
'Beratung und Preisanfrage sind für dich komplett kostenlos und unverbindlich.',
'How much does a consultation cost?',
'A consultation and price estimate are completely free and non-binding.',
0,
],
[
'Muss ich mein Fahrzeug vorbeibringen?',
'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.',
'Do I need to bring my vehicle in?',
"For an initial assessment, your inquiry through the website is often enough. For the installation itself, we'll schedule an appointment together at our workshop in Amorbach.",
1,
],
[
'Wie lange dauert ein Umbau?',
'Das hängt vom Umfang ab von einem einfachen Lautsprecher-Tausch in wenigen Stunden bis zum aufwendigen Komplettumbau über mehrere Tage.',
'How long does an installation take?',
'It depends on the scope from a simple speaker swap in a few hours to an elaborate full installation over several days.',
2,
],
[
'Bietet ihr auch Lösungen für Leasingfahrzeuge?',
'Ja, auf Wunsch bauen wir reversibel um, sodass dein Fahrzeug bei Rückgabe wieder in den Originalzustand versetzt werden kann.',
'Do you offer solutions for leased vehicles?',
'Yes, on request we install everything reversibly, so your vehicle can be returned to its original condition when handed back.',
3,
],
[
'Arbeitet ihr nur mit bestimmten Marken?',
'Nein, wir sind herstellerunabhängig und wählen die Komponenten, die am besten zu deinem Anspruch und Budget passen.',
'Do you only work with certain brands?',
"No, we're brand-independent and choose the components that best fit your needs and budget.",
4,
],
[
'Was ist, wenn mein Fahrzeug nicht gelistet ist?',
'Kein Problem schreib uns einfach über das Kontaktformular, wir finden für jedes Fahrzeug eine passende Lösung.',
"What if my vehicle isn't listed?",
"No problem just message us through the contact form, we'll find a suitable solution for any vehicle.",
5,
],
];
}
} }

View file

@ -9,6 +9,7 @@ class Permissions
'models.manage' => 'Modelle verwalten', 'models.manage' => 'Modelle verwalten',
'packages.manage' => 'Pakete, Produkte & Upgrades verwalten', 'packages.manage' => 'Pakete, Produkte & Upgrades verwalten',
'services.manage' => 'Leistungen verwalten', 'services.manage' => 'Leistungen verwalten',
'content.manage' => 'Startseiten-Inhalte (z. B. FAQ) verwalten',
'gallery.manage' => 'Bildergalerie verwalten', 'gallery.manage' => 'Bildergalerie verwalten',
'contact.manage' => 'Kontaktanfragen ansehen & bearbeiten', 'contact.manage' => 'Kontaktanfragen ansehen & bearbeiten',
'contact.delete' => 'Kontaktanfragen löschen', 'contact.delete' => 'Kontaktanfragen löschen',

View file

@ -195,6 +195,17 @@ class Schema
CONSTRAINT fk_products_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE CONSTRAINT fk_products_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE
) ENGINE=InnoDB", ) ENGINE=InnoDB",
'faqs' => "CREATE TABLE faqs (
id INT AUTO_INCREMENT PRIMARY KEY,
question_de TEXT NOT NULL,
answer_de TEXT NOT NULL,
question_en TEXT NOT NULL,
answer_en TEXT 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
) ENGINE=InnoDB",
'contact_requests' => "CREATE TABLE contact_requests ( 'contact_requests' => "CREATE TABLE contact_requests (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL, name VARCHAR(150) NOT NULL,
@ -374,6 +385,16 @@ class Schema
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', 'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', 'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
], ],
'faqs' => [
'id' => 'INT AUTO_INCREMENT PRIMARY KEY',
'question_de' => 'TEXT NOT NULL',
'answer_de' => 'TEXT NOT NULL',
'question_en' => 'TEXT NOT NULL',
'answer_en' => 'TEXT 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',
],
'contact_requests' => [ 'contact_requests' => [
'id' => 'INT AUTO_INCREMENT PRIMARY KEY', 'id' => 'INT AUTO_INCREMENT PRIMARY KEY',
'name' => 'VARCHAR(150) NOT NULL', 'name' => 'VARCHAR(150) NOT NULL',