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>
This commit is contained in:
Maaxxs 2026-08-01 00:39:23 +02:00
parent 0a96080427
commit b9bfecd1c2
11 changed files with 88 additions and 43 deletions

View file

@ -27,6 +27,7 @@ const DEFAULT_SITE_SETTINGS = {
ga_measurement_id: null,
package_card_theme: 'graphite',
package_card_layout: 'strip',
package_wheel_hint_interval: 8,
};
// Das Impressum (und die anderen rechtlichen Pflichtseiten) müssen laut § 5 DDG

View file

@ -10,6 +10,7 @@ const emptyForm = {
ga_measurement_id: '',
package_card_theme: 'graphite',
package_card_layout: 'strip',
package_wheel_hint_interval: 8,
};
const PACKAGE_CARD_THEMES = [
@ -59,6 +60,7 @@ export default function WebsiteSettings() {
ga_measurement_id: res.ga_measurement_id || '',
package_card_theme: res.package_card_theme || 'graphite',
package_card_layout: res.package_card_layout || 'strip',
package_wheel_hint_interval: res.package_wheel_hint_interval || 8,
})
)
.finally(() => setLoading(false));
@ -267,6 +269,23 @@ export default function WebsiteSettings() {
</label>
))}
</div>
<div className="mt-5 max-w-xs">
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Mausrad-Hinweis: Wiederholung (Sekunden)
</label>
<input
type="number"
min="2"
max="120"
value={form.package_wheel_hint_interval}
onChange={(e) => update('package_wheel_hint_interval', e.target.value)}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
/>
<p className="mt-1 text-xs text-neutral-400">
Nur bei Scroll-Band/3D-Coverflow: Solange ein Besucher noch auf der ersten Kachel steht, wird der
"mit dem Mausrad blättern"-Hinweis in diesem Abstand wiederholt.
</p>
</div>
</section>
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">

View file

@ -265,13 +265,17 @@ export default function ModelPage() {
const [error, setError] = useState('');
const maintenance = useMaintenance();
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 layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
const wheelHintInterval = Math.max(2, Number(wheelHintIntervalSetting) || 8) * 1000;
const scrollerRef = useRef(null);
const trackRef = useRef(null);
const thumbRef = useRef(null);
const hintPlayedRef = useRef(false);
const [wheelHintVisible, setWheelHintVisible] = useState(false);
const formatPrice = (value) =>
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
@ -323,42 +327,43 @@ export default function ModelPage() {
// Scroll-Leiste; Touch-Geraete behalten ohnehin das native, fluessige
// Wisch-Scrolling der scroll-snap-Kartenreihe.
//
// Stattdessen: einmaliger kurzer Wisch-Hinweis beim ersten Laden (Kundenwunsch) -
// die Reihe faehrt kurz ein Stueck nach rechts und wieder zurueck, damit sofort
// klar ist, dass sich die Kacheln wischen lassen. Nur einmal pro Seitenaufruf,
// 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).
// Stattdessen: kurzer Wisch-Hinweis (Reihe faehrt ein Stueck nach rechts und
// zurueck, plus Mausrad-Icon auf Geraeten mit Maus). Wiederholt sich im
// Admin-konfigurierten Abstand (wheelHintInterval), SOLANGE der Besucher noch
// auf der ersten Kachel steht (Kundenwunsch) - wurde bereits weitergescrollt,
// faellt die naechste Wiederholung aus, greift aber wieder, sobald man zur
// ersten Kachel zurueckkehrt. Nicht bei reduzierter Bewegung. Auf Touch-Geraeten
// reicht die Bewegung allein (Wischen ist selbsterklaerend), daher kein Icon.
useEffect(() => {
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 reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) return undefined;
let timers = [];
let animTimers = [];
let intervalId = 0;
const playHint = () => {
hintPlayedRef.current = true;
const peek = Math.min(el.clientWidth * 0.4, 160);
timers = [
setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500),
setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250),
];
animTimers.push(setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500));
animTimers.push(setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250));
if (!isTouch) {
timers.push(setTimeout(() => setWheelHintVisible(true), 450));
timers.push(setTimeout(() => setWheelHintVisible(false), 1700));
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 abspielen, wenn die Reihe wirklich im Blick ist.
// 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 },
@ -367,9 +372,10 @@ export default function ModelPage() {
return () => {
observer.disconnect();
timers.forEach(clearTimeout);
clearInterval(intervalId);
animTimers.forEach(clearTimeout);
};
}, [layout, data]);
}, [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

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,
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
package_wheel_hint_interval INT NOT NULL DEFAULT 8,
mail_host VARCHAR(255) NULL,
mail_port INT NULL,
mail_username VARCHAR(255) NULL,

View file

@ -17,13 +17,14 @@ class SiteSettingsController
'ga_measurement_id' => null,
'package_card_theme' => 'graphite',
'package_card_layout' => 'strip',
'package_wheel_hint_interval' => 8,
];
public static function show(): void
{
try {
$stmt = Database::connection()->query(
'SELECT phone, whatsapp, contact_email, hero_image_path, ga_measurement_id, package_card_theme, package_card_layout 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();
} catch (\Throwable $e) {
@ -57,9 +58,15 @@ class SiteSettingsController
$packageCardLayout = 'strip';
}
// Wiederholungsabstand des Mausrad-Hinweises - auf einen sinnvollen Bereich
// geklammert, damit ein falscher/leerer Wert nicht zu einem Dauer-Flackern
// oder einem quasi-deaktivierten Hinweis fuehrt.
$wheelHintInterval = (int) ($body['package_wheel_hint_interval'] ?? 8);
$wheelHintInterval = max(2, min(120, $wheelHintInterval ?: 8));
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
$stmt = $db->prepare(
'UPDATE app_settings SET phone = ?, whatsapp = ?, contact_email = ?, hero_image_path = ?, ga_measurement_id = ?, package_card_theme = ?, package_card_layout = ? 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([
trim($body['phone'] ?? '') ?: null,
@ -69,6 +76,7 @@ class SiteSettingsController
trim($body['ga_measurement_id'] ?? '') ?: null,
$packageCardTheme,
$packageCardLayout,
$wheelHintInterval,
]);
self::show();

View file

@ -45,6 +45,7 @@ class Schema
google_reviews_error VARCHAR(500) NULL,
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
package_wheel_hint_interval INT NOT NULL DEFAULT 8,
mail_host VARCHAR(255) NULL,
mail_port INT NULL,
mail_username VARCHAR(255) NULL,
@ -293,6 +294,7 @@ class Schema
'google_reviews_error' => 'VARCHAR(500) NULL',
'package_card_theme' => "VARCHAR(30) NOT NULL DEFAULT 'graphite'",
'package_card_layout' => "VARCHAR(20) NOT NULL DEFAULT 'strip'",
'package_wheel_hint_interval' => 'INT NOT NULL DEFAULT 8',
'mail_host' => 'VARCHAR(255) NULL',
'mail_port' => 'INT NULL',
'mail_username' => 'VARCHAR(255) NULL',

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

@ -43,9 +43,9 @@
"sameAs": ["https://www.youtube.com/@hifiplanet2812"]
}
</script>
<script type="module" crossorigin src="/assets/index-DO8EIr5z.js"></script>
<script type="module" crossorigin src="/assets/index-CEtUzIVu.js"></script>
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-66ioQXWI.js">
<link rel="stylesheet" crossorigin href="/assets/index-DK8bkdcK.css">
<link rel="stylesheet" crossorigin href="/assets/index-Cys8aTN9.css">
</head>
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div id="root"></div>