Compare commits
10 commits
fa9e8fd1fc
...
b9bfecd1c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9bfecd1c2 | ||
|
|
0a96080427 | ||
|
|
464164ff36 | ||
|
|
bbaf018a0e | ||
|
|
541339879c | ||
|
|
a9aea99028 | ||
|
|
b1b66f1813 | ||
|
|
eba15ef52b | ||
|
|
eb59e9c1c9 | ||
|
|
79fc5f2f76 |
28 changed files with 1109 additions and 158 deletions
|
|
@ -27,6 +27,7 @@ const DEFAULT_SITE_SETTINGS = {
|
||||||
ga_measurement_id: null,
|
ga_measurement_id: null,
|
||||||
package_card_theme: 'graphite',
|
package_card_theme: 'graphite',
|
||||||
package_card_layout: 'strip',
|
package_card_layout: 'strip',
|
||||||
|
package_wheel_hint_interval: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG
|
// Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,33 @@ export default function TestimonialSlider({ testimonials }) {
|
||||||
<div ref={trackRef} className="marquee-track flex w-max gap-5">
|
<div ref={trackRef} className="marquee-track flex w-max gap-5">
|
||||||
{track.map((t, i) => (
|
{track.map((t, i) => (
|
||||||
<div key={`${t.name}-${i}`} className="w-64 shrink-0 sm:w-72 lg:w-80">
|
<div key={`${t.name}-${i}`} className="w-64 shrink-0 sm:w-72 lg:w-80">
|
||||||
<div className="flex h-full flex-col rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
<div className="relative flex h-full flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white p-6 shadow-sm transition-shadow duration-300 hover:shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
<StarRating className="mb-3 h-4 w-4" />
|
{/* Grosses dekoratives Anfuehrungszeichen hinter dem Text - rein optisch,
|
||||||
<p className="mb-4 flex-1 text-sm text-neutral-600 dark:text-neutral-300">„{t.text}"</p>
|
gedeckt genug um nicht mit dem Text zu konkurrieren. */}
|
||||||
<p className="text-sm font-semibold text-neutral-900 dark:text-white">{t.name}</p>
|
<svg aria-hidden="true" viewBox="0 0 32 24" className="absolute -right-1 -top-1 h-16 w-16 text-brand-500/10 dark:text-brand-400/10">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M9.4 0C4.4 3.5 1 9.1 1 15.3 1 20.6 4.3 24 8.4 24c3.8 0 6.6-3 6.6-6.6 0-3.4-2.4-5.9-5.4-5.9-.6 0-1.4.1-1.6.2C8.5 8.5 11.6 4.6 14.6 2.6L9.4 0zm16.5 0c-4.8 3.5-8.3 9.1-8.3 15.3 0 5.3 3.3 8.6 7.5 8.6 3.7 0 6.6-3 6.6-6.6 0-3.4-2.5-5.9-5.4-5.9-.6 0-1.4.1-1.6.2.5-3.3 3.6-7.1 6.7-9.1L25.9 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Feste Zeilenobergrenze statt unbegrenztem Text - eine einzelne lange
|
||||||
|
Google-Rezension soll nicht mehr die ganze Kartenreihe (per Flex-Stretch)
|
||||||
|
auf ihre Hoehe aufblaehen und kurze Kacheln riesig leer wirken lassen. */}
|
||||||
|
<StarRating className="relative z-10 mb-3 h-4 w-4" count={t.rating || 5} />
|
||||||
|
<p className="relative z-10 mb-4 line-clamp-6 flex-1 text-sm leading-relaxed text-neutral-600 dark:text-neutral-300">
|
||||||
|
„{t.text}"
|
||||||
|
</p>
|
||||||
|
<div className="relative z-10 mt-auto flex items-center gap-3">
|
||||||
|
{t.photo ? (
|
||||||
|
<img src={t.photo} alt="" referrerPolicy="no-referrer" className="h-9 w-9 shrink-0 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-100 text-sm font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-300">
|
||||||
|
{t.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-semibold text-neutral-900 dark:text-white">{t.name}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ export default {
|
||||||
'Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum',
|
'Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum',
|
||||||
],
|
],
|
||||||
reviewsRating: '5,0 von 181 Kunden bewertet',
|
reviewsRating: '5,0 von 181 Kunden bewertet',
|
||||||
|
reviewsRatingDynamic: (rating, count) => `${rating.toFixed(1).replace('.', ',')} von ${count} Kunden bewertet`,
|
||||||
reviewsText: 'Das sagen unsere Kunden über uns.',
|
reviewsText: 'Das sagen unsere Kunden über uns.',
|
||||||
reviewsLink: 'Alle Bewertungen auf Google ansehen →',
|
reviewsLink: 'Alle Bewertungen auf Google ansehen →',
|
||||||
testimonials: [
|
testimonials: [
|
||||||
|
|
@ -151,6 +152,7 @@ export default {
|
||||||
featuredBadge: 'Empfohlen',
|
featuredBadge: 'Empfohlen',
|
||||||
moreBullets: (n) => (n === 1 ? '+ 1 weitere Leistung' : `+ ${n} weitere Leistungen`),
|
moreBullets: (n) => (n === 1 ? '+ 1 weitere Leistung' : `+ ${n} weitere Leistungen`),
|
||||||
lessBullets: 'Weniger anzeigen',
|
lessBullets: 'Weniger anzeigen',
|
||||||
|
wheelHint: 'Mit dem Mausrad blättern',
|
||||||
},
|
},
|
||||||
leistungen: {
|
leistungen: {
|
||||||
metaTitle: 'Leistungen',
|
metaTitle: 'Leistungen',
|
||||||
|
|
@ -196,7 +198,7 @@ export default {
|
||||||
requiredHint: '* Pflichtfelder',
|
requiredHint: '* Pflichtfelder',
|
||||||
sending: 'Wird gesendet…',
|
sending: 'Wird gesendet…',
|
||||||
submit: 'Anfrage senden',
|
submit: 'Anfrage senden',
|
||||||
cardTitle: 'HifiPlanet Amorbach',
|
cardTitle: 'HiFi Planet Amorbach',
|
||||||
address: 'Adresse',
|
address: 'Adresse',
|
||||||
phone: 'Telefon',
|
phone: 'Telefon',
|
||||||
email: 'E-Mail',
|
email: 'E-Mail',
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ export default {
|
||||||
'Custom subwoofer installation with LED lighting in the trunk',
|
'Custom subwoofer installation with LED lighting in the trunk',
|
||||||
],
|
],
|
||||||
reviewsRating: 'Rated 5.0 by 181 customers',
|
reviewsRating: 'Rated 5.0 by 181 customers',
|
||||||
|
reviewsRatingDynamic: (rating, count) => `Rated ${rating.toFixed(1)} by ${count} customers`,
|
||||||
reviewsText: 'Here\'s what our customers say about us.',
|
reviewsText: 'Here\'s what our customers say about us.',
|
||||||
reviewsLink: 'See all reviews on Google →',
|
reviewsLink: 'See all reviews on Google →',
|
||||||
testimonials: [
|
testimonials: [
|
||||||
|
|
@ -151,6 +152,7 @@ export default {
|
||||||
featuredBadge: 'Recommended',
|
featuredBadge: 'Recommended',
|
||||||
moreBullets: (n) => (n === 1 ? '+ 1 more feature' : `+ ${n} more features`),
|
moreBullets: (n) => (n === 1 ? '+ 1 more feature' : `+ ${n} more features`),
|
||||||
lessBullets: 'Show less',
|
lessBullets: 'Show less',
|
||||||
|
wheelHint: 'Scroll to browse',
|
||||||
},
|
},
|
||||||
leistungen: {
|
leistungen: {
|
||||||
metaTitle: 'Services',
|
metaTitle: 'Services',
|
||||||
|
|
@ -196,7 +198,7 @@ export default {
|
||||||
requiredHint: '* Required fields',
|
requiredHint: '* Required fields',
|
||||||
sending: 'Sending…',
|
sending: 'Sending…',
|
||||||
submit: 'Send inquiry',
|
submit: 'Send inquiry',
|
||||||
cardTitle: 'HifiPlanet Amorbach',
|
cardTitle: 'HiFi Planet Amorbach',
|
||||||
address: 'Address',
|
address: 'Address',
|
||||||
phone: 'Phone',
|
phone: 'Phone',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,105 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api, API_BASE } from '../../../api/client.js';
|
import { api, API_BASE } from '../../../api/client.js';
|
||||||
|
|
||||||
|
// Gruppierung muss zur Backend-Konstante SettingsController::IMPORT_SECTIONS passen.
|
||||||
|
// Tabellen innerhalb einer Gruppe haengen per Fremdschluessel voneinander ab, darum
|
||||||
|
// werden sie beim Import immer zusammen ersetzt (nie einzeln).
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
key: 'catalog',
|
||||||
|
label: 'Fahrzeug-Katalog',
|
||||||
|
hint: 'Marken, Modelle, Pakete, Produkte, Upgrades',
|
||||||
|
tables: ['brands', 'car_models', 'packages', 'package_products', 'package_upgrades'],
|
||||||
|
},
|
||||||
|
{ key: 'services', label: 'Leistungen', hint: 'Die Leistungen-Übersicht', tables: ['services'] },
|
||||||
|
{ key: 'faqs', label: 'FAQs', hint: 'Häufig gestellte Fragen', tables: ['faqs'] },
|
||||||
|
{
|
||||||
|
key: 'gallery',
|
||||||
|
label: 'Bildergalerie',
|
||||||
|
hint: 'Galerie-Marken, Projekte und Fotos (inkl. Bilder)',
|
||||||
|
tables: ['gallery_brands', 'gallery_projects', 'gallery_photos'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TABLE_LABELS = {
|
||||||
|
brands: 'Marken',
|
||||||
|
car_models: 'Modelle',
|
||||||
|
packages: 'Pakete',
|
||||||
|
package_products: 'Produkte',
|
||||||
|
package_upgrades: 'Upgrades',
|
||||||
|
services: 'Leistungen',
|
||||||
|
faqs: 'FAQs',
|
||||||
|
gallery_brands: 'Galerie-Marken',
|
||||||
|
gallery_projects: 'Galerie-Projekte',
|
||||||
|
gallery_photos: 'Galerie-Fotos',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kurze, lesbare Zusammenfassung ("3 Marken, 12 Modelle, 45 Pakete") aus den im Export
|
||||||
|
// enthaltenen Zeilen einer Sektion - reine Vorschau, keine Validierung.
|
||||||
|
const summarize = (parsedData, tables) => {
|
||||||
|
if (!parsedData) return null;
|
||||||
|
const parts = tables
|
||||||
|
.map((t) => [t, Array.isArray(parsedData[t]) ? parsedData[t].length : 0])
|
||||||
|
.filter(([, count]) => count > 0)
|
||||||
|
.map(([t, count]) => `${count} ${TABLE_LABELS[t] || t}`);
|
||||||
|
return parts.length ? parts.join(', ') : 'leer in dieser Datei';
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExportImportSettings() {
|
export default function ExportImportSettings() {
|
||||||
const [importFile, setImportFile] = useState(null);
|
const [importFile, setImportFile] = useState(null);
|
||||||
|
const [parsedData, setParsedData] = useState(null);
|
||||||
|
const [parseError, setParseError] = useState('');
|
||||||
|
const [selectedSections, setSelectedSections] = useState(() => new Set(SECTIONS.map((s) => s.key)));
|
||||||
const [importConfirmed, setImportConfirmed] = useState(false);
|
const [importConfirmed, setImportConfirmed] = useState(false);
|
||||||
const [importBusy, setImportBusy] = useState(false);
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
const [importResult, setImportResult] = useState(null);
|
const [importResult, setImportResult] = useState(null);
|
||||||
const [importError, setImportError] = useState('');
|
const [importError, setImportError] = useState('');
|
||||||
|
|
||||||
|
const handleFileChange = async (e) => {
|
||||||
|
const file = e.target.files?.[0] || null;
|
||||||
|
setImportFile(file);
|
||||||
|
setImportResult(null);
|
||||||
|
setImportError('');
|
||||||
|
setParsedData(null);
|
||||||
|
setParseError('');
|
||||||
|
setSelectedSections(new Set(SECTIONS.map((s) => s.key)));
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed.data !== 'object') throw new Error('kein gültiges Export-Format');
|
||||||
|
setParsedData(parsed.data);
|
||||||
|
} catch {
|
||||||
|
// Vorschau ist nur ein Komfort-Feature - schlaegt sie fehl, importiert der
|
||||||
|
// Server trotzdem ganz normal alle Bereiche; die eigentliche Validierung
|
||||||
|
// passiert ohnehin serverseitig.
|
||||||
|
setParseError('Datei konnte nicht gelesen werden – Vorschau der Inhalte nicht möglich. Import würde trotzdem versucht (alle Bereiche).');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSection = (key) => {
|
||||||
|
setSelectedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleImport = async (e) => {
|
const handleImport = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!importFile || !importConfirmed) return;
|
if (!importFile || !importConfirmed || selectedSections.size === 0) return;
|
||||||
setImportBusy(true);
|
setImportBusy(true);
|
||||||
setImportError('');
|
setImportError('');
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', importFile);
|
formData.append('file', importFile);
|
||||||
|
formData.append('sections', JSON.stringify([...selectedSections]));
|
||||||
const result = await api.post('/settings/import', formData);
|
const result = await api.post('/settings/import', formData);
|
||||||
setImportResult(result);
|
setImportResult(result);
|
||||||
setImportFile(null);
|
setImportFile(null);
|
||||||
|
setParsedData(null);
|
||||||
setImportConfirmed(false);
|
setImportConfirmed(false);
|
||||||
e.target.reset();
|
e.target.reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -29,6 +109,8 @@ export default function ExportImportSettings() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedLabels = SECTIONS.filter((s) => selectedSections.has(s.key)).map((s) => s.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<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">
|
||||||
|
|
@ -49,18 +131,50 @@ 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 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. Ersetzt dabei die ausgewählten Bereiche komplett
|
||||||
Marken, Modelle, Pakete, Produkte, Upgrades, Leistungen, FAQs und die komplette Bildergalerie auf
|
durch den Inhalt der Datei – das kann nicht rückgängig gemacht werden. Kontaktanfragen und
|
||||||
diesem Server – das kann nicht rückgängig gemacht werden. Kontaktanfragen und Benutzerkonten bleiben
|
Benutzerkonten bleiben immer unangetastet.
|
||||||
unangetastet.
|
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleImport} className="space-y-3">
|
<form onSubmit={handleImport} className="space-y-4">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/json,.json"
|
accept="application/json,.json"
|
||||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
onChange={handleFileChange}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{importFile && (
|
||||||
|
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="border-b border-neutral-200 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:border-neutral-700 dark:text-neutral-400">
|
||||||
|
Was soll importiert werden?
|
||||||
|
</div>
|
||||||
|
{parseError && <p className="px-4 pt-3 text-sm text-amber-600 dark:text-amber-400">{parseError}</p>}
|
||||||
|
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<label
|
||||||
|
key={section.key}
|
||||||
|
className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSections.has(section.key)}
|
||||||
|
onChange={() => toggleSection(section.key)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-neutral-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{parsedData ? summarize(parsedData, section.tables) : section.hint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
<label className="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -68,12 +182,13 @@ 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, Leistungen,
|
Mir ist bewusst, dass dies{' '}
|
||||||
FAQs und die Bildergalerie auf diesem Server unwiderruflich ersetzt.
|
{selectedLabels.length ? <strong>{selectedLabels.join(', ')}</strong> : 'die ausgewählten Bereiche'} auf
|
||||||
|
diesem Server unwiderruflich ersetzt.
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!importFile || !importConfirmed || importBusy}
|
disabled={!importFile || !importConfirmed || importBusy || selectedSections.size === 0}
|
||||||
className="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
className="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{importBusy ? 'Importiere…' : 'Importieren'}
|
{importBusy ? 'Importiere…' : 'Importieren'}
|
||||||
|
|
@ -84,17 +199,12 @@ export default function ExportImportSettings() {
|
||||||
<div className="mt-3 rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300">
|
<div className="mt-3 rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300">
|
||||||
<p className="mb-1 font-semibold">Import erfolgreich:</p>
|
<p className="mb-1 font-semibold">Import erfolgreich:</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>{importResult.counts.brands} Marken</li>
|
{Object.entries(importResult.counts).map(([table, count]) => (
|
||||||
<li>{importResult.counts.car_models} Modelle</li>
|
<li key={table}>
|
||||||
<li>{importResult.counts.packages} Pakete</li>
|
{count} {TABLE_LABELS[table] || table}
|
||||||
<li>{importResult.counts.package_products} Produkte</li>
|
</li>
|
||||||
<li>{importResult.counts.package_upgrades} Upgrades</li>
|
))}
|
||||||
<li>{importResult.counts.services} Leistungen</li>
|
{importResult.images_restored > 0 && <li>{importResult.images_restored} Bilder wiederhergestellt</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>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const emptyForm = {
|
||||||
ga_measurement_id: '',
|
ga_measurement_id: '',
|
||||||
package_card_theme: 'graphite',
|
package_card_theme: 'graphite',
|
||||||
package_card_layout: 'strip',
|
package_card_layout: 'strip',
|
||||||
|
package_wheel_hint_interval: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PACKAGE_CARD_THEMES = [
|
const PACKAGE_CARD_THEMES = [
|
||||||
|
|
@ -38,6 +39,15 @@ export default function WebsiteSettings() {
|
||||||
const [gaError, setGaError] = useState('');
|
const [gaError, setGaError] = useState('');
|
||||||
const [gaSaved, setGaSaved] = useState(false);
|
const [gaSaved, setGaSaved] = useState(false);
|
||||||
|
|
||||||
|
const [reviewsPlaceId, setReviewsPlaceId] = useState('');
|
||||||
|
const [reviewsApiKey, setReviewsApiKey] = useState('');
|
||||||
|
const [reviewsHasApiKey, setReviewsHasApiKey] = useState(false);
|
||||||
|
const [reviewsStatus, setReviewsStatus] = useState(null); // { rating, rating_count, updated_at, error }
|
||||||
|
const [reviewsSaveBusy, setReviewsSaveBusy] = useState(false);
|
||||||
|
const [reviewsSaveError, setReviewsSaveError] = useState('');
|
||||||
|
const [reviewsSaved, setReviewsSaved] = useState(false);
|
||||||
|
const [reviewsRefreshBusy, setReviewsRefreshBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
.get('/site-settings')
|
.get('/site-settings')
|
||||||
|
|
@ -50,6 +60,7 @@ export default function WebsiteSettings() {
|
||||||
ga_measurement_id: res.ga_measurement_id || '',
|
ga_measurement_id: res.ga_measurement_id || '',
|
||||||
package_card_theme: res.package_card_theme || 'graphite',
|
package_card_theme: res.package_card_theme || 'graphite',
|
||||||
package_card_layout: res.package_card_layout || 'strip',
|
package_card_layout: res.package_card_layout || 'strip',
|
||||||
|
package_wheel_hint_interval: res.package_wheel_hint_interval || 8,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|
@ -57,8 +68,51 @@ export default function WebsiteSettings() {
|
||||||
setGaPropertyId(res.ga_property_id || '');
|
setGaPropertyId(res.ga_property_id || '');
|
||||||
setGaHasCredentials(res.has_credentials);
|
setGaHasCredentials(res.has_credentials);
|
||||||
});
|
});
|
||||||
|
loadReviewsStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadReviewsStatus = () => {
|
||||||
|
api.get('/settings/google-reviews').then((res) => {
|
||||||
|
setReviewsPlaceId(res.place_id || '');
|
||||||
|
setReviewsHasApiKey(res.has_api_key);
|
||||||
|
setReviewsStatus(res);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviewsSave = async () => {
|
||||||
|
setReviewsSaveBusy(true);
|
||||||
|
setReviewsSaveError('');
|
||||||
|
setReviewsSaved(false);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/settings/google-reviews', {
|
||||||
|
place_id: reviewsPlaceId,
|
||||||
|
api_key: reviewsApiKey,
|
||||||
|
});
|
||||||
|
setReviewsHasApiKey(res.has_api_key);
|
||||||
|
setReviewsStatus(res);
|
||||||
|
setReviewsApiKey('');
|
||||||
|
setReviewsSaved(true);
|
||||||
|
} catch (err) {
|
||||||
|
setReviewsSaveError(err.message);
|
||||||
|
} finally {
|
||||||
|
setReviewsSaveBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviewsRefresh = async () => {
|
||||||
|
setReviewsRefreshBusy(true);
|
||||||
|
setReviewsSaveError('');
|
||||||
|
try {
|
||||||
|
const res = await api.post('/settings/google-reviews/refresh', {});
|
||||||
|
setReviewsStatus(res);
|
||||||
|
} catch (err) {
|
||||||
|
setReviewsSaveError(err.message);
|
||||||
|
loadReviewsStatus(); // Fehler wurde serverseitig auch in app_settings vermerkt - Status nachladen
|
||||||
|
} finally {
|
||||||
|
setReviewsRefreshBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleGaDashboardSave = async () => {
|
const handleGaDashboardSave = async () => {
|
||||||
setGaBusy(true);
|
setGaBusy(true);
|
||||||
setGaError('');
|
setGaError('');
|
||||||
|
|
@ -215,6 +269,23 @@ export default function WebsiteSettings() {
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-5 max-w-xs">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Mausrad-Hinweis: Wiederholung (Sekunden)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
max="120"
|
||||||
|
value={form.package_wheel_hint_interval}
|
||||||
|
onChange={(e) => update('package_wheel_hint_interval', 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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
Nur bei Scroll-Band/3D-Coverflow: Solange ein Besucher noch auf der ersten Kachel steht, wird der
|
||||||
|
"mit dem Mausrad blättern"-Hinweis in diesem Abstand wiederholt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</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">
|
||||||
|
|
@ -276,6 +347,95 @@ export default function WebsiteSettings() {
|
||||||
</div>
|
</div>
|
||||||
</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">Google-Rezensionen</h2>
|
||||||
|
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Zeigt die echte Google-Bewertung und die aktuellsten Rezensionen auf der Startseite an, statt der
|
||||||
|
fest eingetragenen Beispiel-Texte. Wird einmal täglich automatisch aktualisiert (nie bei einem
|
||||||
|
Seitenbesuch selbst) – die Seite zeigt also immer den letzten erfolgreich abgerufenen Stand.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Place-ID</label>
|
||||||
|
<input
|
||||||
|
value={reviewsPlaceId}
|
||||||
|
onChange={(e) => { setReviewsPlaceId(e.target.value); setReviewsSaved(false); }}
|
||||||
|
placeholder="z. B. ChIJN1t_tDeuEmsRUsoyG83frY4"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
Eindeutige Google-Kennung eures Eintrags – zu finden über den{' '}
|
||||||
|
<a
|
||||||
|
href="https://developers.google.com/maps/documentation/places/web-service/place-id#find-id"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-brand-600"
|
||||||
|
>
|
||||||
|
Google Place ID Finder
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Places-API-Key {reviewsHasApiKey ? '(leer lassen = unverändert)' : ''}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={reviewsApiKey}
|
||||||
|
onChange={(e) => { setReviewsApiKey(e.target.value); setReviewsSaved(false); }}
|
||||||
|
placeholder={reviewsHasApiKey ? '••••••••••••••••••••• (bereits hinterlegt)' : 'AIzaSy…'}
|
||||||
|
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 font-mono text-sm dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
Aus der Google-Cloud-Console, mit aktivierter „Places API (New)“.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviewsSaveError && <p className="mt-3 text-sm text-red-600">{reviewsSaveError}</p>}
|
||||||
|
{reviewsSaved && <p className="mt-3 text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReviewsSave}
|
||||||
|
disabled={reviewsSaveBusy}
|
||||||
|
className="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{reviewsSaveBusy ? 'Speichere…' : 'Google-Rezensionen-Einstellungen speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReviewsRefresh}
|
||||||
|
disabled={reviewsRefreshBusy || !reviewsPlaceId || !reviewsHasApiKey}
|
||||||
|
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reviewsRefreshBusy ? 'Aktualisiere…' : 'Jetzt aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviewsStatus && (
|
||||||
|
<div className="mt-4 rounded-md bg-neutral-50 p-3 text-sm dark:bg-neutral-800/50">
|
||||||
|
{reviewsStatus.rating != null ? (
|
||||||
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
|
Aktuell hinterlegt: <strong>{reviewsStatus.rating} ★</strong> ({reviewsStatus.rating_count} Bewertungen)
|
||||||
|
{reviewsStatus.updated_at && (
|
||||||
|
<> – zuletzt aktualisiert am {new Date(reviewsStatus.updated_at.replace(' ', 'T')).toLocaleString('de-DE')}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-neutral-500 dark:text-neutral-400">
|
||||||
|
Noch keine erfolgreiche Aktualisierung – die Startseite zeigt bis dahin weiter die
|
||||||
|
Beispiel-Rezensionen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{reviewsStatus.error && (
|
||||||
|
<p className="mt-1 text-red-600">Letzter Fehler: {reviewsStatus.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
{saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
|
{saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="kf-page kontakt-sektion relative w-full overflow-hidden px-4 pb-10 sm:px-10"
|
className="kf-page kontakt-sektion relative w-full overflow-hidden px-4 pb-10 sm:px-10"
|
||||||
style={{ color: 'var(--kf-text2)' }}
|
style={{ color: 'var(--kf-text2)', fontFamily: "'Barlow', sans-serif" }}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 mx-auto max-w-[1280px] pt-10 sm:pt-14">
|
<div className="relative z-10 mx-auto max-w-[1280px] pt-10 sm:pt-14">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
|
|
@ -491,16 +491,17 @@ export default function ContactPage() {
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<ExternalEmbed name={t('contactPage.mapEmbedName')} className="h-[210px] w-full">
|
<ExternalEmbed name={t('contactPage.mapEmbedName')} className="h-[210px] w-full">
|
||||||
{/* iframe hoeher als der sichtbare Ausschnitt + negativer margin-top:
|
{/* iframe hoeher als der sichtbare 210px-Ausschnitt + negativer margin-top:
|
||||||
schneidet Googles eigenes Overlay (oben links) weg, damit nur unser
|
schneidet Googles Info-Overlay (oben links) komplett und die untere
|
||||||
"In Maps oeffnen"-Chip zu sehen ist - wie im Prototyp. */}
|
Attributionszeile bis auf den Rand an - wie im Prototyp fuellt die
|
||||||
|
Karte so buendig bis zur "Route planen"-Leiste. */}
|
||||||
<div className="h-[210px] overflow-hidden">
|
<div className="h-[210px] overflow-hidden">
|
||||||
<iframe
|
<iframe
|
||||||
title="HifiPlanet Amorbach – Standort"
|
title="HifiPlanet Amorbach – Standort"
|
||||||
src={`https://www.google.com/maps?q=${SHOP_ADDRESS_ENCODED}&output=embed`}
|
src={`https://www.google.com/maps?q=${SHOP_ADDRESS_ENCODED}&output=embed`}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="280"
|
height="290"
|
||||||
style={{ border: 0, display: 'block', marginTop: '-70px' }}
|
style={{ border: 0, display: 'block', marginTop: '-60px' }}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,18 @@ export default function Home() {
|
||||||
const stats = t('home.stats');
|
const stats = t('home.stats');
|
||||||
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 [faqs, setFaqs] = useState([]);
|
const [faqs, setFaqs] = useState([]);
|
||||||
|
// Solange noch keine Google-Rezensionen konfiguriert/erfolgreich abgerufen wurden
|
||||||
|
// (googleReviews === null), zeigt die Seite die festen Beispiel-Texte aus den
|
||||||
|
// Uebersetzungen - danach die echten, im Admin-Bereich zwischengespeicherten Rezensionen.
|
||||||
|
const [googleReviews, setGoogleReviews] = useState(null);
|
||||||
|
const testimonials = googleReviews
|
||||||
|
? googleReviews.reviews
|
||||||
|
// Manche Google-Rezensionen sind reine Sterne-Bewertungen ohne Text - als
|
||||||
|
// Zitat-Karte ohne Zitat waeren die nur eine leere Huelle.
|
||||||
|
.filter((r) => r.review_text)
|
||||||
|
.map((r) => ({ name: r.author_name, text: r.review_text, rating: r.rating, photo: r.profile_photo_url }))
|
||||||
|
: t('home.testimonials');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
|
|
@ -62,6 +72,17 @@ export default function Home() {
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get('/google-reviews')
|
||||||
|
.then((res) => {
|
||||||
|
// Nur uebernehmen, wenn mindestens eine Rezension auch echten Text hat -
|
||||||
|
// sonst bliebe die Zitat-Karten-Liste leer (siehe testimonials-Filter oben).
|
||||||
|
if (res.rating != null && res.reviews.some((r) => r.review_text)) setGoogleReviews(res);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
usePageMeta({
|
usePageMeta({
|
||||||
title: t('home.metaTitle'),
|
title: t('home.metaTitle'),
|
||||||
description: t('home.metaDescription'),
|
description: t('home.metaDescription'),
|
||||||
|
|
@ -298,9 +319,11 @@ export default function Home() {
|
||||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||||
<Reveal className="mb-10 text-center">
|
<Reveal className="mb-10 text-center">
|
||||||
<div className="mb-2 flex items-center justify-center gap-2">
|
<div className="mb-2 flex items-center justify-center gap-2">
|
||||||
<StarRating className="h-6 w-6" />
|
<StarRating className="h-6 w-6" count={googleReviews ? Math.round(googleReviews.rating) : 5} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{t('home.reviewsRating')}</h2>
|
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
{googleReviews ? t('home.reviewsRatingDynamic')(googleReviews.rating, googleReviews.rating_count) : t('home.reviewsRating')}
|
||||||
|
</h2>
|
||||||
<p className="mt-1 text-neutral-600 dark:text-neutral-300">{t('home.reviewsText')}</p>
|
<p className="mt-1 text-neutral-600 dark:text-neutral-300">{t('home.reviewsText')}</p>
|
||||||
<a
|
<a
|
||||||
href="https://www.google.com/maps/search/?api=1&query=Hifi+Planet+Amorbach+Boxbrunner+Stra%C3%9Fe+20a"
|
href="https://www.google.com/maps/search/?api=1&query=Hifi+Planet+Amorbach+Boxbrunner+Stra%C3%9Fe+20a"
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,14 @@ export default function Leistungen() {
|
||||||
<div className="p-6 pt-8">
|
<div className="p-6 pt-8">
|
||||||
<h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">{service.title}</h2>
|
<h2 className="mb-2 font-semibold text-neutral-900 dark:text-white">{service.title}</h2>
|
||||||
<p className="mb-3 text-sm text-neutral-600 dark:text-neutral-300">{service.description}</p>
|
<p className="mb-3 text-sm text-neutral-600 dark:text-neutral-300">{service.description}</p>
|
||||||
{service.cta_label && service.cta_url && (
|
{/* Karten ohne eigenen CTA (alles ausser Car-Hifi, das auf /fahrzeuge zeigt)
|
||||||
<Link to={service.cta_url} className="text-sm font-semibold text-brand-600 hover:underline dark:text-brand-400">
|
landen auf dem Kontaktformular statt ins Leere zu laufen. */}
|
||||||
{service.cta_label} →
|
<Link
|
||||||
</Link>
|
to={service.cta_url || '/kontakt'}
|
||||||
)}
|
className="text-sm font-semibold text-brand-600 hover:underline dark:text-brand-400"
|
||||||
|
>
|
||||||
|
{service.cta_label || t('leistungen.contact')} →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import usePageMeta from '../../hooks/usePageMeta.js';
|
||||||
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
|
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
|
||||||
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
|
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
|
||||||
import DynamicIcon from '../../components/DynamicIcon.jsx';
|
import DynamicIcon from '../../components/DynamicIcon.jsx';
|
||||||
|
import { ChevronLeft, ChevronRight, Mouse } from 'lucide-react';
|
||||||
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
|
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
|
||||||
import { useLanguage } from '../../context/LanguageContext.jsx';
|
import { useLanguage } from '../../context/LanguageContext.jsx';
|
||||||
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
|
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
|
||||||
|
|
@ -121,12 +122,12 @@ function PackageCard({ pkg, tier, tierT, layout, bullets, formatPrice, contactUr
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
layout === 'strip'
|
layout === 'strip'
|
||||||
? 'relative flex min-h-[640px] grow shrink basis-[190px] min-w-[190px] max-w-[240px] snap-start flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40 sm:basis-[210px]'
|
? 'relative flex min-h-[420px] grow shrink basis-[190px] min-w-[190px] max-w-[240px] snap-start flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40 sm:min-h-[640px] sm:basis-[210px]'
|
||||||
: layout === 'coverflow'
|
: layout === 'coverflow'
|
||||||
? // Feste Breite + snap-center: die 3D-Transformation (rotateY/scale/translateZ)
|
? // Feste Breite + snap-center: die 3D-Transformation (rotateY/scale/translateZ)
|
||||||
// setzt der Scroll-Handler in ModelPage direkt per style.transform.
|
// setzt der Scroll-Handler in ModelPage direkt per style.transform.
|
||||||
'relative flex min-h-[640px] w-[230px] shrink-0 snap-center flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 will-change-transform dark:shadow-black/40'
|
'relative flex min-h-[420px] w-[230px] shrink-0 snap-center flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 will-change-transform dark:shadow-black/40 sm:min-h-[640px]'
|
||||||
: 'relative flex min-h-[640px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40'
|
: 'relative flex min-h-[420px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40 sm:min-h-[640px]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Vollflaechiges Straßen-Glow-Bild der Preisstufe (Referenzdesign). */}
|
{/* Vollflaechiges Straßen-Glow-Bild der Preisstufe (Referenzdesign). */}
|
||||||
|
|
@ -264,13 +265,18 @@ export default function ModelPage() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const maintenance = useMaintenance();
|
const maintenance = useMaintenance();
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings();
|
const {
|
||||||
|
package_card_theme: packageCardTheme,
|
||||||
|
package_card_layout: packageCardLayout,
|
||||||
|
package_wheel_hint_interval: wheelHintIntervalSetting,
|
||||||
|
} = useSiteSettings();
|
||||||
const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
|
const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
|
||||||
const layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
|
const layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
|
||||||
|
const wheelHintInterval = Math.max(2, Number(wheelHintIntervalSetting) || 8) * 1000;
|
||||||
const scrollerRef = useRef(null);
|
const scrollerRef = useRef(null);
|
||||||
const trackRef = useRef(null);
|
const trackRef = useRef(null);
|
||||||
const thumbRef = useRef(null);
|
const thumbRef = useRef(null);
|
||||||
const hintPlayedRef = useRef(false);
|
const [wheelHintVisible, setWheelHintVisible] = useState(false);
|
||||||
const formatPrice = (value) =>
|
const formatPrice = (value) =>
|
||||||
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
|
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
|
||||||
|
|
||||||
|
|
@ -317,26 +323,149 @@ export default function ModelPage() {
|
||||||
|
|
||||||
// Kein Karten-Drag per Maus mehr (Kundenfeedback: fuehlte sich in Kombination mit der
|
// Kein Karten-Drag per Maus mehr (Kundenfeedback: fuehlte sich in Kombination mit der
|
||||||
// 3D-Transformation im Coverflow nicht gut an). Auf dem Desktop navigiert man
|
// 3D-Transformation im Coverflow nicht gut an). Auf dem Desktop navigiert man
|
||||||
// stattdessen ueber die eigene Scroll-Leiste unten; Touch-Geraete behalten ohnehin
|
// stattdessen per Mausrad (siehe Effect weiter unten) oder ueber die eigene
|
||||||
// das native, fluessige Wisch-Scrolling der scroll-snap-Kartenreihe.
|
// Scroll-Leiste; Touch-Geraete behalten ohnehin das native, fluessige
|
||||||
|
// Wisch-Scrolling der scroll-snap-Kartenreihe.
|
||||||
//
|
//
|
||||||
// Stattdessen: einmaliger kurzer Wisch-Hinweis beim ersten Laden auf Touch-Geraeten
|
// Stattdessen: kurzer Wisch-Hinweis (Reihe faehrt ein Stueck nach rechts und
|
||||||
// (Kundenwunsch) - die Reihe faehrt kurz ein Stueck nach rechts und wieder zurueck,
|
// zurueck, plus Mausrad-Icon auf Geraeten mit Maus). Wiederholt sich im
|
||||||
// damit sofort klar ist, dass sich die Kacheln wischen lassen. Nur einmal pro
|
// Admin-konfigurierten Abstand (wheelHintInterval), SOLANGE der Besucher noch
|
||||||
// Seitenaufruf, nur auf echten Touch-Geraeten, nicht bei reduzierter Bewegung.
|
// auf der ersten Kachel steht (Kundenwunsch) - wurde bereits weitergescrollt,
|
||||||
|
// faellt die naechste Wiederholung aus, greift aber wieder, sobald man zur
|
||||||
|
// ersten Kachel zurueckkehrt. Nicht bei reduzierter Bewegung. Auf Touch-Geraeten
|
||||||
|
// reicht die Bewegung allein (Wischen ist selbsterklaerend), daher kein Icon.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (layout === 'grid' || !el || hintPlayedRef.current) return undefined;
|
if (layout === 'grid' || !el) return undefined;
|
||||||
const isTouch = window.matchMedia('(pointer: coarse)').matches;
|
const isTouch = window.matchMedia('(pointer: coarse)').matches;
|
||||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
if (!isTouch || reducedMotion) return undefined;
|
if (reducedMotion) return undefined;
|
||||||
hintPlayedRef.current = true;
|
|
||||||
const peek = Math.min(el.clientWidth * 0.4, 160);
|
let animTimers = [];
|
||||||
const t1 = setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500);
|
let intervalId = 0;
|
||||||
const t2 = setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250);
|
const playHint = () => {
|
||||||
|
const peek = Math.min(el.clientWidth * 0.4, 160);
|
||||||
|
animTimers.push(setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500));
|
||||||
|
animTimers.push(setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250));
|
||||||
|
if (!isTouch) {
|
||||||
|
animTimers.push(setTimeout(() => setWheelHintVisible(true), 450));
|
||||||
|
animTimers.push(setTimeout(() => setWheelHintVisible(false), 1700));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kundenfeedback: der Hinweis lief bisher direkt beim Laden ab, oft bevor die
|
||||||
|
// Kartenreihe ueberhaupt in den sichtbaren Bereich gescrollt war - dann hat ihn
|
||||||
|
// niemand gesehen. Jetzt erst starten, wenn die Reihe wirklich im Blick ist.
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
observer.disconnect();
|
||||||
|
playHint();
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
if (el.scrollLeft <= 1) playHint();
|
||||||
|
}, wheelHintInterval);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.35 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(t1);
|
observer.disconnect();
|
||||||
clearTimeout(t2);
|
clearInterval(intervalId);
|
||||||
|
animTimers.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, [layout, data, wheelHintInterval]);
|
||||||
|
|
||||||
|
// Mausrad ueber der Kartenreihe scrollt horizontal (Kundenfeedback: die Scroll-
|
||||||
|
// Leiste allein war als einzige Desktop-Bedienung zu fummelig). Steht man an der
|
||||||
|
// ersten/letzten Karte und "scrollt" in Richtung des Randes hinaus, wird NICHT
|
||||||
|
// abgefangen (kein preventDefault) - der Browser scrollt dann ganz normal die
|
||||||
|
// Seite weiter, genau wie es heute schon passiert, wenn die Kartenreihe keinen
|
||||||
|
// Overflow hat. Reines horizontales Wheel/Trackpad-Sideswipe (deltaX) faellt
|
||||||
|
// schon nativ auf den Container und wird hier nicht angefasst.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (layout === 'grid' || !el) return undefined;
|
||||||
|
let idleTimer = 0;
|
||||||
|
let reenableTimer = 0;
|
||||||
|
let burstActive = false;
|
||||||
|
let burstStartIndex = 0;
|
||||||
|
let burstNet = 0;
|
||||||
|
|
||||||
|
const cardCenters = () => [...el.children].map((card) => card.offsetLeft + card.offsetWidth / 2);
|
||||||
|
const nearestIndexAt = (centers, mid) => {
|
||||||
|
let best = 0;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
centers.forEach((center, i) => {
|
||||||
|
const dist = Math.abs(center - mid);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Beim Einrasten nach einer Wheel-Boe NICHT einfach zur naechstgelegenen Karte
|
||||||
|
// (= oft die Startkarte, wenn nur leicht gescrollt wurde - fuehlte sich wie ein
|
||||||
|
// Zurueckspringen an, Kundenfeedback). Stattdessen: landet die naechstgelegene
|
||||||
|
// Karte trotz erkennbarer Scroll-Richtung wieder auf der Startkarte, wird
|
||||||
|
// trotzdem ein Schritt in diese Richtung erzwungen - auch ein leichter Wisch
|
||||||
|
// blaettert also mindestens eine Karte weiter. Bei weiterem Scrollen (die
|
||||||
|
// naechstgelegene Karte liegt schon woanders) gilt weiterhin ganz normal die
|
||||||
|
// naechstgelegene Karte, auch wenn das mehrere Karten weiter ist.
|
||||||
|
const settleBurst = () => {
|
||||||
|
burstActive = false;
|
||||||
|
const centers = cardCenters();
|
||||||
|
if (!centers.length) return;
|
||||||
|
const mid = el.scrollLeft + el.clientWidth / 2;
|
||||||
|
const nearestIdx = nearestIndexAt(centers, mid);
|
||||||
|
let targetIdx = nearestIdx;
|
||||||
|
if (nearestIdx === burstStartIndex && Math.abs(burstNet) > 4) {
|
||||||
|
targetIdx =
|
||||||
|
burstNet > 0 ? Math.min(burstStartIndex + 1, centers.length - 1) : Math.max(burstStartIndex - 1, 0);
|
||||||
|
}
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||||
|
const target = Math.max(0, Math.min(centers[targetIdx] - el.clientWidth / 2, maxScroll));
|
||||||
|
el.scrollTo({ left: target, behavior: 'smooth' });
|
||||||
|
// Snap erst nach der weichen Fahrt wieder aktivieren, sonst kaempft sie
|
||||||
|
// gegen unser eigenes scrollTo an.
|
||||||
|
clearTimeout(reenableTimer);
|
||||||
|
reenableTimer = setTimeout(() => {
|
||||||
|
el.style.scrollSnapType = '';
|
||||||
|
}, 450);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWheel = (e) => {
|
||||||
|
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||||
|
if (maxScroll <= 1) return;
|
||||||
|
const atStart = el.scrollLeft <= 1;
|
||||||
|
const atEnd = el.scrollLeft >= maxScroll - 1;
|
||||||
|
if ((e.deltaY < 0 && atStart) || (e.deltaY > 0 && atEnd)) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
if (!burstActive) {
|
||||||
|
burstActive = true;
|
||||||
|
burstNet = 0;
|
||||||
|
burstStartIndex = nearestIndexAt(cardCenters(), el.scrollLeft + el.clientWidth / 2);
|
||||||
|
}
|
||||||
|
burstNet += e.deltaY;
|
||||||
|
// Snap waehrend der Wheel-Boe aussetzen, sonst kaempft sie gegen schnell
|
||||||
|
// aufeinanderfolgende Wheel-Ticks. Der Nachlauf-Timer wird bei jedem Tick
|
||||||
|
// verlaengert, die Boe gilt also erst kurz nach dem letzten Tick als beendet.
|
||||||
|
el.style.scrollSnapType = 'none';
|
||||||
|
el.scrollLeft += e.deltaY;
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
idleTimer = setTimeout(settleBurst, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('wheel', onWheel);
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
clearTimeout(reenableTimer);
|
||||||
};
|
};
|
||||||
}, [layout, data]);
|
}, [layout, data]);
|
||||||
|
|
||||||
|
|
@ -496,7 +625,7 @@ export default function ModelPage() {
|
||||||
// bleiben dunkel (Referenzdesign), wodurch sie auf heller Flaeche als eigenstaendige
|
// bleiben dunkel (Referenzdesign), wodurch sie auf heller Flaeche als eigenstaendige
|
||||||
// "Karten" wirken. Der Scrollbalken-Stil unten passt sich per dark: mit an.
|
// "Karten" wirken. Der Scrollbalken-Stil unten passt sich per dark: mit an.
|
||||||
<section className="py-10 sm:py-14" style={{ fontFamily: "'Barlow', sans-serif" }}>
|
<section className="py-10 sm:py-14" style={{ fontFamily: "'Barlow', sans-serif" }}>
|
||||||
<div className={layout === 'grid' ? 'mx-auto max-w-6xl px-4 sm:px-6' : 'mx-auto max-w-[1880px] px-4 sm:px-6'}>
|
<div className={layout === 'grid' ? 'mx-auto max-w-6xl px-4 sm:px-6' : 'relative mx-auto max-w-[1880px] px-4 sm:px-6'}>
|
||||||
<div
|
<div
|
||||||
ref={layout === 'grid' ? undefined : scrollerRef}
|
ref={layout === 'grid' ? undefined : scrollerRef}
|
||||||
className={
|
className={
|
||||||
|
|
@ -535,6 +664,25 @@ export default function ModelPage() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mausrad-Hinweis: einmaliger, kurz ein- und ausblendender Pill mittig ueber
|
||||||
|
der Reihe, zeitlich an die Ausweich-Bewegung oben gekoppelt (Hint-Effekt).
|
||||||
|
z-[200] haelt ihn ueber den Coverflow-Karten (die bis z-index 100 reichen). */}
|
||||||
|
{layout !== 'grid' && (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute inset-x-0 top-1/2 z-[200] flex -translate-y-1/2 justify-center transition-opacity duration-300 ${
|
||||||
|
wheelHintVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-neutral-900/85 px-4 py-2 text-xs font-medium text-white shadow-lg backdrop-blur-sm">
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5 text-brand-400" />
|
||||||
|
<Mouse className="h-4 w-4 text-brand-400" />
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-brand-400" />
|
||||||
|
<span>{t('modelPage.wheelHint')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Eigene Scroll-Leiste: zentrierter Track, markengruener leuchtender Daumen.
|
{/* Eigene Scroll-Leiste: zentrierter Track, markengruener leuchtender Daumen.
|
||||||
Ziehbar + klickbar (Logik im Scrollbar-Effekt); blendet aus, wenn alle
|
Ziehbar + klickbar (Logik im Scrollbar-Effekt); blendet aus, wenn alle
|
||||||
Karten ohne Scrollen passen. touch-action none fuer sauberes Pointer-Ziehen. */}
|
Karten ohne Scrollen passen. touch-action none fuer sauberes Pointer-Ziehen. */}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,34 @@ RewriteBase /
|
||||||
# Upload mit "Methode nicht erlaubt" fehlschlägt.
|
# Upload mit "Methode nicht erlaubt" fehlschlägt.
|
||||||
DirectorySlash Off
|
DirectorySlash Off
|
||||||
|
|
||||||
|
# Groessere Upload-/POST-Limits fuer den Daten-Export/-Import im Admin-Bereich
|
||||||
|
# (der Export bettet die Bildergalerie als Base64 direkt in die JSON-Datei ein,
|
||||||
|
# das kann schnell 40+ MB werden - PHPs Standard-Limits liegen oft niedriger).
|
||||||
|
# Nur relevant, wenn PHP als Apache-Modul laeuft (mod_php) - die IfModule-Pruefung
|
||||||
|
# sorgt dafuer, dass dieser Block auf FastCGI/PHP-FPM-Hosting (dort greift
|
||||||
|
# stattdessen api/public/.user.ini) folgenlos uebersprungen wird, statt einen
|
||||||
|
# "Invalid command"-Fehler fuer die ganze Seite auszuloesen.
|
||||||
|
<IfModule mod_php.c>
|
||||||
|
php_value upload_max_filesize 200M
|
||||||
|
php_value post_max_size 200M
|
||||||
|
php_value memory_limit 512M
|
||||||
|
</IfModule>
|
||||||
|
<IfModule php_module>
|
||||||
|
php_value upload_max_filesize 200M
|
||||||
|
php_value post_max_size 200M
|
||||||
|
php_value memory_limit 512M
|
||||||
|
</IfModule>
|
||||||
|
<IfModule php7_module>
|
||||||
|
php_value upload_max_filesize 200M
|
||||||
|
php_value post_max_size 200M
|
||||||
|
php_value memory_limit 512M
|
||||||
|
</IfModule>
|
||||||
|
<IfModule php8_module>
|
||||||
|
php_value upload_max_filesize 200M
|
||||||
|
php_value post_max_size 200M
|
||||||
|
php_value memory_limit 512M
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
# API-Requests an den PHP-Front-Controller weiterleiten
|
# API-Requests an den PHP-Front-Controller weiterleiten
|
||||||
RewriteCond %{REQUEST_URI} ^/api/
|
RewriteCond %{REQUEST_URI} ^/api/
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Intervall (in Sekunden), in dem der Mausrad-Hinweis auf der Paket-Kartenreihe
|
||||||
|
-- wiederholt wird, solange man noch auf der ersten Kachel steht. 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 package_wheel_hint_interval INT NOT NULL DEFAULT 8;
|
||||||
|
|
@ -28,6 +28,7 @@ CREATE TABLE app_settings (
|
||||||
ga_property_id VARCHAR(30) NULL,
|
ga_property_id VARCHAR(30) NULL,
|
||||||
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
|
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
|
||||||
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
|
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
|
||||||
|
package_wheel_hint_interval INT NOT NULL DEFAULT 8,
|
||||||
mail_host VARCHAR(255) NULL,
|
mail_host VARCHAR(255) NULL,
|
||||||
mail_port INT NULL,
|
mail_port INT NULL,
|
||||||
mail_username VARCHAR(255) NULL,
|
mail_username VARCHAR(255) NULL,
|
||||||
|
|
|
||||||
8
hifi/api/public/.user.ini
Normal file
8
hifi/api/public/.user.ini
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
; Groessere Upload-/POST-Limits fuer den Daten-Export/-Import im Admin-Bereich
|
||||||
|
; (der Export bettet die Bildergalerie als Base64 direkt in die JSON-Datei ein,
|
||||||
|
; das kann schnell 40+ MB werden). Greift auf PHP-FPM/CGI-Hosting (z.B. viele
|
||||||
|
; Shared-Hosting-Umgebungen) - fuer klassisches Apache-Modul-PHP (mod_php)
|
||||||
|
; uebernimmt stattdessen der php_value-Block in hifi/.htaccess.
|
||||||
|
upload_max_filesize = 200M
|
||||||
|
post_max_size = 200M
|
||||||
|
memory_limit = 512M
|
||||||
|
|
@ -18,6 +18,7 @@ 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;
|
||||||
|
use App\Controllers\GoogleReviewsController;
|
||||||
use App\Controllers\MailSettingsController;
|
use App\Controllers\MailSettingsController;
|
||||||
use App\Controllers\MaintenanceController;
|
use App\Controllers\MaintenanceController;
|
||||||
use App\Controllers\ModelController;
|
use App\Controllers\ModelController;
|
||||||
|
|
@ -161,6 +162,10 @@ $router->post('/faqs', $perm('content.manage', fn($p) => FaqController::store())
|
||||||
$router->put('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::update($p)));
|
$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->delete('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::destroy($p)));
|
||||||
|
|
||||||
|
// Nur der zuletzt gecachte Stand - kein Live-Aufruf bei Google pro Seitenbesuch
|
||||||
|
// (Aktualisierung laeuft ausschliesslich ueber refresh_google_reviews.php).
|
||||||
|
$router->get('/google-reviews', fn($p) => GoogleReviewsController::publicReviews());
|
||||||
|
|
||||||
$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)));
|
||||||
|
|
@ -191,6 +196,10 @@ $router->post('/settings/mail/test', $perm('settings.manage', fn($p) => MailSett
|
||||||
|
|
||||||
$router->get('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::show()));
|
$router->get('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::show()));
|
||||||
$router->post('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::update()));
|
$router->post('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::update()));
|
||||||
|
|
||||||
|
$router->get('/settings/google-reviews', $perm('settings.manage', fn($p) => GoogleReviewsController::show()));
|
||||||
|
$router->post('/settings/google-reviews', $perm('settings.manage', fn($p) => GoogleReviewsController::update()));
|
||||||
|
$router->post('/settings/google-reviews/refresh', $perm('settings.manage', fn($p) => GoogleReviewsController::refresh()));
|
||||||
$router->get('/analytics/report', $admin(fn($p) => AnalyticsController::report()));
|
$router->get('/analytics/report', $admin(fn($p) => AnalyticsController::report()));
|
||||||
|
|
||||||
$router->get('/maintenance', fn($p) => MaintenanceController::status());
|
$router->get('/maintenance', fn($p) => MaintenanceController::status());
|
||||||
|
|
|
||||||
2
hifi/api/scripts/refresh_google_reviews.bat
Normal file
2
hifi/api/scripts/refresh_google_reviews.bat
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
@echo off
|
||||||
|
"C:\xamppHIFI\php\php.exe" "C:\xamppHIFI\htdocs\hifi\api\scripts\refresh_google_reviews.php" >> "C:\xamppHIFI\htdocs\hifi\api\scripts\refresh_google_reviews.log" 2>&1
|
||||||
27
hifi/api/scripts/refresh_google_reviews.php
Normal file
27
hifi/api/scripts/refresh_google_reviews.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Config\Database;
|
||||||
|
use App\Services\GooglePlacesReviewsFetcher;
|
||||||
|
|
||||||
|
$db = Database::connection();
|
||||||
|
$row = $db->query(
|
||||||
|
'SELECT google_place_id, google_places_api_key FROM app_settings WHERE id = 1'
|
||||||
|
)->fetch();
|
||||||
|
|
||||||
|
$placeId = $row['google_place_id'] ?? '';
|
||||||
|
$apiKey = $row['google_places_api_key'] ?? '';
|
||||||
|
|
||||||
|
if ($placeId === '' || $apiKey === '') {
|
||||||
|
echo "Google-Rezensionen-Refresh übersprungen: Place-ID oder API-Key ist im Admin-Bereich noch nicht hinterlegt.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = (new GooglePlacesReviewsFetcher())->refreshAndStore($db, $apiKey, $placeId);
|
||||||
|
|
||||||
|
if ($result['ok']) {
|
||||||
|
echo "Google-Rezensionen-Refresh erfolgreich: Bewertung {$result['rating']} ({$result['rating_count']} Bewertungen gesamt), {$result['reviews_count']} Rezensionen gespeichert.\n";
|
||||||
|
} else {
|
||||||
|
echo "Google-Rezensionen-Refresh fehlgeschlagen: {$result['error']}\n";
|
||||||
|
}
|
||||||
96
hifi/api/src/Controllers/GoogleReviewsController.php
Normal file
96
hifi/api/src/Controllers/GoogleReviewsController.php
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Config\Database;
|
||||||
|
use App\Services\GooglePlacesReviewsFetcher;
|
||||||
|
use App\Support\Http;
|
||||||
|
|
||||||
|
class GoogleReviewsController
|
||||||
|
{
|
||||||
|
/** Admin: aktuelle Konfiguration + letzter Abruf-Stand (nie den API-Key selbst zurückgeben). */
|
||||||
|
public static function show(): void
|
||||||
|
{
|
||||||
|
$row = self::settingsRow();
|
||||||
|
|
||||||
|
Http::send([
|
||||||
|
'place_id' => $row['google_place_id'] ?? null,
|
||||||
|
'has_api_key' => ($row['google_places_api_key'] ?? '') !== '',
|
||||||
|
'rating' => $row['google_rating'] !== null ? (float) $row['google_rating'] : null,
|
||||||
|
'rating_count' => $row['google_rating_count'] !== null ? (int) $row['google_rating_count'] : null,
|
||||||
|
'updated_at' => $row['google_reviews_updated_at'] ?? null,
|
||||||
|
'error' => $row['google_reviews_error'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: Place-ID + (optional) API-Key speichern. Leeres Key-Feld = bestehenden Key behalten. */
|
||||||
|
public static function update(): void
|
||||||
|
{
|
||||||
|
$body = Http::jsonBody();
|
||||||
|
$placeId = trim($body['place_id'] ?? '');
|
||||||
|
$apiKey = trim($body['api_key'] ?? '');
|
||||||
|
|
||||||
|
$db = Database::connection();
|
||||||
|
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
|
||||||
|
|
||||||
|
if ($apiKey !== '') {
|
||||||
|
$stmt = $db->prepare('UPDATE app_settings SET google_place_id = ?, google_places_api_key = ? WHERE id = 1');
|
||||||
|
$stmt->execute([$placeId ?: null, $apiKey]);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare('UPDATE app_settings SET google_place_id = ? WHERE id = 1');
|
||||||
|
$stmt->execute([$placeId ?: null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: Refresh sofort auslösen (nicht erst auf den naechtlichen Cron warten). */
|
||||||
|
public static function refresh(): void
|
||||||
|
{
|
||||||
|
$row = self::settingsRow();
|
||||||
|
$placeId = $row['google_place_id'] ?? '';
|
||||||
|
$apiKey = $row['google_places_api_key'] ?? '';
|
||||||
|
|
||||||
|
if ($placeId === '' || $apiKey === '') {
|
||||||
|
Http::error('Bitte zuerst Place-ID und API-Key eintragen und speichern.', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = (new GooglePlacesReviewsFetcher())->refreshAndStore(Database::connection(), $apiKey, $placeId);
|
||||||
|
if (!$result['ok']) {
|
||||||
|
Http::error($result['error'], 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffentlich: was die Startseite anzeigt - ausschließlich der zuletzt erfolgreich gecachte Stand. */
|
||||||
|
public static function publicReviews(): void
|
||||||
|
{
|
||||||
|
$row = self::settingsRow();
|
||||||
|
$rating = $row['google_rating'] !== null ? (float) $row['google_rating'] : null;
|
||||||
|
$ratingCount = $row['google_rating_count'] !== null ? (int) $row['google_rating_count'] : null;
|
||||||
|
|
||||||
|
$reviews = [];
|
||||||
|
if ($rating !== null) {
|
||||||
|
$stmt = Database::connection()->query(
|
||||||
|
'SELECT author_name, profile_photo_url, rating, review_text FROM google_reviews ORDER BY sort_order'
|
||||||
|
);
|
||||||
|
$reviews = $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Http::send(['rating' => $rating, 'rating_count' => $ratingCount, 'reviews' => $reviews]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function settingsRow(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stmt = Database::connection()->query(
|
||||||
|
'SELECT google_place_id, google_places_api_key, google_rating, google_rating_count, google_reviews_updated_at, google_reviews_error FROM app_settings WHERE id = 1'
|
||||||
|
);
|
||||||
|
return $stmt->fetch() ?: [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Spalten fehlen noch (vor Schema-Migration).
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,28 @@ class SettingsController
|
||||||
'gallery_photos' => ['id', 'gallery_project_id', 'image_path', 'caption', 'sort_order', 'created_at', 'updated_at'],
|
'gallery_photos' => ['id', 'gallery_project_id', 'image_path', 'caption', 'sort_order', 'created_at', 'updated_at'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Gruppierung fuer den wahlweisen Import: jede Gruppe wird beim Import als Ganzes
|
||||||
|
// ersetzt oder komplett unangetastet gelassen. Tabellen innerhalb einer Gruppe
|
||||||
|
// haengen per Fremdschluessel voneinander ab (z.B. packages -> car_models), darum
|
||||||
|
// keine feinere Aufteilung - sonst koennten verwaiste/falsch verknuepfte Zeilen
|
||||||
|
// entstehen, wenn z.B. nur "Pakete" ohne die zugehoerigen Modelle importiert wuerden.
|
||||||
|
private const IMPORT_SECTIONS = [
|
||||||
|
'catalog' => ['brands', 'car_models', 'packages', 'package_products', 'package_upgrades'],
|
||||||
|
'services' => ['services'],
|
||||||
|
'faqs' => ['faqs'],
|
||||||
|
'gallery' => ['gallery_brands', 'gallery_projects', 'gallery_photos'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Welche Tabellenspalte pro Tabelle einen Bild-Pfad enthaelt - dieselbe Zuordnung
|
||||||
|
// wird sowohl beim Export (Bilder einsammeln) als auch beim Teil-Import (nur die
|
||||||
|
// Bilder der tatsaechlich ausgewaehlten Bereiche wiederherstellen) verwendet.
|
||||||
|
private const IMAGE_COLUMNS = [
|
||||||
|
'services' => 'image_path',
|
||||||
|
'gallery_brands' => 'cover_image_path',
|
||||||
|
'gallery_projects' => 'cover_image_path',
|
||||||
|
'gallery_photos' => 'image_path',
|
||||||
|
];
|
||||||
|
|
||||||
// Kein const, da abhängig von der Umgebung (base_path unterscheidet sich
|
// Kein const, da abhängig von der Umgebung (base_path unterscheidet sich
|
||||||
// zwischen lokalem /hifi und der Root-Domain auf IONOS).
|
// zwischen lokalem /hifi und der Root-Domain auf IONOS).
|
||||||
private static function uploadsUrlPrefix(): string
|
private static function uploadsUrlPrefix(): string
|
||||||
|
|
@ -43,13 +65,7 @@ class SettingsController
|
||||||
}
|
}
|
||||||
|
|
||||||
$images = [];
|
$images = [];
|
||||||
$imageColumns = [
|
foreach (self::IMAGE_COLUMNS as $table => $column) {
|
||||||
'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);
|
||||||
}
|
}
|
||||||
|
|
@ -70,11 +86,53 @@ class SettingsController
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wandelt eine php.ini-Groessenangabe ("200M", "1G", "512K") in Bytes um. */
|
||||||
|
private static function iniBytes(string $value): int
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$num = (float) $value;
|
||||||
|
return match (strtolower(substr($value, -1))) {
|
||||||
|
'g' => (int) ($num * 1024 * 1024 * 1024),
|
||||||
|
'm' => (int) ($num * 1024 * 1024),
|
||||||
|
'k' => (int) ($num * 1024),
|
||||||
|
default => (int) $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static function importData(): void
|
public static function importData(): void
|
||||||
{
|
{
|
||||||
if (!empty($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
if (!empty($_FILES['file'])) {
|
||||||
|
$uploadError = $_FILES['file']['error'];
|
||||||
|
if ($uploadError === UPLOAD_ERR_INI_SIZE || $uploadError === UPLOAD_ERR_FORM_SIZE) {
|
||||||
|
Http::error(
|
||||||
|
'Die Datei überschreitet das Upload-Limit dieses Servers (aktuell ' . ini_get('upload_max_filesize') .
|
||||||
|
'). Bitte den Hosting-Anbieter um ein höheres PHP-Upload-Limit bitten.',
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($uploadError !== UPLOAD_ERR_OK) {
|
||||||
|
Http::error('Datei-Upload fehlgeschlagen (Fehlercode ' . $uploadError . ').', 422);
|
||||||
|
}
|
||||||
$raw = file_get_contents($_FILES['file']['tmp_name']);
|
$raw = file_get_contents($_FILES['file']['tmp_name']);
|
||||||
} else {
|
} else {
|
||||||
|
// Ein Upload, der post_max_size ueberschreitet, wird von PHP nicht in $_FILES
|
||||||
|
// aufgenommen - der rohe Body ist ueber php://input aber trotzdem da (nur eben
|
||||||
|
// noch als unverarbeitetes Multipart-Gemisch, kein gueltiges JSON). Deshalb direkt
|
||||||
|
// Content-Length gegen das konfigurierte Limit pruefen, statt block auf einen
|
||||||
|
// (nicht garantiert leeren) Body zu vertrauen - sonst landet eine zu grosse, aber
|
||||||
|
// eigentlich intakte Export-Datei im irrefuehrenden generischen "ungueltige Datei"-Fehler.
|
||||||
|
$contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||||
|
$maxPost = self::iniBytes((string) ini_get('post_max_size'));
|
||||||
|
if ($maxPost > 0 && $contentLength > $maxPost) {
|
||||||
|
Http::error(
|
||||||
|
'Die Datei überschreitet das Größen-Limit dieses Servers (aktuell ' . ini_get('post_max_size') .
|
||||||
|
'). Bitte den Hosting-Anbieter um ein höheres PHP-Upload-Limit (post_max_size) bitten.',
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
$raw = file_get_contents('php://input');
|
$raw = file_get_contents('php://input');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +144,25 @@ class SettingsController
|
||||||
$data = $payload['data'];
|
$data = $payload['data'];
|
||||||
$images = is_array($payload['images'] ?? null) ? $payload['images'] : [];
|
$images = is_array($payload['images'] ?? null) ? $payload['images'] : [];
|
||||||
|
|
||||||
|
// Welche Bereiche importiert werden sollen (Formularfeld "sections", JSON-Array
|
||||||
|
// von Schluesseln aus IMPORT_SECTIONS). Ohne Angabe oder mit ungueltigem Inhalt:
|
||||||
|
// wie bisher alles importieren (abwaertskompatibel zu aelteren Aufrufen).
|
||||||
|
$requestedSections = $_POST['sections'] ?? null;
|
||||||
|
if (is_string($requestedSections)) {
|
||||||
|
$requestedSections = json_decode($requestedSections, true);
|
||||||
|
}
|
||||||
|
$sections = is_array($requestedSections)
|
||||||
|
? array_values(array_intersect(array_keys(self::IMPORT_SECTIONS), $requestedSections))
|
||||||
|
: array_keys(self::IMPORT_SECTIONS);
|
||||||
|
if (!$sections) {
|
||||||
|
Http::error('Kein Bereich zum Importieren ausgewählt', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tables = [];
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
$tables = array_merge($tables, self::IMPORT_SECTIONS[$section]);
|
||||||
|
}
|
||||||
|
|
||||||
$db = Database::connection();
|
$db = Database::connection();
|
||||||
$counts = [];
|
$counts = [];
|
||||||
|
|
||||||
|
|
@ -93,13 +170,28 @@ class SettingsController
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
$db->exec('SET FOREIGN_KEY_CHECKS=0');
|
$db->exec('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
|
||||||
foreach (self::TABLE_COLUMNS as $table => $allowedColumns) {
|
foreach ($tables as $table) {
|
||||||
$rows = is_array($data[$table] ?? null) ? $data[$table] : [];
|
$rows = is_array($data[$table] ?? null) ? $data[$table] : [];
|
||||||
$db->exec("DELETE FROM `$table`");
|
$db->exec("DELETE FROM `$table`");
|
||||||
$counts[$table] = self::insertRows($db, $table, $allowedColumns, $rows);
|
$counts[$table] = self::insertRows($db, $table, self::TABLE_COLUMNS[$table], $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
self::restoreImages($images);
|
// Nur Bilder wiederherstellen, die von Zeilen der tatsaechlich importierten
|
||||||
|
// Tabellen referenziert werden - sonst wuerden z.B. Galerie-Bilder auf den
|
||||||
|
// Server geschrieben, obwohl "Bildergalerie" beim Import abgewaehlt war.
|
||||||
|
$wantedPaths = [];
|
||||||
|
foreach (self::IMAGE_COLUMNS as $table => $column) {
|
||||||
|
if (!in_array($table, $tables, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (($data[$table] ?? []) as $row) {
|
||||||
|
if (!empty($row[$column])) {
|
||||||
|
$wantedPaths[$row[$column]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$imagesToRestore = array_intersect_key($images, $wantedPaths);
|
||||||
|
self::restoreImages($imagesToRestore);
|
||||||
|
|
||||||
$db->exec('SET FOREIGN_KEY_CHECKS=1');
|
$db->exec('SET FOREIGN_KEY_CHECKS=1');
|
||||||
$db->commit();
|
$db->commit();
|
||||||
|
|
@ -109,7 +201,7 @@ class SettingsController
|
||||||
Http::error('Import fehlgeschlagen, es wurde nichts verändert: ' . $e->getMessage(), 500);
|
Http::error('Import fehlgeschlagen, es wurde nichts verändert: ' . $e->getMessage(), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
Http::send(['ok' => true, 'counts' => $counts, 'images_restored' => count($images)]);
|
Http::send(['ok' => true, 'sections' => $sections, 'counts' => $counts, 'images_restored' => count($imagesToRestore)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resetServicesToDefaults(): void
|
public static function resetServicesToDefaults(): void
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,14 @@ class SiteSettingsController
|
||||||
'ga_measurement_id' => null,
|
'ga_measurement_id' => null,
|
||||||
'package_card_theme' => 'graphite',
|
'package_card_theme' => 'graphite',
|
||||||
'package_card_layout' => 'strip',
|
'package_card_layout' => 'strip',
|
||||||
|
'package_wheel_hint_interval' => 8,
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function show(): void
|
public static function show(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$stmt = Database::connection()->query(
|
$stmt = Database::connection()->query(
|
||||||
'SELECT phone, whatsapp, contact_email, hero_image_path, ga_measurement_id, package_card_theme, package_card_layout FROM app_settings WHERE id = 1'
|
'SELECT phone, whatsapp, contact_email, hero_image_path, ga_measurement_id, package_card_theme, package_card_layout, package_wheel_hint_interval FROM app_settings WHERE id = 1'
|
||||||
);
|
);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
@ -57,9 +58,15 @@ class SiteSettingsController
|
||||||
$packageCardLayout = 'strip';
|
$packageCardLayout = 'strip';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wiederholungsabstand des Mausrad-Hinweises - auf einen sinnvollen Bereich
|
||||||
|
// geklammert, damit ein falscher/leerer Wert nicht zu einem Dauer-Flackern
|
||||||
|
// oder einem quasi-deaktivierten Hinweis fuehrt.
|
||||||
|
$wheelHintInterval = (int) ($body['package_wheel_hint_interval'] ?? 8);
|
||||||
|
$wheelHintInterval = max(2, min(120, $wheelHintInterval ?: 8));
|
||||||
|
|
||||||
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
|
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ?, ga_measurement_id = ?, package_card_theme = ?, package_card_layout = ? WHERE id = 1'
|
'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ?, ga_measurement_id = ?, package_card_theme = ?, package_card_layout = ?, package_wheel_hint_interval = ? WHERE id = 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
trim($body['phone'] ?? '') ?: null,
|
trim($body['phone'] ?? '') ?: null,
|
||||||
|
|
@ -69,6 +76,7 @@ class SiteSettingsController
|
||||||
trim($body['ga_measurement_id'] ?? '') ?: null,
|
trim($body['ga_measurement_id'] ?? '') ?: null,
|
||||||
$packageCardTheme,
|
$packageCardTheme,
|
||||||
$packageCardLayout,
|
$packageCardLayout,
|
||||||
|
$wheelHintInterval,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::show();
|
self::show();
|
||||||
|
|
|
||||||
157
hifi/api/src/Services/GooglePlacesReviewsFetcher.php
Normal file
157
hifi/api/src/Services/GooglePlacesReviewsFetcher.php
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt Gesamtbewertung + die (von Google gewaehlten, max. 5) neuesten Rezensionen
|
||||||
|
* eines Google-Business-Eintrags ueber die Places API (New) und cached sie in der
|
||||||
|
* eigenen Datenbank (Tabelle google_reviews + app_settings.google_rating*). Die
|
||||||
|
* oeffentliche Seite liest ausschliesslich aus diesem Cache - nie live von Google,
|
||||||
|
* damit kein Seitenaufruf ein API-Kontingent verbraucht (siehe refresh_google_reviews.php
|
||||||
|
* fuer den geplanten, taeglichen Refresh).
|
||||||
|
*/
|
||||||
|
class GooglePlacesReviewsFetcher
|
||||||
|
{
|
||||||
|
private const ENDPOINT = 'https://places.googleapis.com/v1/places/';
|
||||||
|
private const FIELD_MASK = 'displayName,rating,userRatingCount,reviews';
|
||||||
|
|
||||||
|
private int $timeout;
|
||||||
|
|
||||||
|
public function __construct(int $timeout = 12)
|
||||||
|
{
|
||||||
|
$this->timeout = $timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiner API-Aufruf ohne Datenbankzugriff - eigenstaendig testbar.
|
||||||
|
* @return array{ok: bool, rating?: float, rating_count?: int, reviews?: array, error?: string}
|
||||||
|
*/
|
||||||
|
public function fetch(string $apiKey, string $placeId): array
|
||||||
|
{
|
||||||
|
$ch = curl_init(self::ENDPOINT . rawurlencode($placeId));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Goog-Api-Key: ' . $apiKey,
|
||||||
|
'X-Goog-FieldMask: ' . self::FIELD_MASK,
|
||||||
|
'Accept-Language: de',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($body === false || $curlError !== '') {
|
||||||
|
return ['ok' => false, 'error' => 'Verbindung zu Google fehlgeschlagen: ' . $curlError];
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($body, true);
|
||||||
|
|
||||||
|
if ($status !== 200) {
|
||||||
|
$message = is_array($json) ? ($json['error']['message'] ?? null) : null;
|
||||||
|
return ['ok' => false, 'error' => "Google-Antwort HTTP $status" . ($message ? ": $message" : '')];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($json)) {
|
||||||
|
return ['ok' => false, 'error' => 'Ungültige Antwort von Google (kein JSON)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reviews = [];
|
||||||
|
foreach ((array) ($json['reviews'] ?? []) as $review) {
|
||||||
|
$reviews[] = [
|
||||||
|
'google_review_id' => is_string($review['name'] ?? null) ? $review['name'] : null,
|
||||||
|
'author_name' => (string) ($review['authorAttribution']['displayName'] ?? 'Google-Nutzer'),
|
||||||
|
'profile_photo_url' => $review['authorAttribution']['photoUri'] ?? null,
|
||||||
|
'rating' => (int) ($review['rating'] ?? 5),
|
||||||
|
'review_text' => $review['text']['text'] ?? ($review['originalText']['text'] ?? null),
|
||||||
|
'relative_time_description' => $review['relativePublishTimeDescription'] ?? null,
|
||||||
|
'review_time' => isset($review['publishTime']) ? strtotime((string) $review['publishTime']) ?: null : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'rating' => isset($json['rating']) ? (float) $json['rating'] : null,
|
||||||
|
'rating_count' => isset($json['userRatingCount']) ? (int) $json['userRatingCount'] : null,
|
||||||
|
'reviews' => $reviews,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruft fetch() auf und schreibt das Ergebnis in die Datenbank. Bei einem Fehler
|
||||||
|
* bleiben zuvor erfolgreich geladene Rezensionen unangetastet (die Seite zeigt
|
||||||
|
* dann weiter den letzten guten Stand) - nur die Fehlermeldung wird vermerkt,
|
||||||
|
* damit sie im Admin-Bereich sichtbar ist.
|
||||||
|
* @return array{ok: bool, error?: string, rating?: float, rating_count?: int, reviews_count?: int}
|
||||||
|
*/
|
||||||
|
public function refreshAndStore(PDO $db, string $apiKey, string $placeId): array
|
||||||
|
{
|
||||||
|
if ($apiKey === '' || $placeId === '') {
|
||||||
|
$error = 'Kein API-Key oder keine Place-ID hinterlegt';
|
||||||
|
$this->recordError($db, $error);
|
||||||
|
return ['ok' => false, 'error' => $error];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->fetch($apiKey, $placeId);
|
||||||
|
if (!$result['ok']) {
|
||||||
|
$this->recordError($db, $result['error']);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
$db->exec('DELETE FROM google_reviews');
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
'INSERT INTO google_reviews
|
||||||
|
(google_review_id, author_name, profile_photo_url, rating, review_text, relative_time_description, review_time, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($result['reviews'] as $i => $review) {
|
||||||
|
$stmt->execute([
|
||||||
|
$review['google_review_id'],
|
||||||
|
$review['author_name'],
|
||||||
|
$review['profile_photo_url'],
|
||||||
|
$review['rating'],
|
||||||
|
$review['review_text'],
|
||||||
|
$review['relative_time_description'],
|
||||||
|
$review['review_time'],
|
||||||
|
$i,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
|
||||||
|
$update = $db->prepare(
|
||||||
|
'UPDATE app_settings
|
||||||
|
SET google_rating = ?, google_rating_count = ?, google_reviews_updated_at = NOW(), google_reviews_error = NULL
|
||||||
|
WHERE id = 1'
|
||||||
|
);
|
||||||
|
$update->execute([$result['rating'], $result['rating_count']]);
|
||||||
|
|
||||||
|
$db->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
$error = 'Speichern fehlgeschlagen: ' . $e->getMessage();
|
||||||
|
$this->recordError($db, $error);
|
||||||
|
return ['ok' => false, 'error' => $error];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'rating' => $result['rating'],
|
||||||
|
'rating_count' => $result['rating_count'],
|
||||||
|
'reviews_count' => count($result['reviews']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordError(PDO $db, string $error): void
|
||||||
|
{
|
||||||
|
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
|
||||||
|
$stmt = $db->prepare('UPDATE app_settings SET google_reviews_error = ? WHERE id = 1');
|
||||||
|
$stmt->execute([$error]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,8 +37,15 @@ class Schema
|
||||||
hero_image_path VARCHAR(255) NULL,
|
hero_image_path VARCHAR(255) NULL,
|
||||||
ga_measurement_id VARCHAR(20) NULL,
|
ga_measurement_id VARCHAR(20) NULL,
|
||||||
ga_property_id VARCHAR(30) NULL,
|
ga_property_id VARCHAR(30) NULL,
|
||||||
|
google_place_id VARCHAR(255) NULL,
|
||||||
|
google_places_api_key VARCHAR(255) NULL,
|
||||||
|
google_rating DECIMAL(2,1) NULL,
|
||||||
|
google_rating_count INT NULL,
|
||||||
|
google_reviews_updated_at DATETIME NULL,
|
||||||
|
google_reviews_error VARCHAR(500) NULL,
|
||||||
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
|
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
|
||||||
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
|
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
|
||||||
|
package_wheel_hint_interval INT NOT NULL DEFAULT 8,
|
||||||
mail_host VARCHAR(255) NULL,
|
mail_host VARCHAR(255) NULL,
|
||||||
mail_port INT NULL,
|
mail_port INT NULL,
|
||||||
mail_username VARCHAR(255) NULL,
|
mail_username VARCHAR(255) NULL,
|
||||||
|
|
@ -231,6 +238,22 @@ class Schema
|
||||||
CONSTRAINT fk_contact_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE SET NULL,
|
CONSTRAINT fk_contact_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE SET NULL,
|
||||||
CONSTRAINT fk_contact_product FOREIGN KEY (package_product_id) REFERENCES package_products(id) ON DELETE SET NULL
|
CONSTRAINT fk_contact_product FOREIGN KEY (package_product_id) REFERENCES package_products(id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB",
|
) ENGINE=InnoDB",
|
||||||
|
|
||||||
|
// Reiner Cache der letzten Google-Places-Abfrage (siehe GooglePlacesReviewsFetcher) -
|
||||||
|
// wird bei jedem erfolgreichen Refresh komplett neu befuellt (DELETE + INSERT),
|
||||||
|
// keine eigenen Fremdschluessel-Bezuege von aussen.
|
||||||
|
'google_reviews' => "CREATE TABLE google_reviews (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
google_review_id VARCHAR(255) NULL,
|
||||||
|
author_name VARCHAR(150) NOT NULL,
|
||||||
|
profile_photo_url VARCHAR(500) NULL,
|
||||||
|
rating TINYINT NOT NULL,
|
||||||
|
review_text TEXT NULL,
|
||||||
|
relative_time_description VARCHAR(100) NULL,
|
||||||
|
review_time INT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Erwartete Spalten je Tabelle (ohne Primary Key / Unique / Foreign-Key-Klauseln –
|
// Erwartete Spalten je Tabelle (ohne Primary Key / Unique / Foreign-Key-Klauseln –
|
||||||
|
|
@ -263,8 +286,15 @@ class Schema
|
||||||
'hero_image_path' => 'VARCHAR(255) NULL',
|
'hero_image_path' => 'VARCHAR(255) NULL',
|
||||||
'ga_measurement_id' => 'VARCHAR(20) NULL',
|
'ga_measurement_id' => 'VARCHAR(20) NULL',
|
||||||
'ga_property_id' => 'VARCHAR(30) NULL',
|
'ga_property_id' => 'VARCHAR(30) NULL',
|
||||||
|
'google_place_id' => 'VARCHAR(255) NULL',
|
||||||
|
'google_places_api_key' => 'VARCHAR(255) NULL',
|
||||||
|
'google_rating' => 'DECIMAL(2,1) NULL',
|
||||||
|
'google_rating_count' => 'INT NULL',
|
||||||
|
'google_reviews_updated_at' => 'DATETIME NULL',
|
||||||
|
'google_reviews_error' => 'VARCHAR(500) NULL',
|
||||||
'package_card_theme' => "VARCHAR(30) NOT NULL DEFAULT 'graphite'",
|
'package_card_theme' => "VARCHAR(30) NOT NULL DEFAULT 'graphite'",
|
||||||
'package_card_layout' => "VARCHAR(20) NOT NULL DEFAULT 'strip'",
|
'package_card_layout' => "VARCHAR(20) NOT NULL DEFAULT 'strip'",
|
||||||
|
'package_wheel_hint_interval' => 'INT NOT NULL DEFAULT 8',
|
||||||
'mail_host' => 'VARCHAR(255) NULL',
|
'mail_host' => 'VARCHAR(255) NULL',
|
||||||
'mail_port' => 'INT NULL',
|
'mail_port' => 'INT NULL',
|
||||||
'mail_username' => 'VARCHAR(255) NULL',
|
'mail_username' => 'VARCHAR(255) NULL',
|
||||||
|
|
@ -424,5 +454,17 @@ class Schema
|
||||||
'status' => "ENUM('new','in_progress','done') NOT NULL DEFAULT 'new'",
|
'status' => "ENUM('new','in_progress','done') NOT NULL DEFAULT 'new'",
|
||||||
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||||
],
|
],
|
||||||
|
'google_reviews' => [
|
||||||
|
'id' => 'INT AUTO_INCREMENT PRIMARY KEY',
|
||||||
|
'google_review_id' => 'VARCHAR(255) NULL',
|
||||||
|
'author_name' => 'VARCHAR(150) NOT NULL',
|
||||||
|
'profile_photo_url' => 'VARCHAR(500) NULL',
|
||||||
|
'rating' => 'TINYINT NOT NULL',
|
||||||
|
'review_text' => 'TEXT NULL',
|
||||||
|
'relative_time_description' => 'VARCHAR(100) NULL',
|
||||||
|
'review_time' => 'INT NULL',
|
||||||
|
'sort_order' => 'INT NOT NULL DEFAULT 0',
|
||||||
|
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
78
hifi/assets/index-CEtUzIVu.js
Normal file
78
hifi/assets/index-CEtUzIVu.js
Normal file
File diff suppressed because one or more lines are too long
1
hifi/assets/index-Cys8aTN9.css
Normal file
1
hifi/assets/index-Cys8aTN9.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -8766,4 +8766,4 @@ function IE(t,r){for(var _=0;_<r.length;_++){const a=r[_];if(typeof a!="string"&
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
* See the LICENSE file in the root directory of this source tree.
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
*/const wE=[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["line",{x1:"21",x2:"16.65",y1:"21",y2:"16.65",key:"13gj7c"}],["line",{x1:"8",x2:"14",y1:"11",y2:"11",key:"durymu"}]],bPe=o("zoom-out",wE),gPe=Object.freeze(Object.defineProperty({__proto__:null,__iconNode:wE,default:bPe},Symbol.toStringTag,{value:"Module"}));export{AR as C,OPe as D,EW as E,_ae as M,Lce as P,MPe as R,s4e as S,vge as U,wie as a,Lae as b,oS as c,PPe as i,p as r};
|
*/const wE=[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["line",{x1:"21",x2:"16.65",y1:"21",y2:"16.65",key:"13gj7c"}],["line",{x1:"8",x2:"14",y1:"11",y2:"11",key:"durymu"}]],bPe=o("zoom-out",wE),gPe=Object.freeze(Object.defineProperty({__proto__:null,__iconNode:wE,default:bPe},Symbol.toStringTag,{value:"Module"}));export{h$ as C,OPe as D,EW as E,Wne as M,Lce as P,MPe as R,s4e as S,vge as U,p$ as a,_ae as b,wie as c,AR as d,Lae as e,oS as f,PPe as i,p as r};
|
||||||
|
|
@ -43,9 +43,9 @@
|
||||||
"sameAs": ["https://www.youtube.com/@hifiplanet2812"]
|
"sameAs": ["https://www.youtube.com/@hifiplanet2812"]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-BUHP9G1k.js"></script>
|
<script type="module" crossorigin src="/assets/index-CEtUzIVu.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-CnpfS6R8.js">
|
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-66ioQXWI.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-9666bnLb.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cys8aTN9.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue