Compare commits

..

10 commits

Author SHA1 Message Date
Maaxxs
b9bfecd1c2 Repeat the wheel-scroll hint on an admin-configurable interval
Bisher lief der Hinweis nur einmal, nachdem die Kartenreihe sichtbar wurde -
Kundenwunsch war eine regelmaessige Wiederholung, solange man noch auf der
ersten Kachel steht. Neues Setting package_wheel_hint_interval (Sekunden,
Default 8, 2-120 geklammert) steuert den Abstand ueber Admin -> Einstellungen
-> Kachel-Layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 00:39:23 +02:00
Maaxxs
0a96080427 Fix mobile package card overflow, gate scroll hint on visibility, fix dead-end service links
Kachel-Mindesthoehe war fix auf 640px, dadurch ragten Pakete auf dem Handy
weit ueber den Bildschirm hinaus (Kundenfeedback). Jetzt 420px auf Mobile,
640px ab Desktop-Breite.

Der Mausrad-Hinweis lief bisher sofort beim Laden ab, oft bevor die
Kartenreihe ueberhaupt sichtbar gescrollt war - deshalb kam er beim Kunden
nicht an. Spielt jetzt erst ab, sobald die Reihe tatsaechlich im Blickfeld ist.

Leistungen-Karten ohne eigenen CTA (alles ausser Car-Hifi) hatten gar keinen
Link. Fallen jetzt auf das Kontaktformular zurueck statt ins Leere zu laufen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 00:26:39 +02:00
Maaxxs
464164ff36 Fix wildly uneven testimonial card heights, add avatar polish
A single long Google review made every card in the row stretch to match
it (default flex align-items: stretch), leaving short reviews with a
huge empty gap before the name - reported with a real screenshot where
one card towered over its neighbors.

The review paragraph is now capped at line-clamp-6, so no single card's
natural height is unbounded anymore - stretch still aligns the row, but
to a reasonable, consistent height instead of an outlier's full text.
Full text stays in the DOM (still copyable/accessible), just visually
truncated with a native ellipsis.

Added a bit more visual polish while in there: a subtle decorative quote
mark per card, a hover shadow lift, and an avatar next to each name -
the reviewer's real Google profile photo when available, otherwise a
colored initial-letter circle (Home.jsx now passes profile_photo_url
through, previously fetched but unused).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:54:07 +02:00
Maaxxs
bbaf018a0e Add live Google Reviews, refreshed daily instead of hand-typed testimonials
The homepage's "reviews" section was never real - a fixed array of 10
hand-written quotes in the translation files plus a manually-typed
"5,0 von 181 Kunden bewertet" string, none of it sourced from Google.

Wires it to the real thing via the Places API (New), cached in the
database and refreshed on a schedule rather than fetched live per
visitor - the public /google-reviews endpoint only ever reads the last
successfully cached result, so page traffic never touches Google's API
or its quota. Mirrors the existing audio4cars price-refresh cron exactly
(same bootstrap-config-fetch-store shape): refresh_google_reviews.php
reads the Place ID + API key from app_settings, calls
GooglePlacesReviewsFetcher, and replaces the google_reviews table
wholesale on success; refresh_google_reviews.bat is the Task Scheduler
entrypoint. A failed refresh leaves the previous good data in place and
only records the error message, so a bad night never blanks the section.

Admin config lives in a new "Google-Rezensionen" block on the Website
settings page (Place ID + API key, matching the existing GA
service-account field's "leave blank to keep" behavior for the key) plus
a "Jetzt aktualisieren" button to test immediately rather than wait for
the nightly job - both hit the same GoogleReviewsController the cron
script's service class does.

Home.jsx now fetches /google-reviews and swaps in the live rating and
quotes only once at least one fetched review has actual text (Google
sometimes returns star-only reviews with no comment, which StarRating's
quote cards can't render) - until then, or if nothing is configured yet,
it falls back to exactly the previous hardcoded testimonials, so the
section never looks broken mid-rollout. TestimonialSlider/StarRating now
take a per-review rating instead of always drawing 5 stars.

Verified end-to-end except the live Google call itself, which needs a
real API key: schema migration, settings save/load (including the
key's "blank = unchanged" behavior), the CLI script's two failure paths
(unconfigured, and a real Google 400 for an invalid key - confirming the
Places API (New) request shape is correct), and the full display path by
seeding fake "successful" rows directly - homepage correctly switched to
the live rating/quotes and back to the fallback after clearing them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:40:08 +02:00
Maaxxs
541339879c Let admins choose which data to import instead of all-or-nothing
Import always replaced everything - brands, packages, services, FAQs,
gallery - in one irreversible sweep, even if you only wanted to bring in
one part (e.g. restoring just the gallery from an old backup while
leaving today's live catalog alone).

Backend: importData() now accepts a "sections" field (JSON array of
catalog/services/faqs/gallery) and only DELETE+INSERTs the tables that
belong to selected sections; anything not selected is left completely
untouched. Tables stay grouped the way their foreign keys require
(brands -> car_models -> packages -> package_products/upgrades as one
unit, gallery_brands -> gallery_projects -> gallery_photos as another) -
importing "packages" without its models would either orphan rows or
silently rewrite unrelated ones, so the groups aren't splittable further.
Restored images are filtered to just the ones referenced by the tables
actually being imported, so deselecting the gallery means its images
aren't written to /uploads either. No sections field (older callers)
still imports everything, matching prior behavior.

Frontend: picking a file parses it client-side (file.text() + JSON.parse)
to show each section with live counts pulled straight from the file
("4 Marken, 15 Modelle, 17 Pakete"), all checked by default. The
confirmation checkbox's warning text names only what's actually
selected. If parsing fails client-side the picker just doesn't render -
the import still goes through server-side with the (safe) default of
importing everything.

Verified end-to-end against a real ~54MB export: importing only "faqs"
reverted a changed FAQ while a newly added test brand survived
untouched; importing only "catalog" right after removed that test brand
while the FAQ stayed as already restored - confirming sections are
fully independent both ways.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:09:21 +02:00
Maaxxs
a9aea99028 Fix data export/import: PHP's default upload limits rejected real exports
Root cause: the export embeds the whole gallery as base64 directly in the
JSON (that's how it stays a single self-contained file), which pushes a
realistic export well past 40MB. PHP's post_max_size/upload_max_filesize
default to values well under that on most hosting, so the browser's
upload got silently discarded before the app ever saw it - PHP clears
$_FILES and $_POST once post_max_size is exceeded, and the leftover raw
body in php://input is unparsed multipart data, not JSON, so it fell
into a generic "invalid file" 422 with no indication of what actually
went wrong. Reproduced locally with a real ~43MB export against the
previous 40M limit.

Raises the limits to 200M via two paths, since we don't know which PHP
SAPI the various hosting targets (All-Inkl, the Plesk test server) use:
hifi/.htaccess sets php_value overrides for classic Apache module PHP,
guarded by <IfModule> checks for several common module names so hosts
running PHP-FPM/CGI (which ignore php_value and would otherwise choke on
an unrecognized directive) skip the block instead of 500ing the entire
site; hifi/api/public/.user.ini covers exactly that FPM/CGI case, which
mod_php hosts in turn simply don't read.

Also makes SettingsController::importData() detect an oversized upload
by comparing Content-Length against the configured post_max_size, and
report the actual limit instead of the generic corrupt-file message -
so if some host's real limit is still too low, the admin sees why
instead of a dead end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:54:34 +02:00
Maaxxs
b1b66f1813 Add a one-time visual hint for wheel-scrolling the tile row
The existing peek animation (row nudges right and back on first load)
only demonstrated that the row moves, not how to move it - ambiguous
now that mouse-drag is gone. On non-touch devices it's paired with a
small pill ("Mit dem Mausrad blättern" / "Scroll to browse", mouse icon
flanked by chevrons) that fades in around the peek and out ~1.2s later,
floating centered over the row at a z-index above even the frontmost
coverflow card. Touch devices keep just the peek, since swiping is
already self-explanatory there. Plays once per page load, gated behind
the same reduced-motion check as the peek itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:42:33 +02:00
Maaxxs
eba15ef52b Make light wheel scrolls on the tile row commit to the next card
Client feedback: a light/small scroll snapped back to the current card
instead of advancing - native scroll-snap always settles on whichever
card is nearest, and a light nudge rarely crosses the halfway point.

Each wheel burst now tracks its starting card index and net deltaY.
On settle, if the nearest card by position is still the start card but
there was clear directional movement (>4px net), the row advances one
card in that direction instead of springing back - so even a light
wheel tick pages forward. Larger scrolls are unaffected: if the actual
scroll position already lands closer to a farther card, it jumps
straight there (no artificial one-card-per-burst cap), and a burst that
nets out near zero (e.g. wiggling back and forth) correctly stays put.
The settle target is computed the same way the old drag-to-scroll
feature centered cards, and re-enables scroll-snap only after the
settling scrollTo animation would have finished, so it doesn't fight
the smooth scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:30:16 +02:00
Maaxxs
eb59e9c1c9 Add mouse-wheel scrolling to the package tile row
Client feedback: after removing mouse-drag on the coverflow (it fought
the 3D transform), the thin scrollbar underneath was the only desktop
way to browse tiles - too fiddly.

Hovering the tile row and scrolling the wheel now moves the row
horizontally, fluid/1:1 with the wheel delta rather than snapping one
card per tick, with scroll-snap briefly suspended during the burst and
re-enabled ~150ms after the last tick so cards settle into place
afterward - the same pattern already used for the old drag interaction.

At the first card, scrolling further "up" is left alone (no
preventDefault): the event falls through to native behavior, which is
already exactly how the page scrolls today when hovering the row, so
the page scrolls up instead. Same at the last card scrolling "down" -
the page takes over. Horizontal wheel/trackpad sideswipe (deltaX) is
untouched, since the container already handles that natively. Applies
uniformly to both strip and coverflow (same scrollerRef container); the
scrollbar stays as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:24:28 +02:00
Maaxxs
79fc5f2f76 Match the contact section to the prototype: Barlow, map crop, card title
Side-by-side with the prototype revealed the section never set its font
family - everything except the Michroma headings fell back to the system
font, which was the biggest visual mismatch (labels, subtitle, feature
bar). The section root now sets Barlow like the handoff specifies; the
subtitle even wraps identically to the prototype again.

The maps iframe crop is rebalanced (290px shifted -60px inside the 210px
window) so the map runs flush to the "Route planen" bar with Google's
bottom attribution cut at the card edge like the prototype, instead of
showing the full attribution line with a gap. The contact card is titled
"HiFi Planet Amorbach" per the design (was "HifiPlanet Amorbach").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 07:29:26 +02:00
28 changed files with 1109 additions and 158 deletions

View file

@ -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

View file

@ -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>
))} ))}

View file

@ -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',

View file

@ -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',

View file

@ -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>
)} )}

View file

@ -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>}

View file

@ -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"
/> />

View file

@ -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"

View file

@ -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>
))} ))}

View file

@ -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. */}

View file

@ -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

View file

@ -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;

View file

@ -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,

View 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

View file

@ -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());

View 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

View 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";
}

View 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 [];
}
}
}

View file

@ -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

View file

@ -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();

View 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]);
}
}

View file

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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};

View file

@ -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>