Compare commits
No commits in common. "b9bfecd1c2bfe7fcdc1bd5de255f08c7feed873f" and "fa9e8fd1fc4eeea3dccf6589bda5c71c18d06344" have entirely different histories.
b9bfecd1c2
...
fa9e8fd1fc
28 changed files with 158 additions and 1109 deletions
|
|
@ -27,7 +27,6 @@ const DEFAULT_SITE_SETTINGS = {
|
|||
ga_measurement_id: null,
|
||||
package_card_theme: 'graphite',
|
||||
package_card_layout: 'strip',
|
||||
package_wheel_hint_interval: 8,
|
||||
};
|
||||
|
||||
// Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG
|
||||
|
|
|
|||
|
|
@ -123,35 +123,12 @@ export default function TestimonialSlider({ testimonials }) {
|
|||
<div ref={trackRef} className="marquee-track flex w-max gap-5">
|
||||
{track.map((t, i) => (
|
||||
<div key={`${t.name}-${i}`} className="w-64 shrink-0 sm:w-72 lg:w-80">
|
||||
<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">
|
||||
{/* Grosses dekoratives Anfuehrungszeichen hinter dem Text - rein optisch,
|
||||
gedeckt genug um nicht mit dem Text zu konkurrieren. */}
|
||||
<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>
|
||||
)}
|
||||
<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">
|
||||
<StarRating className="mb-3 h-4 w-4" />
|
||||
<p className="mb-4 flex-1 text-sm text-neutral-600 dark:text-neutral-300">„{t.text}"</p>
|
||||
<p className="text-sm font-semibold text-neutral-900 dark:text-white">{t.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ export default {
|
|||
'Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum',
|
||||
],
|
||||
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.',
|
||||
reviewsLink: 'Alle Bewertungen auf Google ansehen →',
|
||||
testimonials: [
|
||||
|
|
@ -152,7 +151,6 @@ export default {
|
|||
featuredBadge: 'Empfohlen',
|
||||
moreBullets: (n) => (n === 1 ? '+ 1 weitere Leistung' : `+ ${n} weitere Leistungen`),
|
||||
lessBullets: 'Weniger anzeigen',
|
||||
wheelHint: 'Mit dem Mausrad blättern',
|
||||
},
|
||||
leistungen: {
|
||||
metaTitle: 'Leistungen',
|
||||
|
|
@ -198,7 +196,7 @@ export default {
|
|||
requiredHint: '* Pflichtfelder',
|
||||
sending: 'Wird gesendet…',
|
||||
submit: 'Anfrage senden',
|
||||
cardTitle: 'HiFi Planet Amorbach',
|
||||
cardTitle: 'HifiPlanet Amorbach',
|
||||
address: 'Adresse',
|
||||
phone: 'Telefon',
|
||||
email: 'E-Mail',
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ export default {
|
|||
'Custom subwoofer installation with LED lighting in the trunk',
|
||||
],
|
||||
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.',
|
||||
reviewsLink: 'See all reviews on Google →',
|
||||
testimonials: [
|
||||
|
|
@ -152,7 +151,6 @@ export default {
|
|||
featuredBadge: 'Recommended',
|
||||
moreBullets: (n) => (n === 1 ? '+ 1 more feature' : `+ ${n} more features`),
|
||||
lessBullets: 'Show less',
|
||||
wheelHint: 'Scroll to browse',
|
||||
},
|
||||
leistungen: {
|
||||
metaTitle: 'Services',
|
||||
|
|
@ -198,7 +196,7 @@ export default {
|
|||
requiredHint: '* Required fields',
|
||||
sending: 'Sending…',
|
||||
submit: 'Send inquiry',
|
||||
cardTitle: 'HiFi Planet Amorbach',
|
||||
cardTitle: 'HifiPlanet Amorbach',
|
||||
address: 'Address',
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
|
|
|
|||
|
|
@ -1,105 +1,25 @@
|
|||
import { useState } from 'react';
|
||||
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() {
|
||||
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 [importBusy, setImportBusy] = useState(false);
|
||||
const [importResult, setImportResult] = useState(null);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!importFile || !importConfirmed || selectedSections.size === 0) return;
|
||||
if (!importFile || !importConfirmed) return;
|
||||
setImportBusy(true);
|
||||
setImportError('');
|
||||
setImportResult(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile);
|
||||
formData.append('sections', JSON.stringify([...selectedSections]));
|
||||
const result = await api.post('/settings/import', formData);
|
||||
setImportResult(result);
|
||||
setImportFile(null);
|
||||
setParsedData(null);
|
||||
setImportConfirmed(false);
|
||||
e.target.reset();
|
||||
} catch (err) {
|
||||
|
|
@ -109,8 +29,6 @@ export default function ExportImportSettings() {
|
|||
}
|
||||
};
|
||||
|
||||
const selectedLabels = SECTIONS.filter((s) => selectedSections.has(s.key)).map((s) => s.label);
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
|
@ -131,50 +49,18 @@ export default function ExportImportSettings() {
|
|||
<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>
|
||||
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Lädt eine zuvor exportierte Datei auf diesen Server. Ersetzt dabei die ausgewählten Bereiche komplett
|
||||
durch den Inhalt der Datei – das kann nicht rückgängig gemacht werden. Kontaktanfragen und
|
||||
Benutzerkonten bleiben immer unangetastet.
|
||||
Lädt eine zuvor exportierte Datei auf diesen Server. <strong>Ersetzt dabei alle</strong> aktuellen
|
||||
Marken, Modelle, Pakete, Produkte, Upgrades, Leistungen, FAQs und die komplette Bildergalerie auf
|
||||
diesem Server – das kann nicht rückgängig gemacht werden. Kontaktanfragen und Benutzerkonten bleiben
|
||||
unangetastet.
|
||||
</p>
|
||||
<form onSubmit={handleImport} className="space-y-4">
|
||||
<form onSubmit={handleImport} className="space-y-3">
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileChange}
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -182,13 +68,12 @@ export default function ExportImportSettings() {
|
|||
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"
|
||||
/>
|
||||
Mir ist bewusst, dass dies{' '}
|
||||
{selectedLabels.length ? <strong>{selectedLabels.join(', ')}</strong> : 'die ausgewählten Bereiche'} auf
|
||||
diesem Server unwiderruflich ersetzt.
|
||||
Mir ist bewusst, dass dies alle aktuellen Marken, Modelle, Pakete, Produkte, Upgrades, Leistungen,
|
||||
FAQs und die Bildergalerie auf diesem Server unwiderruflich ersetzt.
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!importFile || !importConfirmed || importBusy || selectedSections.size === 0}
|
||||
disabled={!importFile || !importConfirmed || importBusy}
|
||||
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'}
|
||||
|
|
@ -199,12 +84,17 @@ 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">
|
||||
<p className="mb-1 font-semibold">Import erfolgreich:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
{Object.entries(importResult.counts).map(([table, count]) => (
|
||||
<li key={table}>
|
||||
{count} {TABLE_LABELS[table] || table}
|
||||
</li>
|
||||
))}
|
||||
{importResult.images_restored > 0 && <li>{importResult.images_restored} Bilder wiederhergestellt</li>}
|
||||
<li>{importResult.counts.brands} Marken</li>
|
||||
<li>{importResult.counts.car_models} Modelle</li>
|
||||
<li>{importResult.counts.packages} Pakete</li>
|
||||
<li>{importResult.counts.package_products} Produkte</li>
|
||||
<li>{importResult.counts.package_upgrades} Upgrades</li>
|
||||
<li>{importResult.counts.services} Leistungen</li>
|
||||
<li>{importResult.counts.faqs} FAQs</li>
|
||||
<li>{importResult.counts.gallery_brands} Galerie-Marken</li>
|
||||
<li>{importResult.counts.gallery_projects} Galerie-Projekte</li>
|
||||
<li>{importResult.counts.gallery_photos} Galerie-Fotos</li>
|
||||
<li>{importResult.images_restored} Bilder wiederhergestellt</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ const emptyForm = {
|
|||
ga_measurement_id: '',
|
||||
package_card_theme: 'graphite',
|
||||
package_card_layout: 'strip',
|
||||
package_wheel_hint_interval: 8,
|
||||
};
|
||||
|
||||
const PACKAGE_CARD_THEMES = [
|
||||
|
|
@ -39,15 +38,6 @@ export default function WebsiteSettings() {
|
|||
const [gaError, setGaError] = useState('');
|
||||
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(() => {
|
||||
api
|
||||
.get('/site-settings')
|
||||
|
|
@ -60,7 +50,6 @@ export default function WebsiteSettings() {
|
|||
ga_measurement_id: res.ga_measurement_id || '',
|
||||
package_card_theme: res.package_card_theme || 'graphite',
|
||||
package_card_layout: res.package_card_layout || 'strip',
|
||||
package_wheel_hint_interval: res.package_wheel_hint_interval || 8,
|
||||
})
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
|
|
@ -68,51 +57,8 @@ export default function WebsiteSettings() {
|
|||
setGaPropertyId(res.ga_property_id || '');
|
||||
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 () => {
|
||||
setGaBusy(true);
|
||||
setGaError('');
|
||||
|
|
@ -269,23 +215,6 @@ export default function WebsiteSettings() {
|
|||
</label>
|
||||
))}
|
||||
</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 className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
|
|
@ -347,95 +276,6 @@ export default function WebsiteSettings() {
|
|||
</div>
|
||||
</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>}
|
||||
{saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export default function ContactPage() {
|
|||
return (
|
||||
<div
|
||||
className="kf-page kontakt-sektion relative w-full overflow-hidden px-4 pb-10 sm:px-10"
|
||||
style={{ color: 'var(--kf-text2)', fontFamily: "'Barlow', sans-serif" }}
|
||||
style={{ color: 'var(--kf-text2)' }}
|
||||
>
|
||||
<div className="relative z-10 mx-auto max-w-[1280px] pt-10 sm:pt-14">
|
||||
{/* Hero */}
|
||||
|
|
@ -491,17 +491,16 @@ export default function ContactPage() {
|
|||
</a>
|
||||
)}
|
||||
<ExternalEmbed name={t('contactPage.mapEmbedName')} className="h-[210px] w-full">
|
||||
{/* iframe hoeher als der sichtbare 210px-Ausschnitt + negativer margin-top:
|
||||
schneidet Googles Info-Overlay (oben links) komplett und die untere
|
||||
Attributionszeile bis auf den Rand an - wie im Prototyp fuellt die
|
||||
Karte so buendig bis zur "Route planen"-Leiste. */}
|
||||
{/* iframe hoeher als der sichtbare Ausschnitt + negativer margin-top:
|
||||
schneidet Googles eigenes Overlay (oben links) weg, damit nur unser
|
||||
"In Maps oeffnen"-Chip zu sehen ist - wie im Prototyp. */}
|
||||
<div className="h-[210px] overflow-hidden">
|
||||
<iframe
|
||||
title="HifiPlanet Amorbach – Standort"
|
||||
src={`https://www.google.com/maps?q=${SHOP_ADDRESS_ENCODED}&output=embed`}
|
||||
width="100%"
|
||||
height="290"
|
||||
style={{ border: 0, display: 'block', marginTop: '-60px' }}
|
||||
height="280"
|
||||
style={{ border: 0, display: 'block', marginTop: '-70px' }}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -45,18 +45,8 @@ export default function Home() {
|
|||
const stats = t('home.stats');
|
||||
const galleryAlts = t('home.galleryAlts');
|
||||
const gallery = galleryImages.map((img, i) => ({ ...img, alt: galleryAlts[i] }));
|
||||
const testimonials = t('home.testimonials');
|
||||
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(() => {
|
||||
api
|
||||
|
|
@ -72,17 +62,6 @@ export default function Home() {
|
|||
.catch(() => {});
|
||||
}, [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({
|
||||
title: t('home.metaTitle'),
|
||||
description: t('home.metaDescription'),
|
||||
|
|
@ -319,11 +298,9 @@ export default function Home() {
|
|||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<Reveal className="mb-10 text-center">
|
||||
<div className="mb-2 flex items-center justify-center gap-2">
|
||||
<StarRating className="h-6 w-6" count={googleReviews ? Math.round(googleReviews.rating) : 5} />
|
||||
<StarRating className="h-6 w-6" />
|
||||
</div>
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{t('home.reviewsRating')}</h2>
|
||||
<p className="mt-1 text-neutral-600 dark:text-neutral-300">{t('home.reviewsText')}</p>
|
||||
<a
|
||||
href="https://www.google.com/maps/search/?api=1&query=Hifi+Planet+Amorbach+Boxbrunner+Stra%C3%9Fe+20a"
|
||||
|
|
|
|||
|
|
@ -55,14 +55,11 @@ export default function Leistungen() {
|
|||
<div className="p-6 pt-8">
|
||||
<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>
|
||||
{/* Karten ohne eigenen CTA (alles ausser Car-Hifi, das auf /fahrzeuge zeigt)
|
||||
landen auf dem Kontaktformular statt ins Leere zu laufen. */}
|
||||
<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')} →
|
||||
{service.cta_label && service.cta_url && (
|
||||
<Link to={service.cta_url} className="text-sm font-semibold text-brand-600 hover:underline dark:text-brand-400">
|
||||
{service.cta_label} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import usePageMeta from '../../hooks/usePageMeta.js';
|
|||
import MaintenanceNotice from '../../components/MaintenanceNotice.jsx';
|
||||
import MaintenanceBypassBanner from '../../components/MaintenanceBypassBanner.jsx';
|
||||
import DynamicIcon from '../../components/DynamicIcon.jsx';
|
||||
import { ChevronLeft, ChevronRight, Mouse } from 'lucide-react';
|
||||
import { useMaintenance } from '../../context/MaintenanceContext.jsx';
|
||||
import { useLanguage } from '../../context/LanguageContext.jsx';
|
||||
import { useSiteSettings } from '../../context/SiteSettingsContext.jsx';
|
||||
|
|
@ -122,12 +121,12 @@ function PackageCard({ pkg, tier, tierT, layout, bullets, formatPrice, contactUr
|
|||
}}
|
||||
className={
|
||||
layout === 'strip'
|
||||
? '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]'
|
||||
? '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]'
|
||||
: layout === 'coverflow'
|
||||
? // Feste Breite + snap-center: die 3D-Transformation (rotateY/scale/translateZ)
|
||||
// setzt der Scroll-Handler in ModelPage direkt per style.transform.
|
||||
'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-[420px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40 sm:min-h-[640px]'
|
||||
'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-[640px] flex-col overflow-hidden rounded-[18px] border shadow-xl shadow-neutral-900/20 dark:shadow-black/40'
|
||||
}
|
||||
>
|
||||
{/* Vollflaechiges Straßen-Glow-Bild der Preisstufe (Referenzdesign). */}
|
||||
|
|
@ -265,18 +264,13 @@ export default function ModelPage() {
|
|||
const [error, setError] = useState('');
|
||||
const maintenance = useMaintenance();
|
||||
const { t, language } = useLanguage();
|
||||
const {
|
||||
package_card_theme: packageCardTheme,
|
||||
package_card_layout: packageCardLayout,
|
||||
package_wheel_hint_interval: wheelHintIntervalSetting,
|
||||
} = useSiteSettings();
|
||||
const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings();
|
||||
const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
|
||||
const layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
|
||||
const wheelHintInterval = Math.max(2, Number(wheelHintIntervalSetting) || 8) * 1000;
|
||||
const scrollerRef = useRef(null);
|
||||
const trackRef = useRef(null);
|
||||
const thumbRef = useRef(null);
|
||||
const [wheelHintVisible, setWheelHintVisible] = useState(false);
|
||||
const hintPlayedRef = useRef(false);
|
||||
const formatPrice = (value) =>
|
||||
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
|
||||
|
||||
|
|
@ -323,149 +317,26 @@ export default function ModelPage() {
|
|||
|
||||
// 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
|
||||
// stattdessen per Mausrad (siehe Effect weiter unten) oder ueber die eigene
|
||||
// Scroll-Leiste; Touch-Geraete behalten ohnehin das native, fluessige
|
||||
// Wisch-Scrolling der scroll-snap-Kartenreihe.
|
||||
// stattdessen ueber die eigene Scroll-Leiste unten; Touch-Geraete behalten ohnehin
|
||||
// das native, fluessige Wisch-Scrolling der scroll-snap-Kartenreihe.
|
||||
//
|
||||
// Stattdessen: kurzer Wisch-Hinweis (Reihe faehrt ein Stueck nach rechts und
|
||||
// zurueck, plus Mausrad-Icon auf Geraeten mit Maus). Wiederholt sich im
|
||||
// Admin-konfigurierten Abstand (wheelHintInterval), SOLANGE der Besucher noch
|
||||
// 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.
|
||||
// Stattdessen: einmaliger kurzer Wisch-Hinweis beim ersten Laden auf Touch-Geraeten
|
||||
// (Kundenwunsch) - die Reihe faehrt kurz ein Stueck nach rechts und wieder zurueck,
|
||||
// damit sofort klar ist, dass sich die Kacheln wischen lassen. Nur einmal pro
|
||||
// Seitenaufruf, nur auf echten Touch-Geraeten, nicht bei reduzierter Bewegung.
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (layout === 'grid' || !el) return undefined;
|
||||
if (layout === 'grid' || !el || hintPlayedRef.current) return undefined;
|
||||
const isTouch = window.matchMedia('(pointer: coarse)').matches;
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reducedMotion) return undefined;
|
||||
|
||||
let animTimers = [];
|
||||
let intervalId = 0;
|
||||
const playHint = () => {
|
||||
if (!isTouch || reducedMotion) return undefined;
|
||||
hintPlayedRef.current = true;
|
||||
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);
|
||||
|
||||
const t1 = setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500);
|
||||
const t2 = setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
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);
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
};
|
||||
}, [layout, data]);
|
||||
|
||||
|
|
@ -625,7 +496,7 @@ export default function ModelPage() {
|
|||
// bleiben dunkel (Referenzdesign), wodurch sie auf heller Flaeche als eigenstaendige
|
||||
// "Karten" wirken. Der Scrollbalken-Stil unten passt sich per dark: mit an.
|
||||
<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' : 'relative mx-auto max-w-[1880px] px-4 sm:px-6'}>
|
||||
<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
|
||||
ref={layout === 'grid' ? undefined : scrollerRef}
|
||||
className={
|
||||
|
|
@ -664,25 +535,6 @@ export default function ModelPage() {
|
|||
})}
|
||||
</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.
|
||||
Ziehbar + klickbar (Logik im Scrollbar-Effekt); blendet aus, wenn alle
|
||||
Karten ohne Scrollen passen. touch-action none fuer sauberes Pointer-Ziehen. */}
|
||||
|
|
|
|||
|
|
@ -22,34 +22,6 @@ RewriteBase /
|
|||
# Upload mit "Methode nicht erlaubt" fehlschlägt.
|
||||
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
|
||||
RewriteCond %{REQUEST_URI} ^/api/
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
-- 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,7 +28,6 @@ CREATE TABLE app_settings (
|
|||
ga_property_id VARCHAR(30) NULL,
|
||||
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
|
||||
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
|
||||
package_wheel_hint_interval INT NOT NULL DEFAULT 8,
|
||||
mail_host VARCHAR(255) NULL,
|
||||
mail_port INT NULL,
|
||||
mail_username VARCHAR(255) NULL,
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
; 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,7 +18,6 @@ use App\Controllers\FaqController;
|
|||
use App\Controllers\GalleryBrandController;
|
||||
use App\Controllers\GalleryPhotoController;
|
||||
use App\Controllers\GalleryProjectController;
|
||||
use App\Controllers\GoogleReviewsController;
|
||||
use App\Controllers\MailSettingsController;
|
||||
use App\Controllers\MaintenanceController;
|
||||
use App\Controllers\ModelController;
|
||||
|
|
@ -162,10 +161,6 @@ $router->post('/faqs', $perm('content.manage', fn($p) => FaqController::store())
|
|||
$router->put('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::update($p)));
|
||||
$router->delete('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::destroy($p)));
|
||||
|
||||
// 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->post('/admin-users', $perm('users.manage', fn($p) => AdminUserController::store()));
|
||||
$router->put('/admin-users/{id}', $perm('users.manage', fn($p) => AdminUserController::update($p)));
|
||||
|
|
@ -196,10 +191,6 @@ $router->post('/settings/mail/test', $perm('settings.manage', fn($p) => MailSett
|
|||
|
||||
$router->get('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::show()));
|
||||
$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('/maintenance', fn($p) => MaintenanceController::status());
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
@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
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?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";
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
<?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,28 +26,6 @@ class SettingsController
|
|||
'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
|
||||
// zwischen lokalem /hifi und der Root-Domain auf IONOS).
|
||||
private static function uploadsUrlPrefix(): string
|
||||
|
|
@ -65,7 +43,13 @@ class SettingsController
|
|||
}
|
||||
|
||||
$images = [];
|
||||
foreach (self::IMAGE_COLUMNS as $table => $column) {
|
||||
$imageColumns = [
|
||||
'services' => 'image_path',
|
||||
'gallery_brands' => 'cover_image_path',
|
||||
'gallery_projects' => 'cover_image_path',
|
||||
'gallery_photos' => 'image_path',
|
||||
];
|
||||
foreach ($imageColumns as $table => $column) {
|
||||
foreach ($data[$table] as $row) {
|
||||
self::collectImage($row[$column] ?? null, $images);
|
||||
}
|
||||
|
|
@ -86,53 +70,11 @@ class SettingsController
|
|||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
if (!empty($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
$raw = file_get_contents($_FILES['file']['tmp_name']);
|
||||
} 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');
|
||||
}
|
||||
|
||||
|
|
@ -144,25 +86,6 @@ class SettingsController
|
|||
$data = $payload['data'];
|
||||
$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();
|
||||
$counts = [];
|
||||
|
||||
|
|
@ -170,28 +93,13 @@ class SettingsController
|
|||
$db->beginTransaction();
|
||||
$db->exec('SET FOREIGN_KEY_CHECKS=0');
|
||||
|
||||
foreach ($tables as $table) {
|
||||
foreach (self::TABLE_COLUMNS as $table => $allowedColumns) {
|
||||
$rows = is_array($data[$table] ?? null) ? $data[$table] : [];
|
||||
$db->exec("DELETE FROM `$table`");
|
||||
$counts[$table] = self::insertRows($db, $table, self::TABLE_COLUMNS[$table], $rows);
|
||||
$counts[$table] = self::insertRows($db, $table, $allowedColumns, $rows);
|
||||
}
|
||||
|
||||
// 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);
|
||||
self::restoreImages($images);
|
||||
|
||||
$db->exec('SET FOREIGN_KEY_CHECKS=1');
|
||||
$db->commit();
|
||||
|
|
@ -201,7 +109,7 @@ class SettingsController
|
|||
Http::error('Import fehlgeschlagen, es wurde nichts verändert: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
Http::send(['ok' => true, 'sections' => $sections, 'counts' => $counts, 'images_restored' => count($imagesToRestore)]);
|
||||
Http::send(['ok' => true, 'counts' => $counts, 'images_restored' => count($images)]);
|
||||
}
|
||||
|
||||
public static function resetServicesToDefaults(): void
|
||||
|
|
|
|||
|
|
@ -17,14 +17,13 @@ class SiteSettingsController
|
|||
'ga_measurement_id' => null,
|
||||
'package_card_theme' => 'graphite',
|
||||
'package_card_layout' => 'strip',
|
||||
'package_wheel_hint_interval' => 8,
|
||||
];
|
||||
|
||||
public static function show(): void
|
||||
{
|
||||
try {
|
||||
$stmt = Database::connection()->query(
|
||||
'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'
|
||||
'SELECT phone, whatsapp, contact_email, hero_image_path, ga_measurement_id, package_card_theme, package_card_layout FROM app_settings WHERE id = 1'
|
||||
);
|
||||
$row = $stmt->fetch();
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -58,15 +57,9 @@ class SiteSettingsController
|
|||
$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)');
|
||||
$stmt = $db->prepare(
|
||||
'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'
|
||||
'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ?, ga_measurement_id = ?, package_card_theme = ?, package_card_layout = ? WHERE id = 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
trim($body['phone'] ?? '') ?: null,
|
||||
|
|
@ -76,7 +69,6 @@ class SiteSettingsController
|
|||
trim($body['ga_measurement_id'] ?? '') ?: null,
|
||||
$packageCardTheme,
|
||||
$packageCardLayout,
|
||||
$wheelHintInterval,
|
||||
]);
|
||||
|
||||
self::show();
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
<?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,15 +37,8 @@ class Schema
|
|||
hero_image_path VARCHAR(255) NULL,
|
||||
ga_measurement_id VARCHAR(20) 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_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
|
||||
package_wheel_hint_interval INT NOT NULL DEFAULT 8,
|
||||
mail_host VARCHAR(255) NULL,
|
||||
mail_port INT NULL,
|
||||
mail_username VARCHAR(255) NULL,
|
||||
|
|
@ -238,22 +231,6 @@ class Schema
|
|||
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
|
||||
) 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 –
|
||||
|
|
@ -286,15 +263,8 @@ class Schema
|
|||
'hero_image_path' => 'VARCHAR(255) NULL',
|
||||
'ga_measurement_id' => 'VARCHAR(20) 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_layout' => "VARCHAR(20) NOT NULL DEFAULT 'strip'",
|
||||
'package_wheel_hint_interval' => 'INT NOT NULL DEFAULT 8',
|
||||
'mail_host' => 'VARCHAR(255) NULL',
|
||||
'mail_port' => 'INT NULL',
|
||||
'mail_username' => 'VARCHAR(255) NULL',
|
||||
|
|
@ -454,17 +424,5 @@ class Schema
|
|||
'status' => "ENUM('new','in_progress','done') NOT NULL DEFAULT 'new'",
|
||||
'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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
1
hifi/assets/index-9666bnLb.css
Normal file
1
hifi/assets/index-9666bnLb.css
Normal file
File diff suppressed because one or more lines are too long
78
hifi/assets/index-BUHP9G1k.js
Normal file
78
hifi/assets/index-BUHP9G1k.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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.
|
||||
* 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{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};
|
||||
*/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};
|
||||
|
|
@ -43,9 +43,9 @@
|
|||
"sameAs": ["https://www.youtube.com/@hifiplanet2812"]
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CEtUzIVu.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-66ioQXWI.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cys8aTN9.css">
|
||||
<script type="module" crossorigin src="/assets/index-BUHP9G1k.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-CnpfS6R8.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-9666bnLb.css">
|
||||
</head>
|
||||
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue