- {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 (
+
)
+ }
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!
+ */}
- {activeScenes.map((s, idx) => (
-
+ {/* 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) */}
+
+ {activeScenes.map((s, idx) => (
+
{/* End CTA Section */}