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>
This commit is contained in:
Maaxxs 2026-07-21 00:42:33 +02:00
parent eba15ef52b
commit b1b66f1813
9 changed files with 124 additions and 95 deletions

View file

@ -151,6 +151,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',

View file

@ -151,6 +151,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',

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';
@ -271,6 +272,7 @@ export default function ModelPage() {
const trackRef = useRef(null); const trackRef = useRef(null);
const thumbRef = useRef(null); const thumbRef = useRef(null);
const hintPlayedRef = useRef(false); 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);
@ -321,24 +323,30 @@ export default function ModelPage() {
// Scroll-Leiste; Touch-Geraete behalten ohnehin das native, fluessige // Scroll-Leiste; Touch-Geraete behalten ohnehin das native, fluessige
// Wisch-Scrolling der scroll-snap-Kartenreihe. // Wisch-Scrolling der scroll-snap-Kartenreihe.
// //
// Stattdessen: einmaliger kurzer Wisch-Hinweis beim ersten Laden auf Touch-Geraeten // Stattdessen: einmaliger kurzer Wisch-Hinweis beim ersten Laden (Kundenwunsch) -
// (Kundenwunsch) - die Reihe faehrt kurz ein Stueck nach rechts und wieder zurueck, // die Reihe faehrt kurz ein Stueck nach rechts und wieder zurueck, damit sofort
// damit sofort klar ist, dass sich die Kacheln wischen lassen. Nur einmal pro // klar ist, dass sich die Kacheln wischen lassen. Nur einmal pro Seitenaufruf,
// Seitenaufruf, nur auf echten Touch-Geraeten, nicht bei reduzierter Bewegung. // nicht bei reduzierter Bewegung. Auf Touch-Geraeten reicht die Bewegung allein
// (Wischen ist selbsterklaerend); auf Geraeten mit Maus blendet zusaetzlich kurz
// ein Mausrad-Icon mit Pfeilen ein, weil die Ausweich-Bewegung allein offen liesse,
// WIE man scrollt (Ziehen mit der Maus gibt es seit Kundenfeedback nicht mehr).
useEffect(() => { useEffect(() => {
const el = scrollerRef.current; const el = scrollerRef.current;
if (layout === 'grid' || !el || hintPlayedRef.current) return undefined; if (layout === 'grid' || !el || hintPlayedRef.current) 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; hintPlayedRef.current = true;
const peek = Math.min(el.clientWidth * 0.4, 160); const peek = Math.min(el.clientWidth * 0.4, 160);
const t1 = setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500); const timers = [
const t2 = setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250); setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500),
return () => { setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250),
clearTimeout(t1); ];
clearTimeout(t2); if (!isTouch) {
}; timers.push(setTimeout(() => setWheelHintVisible(true), 450));
timers.push(setTimeout(() => setWheelHintVisible(false), 1700));
}
return () => timers.forEach(clearTimeout);
}, [layout, data]); }, [layout, data]);
// Mausrad ueber der Kartenreihe scrollt horizontal (Kundenfeedback: die Scroll- // Mausrad ueber der Kartenreihe scrollt horizontal (Kundenfeedback: die Scroll-
@ -589,7 +597,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={
@ -628,6 +636,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. */}

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-D-bbfDHb.js"></script> <script type="module" crossorigin src="/assets/index-DQAs_YfT.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-wBHRPmjw.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>