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:
parent
0a96080427
commit
b9bfecd1c2
11 changed files with 88 additions and 43 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
@ -59,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));
|
||||||
|
|
@ -267,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">
|
||||||
|
|
|
||||||
|
|
@ -265,13 +265,17 @@ 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 [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);
|
||||||
|
|
@ -323,42 +327,43 @@ 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 (Kundenwunsch) -
|
// Stattdessen: kurzer Wisch-Hinweis (Reihe faehrt ein Stueck nach rechts und
|
||||||
// die Reihe faehrt kurz ein Stueck nach rechts und wieder zurueck, damit sofort
|
// zurueck, plus Mausrad-Icon auf Geraeten mit Maus). Wiederholt sich im
|
||||||
// klar ist, dass sich die Kacheln wischen lassen. Nur einmal pro Seitenaufruf,
|
// Admin-konfigurierten Abstand (wheelHintInterval), SOLANGE der Besucher noch
|
||||||
// nicht bei reduzierter Bewegung. Auf Touch-Geraeten reicht die Bewegung allein
|
// auf der ersten Kachel steht (Kundenwunsch) - wurde bereits weitergescrollt,
|
||||||
// (Wischen ist selbsterklaerend); auf Geraeten mit Maus blendet zusaetzlich kurz
|
// faellt die naechste Wiederholung aus, greift aber wieder, sobald man zur
|
||||||
// ein Mausrad-Icon mit Pfeilen ein, weil die Ausweich-Bewegung allein offen liesse,
|
// ersten Kachel zurueckkehrt. Nicht bei reduzierter Bewegung. Auf Touch-Geraeten
|
||||||
// WIE man scrollt (Ziehen mit der Maus gibt es seit Kundenfeedback nicht mehr).
|
// 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 (reducedMotion) return undefined;
|
if (reducedMotion) return undefined;
|
||||||
|
|
||||||
let timers = [];
|
let animTimers = [];
|
||||||
|
let intervalId = 0;
|
||||||
const playHint = () => {
|
const playHint = () => {
|
||||||
hintPlayedRef.current = true;
|
|
||||||
const peek = Math.min(el.clientWidth * 0.4, 160);
|
const peek = Math.min(el.clientWidth * 0.4, 160);
|
||||||
timers = [
|
animTimers.push(setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500));
|
||||||
setTimeout(() => el.scrollTo({ left: peek, behavior: 'smooth' }), 500),
|
animTimers.push(setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250));
|
||||||
setTimeout(() => el.scrollTo({ left: 0, behavior: 'smooth' }), 1250),
|
|
||||||
];
|
|
||||||
if (!isTouch) {
|
if (!isTouch) {
|
||||||
timers.push(setTimeout(() => setWheelHintVisible(true), 450));
|
animTimers.push(setTimeout(() => setWheelHintVisible(true), 450));
|
||||||
timers.push(setTimeout(() => setWheelHintVisible(false), 1700));
|
animTimers.push(setTimeout(() => setWheelHintVisible(false), 1700));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Kundenfeedback: der Hinweis lief bisher direkt beim Laden ab, oft bevor die
|
// Kundenfeedback: der Hinweis lief bisher direkt beim Laden ab, oft bevor die
|
||||||
// Kartenreihe ueberhaupt in den sichtbaren Bereich gescrollt war - dann hat ihn
|
// 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(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
playHint();
|
playHint();
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
if (el.scrollLeft <= 1) playHint();
|
||||||
|
}, wheelHintInterval);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ threshold: 0.35 },
|
{ threshold: 0.35 },
|
||||||
|
|
@ -367,9 +372,10 @@ export default function ModelPage() {
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
timers.forEach(clearTimeout);
|
clearInterval(intervalId);
|
||||||
|
animTimers.forEach(clearTimeout);
|
||||||
};
|
};
|
||||||
}, [layout, data]);
|
}, [layout, data, wheelHintInterval]);
|
||||||
|
|
||||||
// Mausrad ueber der Kartenreihe scrollt horizontal (Kundenfeedback: die Scroll-
|
// Mausrad ueber der Kartenreihe scrollt horizontal (Kundenfeedback: die Scroll-
|
||||||
// Leiste allein war als einzige Desktop-Bedienung zu fummelig). Steht man an der
|
// Leiste allein war als einzige Desktop-Bedienung zu fummelig). Steht man an der
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class Schema
|
||||||
google_reviews_error VARCHAR(500) 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,
|
||||||
|
|
@ -293,6 +294,7 @@ class Schema
|
||||||
'google_reviews_error' => 'VARCHAR(500) 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',
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
1
hifi/assets/index-Cys8aTN9.css
Normal file
1
hifi/assets/index-Cys8aTN9.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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-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="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>
|
</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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue