HifiPlanet-Website/hifi-src/src/components/TestimonialSlider.jsx
Maaxxs b6909e756c Redesign navbar mobile menu and rework site color palette
Swaps navbar layout (theme toggle left, nav links right), replaces the
mobile dropdown with a fullscreen overlay whose items fly in top-to-bottom,
and renames the site-wide "slate" gray to "neutral" to remove a blue
undertone that clashed with the brand green. Also converts the flat
saturated-green YouTube and stats sections into dark backgrounds with
green used only as an accent, matching typical premium car-audio branding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 16:21:48 +02:00

136 lines
4.5 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" />
<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>
);
}