fix(website): eliminate story scroll dead zones and blank space with continuous progress engine

This commit is contained in:
Your Name committed 2026-10-17 08:38:56 +07:00
1 parent 9a5a30778e
commit 25cbe3549e
3 files changed
+274 -129

No files matched your search

@@ -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',
})
@@ -123,10 +123,11 @@ 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 (
<div className="space-y-12">
<div className="mx-auto max-w-7xl px-6 pt-28 md:pt-32">
<div id="product-purchase-top" className="space-y-8 scroll-mt-24">
{showBreadcrumbs && (
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
@@ -152,9 +153,9 @@ function ProductMainSection({
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
</section>
</div>
</div>
{/* Cinematic Scrollytelling Story Showcase */}
<div className="-mx-4 sm:-mx-6 lg:-mx-8 xl:-mx-12">
{/* Full-Bleed Cinematic Scrollytelling Story Showcase */}
<CinematicStoryScroll
product={product}
heroImage={storyHeroImage}
@@ -165,7 +166,6 @@ function ProductMainSection({
storyTheme={storyTheme}
/>
</div>
</div>
)
}
@@ -174,6 +174,7 @@ function ProductMainSection({
return (
<div className="space-y-12">
<div className="mx-auto max-w-7xl px-6 pt-28 md:pt-32">
<div id="product-purchase-top" className="space-y-8 scroll-mt-24">
{showBreadcrumbs && (
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
@@ -199,16 +200,15 @@ function ProductMainSection({
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
</section>
</div>
</div>
{/* Apple Watch Style Keynote & Finishes Tour Showcase */}
<div className="-mx-4 sm:-mx-6 lg:-mx-8 xl:-mx-12">
{/* Full-Bleed Apple Watch Style Keynote & Finishes Tour Showcase */}
<AppleWatchShowcase
product={product}
heroImage={storyHeroImage}
lang="th"
/>
</div>
</div>
)
}
@@ -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 (
<div key={section.id} className="w-full">
<ProductMainSection product={product} productName={productName} categoryName={categoryName} imageUrl={imageUrl} payload={payload} />
</div>
)
}
return (
<div key={section.id} className={sectionShellClass}>
<ProductMainSection product={product} productName={productName} categoryName={categoryName} imageUrl={imageUrl} payload={payload} />
</div>
)
}
case 'PRODUCT_DETAILS':
return (
<div key={section.id} className={sectionShellClass}>
@@ -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<string>('hero')
const [isStoryVisible, setIsStoryVisible] = useState(false)
const storyRef = useRef<HTMLDivElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const [activeIndex, setActiveIndex] = useState<number>(0)
const [isStageVisible, setIsStageVisible] = useState<boolean>(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<HTMLElement>('.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
if (totalScrollable <= 0) return
const scrolled = -rect.top
const rawProgress = scrolled / totalScrollable
const progress = Math.max(0, Math.min(1, rawProgress))
const numScenes = activeScenes.length
const calculatedIndex = Math.min(
numScenes - 1,
Math.max(0, Math.floor(progress * numScenes))
)
triggers.forEach((t) => observer.observe(t))
setActiveIndex(calculatedIndex)
setIsStageVisible(rect.top <= viewportHeight && rect.bottom >= 0)
}
const visibilityObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsStoryVisible(entry.isIntersecting)
})
},
{ threshold: 0.05 }
)
const onScroll = () => {
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(handleScroll)
}
visibilityObserver.observe(storyEl)
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<HTMLElement>('.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 (
<div className="w-full">
{/*
Scroll Track Container
Explicit height provides seamless scroll distance.
NO overflow-hidden here so sticky can stick reliably to viewport!
*/}
<div
ref={storyRef}
className={`relative w-full overflow-hidden transition-colors duration-700 select-none ${
ref={containerRef}
className={`relative w-full transition-colors duration-700 select-none ${
isLight
? 'bg-[#f7f5ed] text-slate-900 border-y border-line-primary/20'
: 'bg-[#0b101c] text-white'
}`}
style={{ height: `${totalHeightVh}vh` }}
>
{/* Top Atmosphere Gradient Blend (prevents harsh abrupt cut into dark) */}
{/* Top Atmosphere Gradient Blend (Smooth transition into dark mode if active) */}
{!isLight && (
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-surface-page via-[#0b101c]/60 to-[#0b101c] pointer-events-none z-20" />
<div className="absolute top-0 left-0 right-0 h-40 bg-gradient-to-b from-surface-page via-[#0b101c]/80 to-[#0b101c] pointer-events-none z-20" />
)}
{/* Side Progress Dots (Desktop) */}
<div
className={`fixed right-6 top-1/2 -translate-y-1/2 z-40 hidden lg:flex flex-col gap-2.5 transition-opacity duration-300 pointer-events-auto ${
isStoryVisible ? 'opacity-100' : 'opacity-0'
}`}
>
{activeScenes.map((s, idx) => (
<button
key={s.id}
type="button"
onClick={() => scrollToScene(idx)}
className={`rounded-full transition-all duration-300 cursor-pointer ${
activeIndex === idx
? isLight
? 'w-2.5 h-7 bg-brand-primary shadow-md'
: 'w-2.5 h-7 bg-white shadow-lg shadow-white/40'
: isLight
? 'w-2.5 h-2.5 bg-slate-300 hover:bg-slate-400'
: 'w-2.5 h-2.5 bg-white/20 hover:bg-white/50'
}`}
title={`Scene 0${idx + 1}`}
aria-label={`Jump to scene 0${idx + 1}`}
/>
))}
</div>
{/* Main Scrollytelling Stage */}
<div className="relative">
{/* Sticky 100vh Stage */}
<div className="sticky top-0 h-screen w-full overflow-hidden flex items-center justify-center">
{/*
Sticky 100dvh Stage
Sticks to top of viewport as user scrolls through totalHeightVh
*/}
<div className="sticky top-0 h-[100dvh] w-full overflow-hidden flex flex-col justify-between">
{/* Ambient Reactive Glow Backdrops */}
{isLight ? (
<>
{/* Light Theme Reactive Glows */}
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'heat' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'heat' ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'radial-gradient(circle at 35% 50%, rgba(255, 120, 30, 0.16), transparent 45%), linear-gradient(180deg, #fef7ee 0%, #faeee0 100%)',
@@ -243,7 +261,7 @@ export default function CinematicStoryScroll({
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'material' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'material' ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'radial-gradient(circle at 35% 50%, rgba(30, 120, 255, 0.14), transparent 45%), linear-gradient(180deg, #f4f8fe 0%, #edf3fd 100%)',
@@ -251,7 +269,7 @@ export default function CinematicStoryScroll({
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'friction' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'friction' ? 'opacity-100' : 'opacity-0'
}`}
style={{
backgroundImage: 'linear-gradient(rgba(0,0,0,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(0,0,0,0.04) 1px, transparent 1px)',
@@ -260,7 +278,7 @@ export default function CinematicStoryScroll({
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'durability' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'durability' ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'radial-gradient(circle at 40% 60%, rgba(216, 179, 90, 0.18), transparent 45%), linear-gradient(120deg, #faf6ed 0%, #f3ece0 100%)',
@@ -268,7 +286,7 @@ export default function CinematicStoryScroll({
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'hero' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'hero' ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'radial-gradient(circle at 35% 50%, rgba(20, 103, 255, 0.12), transparent 42%), linear-gradient(180deg, #f7f5ed 0%, #f0ede3 100%)',
@@ -277,10 +295,9 @@ export default function CinematicStoryScroll({
</>
) : (
<>
{/* Dark Theater Reactive Glows */}
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'heat' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'heat' ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'radial-gradient(circle at 35% 50%, rgba(255, 102, 0, 0.45), transparent 40%), linear-gradient(110deg, #180d08 0%, #0d1422 75%)',
@@ -288,7 +305,7 @@ export default function CinematicStoryScroll({
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'material' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'material' ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'radial-gradient(circle at 35% 50%, rgba(30, 110, 255, 0.35), transparent 38%), linear-gradient(180deg, #0d1728 0%, #09101a 100%)',
@@ -296,7 +313,7 @@ export default function CinematicStoryScroll({
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'friction' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'friction' ? 'opacity-100' : 'opacity-0'
}`}
style={{
backgroundImage: 'linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px)',
@@ -305,7 +322,7 @@ export default function CinematicStoryScroll({
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'durability' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'durability' ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'radial-gradient(circle at 40% 60%, rgba(216, 179, 90, 0.28), transparent 35%), linear-gradient(120deg, #131c28 0%, #0a0e16 100%)',
@@ -313,7 +330,7 @@ export default function CinematicStoryScroll({
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === 'hero' ? 'opacity-100' : 'opacity-0'
currentScene.theme === 'hero' ? 'opacity-100' : 'opacity-0'
}`}
style={{
background: 'radial-gradient(circle at 35% 50%, rgba(20, 103, 255, 0.3), transparent 35%), linear-gradient(180deg, #0c1320 0%, #0a0f18 100%)',
@@ -322,29 +339,88 @@ export default function CinematicStoryScroll({
</>
)}
{/* Desktop Layout: Left Showcase Visual, Right Copy */}
<div className="relative w-full max-w-7xl mx-auto px-6 md:px-12 h-full flex flex-col lg:flex-row items-center justify-between gap-8 z-10">
{/* Left Product Core / Showcase Visual */}
<div className="w-full lg:w-1/2 flex items-center justify-center relative mt-16 lg:mt-0">
{/* Top Stage Control Header: Scene Pills Bar & Theme Switcher */}
<div className="relative w-full max-w-7xl mx-auto px-6 pt-20 sm:pt-24 pb-2 flex items-center justify-between gap-3 z-30">
{/* Apple-style Interactive Scene Tabs */}
<div
className={`relative w-64 h-64 sm:w-80 sm:h-80 md:w-96 md:h-96 lg:w-[440px] lg:h-[440px] rounded-full transition-all duration-700 flex items-center justify-center ${
className={`flex items-center gap-1.5 sm:gap-2 px-3 py-1.5 rounded-full backdrop-blur-md max-w-[calc(100vw-120px)] sm:max-w-2xl overflow-x-auto no-scrollbar shadow-sm border ${
isLight
? activeScene === 'heat'
? 'bg-white/80 border-slate-200/80 text-slate-700'
: 'bg-white/10 border-white/15 text-white/80'
}`}
>
{activeScenes.map((scene, idx) => {
const isSelected = activeIndex === idx
const label = scene.kicker?.split('/')[1]?.trim() || `0${idx + 1}`
return (
<button
key={scene.id}
type="button"
onClick={() => scrollToScene(idx)}
className={`px-3 py-1 rounded-full text-[11px] sm:text-xs font-black transition-all whitespace-nowrap cursor-pointer ${
isSelected
? isLight
? 'bg-brand-primary text-white shadow-sm'
: 'bg-white text-slate-950 shadow-md'
: isLight
? 'text-slate-600 hover:text-slate-950 hover:bg-black/5'
: 'text-white/70 hover:text-white hover:bg-white/10'
}`}
>
{label}
</button>
)
})}
</div>
{/* Quick Theme Switcher (Light / Dark) */}
<button
type="button"
onClick={() => setThemeMode(prev => (prev === 'light' ? 'dark' : 'light'))}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold transition-all shadow-sm backdrop-blur-md cursor-pointer shrink-0 ${
isLight
? 'bg-white/90 text-slate-800 border border-slate-200 hover:bg-white'
: 'bg-white/10 text-white border border-white/15 hover:bg-white/20'
}`}
title="สลับธีมแสดงผล Light / Dark"
>
{isLight ? (
<>
<Moon className="w-3.5 h-3.5 text-slate-700" />
<span className="hidden md:inline">Dark</span>
</>
) : (
<>
<Sun className="w-3.5 h-3.5 text-amber-400" />
<span className="hidden md:inline">Light</span>
</>
)}
</button>
</div>
{/* Main Content Stage: Left Visual, Right Narrative */}
<div className="relative w-full max-w-7xl mx-auto px-6 md:px-12 flex-1 flex flex-col lg:flex-row items-center justify-between gap-8 z-10 my-auto">
{/* Left Product Core / Showcase Visual */}
<div className="w-full lg:w-1/2 flex items-center justify-center relative">
<div
className={`relative w-60 h-60 sm:w-80 sm:h-80 md:w-96 md:h-96 lg:w-[440px] lg:h-[440px] rounded-full transition-all duration-700 flex items-center justify-center ${
isLight
? currentScene.theme === 'heat'
? 'scale-105 border-[14px] border-orange-400 shadow-[0_20px_60px_rgba(255,102,0,0.25)]'
: activeScene === 'material'
: currentScene.theme === 'material'
? 'scale-110 border-[14px] border-blue-500 shadow-[0_20px_60px_rgba(20,103,255,0.22)]'
: activeScene === 'control'
: currentScene.theme === 'control'
? 'scale-95 -rotate-12 border-[14px] border-cyan-500 shadow-[0_20px_50px_rgba(6,182,212,0.2)]'
: activeScene === 'durability'
: currentScene.theme === 'durability'
? 'scale-100 border-[14px] border-[#d8b35a] shadow-[0_20px_50px_rgba(216,179,90,0.25)]'
: 'scale-100 border-[14px] border-slate-300 shadow-[0_20px_60px_rgba(0,0,0,0.08)]'
: activeScene === 'heat'
: currentScene.theme === 'heat'
? 'scale-105 border-[16px] border-orange-500 shadow-[0_0_90px_rgba(255,102,0,0.6)]'
: activeScene === 'material'
: currentScene.theme === 'material'
? 'scale-110 border-[16px] border-blue-500 shadow-[0_0_80px_rgba(20,103,255,0.5)]'
: activeScene === 'control'
: currentScene.theme === 'control'
? 'scale-95 -rotate-12 border-[14px] border-cyan-400 shadow-[0_0_70px_rgba(34,211,238,0.4)]'
: activeScene === 'durability'
: currentScene.theme === 'durability'
? 'scale-100 border-[16px] border-[#d8b35a] shadow-[0_0_80px_rgba(216,179,90,0.45)]'
: 'scale-100 border-[16px] border-[#1467ff] shadow-[0_0_70px_rgba(20,103,255,0.4)]'
}`}
@@ -362,7 +438,7 @@ export default function CinematicStoryScroll({
alt={resolvedProductName}
fill
sizes="(max-width: 1024px) 320px, 440px"
className="object-contain drop-shadow-[0_20px_35px_rgba(0,0,0,0.4)] transition-transform duration-700"
className="object-contain drop-shadow-[0_20px_35px_rgba(0,0,0,0.35)] transition-transform duration-700"
priority
/>
</div>
@@ -386,16 +462,16 @@ export default function CinematicStoryScroll({
</div>
{/* Right Narrative Copy Block */}
<div className="w-full lg:w-1/2 flex items-center justify-start relative min-h-[320px] lg:min-h-[460px] pb-12 lg:pb-0">
{activeScenes.map((scene) => {
const isActive = scene.id === activeScene
<div className="w-full lg:w-1/2 flex items-center justify-start relative min-h-[300px] sm:min-h-[360px] lg:min-h-[460px]">
{activeScenes.map((scene, idx) => {
const isActive = idx === activeIndex
return (
<div
key={scene.id}
className={`absolute inset-x-0 top-1/2 -translate-y-1/2 space-y-5 transition-all duration-500 max-w-lg ${
className={`absolute inset-x-0 top-1/2 -translate-y-1/2 space-y-4 sm:space-y-5 transition-all duration-500 max-w-lg ${
isActive
? 'opacity-100 translate-y-[-50%] pointer-events-auto'
: 'opacity-0 translate-y-[-40%] pointer-events-none'
: 'opacity-0 translate-y-[-42%] pointer-events-none'
}`}
>
{/* Kicker */}
@@ -435,9 +511,9 @@ export default function CinematicStoryScroll({
{/* Feature Chips */}
{scene.chips && scene.chips.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{scene.chips.map((chip, idx) => (
{scene.chips.map((chip, chipIdx) => (
<span
key={idx}
key={chipIdx}
className={`px-3.5 py-1.5 rounded-full text-xs font-semibold backdrop-blur-sm ${
isLight
? 'bg-slate-900/5 border border-slate-300 text-slate-800'
@@ -461,9 +537,9 @@ export default function CinematicStoryScroll({
รุ่นรถที่รองรับ ({fitmentModels.length} รุ่น):
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto custom-scrollbar pr-1">
{fitmentModels.slice(0, 18).map((model, idx) => (
{fitmentModels.slice(0, 18).map((model, mIdx) => (
<span
key={idx}
key={mIdx}
className={`px-3 py-2 rounded-xl text-center text-xs font-bold truncate ${
isLight
? 'bg-white border border-slate-200 text-slate-900 shadow-sm'
@@ -482,21 +558,80 @@ export default function CinematicStoryScroll({
})}
</div>
</div>
{/* Bottom Stage Footer: Scene Counter & Scroll Prompt */}
<div className="relative w-full max-w-7xl mx-auto px-6 pb-6 pt-2 flex items-center justify-between text-xs font-bold z-30">
<span className={isLight ? 'text-slate-500' : 'text-white/60'}>
ฉาก {activeIndex + 1} จาก {activeScenes.length}
</span>
<div className="flex items-center gap-2">
<span className={`hidden sm:inline ${isLight ? 'text-slate-500' : 'text-white/50'}`}>
เลื่อนลงเพื่อดูฉากถัดไป หรือกดปุ่มลูกศร
</span>
<button
type="button"
onClick={handlePrev}
disabled={activeIndex === 0}
className={`p-2 rounded-xl border transition-all cursor-pointer ${
activeIndex === 0
? 'opacity-30 pointer-events-none'
: isLight
? 'border-slate-300 text-slate-700 hover:bg-black/5'
: 'border-white/20 text-white hover:bg-white/10'
}`}
aria-label="Previous scene"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleNext}
disabled={activeIndex === activeScenes.length - 1}
className={`p-2 rounded-xl border transition-all cursor-pointer ${
activeIndex === activeScenes.length - 1
? 'opacity-30 pointer-events-none'
: isLight
? 'border-slate-300 text-slate-700 hover:bg-black/5'
: 'border-white/20 text-white hover:bg-white/10'
}`}
aria-label="Next scene"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Scroll Trigger Sections (each 90vh tall to drive IntersectionObserver) */}
{activeScenes.map((scene) => (
{/* Side Progress Dots (Desktop) */}
<div
key={scene.id}
data-scene={scene.id}
className="story-trigger h-[90vh] w-full pointer-events-none opacity-0"
className={`fixed right-6 top-1/2 -translate-y-1/2 z-40 hidden lg:flex flex-col gap-2.5 transition-opacity duration-300 pointer-events-auto ${
isStageVisible ? 'opacity-100' : 'opacity-0'
}`}
>
{activeScenes.map((s, idx) => (
<button
key={s.id}
type="button"
onClick={() => scrollToScene(idx)}
className={`rounded-full transition-all duration-300 cursor-pointer ${
activeIndex === idx
? isLight
? 'w-2.5 h-7 bg-brand-primary shadow-md'
: 'w-2.5 h-7 bg-white shadow-lg shadow-white/40'
: isLight
? 'w-2.5 h-2.5 bg-slate-300 hover:bg-slate-400'
: 'w-2.5 h-2.5 bg-white/20 hover:bg-white/50'
}`}
title={`Scene 0${idx + 1}`}
aria-label={`Jump to scene 0${idx + 1}`}
/>
))}
</div>
</div>
{/* End CTA Section */}
<section
className={`relative z-10 min-h-[50vh] flex flex-col items-center justify-center text-center px-6 py-20 border-t space-y-5 ${
className={`relative z-10 min-h-[45vh] flex flex-col items-center justify-center text-center px-6 py-20 border-t space-y-5 ${
isLight
? 'bg-[#f0ede3] border-slate-200 text-slate-900'
: 'bg-gradient-to-b from-[#0b101c] via-[#09101a] to-surface-page border-white/10 text-white'