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:
parent
75525cb0c3
commit
438131fad0
15 changed files with 460 additions and 31 deletions
|
|
@ -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>} />
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
166
hifi-src/src/pages/admin/Faqs.jsx
Normal file
166
hifi-src/src/pages/admin/Faqs.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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}
|
||||||
|
|
|
||||||
|
|
@ -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'] });
|
||||||
|
|
|
||||||
14
hifi/api/database/migration_faqs.sql
Normal file
14
hifi/api/database/migration_faqs.sql
Normal 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;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
|
||||||
|
|
|
||||||
82
hifi/api/src/Controllers/FaqController.php
Normal file
82
hifi/api/src/Controllers/FaqController.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue