HifiPlanet-Website/hifi-src/src/components/TestimonialSlider.jsx
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

136 lines
4.6 KiB
JavaScript

import { useLayoutEffect, useRef } from 'react';
import StarRating from './StarRating.jsx';
const SPEED_PX_PER_SEC = 70;
// Läuft als Endlos-Marquee (links -> rechts): der Track enthält die Liste doppelt
// hintereinander. Die Position wird per rAF selbst verwaltet (statt CSS-Animation),
// damit sie sich beim Hovern anhalten UND per Drag manuell verschieben lässt.
export default function TestimonialSlider({ testimonials }) {
const viewportRef = useRef(null);
const trackRef = useRef(null);
const state = useRef({
pos: 0,
half: 0,
hovering: false,
dragging: false,
dragStartX: 0,
dragStartPos: 0,
lastTime: null,
rafId: null,
}).current;
useLayoutEffect(() => {
const track = trackRef.current;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const applyPos = () => {
track.style.transform = `translateX(${state.pos}px)`;
};
const normalize = (pos) => {
if (state.half <= 0) return pos;
while (pos > 0) pos -= state.half;
while (pos <= -state.half) pos += state.half;
return pos;
};
const measure = () => {
state.half = track.scrollWidth / 2;
if (state.pos === 0) {
state.pos = -state.half;
} else {
state.pos = normalize(state.pos);
}
applyPos();
};
measure();
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(track);
const tick = (time) => {
if (state.lastTime === null) state.lastTime = time;
const dt = (time - state.lastTime) / 1000;
state.lastTime = time;
if (!state.dragging && !state.hovering && !reduceMotion) {
state.pos = normalize(state.pos + SPEED_PX_PER_SEC * dt);
applyPos();
}
state.rafId = requestAnimationFrame(tick);
};
state.rafId = requestAnimationFrame(tick);
const viewport = viewportRef.current;
const onPointerEnter = () => {
state.hovering = true;
};
const onPointerLeave = () => {
state.hovering = false;
};
const onPointerDown = (e) => {
state.dragging = true;
state.dragStartX = e.clientX;
state.dragStartPos = state.pos;
viewport.setPointerCapture(e.pointerId);
viewport.classList.add('cursor-grabbing');
viewport.classList.remove('cursor-grab');
};
const onPointerMove = (e) => {
if (!state.dragging) return;
state.pos = normalize(state.dragStartPos + (e.clientX - state.dragStartX));
applyPos();
};
const endDrag = (e) => {
if (!state.dragging) return;
state.dragging = false;
if (viewport.hasPointerCapture(e.pointerId)) {
viewport.releasePointerCapture(e.pointerId);
}
viewport.classList.remove('cursor-grabbing');
viewport.classList.add('cursor-grab');
};
viewport.addEventListener('pointerenter', onPointerEnter);
viewport.addEventListener('pointerleave', onPointerLeave);
viewport.addEventListener('pointerdown', onPointerDown);
viewport.addEventListener('pointermove', onPointerMove);
viewport.addEventListener('pointerup', endDrag);
viewport.addEventListener('pointercancel', endDrag);
return () => {
cancelAnimationFrame(state.rafId);
resizeObserver.disconnect();
viewport.removeEventListener('pointerenter', onPointerEnter);
viewport.removeEventListener('pointerleave', onPointerLeave);
viewport.removeEventListener('pointerdown', onPointerDown);
viewport.removeEventListener('pointermove', onPointerMove);
viewport.removeEventListener('pointerup', endDrag);
viewport.removeEventListener('pointercancel', endDrag);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testimonials.length]);
const track = [...testimonials, ...testimonials];
return (
<div
ref={viewportRef}
className="marquee-viewport cursor-grab touch-pan-y select-none overflow-hidden"
>
<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="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" count={t.rating || 5} />
<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>
);
}