From 25cbe3549e6ea97e96b32e004dda64209f608cdb Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 17 Oct 2026 08:38:56 +0700 Subject: [PATCH] fix(website): eliminate story scroll dead zones and blank space with continuous progress engine --- .../products/ProductStoryScrollForm.tsx | 1 + .../src/app/[lang]/products/[slug]/page.tsx | 141 ++++--- .../src/components/CinematicStoryScroll.tsx | 393 ++++++++++++------ 3 files changed, 340 insertions(+), 195 deletions(-) diff --git a/apps/admin/src/components/products/ProductStoryScrollForm.tsx b/apps/admin/src/components/products/ProductStoryScrollForm.tsx index f737246..5e90ac9 100644 --- a/apps/admin/src/components/products/ProductStoryScrollForm.tsx +++ b/apps/admin/src/components/products/ProductStoryScrollForm.tsx @@ -94,6 +94,7 @@ export default function ProductStoryScrollForm({ payload, onChange, onBrowseMedi onChange({ ...payload, storyScenes: DEFAULT_SCENES, + storyTheme: payload.storyTheme || 'light', endCtaTitle: payload.endCtaTitle || 'SELECT YOUR MODEL.', endCtaButtonText: payload.endCtaButtonText || 'Choose Your Bike', }) diff --git a/apps/website/src/app/[lang]/products/[slug]/page.tsx b/apps/website/src/app/[lang]/products/[slug]/page.tsx index 5754780..ca0787b 100644 --- a/apps/website/src/app/[lang]/products/[slug]/page.tsx +++ b/apps/website/src/app/[lang]/products/[slug]/page.tsx @@ -123,48 +123,48 @@ function ProductMainSection({ const storyScenes = Array.isArray(payload.storyScenes) ? (payload.storyScenes as any[]) : undefined const endCtaTitle = (payload.endCtaTitle as string) || undefined const endCtaButtonText = (payload.endCtaButtonText as string) || undefined - const storyTheme = (payload.storyTheme as string) || 'dark' + const storyTheme = (payload.storyTheme as string) || 'light' return (
-
- {showBreadcrumbs && ( - - )} +
+
+ {showBreadcrumbs && ( + + )} -
- +
+ - -
+ +
+
- {/* Cinematic Scrollytelling Story Showcase */} -
- -
+ {/* Full-Bleed Cinematic Scrollytelling Story Showcase */} +
) } @@ -174,40 +174,40 @@ function ProductMainSection({ return (
-
- {showBreadcrumbs && ( - - )} +
+
+ {showBreadcrumbs && ( + + )} -
- +
+ - -
+ +
+
- {/* Apple Watch Style Keynote & Finishes Tour Showcase */} -
- -
+ {/* Full-Bleed Apple Watch Style Keynote & Finishes Tour Showcase */} +
) } @@ -594,12 +594,21 @@ export default async function ProductDetailPage({ params }: { params: Promise<{ const sectionShellClass = `mx-auto max-w-7xl px-6 ${index === 0 ? 'pt-28 md:pt-32' : 'pt-8'} pb-8` switch (section.type.toUpperCase()) { - case 'PRODUCT_MAIN': + case 'PRODUCT_MAIN': { + const rawLayout = typeof payload.layoutMode === 'string' ? payload.layoutMode : 'standard' + if (rawLayout === 'story_scroll' || rawLayout === 'apple_watch_tour' || rawLayout === 'apple_keynote') { + return ( +
+ +
+ ) + } return (
) + } case 'PRODUCT_DETAILS': return (
diff --git a/apps/website/src/components/CinematicStoryScroll.tsx b/apps/website/src/components/CinematicStoryScroll.tsx index 847c106..64cbcb0 100644 --- a/apps/website/src/components/CinematicStoryScroll.tsx +++ b/apps/website/src/components/CinematicStoryScroll.tsx @@ -1,9 +1,10 @@ 'use client' -import React, { useState, useEffect, useRef, useMemo } from 'react' +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react' import Image from 'next/image' import type { Product } from '@homaroma/types' import { resolveLocalized } from '../lib/localization' +import { ChevronLeft, ChevronRight, Sun, Moon } from 'lucide-react' export interface StoryScene { id: string @@ -88,13 +89,22 @@ export default function CinematicStoryScroll({ lang, endCtaTitle, endCtaButtonText, - storyTheme = 'dark', + storyTheme = 'light', }: Props) { - const [activeScene, setActiveScene] = useState('hero') - const [isStoryVisible, setIsStoryVisible] = useState(false) - const storyRef = useRef(null) + const containerRef = useRef(null) + const [activeIndex, setActiveIndex] = useState(0) + const [isStageVisible, setIsStageVisible] = useState(false) + const [themeMode, setThemeMode] = useState<'light' | 'dark'>(() => { + return storyTheme === 'dark' ? 'dark' : 'light' + }) - const isLight = storyTheme === 'light' + useEffect(() => { + if (storyTheme === 'dark' || storyTheme === 'light') { + setThemeMode(storyTheme) + } + }, [storyTheme]) + + const isLight = themeMode === 'light' const activeScenes = useMemo(() => { if (scenes && scenes.length > 0) return scenes @@ -124,46 +134,82 @@ export default function CinematicStoryScroll({ return ['NVX155', 'LC135', 'Vario125', 'Vario150', 'PCX150', 'Beat110', 'Wave110-I', 'R15'] }, [product.options, lang]) + // Total container height: 75vh of scroll distance per scene + const totalHeightVh = Math.max(300, activeScenes.length * 75) + + // Direct, continuous scroll listener (NO dummy divs, NO empty space possible) useEffect(() => { - const storyEl = storyRef.current - if (!storyEl) return + let rafId: number - const triggers = storyEl.querySelectorAll('.story-trigger') - if (triggers.length === 0) return + const handleScroll = () => { + const el = containerRef.current + if (!el) return - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const sceneId = entry.target.getAttribute('data-scene') - if (sceneId) setActiveScene(sceneId) - } - }) - }, - { - rootMargin: '-20% 0px -20% 0px', - threshold: [0.1, 0.4], - } - ) + const rect = el.getBoundingClientRect() + const viewportHeight = window.innerHeight + const totalScrollable = rect.height - viewportHeight - triggers.forEach((t) => observer.observe(t)) + if (totalScrollable <= 0) return - const visibilityObserver = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - setIsStoryVisible(entry.isIntersecting) - }) - }, - { threshold: 0.05 } - ) + const scrolled = -rect.top + const rawProgress = scrolled / totalScrollable + const progress = Math.max(0, Math.min(1, rawProgress)) - visibilityObserver.observe(storyEl) + const numScenes = activeScenes.length + const calculatedIndex = Math.min( + numScenes - 1, + Math.max(0, Math.floor(progress * numScenes)) + ) + + setActiveIndex(calculatedIndex) + setIsStageVisible(rect.top <= viewportHeight && rect.bottom >= 0) + } + + const onScroll = () => { + cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(handleScroll) + } + + window.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('resize', onScroll, { passive: true }) + handleScroll() return () => { - observer.disconnect() - visibilityObserver.disconnect() + cancelAnimationFrame(rafId) + window.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onScroll) } - }, [activeScenes]) + }, [activeScenes.length]) + + const scrollToScene = useCallback((idx: number) => { + const el = containerRef.current + if (!el) return + + const rect = el.getBoundingClientRect() + const viewportHeight = window.innerHeight + const totalScrollable = rect.height - viewportHeight + const numScenes = activeScenes.length + + const targetProgress = (idx + 0.35) / numScenes + const targetScrollY = window.scrollY + rect.top + targetProgress * totalScrollable + + window.scrollTo({ + top: targetScrollY, + behavior: 'smooth', + }) + }, [activeScenes.length]) + + const handlePrev = useCallback(() => { + if (activeIndex > 0) { + scrollToScene(activeIndex - 1) + } + }, [activeIndex, scrollToScene]) + + const handleNext = useCallback(() => { + if (activeIndex < activeScenes.length - 1) { + scrollToScene(activeIndex + 1) + } + }, [activeIndex, activeScenes.length, scrollToScene]) const scrollToPurchase = () => { const el = document.getElementById('product-purchase-top') @@ -174,68 +220,40 @@ export default function CinematicStoryScroll({ } } - const scrollToScene = (index: number) => { - const storyEl = storyRef.current - if (!storyEl) return - const triggers = storyEl.querySelectorAll('.story-trigger') - if (triggers[index]) { - triggers[index].scrollIntoView({ behavior: 'smooth' }) - } - } - - const activeIndex = Math.max(0, activeScenes.findIndex(s => s.id === activeScene)) + const currentScene = activeScenes[activeIndex] || activeScenes[0] return ( -
- {/* Top Atmosphere Gradient Blend (prevents harsh abrupt cut into dark) */} - {!isLight && ( -
- )} - - {/* Side Progress Dots (Desktop) */} +
+ {/* + Scroll Track Container + Explicit height provides seamless scroll distance. + NO overflow-hidden here so sticky can stick reliably to viewport! + */} + {/* Top Atmosphere Gradient Blend (Smooth transition into dark mode if active) */} + {!isLight && ( +
+ )} - {/* Main Scrollytelling Stage */} -
- {/* Sticky 100vh Stage */} -
+ {/* + Sticky 100dvh Stage + Sticks to top of viewport as user scrolls through totalHeightVh + */} +
{/* Ambient Reactive Glow Backdrops */} {isLight ? ( <> - {/* Light Theme Reactive Glows */}
) : ( <> - {/* Dark Theater Reactive Glows */}
)} - {/* Desktop Layout: Left Showcase Visual, Right Copy */} -
+ {/* Top Stage Control Header: Scene Pills Bar & Theme Switcher */} +
+ {/* Apple-style Interactive Scene Tabs */} +
+ {activeScenes.map((scene, idx) => { + const isSelected = activeIndex === idx + const label = scene.kicker?.split('/')[1]?.trim() || `0${idx + 1}` + return ( + + ) + })} +
+ + {/* Quick Theme Switcher (Light / Dark) */} + +
+ + {/* Main Content Stage: Left Visual, Right Narrative */} +
{/* Left Product Core / Showcase Visual */} -
+
@@ -386,16 +462,16 @@ export default function CinematicStoryScroll({
{/* Right Narrative Copy Block */} -
- {activeScenes.map((scene) => { - const isActive = scene.id === activeScene +
+ {activeScenes.map((scene, idx) => { + const isActive = idx === activeIndex return (
{/* Kicker */} @@ -435,9 +511,9 @@ export default function CinematicStoryScroll({ {/* Feature Chips */} {scene.chips && scene.chips.length > 0 && (
- {scene.chips.map((chip, idx) => ( + {scene.chips.map((chip, chipIdx) => (
- {fitmentModels.slice(0, 18).map((model, idx) => ( + {fitmentModels.slice(0, 18).map((model, mIdx) => (
+ + {/* Bottom Stage Footer: Scene Counter & Scroll Prompt */} +
+ + ฉาก {activeIndex + 1} จาก {activeScenes.length} + +
+ + เลื่อนลงเพื่อดูฉากถัดไป หรือกดปุ่มลูกศร + + + +
+
- {/* Scroll Trigger Sections (each 90vh tall to drive IntersectionObserver) */} - {activeScenes.map((scene) => ( -
- ))} + {/* Side Progress Dots (Desktop) */} +
{/* End CTA Section */}