fix(website): eliminate story scroll dead zones and blank space with continuous progress engine
This commit is contained in:
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'
|
||||
|
||||
Reference in new issue
Block a user