This commit is contained in:
Your Name committed 2026-10-17 08:11:33 +07:00
1 parent 34be2599fa
commit 3f9db48df4
8 files changed
+2037 -4

No files matched your search

@@ -0,0 +1,390 @@
import { useState, useEffect } from 'react'
import {
Sparkles,
Upload,
Image as ImageIcon,
X,
Layers,
CheckCircle,
RotateCcw,
} from 'lucide-react'
export interface StorySceneItem {
id: string
kicker: string
title: string
description: string
metric?: string
chips?: string[]
theme: string
imageUrl?: string
}
const DEFAULT_SCENES: StorySceneItem[] = [
{
id: 'hero',
kicker: '01 / PRODUCT STORY',
title: 'STOP WITH\nCONFIDENCE.',
description: 'พัฒนาขึ้นสำหรับการแข่งขันและการขับขี่สมรรถนะสูง ให้สินค้ากลายเป็นพระเอกก่อนพาลงลึกไปทีละมิติของประสิทธิภาพ',
theme: 'hero',
},
{
id: 'material',
kicker: '02 / MATERIAL',
title: 'CERAMIC\nCOMPOUND.',
description: 'สูตรผสมเนื้อเซรามิกเกรดพรีเมียม สึกหรอน้อย เสียงเบียบเงียบ ไร้แรงสั่นสะเทือน และลดฝุ่นผงจับจานเบรกอย่างเห็นได้ชัด',
chips: ['Low Noise', 'Low Vibration', 'Cleaner Braking'],
theme: 'material',
},
{
id: 'heat',
kicker: '03 / HEAT CONTROL',
title: 'HIGH-HEAT\nCONTROL.',
metric: '300°C',
description: 'ทนต่อความร้อนสูงสะสม ป้องกันอาการเบรกเฟด (Brake Fade) ให้ระยะหยุดสม่ำเสมอแม้ใช้งานต่อเนื่องบนสนามแข่งหรือทางลาดชัน',
theme: 'heat',
},
{
id: 'friction',
kicker: '04 / PERFORMANCE',
title: 'STABLE\nFRICTION.',
description: 'ค่าสัมประสิทธิ์แรงเสียดทานคงที่ในทุกช่วงอุณหภูมิ ให้การจับตัวนุ่มนวลแต่แน่นหนา มั่นใจในทุกโค้ง',
theme: 'friction',
},
{
id: 'control',
kicker: '05 / CONTROL',
title: 'SMOOTH\nENGAGEMENT.',
description: 'โครงสร้างแม่นยำมาตรฐานสูง สัมผัสก้านเบรกตอบสนองเป็นเส้นตรง (Linear Modulation) คุมน้ำหนักเบรกได้ละเอียดดั่งใจ',
theme: 'control',
},
{
id: 'durability',
kicker: '06 / DURABILITY',
title: 'BUILT\nTO LAST.',
description: 'เนื้อผ้าเบรกถนอมจานเบรก ไม่กินจาน ยืดอายุการใช้งานยาวนาน คุ้มค่าสำหรับการขับขี่ทั้งในชีวิตประจำวันและทริปเดินทางไกล',
chips: ['Durable', 'Rotor Friendly', 'Everyday Use'],
theme: 'durability',
},
{
id: 'fitment',
kicker: '07 / FITMENT',
title: 'MADE FOR\nYOUR BIKE.',
description: 'ออกแบบตรงรุ่นพร้อมติดตั้งแบบ Plug & Play ครอบคลุมรถจักรยานยนต์ยอดนิยมทุกค่าย',
theme: 'fitment',
},
]
interface Props {
payload: Record<string, unknown>
onChange: (nextPayload: Record<string, unknown>) => void
onBrowseMedia: (onSelect: (url: string) => void) => void
}
export default function ProductStoryScrollForm({ payload, onChange, onBrowseMedia }: Props) {
const [selectedSceneIndex, setSelectedSceneIndex] = useState(0)
const heroImage = (payload.storyHeroImage as string) || ''
const endCtaTitle = (payload.endCtaTitle as string) || ''
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
// Auto-seed default mockup scenes on first open if empty
useEffect(() => {
if (!payload.storyScenes || !Array.isArray(payload.storyScenes) || payload.storyScenes.length === 0) {
onChange({
...payload,
storyScenes: DEFAULT_SCENES,
endCtaTitle: payload.endCtaTitle || 'SELECT YOUR MODEL.',
endCtaButtonText: payload.endCtaButtonText || 'Choose Your Bike',
})
}
}, [])
const rawScenes = payload.storyScenes
const scenes: StorySceneItem[] = Array.isArray(rawScenes) && rawScenes.length > 0
? (rawScenes as any[]).map((s, idx) => ({
...DEFAULT_SCENES[idx % DEFAULT_SCENES.length],
...s,
}))
: DEFAULT_SCENES
const updateScenes = (newScenes: StorySceneItem[]) => {
onChange({
...payload,
storyScenes: newScenes,
})
}
const handleResetToMockup = () => {
onChange({
...payload,
storyScenes: DEFAULT_SCENES,
endCtaTitle: 'SELECT YOUR MODEL.',
endCtaButtonText: 'Choose Your Bike',
})
}
const updateSelectedScene = (field: keyof StorySceneItem, val: any) => {
const updated = [...scenes]
updated[selectedSceneIndex] = {
...updated[selectedSceneIndex],
[field]: val,
}
updateScenes(updated)
}
const currentScene = scenes[selectedSceneIndex] || scenes[0]
return (
<div className="space-y-6 pt-4 border-t border-cyan-glow/20">
{/* Banner Intro */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-cyan-glow/[0.05] border border-cyan-glow/20 text-cyan-glow">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">
โหมดนำเสนอพิเศษ: Cinematic Story Scroll (Apple Style)
</p>
<p className="text-xs text-slate-400 leading-relaxed">
เมื่อเปิดโหมดนี้ หน้าสินค้าบนจอ PC Wide จะแสดงส่วน Scrollytelling เต็มจอ 100vh เล่าเรื่องเทคโนโลยี 7 ฉากแบบภาพยนตร์ไฮเทค พร้อมปุ่มสั่งซื้อที่ด้านล่างสุด
</p>
</div>
</div>
<button
type="button"
onClick={handleResetToMockup}
className="shrink-0 flex items-center gap-2 px-3 py-2 rounded-xl bg-cyan-glow/10 border border-cyan-glow/30 hover:bg-cyan-glow/20 text-cyan-glow text-xs font-bold transition-all shadow-sm"
>
<RotateCcw className="w-3.5 h-3.5" />
โหลด Mockup Data ทั้งหมด
</button>
</div>
{/* 1. Core Hero Showcase Image */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
ภาพสินค้าหลักสำหรับโชว์ใน Story Stage
</h4>
<p className="text-xs text-slate-500">
แนะนำใช้ภาพสินค้าไดคัทพื้นหลังโปร่งใส (Transparent PNG หรือ WebP) เพื่อให้หมุนและเรืองแสงได้เต็มตา (หากไม่ระบุ จะดึงภาพแรกของสินค้าให้อัตโนมัติ)
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative w-24 h-24 rounded-2xl bg-night-900 border border-white/15 overflow-hidden flex items-center justify-center shrink-0">
{heroImage ? (
<img src={heroImage} alt="Story Hero" className="w-full h-full object-contain p-2" />
) : (
<span className="text-xs text-slate-600 font-semibold text-center px-2">
ใช้ภาพแรกอัตโนมัติ
</span>
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onBrowseMedia((url) => onChange({ ...payload, storyHeroImage: url }))}
className="px-4 py-2 rounded-xl bg-cyan-glow/15 hover:bg-cyan-glow/25 text-cyan-glow text-xs font-bold flex items-center gap-2 transition-all cursor-pointer border border-cyan-glow/30"
>
<Upload className="w-3.5 h-3.5" />
<span>{heroImage ? 'เปลี่ยนรูปภาพ' : 'อัปโหลด / เลือกรูปภาพ'}</span>
</button>
{heroImage && (
<button
type="button"
onClick={() => onChange({ ...payload, storyHeroImage: '' })}
className="px-3 py-2 rounded-xl bg-white/5 hover:bg-rose-500/10 text-slate-400 hover:text-rose-400 text-xs font-semibold transition-all cursor-pointer"
>
ล้างรูปภาพ
</button>
)}
</div>
<p className="text-[11px] text-slate-500">
{heroImage ? `URL: ${heroImage.slice(0, 50)}...` : '* ดึงภาพสินค้าที่มีอยู่แล้วมาแสดงอัตโนมัติ'}
</p>
</div>
</div>
</div>
{/* 2. Story Scenes Manager (7 Scenes Tabs) */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-5">
<div>
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<Layers className="w-4 h-4 text-cyan-glow" />
ปรับแต่งฉาก Story Scenes ทั้ง 7 ตอน
</h4>
<p className="text-xs text-slate-500 mt-1">
คลิกเลือกตอนที่ต้องการปรับเปลี่ยนข้อความ ตัวเลข หรือใส่รูปภาพเฉพาะฉาก
</p>
</div>
{/* Scene Tabs */}
<div className="flex gap-2 overflow-x-auto pb-2 custom-scrollbar">
{scenes.map((s, idx) => (
<button
key={s.id}
type="button"
onClick={() => setSelectedSceneIndex(idx)}
className={`px-3.5 py-2 rounded-xl text-xs font-bold whitespace-nowrap transition-all cursor-pointer border ${
selectedSceneIndex === idx
? 'bg-cyan-glow/20 text-cyan-glow border-cyan-glow/40 shadow-sm'
: 'bg-white/5 text-slate-400 border-white/5 hover:bg-white/10 hover:text-white'
}`}
>
{`Scene ${idx + 1}: ${s.kicker.split('/')[1]?.trim() || s.id}`}
</button>
))}
</div>
{/* Selected Scene Editor */}
{currentScene && (
<div className="p-4 rounded-2xl bg-night-900 border border-white/10 space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-white/5">
<span className="text-xs uppercase font-mono font-bold tracking-wider text-cyan-glow">
แก้ไขฉากที่ {selectedSceneIndex + 1} ({currentScene.id.toUpperCase()})
</span>
<span className="text-[11px] text-slate-500">
ธีมแสง: {currentScene.theme}
</span>
</div>
{/* Custom Image for this Scene */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
รูปภาพเฉพาะของฉากนี้ (ถ้ามี เช่น รูปซูมเนื้อผิว / กราฟความร้อน)
</label>
<div className="flex items-center gap-3">
<div className="relative w-16 h-16 rounded-xl bg-night-950 border border-white/10 overflow-hidden flex items-center justify-center shrink-0">
{currentScene.imageUrl ? (
<img src={currentScene.imageUrl} alt={currentScene.title} className="w-full h-full object-cover" />
) : (
<span className="text-[10px] text-slate-600 text-center">ไม่มีรูป</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onBrowseMedia((url) => updateSelectedScene('imageUrl', url))}
className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/15 text-slate-200 text-xs font-semibold flex items-center gap-1.5 cursor-pointer"
>
<Upload className="w-3.5 h-3.5" />
<span>{currentScene.imageUrl ? 'เปลี่ยนรูป' : 'อัปโหลดรูป'}</span>
</button>
{currentScene.imageUrl && (
<button
type="button"
onClick={() => updateSelectedScene('imageUrl', '')}
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-400"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
{/* Kicker & Title */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Kicker</label>
<input
type="text"
value={currentScene.kicker || ''}
onChange={(e) => updateSelectedScene('kicker', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. 02 / MATERIAL"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Metric ตัวเลขเด่น (ถ้ามี)</label>
<input
type="text"
value={currentScene.metric || ''}
onChange={(e) => updateSelectedScene('metric', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. 300°C หรือ 100%"
/>
</div>
</div>
{/* Headline */}
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Headline (ขึ้นบรรทัดใหม่ได้)</label>
<textarea
value={currentScene.title || ''}
onChange={(e) => updateSelectedScene('title', e.target.value)}
rows={2}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40 resize-none font-bold"
placeholder="e.g. CERAMIC\nCOMPOUND."
/>
</div>
{/* Description */}
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">คำอธิบาย</label>
<textarea
value={currentScene.description || ''}
onChange={(e) => updateSelectedScene('description', e.target.value)}
rows={2}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-slate-300 outline-none focus:border-cyan-glow/40 resize-none leading-relaxed"
placeholder="คำอธิบายรายละเอียดทางเทคนิคของฉากนี้..."
/>
</div>
{/* Chips */}
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Feature Chips (คั่นด้วยจุลภาค ,)</label>
<input
type="text"
value={(currentScene.chips || []).join(', ')}
onChange={(e) => {
const items = e.target.value.split(',').map(s => s.trim()).filter(Boolean)
updateSelectedScene('chips', items)
}}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. Low Noise, Low Vibration, Cleaner Braking"
/>
</div>
</div>
)}
</div>
{/* 3. End CTA Settings */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-4">
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-400" />
ข้อความส่วนท้าย (End CTA)
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">หัวข้อส่วนท้าย</label>
<input
type="text"
value={endCtaTitle}
onChange={(e) => onChange({ ...payload, endCtaTitle: e.target.value })}
className="w-full px-3 py-2 rounded-xl bg-night-900 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. SELECT YOUR MODEL."
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">ข้อความบนปุ่มสั่งซื้อ</label>
<input
type="text"
value={endCtaButtonText}
onChange={(e) => onChange({ ...payload, endCtaButtonText: e.target.value })}
className="w-full px-3 py-2 rounded-xl bg-night-900 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. Choose Your Bike / สั่งซื้อรุ่นของคุณ"
/>
</div>
</div>
</div>
</div>
)
}
+43 -1
View File
@@ -36,6 +36,7 @@ import FocalPointPicker from '../components/page-builder/FocalPointPicker'
import ProductPickerModal from '../components/page-builder/ProductPickerModal'
import ProductHeroCarouselBlockForm from '../components/products/ProductHeroCarouselBlockForm'
import ProductDividerBlockForm from '../components/products/ProductDividerBlockForm'
import ProductStoryScrollForm from '../components/products/ProductStoryScrollForm'
import { validateProductHeroCarouselPayload } from '../components/products/productHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization'
import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal'
@@ -898,11 +899,38 @@ export default function ProductCmsPage() {
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Layout Mode</label>
<select
value={typeof sectionPayload.layoutMode === 'string' ? sectionPayload.layoutMode : 'standard'}
onChange={(event) => syncNativePayload('layoutMode', event.target.value)}
onChange={(event) => {
const val = event.target.value
if (val === 'story_scroll') {
const next = {
...sectionPayload,
layoutMode: 'story_scroll',
storyScenes: Array.isArray(sectionPayload.storyScenes) && sectionPayload.storyScenes.length > 0
? sectionPayload.storyScenes
: [
{ id: 'hero', kicker: '01 / PRODUCT STORY', title: 'STOP WITH\nCONFIDENCE.', description: 'พัฒนาขึ้นสำหรับการแข่งขันและการขับขี่สมรรถนะสูง ให้สินค้ากลายเป็นพระเอกก่อนพาลงลึกไปทีละมิติของประสิทธิภาพ', theme: 'hero' },
{ id: 'material', kicker: '02 / MATERIAL', title: 'CERAMIC\nCOMPOUND.', description: 'สูตรผสมเนื้อเซรามิกเกรดพรีเมียม สึกหรอน้อย เสียงเบียบเงียบ ไร้แรงสั่นสะเทือน และลดฝุ่นผงจับจานเบรกอย่างเห็นได้ชัด', chips: ['Low Noise', 'Low Vibration', 'Cleaner Braking'], theme: 'material' },
{ id: 'heat', kicker: '03 / HEAT CONTROL', title: 'HIGH-HEAT\nCONTROL.', metric: '300°C', description: 'ทนต่อความร้อนสูงสะสม ป้องกันอาการเบรกเฟด (Brake Fade) ให้ระยะหยุดสม่ำเสมอแม้ใช้งานต่อเนื่องบนสนามแข่งหรือทางลาดชัน', theme: 'heat' },
{ id: 'friction', kicker: '04 / PERFORMANCE', title: 'STABLE\nFRICTION.', description: 'ค่าสัมประสิทธิ์แรงเสียดทานคงที่ในทุกช่วงอุณหภูมิ ให้การจับตัวนุ่มนวลแต่แน่นหนา มั่นใจในทุกโค้ง', theme: 'friction' },
{ id: 'control', kicker: '05 / CONTROL', title: 'SMOOTH\nENGAGEMENT.', description: 'โครงสร้างแม่นยำมาตรฐานสูง สัมผัสก้านเบรกตอบสนองเป็นเส้นตรง (Linear Modulation) คุมน้ำหนักเบรกได้ละเอียดดั่งใจ', theme: 'control' },
{ id: 'durability', kicker: '06 / DURABILITY', title: 'BUILT\nTO LAST.', description: 'เนื้อผ้าเบรกถนอมจานเบรก ไม่กินจาน ยืดอายุการใช้งานยาวนาน คุ้มค่าสำหรับการขับขี่ทั้งในชีวิตประจำวันและทริปเดินทางไกล', chips: ['Durable', 'Rotor Friendly', 'Everyday Use'], theme: 'durability' },
{ id: 'fitment', kicker: '07 / FITMENT', title: 'MADE FOR\nYOUR BIKE.', description: 'ออกแบบตรงรุ่นพร้อมติดตั้งแบบ Plug & Play ครอบคลุมรถจักรยานยนต์ยอดนิยมทุกค่าย', theme: 'fitment' },
],
endCtaTitle: (sectionPayload.endCtaTitle as string) || 'SELECT YOUR MODEL.',
endCtaButtonText: (sectionPayload.endCtaButtonText as string) || 'Choose Your Bike',
}
setSectionPayload(next)
setSectionJson(JSON.stringify(next, null, 2))
} else {
syncNativePayload('layoutMode', val)
}
}}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="standard">Standard</option>
<option value="sticky_gallery">Sticky Gallery</option>
<option value="story_scroll">Cinematic Story Scroll (Apple Style)</option>
<option value="apple_watch_tour">Apple Watch Keynote & Swatch Tour</option>
</select>
</div>
<div className="space-y-2">
@@ -936,6 +964,20 @@ export default function ProductCmsPage() {
<span className="text-xs font-bold text-slate-300">Show brand/category meta</span>
</label>
</div>
{(sectionPayload.layoutMode === 'story_scroll' || sectionPayload.layoutMode === 'apple_watch_tour') && (
<ProductStoryScrollForm
payload={sectionPayload}
onChange={(nextPayload) => {
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
onBrowseMedia={(cb) => {
setOnSelectMedia(() => cb)
setIsMediaModalOpen(true)
}}
/>
)}
</div>
)}
{sectionType === 'PRODUCT_REVIEWS' && (
+1 -1
View File
@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
// Trigger reload: support titleSize in HERO and HERO_VIDEO payloads
// Trigger reload: support story_scroll and apple_watch_tour in ProductLayoutSectionPayloadSchema
import {
LocalizedStringSchema,
HeroSectionPayloadSchema,
@@ -11,6 +11,8 @@ import ProductGallery from '@/components/ProductGallery'
import ProductHeroCarousel from '@/components/ProductHeroCarousel'
import ProductDivider from '@/components/ProductDivider'
import ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
import CinematicStoryScroll from '@/components/CinematicStoryScroll'
import AppleWatchShowcase from '@/components/AppleWatchShowcase'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import { getCategoriesResult, getProduct, getProductDetailSectionsResult, getProductResult, getProductReviewsResult, getProductsResult, getStoreSettings, getStoreSettingsResult, type ProductReview } from '@/lib/cms'
@@ -99,7 +101,14 @@ function ProductMainSection({
imageUrl: string
payload: Record<string, unknown>
}) {
const layoutMode = payload.layoutMode === 'sticky_gallery' ? 'sticky_gallery' : 'standard'
const rawLayout = typeof payload.layoutMode === 'string' ? payload.layoutMode : 'standard'
const layoutMode = rawLayout === 'story_scroll'
? 'story_scroll'
: rawLayout === 'apple_watch_tour' || rawLayout === 'apple_keynote'
? 'apple_watch_tour'
: rawLayout === 'sticky_gallery'
? 'sticky_gallery'
: 'standard'
const galleryBehavior = payload.galleryBehavior === 'stacked' ? 'stacked' : 'carousel'
const purchaseCardSticky = payload.purchaseCardSticky !== false
const showBreadcrumbs = payload.showBreadcrumbs !== false
@@ -109,6 +118,98 @@ function ProductMainSection({
? images
: [{ id: -1, url: imageUrl, mobileUrl: null, order: 0 }]
if (layoutMode === 'story_scroll') {
const storyHeroImage = (payload.storyHeroImage as string) || undefined
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
return (
<div className="space-y-12">
<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">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
{categoryName}
</Link>
<span>/</span>
<span className="text-text-main">{productName}</span>
</nav>
)}
<section className="grid gap-10 md:grid-cols-2 md:items-start md:gap-16">
<ProductGallery
images={galleryImages}
productName={productName}
categoryName={categoryName}
/>
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
</section>
</div>
{/* Cinematic Scrollytelling Story Showcase */}
<div className="-mx-4 sm:-mx-6 lg:-mx-8 xl:-mx-12">
<CinematicStoryScroll
product={product}
heroImage={storyHeroImage}
scenes={storyScenes}
lang="th"
endCtaTitle={endCtaTitle}
endCtaButtonText={endCtaButtonText}
/>
</div>
</div>
)
}
if (layoutMode === 'apple_watch_tour') {
const storyHeroImage = (payload.storyHeroImage as string) || undefined
return (
<div className="space-y-12">
<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">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
{categoryName}
</Link>
<span>/</span>
<span className="text-text-main">{productName}</span>
</nav>
)}
<section className="grid gap-10 md:grid-cols-2 md:items-start md:gap-16">
<ProductGallery
images={galleryImages}
productName={productName}
categoryName={categoryName}
/>
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
</section>
</div>
{/* Apple Watch Style Keynote & Finishes Tour Showcase */}
<div className="-mx-4 sm:-mx-6 lg:-mx-8 xl:-mx-12">
<AppleWatchShowcase
product={product}
heroImage={storyHeroImage}
lang="th"
/>
</div>
</div>
)
}
if (layoutMode === 'sticky_gallery') {
return (
<div className="space-y-8">
@@ -0,0 +1,642 @@
'use client'
import React, { useState, useEffect, useRef } from 'react'
import Image from 'next/image'
import type { Product } from '@homaroma/types'
import { resolveLocalized } from '../lib/localization'
interface Props {
product: Product
heroImage?: string
lang?: string
}
interface HighlightCard {
id: string
title: string
subtitle: string
metric?: string
metricLabel?: string
tag: string
accentColor: string
}
const HIGHLIGHTS: HighlightCard[] = [
{
id: 'sensing',
title: 'ระบบสัมผัสและเนื้อเซรามิกแบบใหม่หมด',
subtitle: 'มาพร้อมการกระจายแรงเสียดทานที่สม่ำเสมอที่สุดในระดับรถมอเตอร์ไซค์สมรรถนะสูง',
metric: '0.42 μ',
metricLabel: 'สัมประสิทธิ์แรงเสียดทานคงที่',
tag: 'Friction Material',
accentColor: '#38bdf8',
},
{
id: 'heat',
title: 'ทนความร้อนสูงระดับ 300°C',
subtitle: 'ฉนวนเซรามิกช่วยสลายความร้อนสะสม ป้องกันอาการเบรกเฟด (Brake Fade) แม้ลงเขายาวนาน',
metric: '300°C',
metricLabel: 'Peak Operating Heat',
tag: 'Thermal Resistance',
accentColor: '#fb923c',
},
{
id: 'acoustics',
title: 'เงียบสนิท ไร้เสียงหวีดและแรงสั่น',
subtitle: 'แผ่นชิมกันเสียงรบกวนความถี่สูง พร้อมโครงสร้างซับแรงกระแทก ให้ทุกการกำเบรกนุ่มนวลเป็นธรรมชาติ',
metric: '< 15 dB',
metricLabel: 'Low Acoustic Noise',
tag: 'Acoustic Comfort',
accentColor: '#a78bfa',
},
{
id: 'longevity',
title: 'ถนอมจานเบรก ยืดอายุการใช้งาน 2 เท่า',
subtitle: 'ส่วนผสมไร้ใยหิน (Non-Asbestos) ไม่กินเนื้อจานเบรก ไม่ทิ้งคราบฝุ่นดำสกปรกบนล้อแม็ก',
metric: '2.4x',
metricLabel: 'Rotor Life Extension',
tag: 'Rotor Friendly',
accentColor: '#4ade80',
},
{
id: 'safety',
title: 'ความปลอดภัยสูงสุดในทุกสภาพถนน',
subtitle: 'ผ่านการทดสอบมาตรฐานความปลอดภัยระดับสากล จับตัวได้แม่นยำแม้ในสภาวะฝนตกหนักหรือถนนเปียกลื่น',
metric: '100%',
metricLabel: 'All-Weather Confidence',
tag: 'Safety Features',
accentColor: '#f43f5e',
},
]
interface FinishOption {
id: string
name: string
labelTh: string
color: string
ringBorder: string
desc: string
glow: string
}
const FINISHES: FinishOption[] = [
{
id: 'ceramic-white',
name: 'Ceramic Pearl White',
labelTh: 'เซรามิก สีขาวไข่มุก',
color: '#e8e6df',
ringBorder: '#ffffff',
desc: 'ตัวเรือนและเนื้อคอมพาวด์เซรามิกเกรดบริสุทธิ์ ลดการสะสมของคราบเขม่าดำ ให้สัมผัสเรียบหรูและทนทานระดับพรีเมียม',
glow: 'rgba(232, 230, 223, 0.22)',
},
{
id: 'ceramic-blue',
name: 'Ceramic Night Blue',
labelTh: 'เซรามิก สีไนท์บลู',
color: '#1e3a5f',
ringBorder: '#3b82f6',
desc: 'เฉดสีน้ำเงินล้ำลึก สื่อถึงประสิทธิภาพการสลายความร้อนและเทคโนโลยีการระบายอุณหภูมิขั้นสูง',
glow: 'rgba(59, 130, 246, 0.28)',
},
{
id: 'aluminum-bronze',
name: 'Aluminum Dark Bronze',
labelTh: 'อะลูมิเนียม สีบรอนซ์เข้ม',
color: '#453529',
ringBorder: '#d97706',
desc: 'โครงสร้างโลหะผสมบรอนซ์เข้ม แข็งแกร่ง ทนต่อการบิดงอในสภาวะการเบรกกระชากด้วยความเร็วสูง',
glow: 'rgba(217, 119, 6, 0.25)',
},
{
id: 'aluminum-gold',
name: 'Aluminum Light Gold',
labelTh: 'อะลูมิเนียม สีทองอ่อน',
color: '#d4c29d',
ringBorder: '#fef08a',
desc: 'เฉดสีทองแชมเปญแบบสปอร์ต สะท้อนเอกลักษณ์ของชุดอัปเกรดระดับท็อปสำหรับมอเตอร์ไซค์คันโปรด',
glow: 'rgba(234, 179, 8, 0.22)',
},
{
id: 'aluminum-black',
name: 'Stealth Racing Black',
labelTh: 'อะลูมิเนียม สีดำสเตลท์',
color: '#1a1a1e',
ringBorder: '#71717a',
desc: 'การเคลือบผิวแบบ Hard Anodized สีดำด้าน ดุดัน สไตล์สนามแข่ง ทนการกัดกร่อนจากสารเคมีและไอเกลือ',
glow: 'rgba(113, 113, 122, 0.2)',
},
{
id: 'titanium-gold',
name: 'Titanium Radiant Gold',
labelTh: 'ไทเทเนียม เรเดียนท์โกลด์',
color: '#bfa163',
ringBorder: '#fbbf24',
desc: 'ผิวสัมผัสไทเทเนียมเรเดียนท์โกลด์ ทนแรงเสียดสีและความร้อนสูงพิเศษ เหมาะสำหรับการขับขี่แบบสปอร์ตดุดัน',
glow: 'rgba(251, 191, 36, 0.3)',
},
{
id: 'titanium-natural',
name: 'Titanium Natural Raw',
labelTh: 'ไทเทเนียมธรรมชาติ',
color: '#8b8a85',
ringBorder: '#e4e4e7',
desc: 'สีเนื้อไทเทเนียมธรรมชาติแบบดิบ สะท้อนวิศวกรรมน้ำหนักเบาและโครงสร้างทนทานระดับ Superbike',
glow: 'rgba(228, 228, 231, 0.2)',
},
]
interface DeepFeature {
id: string
title: string
badge: string
description: string
stats: string
}
const DEEP_FEATURES: DeepFeature[] = [
{
id: 'compound',
title: 'Ceramic Shield Compound',
badge: 'วัสดุเซรามิกทนความร้อนสูง',
description: 'พัฒนาขึ้นด้วยโครงสร้างไมโครเซรามิกโมเลกุลละเอียด ช่วยสร้างฟิล์มสัมผัสบางเบาบนจานเบรก ทำให้เบรกจับตัวแน่นขึ้นโดยไม่ทำลายผิวหน้าจาน',
stats: 'ทนความร้อนสูงสุด 300°C',
},
{
id: 'fade',
title: 'Anti-Fade Thermal Dynamics',
badge: 'ป้องกันอาการเบรกเฟด 100%',
description: 'ร่องระบายฝุ่นและแก๊สร้อนที่ออกแบบตามหลักอากาศพลศาสตร์ ช่วยลดการก่อตัวของชั้นก๊าซระหว่างผ้าเบรกกับจานเบรกขณะเบรกต่อเนื่อง',
stats: 'ความหน่วงสม่ำเสมอตลอดทริป',
},
{
id: 'acoustic',
title: 'Acoustic Damping Shim',
badge: 'แผ่นซับเสียงและลดแรงสั่น',
description: 'ติดตั้งแผ่นชิมโลหะหลายชั้นพร้อมชั้นกาวหน่วงการสั่นสะเทือน (Viscoelastic Layer) เพื่อตัดวงจรเสียงหวีดแหลมขณะเบรกที่ความเร็วต่ำ',
stats: 'ลดเสียงดังรบกวนได้ถึง 90%',
},
{
id: 'fitment',
title: '100% OEM Direct Fit',
badge: 'ติดตั้งตรงรุ่นโดยไม่ต้องดัดแปลง',
description: 'ผ่านการขึ้นรูปตามสเปกมาตรฐานของแท้ติดรถ (Original Equipment) สลักและสปริงกดแนบสนิท สามารถเปลี่ยนแทนของเดิมได้ทันที',
stats: 'รองรับ ADV, PCX, Vario, NVX',
},
]
export default function AppleWatchShowcase({ product, heroImage, lang = 'th' }: Props) {
const productName = resolveLocalized(product.name, lang)
const mainImage = heroImage || product.images?.[0]?.url || '/placeholder.jpg'
// Highlights state
const [activeHighlight, setActiveHighlight] = useState(0)
const [isPlaying, setIsPlaying] = useState(true)
const [progress, setProgress] = useState(0)
// Finishes state
const [activeFinish, setActiveFinish] = useState(0)
const [activeDeepFeature, setActiveDeepFeature] = useState<string | null>('compound')
const autoPlayRef = useRef<NodeJS.Timeout | null>(null)
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null)
// Timer for highlights gallery auto-play
useEffect(() => {
if (!isPlaying) {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current)
return
}
setProgress(0)
const stepTime = 50 // ms
const totalTime = 4000 // 4 seconds per slide
const increment = (stepTime / totalTime) * 100
progressIntervalRef.current = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
setActiveHighlight((curr) => (curr + 1) % HIGHLIGHTS.length)
return 0
}
return prev + increment
})
}, stepTime)
return () => {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current)
}
}, [activeHighlight, isPlaying])
const currentFinish = FINISHES[activeFinish]
const currentHighlight = HIGHLIGHTS[activeHighlight]
const scrollToPurchase = () => {
const el = document.getElementById('product-purchase-top')
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
}
}
return (
<div className="relative w-full overflow-hidden bg-[#0c0f17] text-white font-sans selection:bg-sky-500/30 selection:text-white">
{/* ---------------- 1. MEDIA HERO SECTION ---------------- */}
<section className="relative min-h-[90vh] flex flex-col justify-between px-6 pt-16 pb-12 md:px-12 lg:px-20 border-b border-white/10 overflow-hidden">
{/* Ambient Top Glow */}
<div
className="pointer-events-none absolute -top-40 left-1/2 -translate-x-1/2 h-[560px] w-[900px] rounded-full blur-[140px] opacity-40 transition-all duration-1000"
style={{ background: currentFinish.glow }}
/>
{/* Hero Header Lockup */}
<div className="relative z-10 max-w-4xl mx-auto text-center space-y-4 pt-6">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-1.5 text-xs font-bold uppercase tracking-widest text-sky-400 backdrop-blur-md">
<span className="h-2 w-2 rounded-full bg-sky-400 animate-pulse" />
Flagship Engineering Series
</div>
<h1 className="text-4xl sm:text-6xl md:text-7xl lg:text-8xl font-black tracking-tight leading-[0.95] text-white">
นวัตกรรมที่คิด<br />
<span className="bg-gradient-to-r from-white via-slate-200 to-slate-400 bg-clip-text text-transparent">
เพื่อทุกจังหวะเบรก
</span>
</h1>
<p className="max-w-2xl mx-auto text-base sm:text-lg md:text-xl text-slate-400 font-normal leading-relaxed pt-2">
สัมผัสประสบการณ์เบรกระดับพรีเมียม ตอบสนองฉับไว ปลอดภัย ทนความร้อนสูง 300°C และเป็นมิตรต่อจานเบรกในทุกย่านความเร็ว
</p>
{/* Dynamic Pricing & Fast Action Platter */}
<div className="pt-4 flex flex-wrap items-center justify-center gap-4">
<div className="inline-flex items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-5 py-3 backdrop-blur-xl">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400">เริ่มต้นที่</span>
<span className="text-2xl font-black text-white">
฿{typeof product.price === 'number' ? product.price.toLocaleString() : '—'}
</span>
<span className="rounded-full bg-emerald-500/20 px-2.5 py-0.5 text-[11px] font-bold text-emerald-400">
พร้อมจัดส่งทันที
</span>
</div>
<button
type="button"
onClick={scrollToPurchase}
className="inline-flex items-center justify-center rounded-2xl bg-white px-8 py-3.5 text-sm font-bold text-slate-950 hover:bg-slate-200 active:scale-95 transition-all shadow-xl shadow-white/10"
>
เลือกซื้อรุ่นของคุณ
</button>
</div>
</div>
{/* Hero Visual Stage */}
<div className="relative z-10 my-10 flex justify-center items-center">
<div className="relative w-full max-w-[540px] aspect-square flex items-center justify-center">
{/* Multi-ring metallic visual background */}
<div className="absolute inset-4 rounded-full border border-white/10 bg-gradient-to-b from-white/5 to-transparent backdrop-blur-md" />
<div className="absolute inset-16 rounded-full border border-white/5" />
<div
className="absolute inset-28 rounded-full blur-3xl opacity-30 transition-all duration-700"
style={{ background: currentFinish.glow }}
/>
{/* Product Center Image */}
<div className="relative w-4/5 h-4/5 transition-transform duration-700 hover:scale-105 drop-shadow-[0_35px_60px_rgba(0,0,0,0.8)]">
<Image
src={mainImage}
alt={productName}
fill
priority
sizes="(min-width: 1024px) 500px, 80vw"
className="object-contain"
/>
</div>
</div>
</div>
{/* Scroll down teaser */}
<div className="relative z-10 flex justify-center text-xs uppercase tracking-widest text-slate-500">
<span>เลื่อนลงเพื่อดูไฮไลท์และนวัตกรรม ↓</span>
</div>
</section>
{/* ---------------- 2. HIGHLIGHTS GALLERY CAROUSEL ---------------- */}
<section className="relative px-6 py-20 md:px-12 lg:px-20 border-b border-white/10 bg-[#090c13]">
<div className="max-w-6xl mx-auto space-y-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-sky-400">KEY HIGHLIGHTS</p>
<h2 className="text-3xl sm:text-5xl font-black tracking-tight text-white mt-1">
ดูไฮไลท์ต่างๆ
</h2>
</div>
{/* Timed Controls & Dotnav */}
<div className="flex items-center gap-3 bg-white/5 border border-white/10 rounded-full px-4 py-2 backdrop-blur-md">
<button
type="button"
onClick={() => setIsPlaying(!isPlaying)}
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold bg-white/10 hover:bg-white/20 text-white transition-all"
title={isPlaying ? 'Pause' : 'Play'}
aria-label={isPlaying ? 'Pause Highlights' : 'Play Highlights'}
>
{isPlaying ? '⏸' : '▶'}
</button>
<div className="flex items-center gap-2">
{HIGHLIGHTS.map((item, idx) => (
<button
key={item.id}
type="button"
onClick={() => {
setActiveHighlight(idx)
setProgress(0)
}}
className="group relative h-2.5 rounded-full transition-all duration-300 overflow-hidden"
style={{ width: activeHighlight === idx ? '48px' : '10px' }}
aria-label={`Highlight ${idx + 1}`}
>
<div className="absolute inset-0 bg-white/20 rounded-full" />
{activeHighlight === idx && (
<div
className="absolute inset-y-0 left-0 bg-white rounded-full transition-all duration-75"
style={{ width: `${progress}%` }}
/>
)}
</button>
))}
</div>
</div>
</div>
{/* Main Active Highlight Card */}
<div className="relative rounded-3xl border border-white/10 bg-gradient-to-br from-white/10 via-white/5 to-transparent p-8 md:p-14 backdrop-blur-2xl shadow-2xl overflow-hidden transition-all duration-500">
{/* Background Glow */}
<div
className="absolute -top-32 -right-32 w-96 h-96 rounded-full blur-[120px] opacity-25 transition-all duration-500"
style={{ background: currentHighlight.accentColor }}
/>
<div className="relative z-10 grid gap-8 lg:grid-cols-[1.2fr_0.8fr] items-center">
<div className="space-y-6">
<span
className="inline-block rounded-full px-4 py-1 text-xs font-bold uppercase tracking-wider border"
style={{
borderColor: `${currentHighlight.accentColor}40`,
backgroundColor: `${currentHighlight.accentColor}15`,
color: currentHighlight.accentColor,
}}
>
{currentHighlight.tag}
</span>
<h3 className="text-2xl sm:text-4xl md:text-5xl font-black tracking-tight text-white leading-tight">
{currentHighlight.title}
</h3>
<p className="text-base sm:text-lg text-slate-300 leading-relaxed max-w-xl">
{currentHighlight.subtitle}
</p>
{currentHighlight.metric && (
<div className="pt-2">
<div
className="text-5xl sm:text-7xl font-black tracking-tight"
style={{ color: currentHighlight.accentColor }}
>
{currentHighlight.metric}
</div>
<div className="text-xs uppercase tracking-widest text-slate-400 mt-1">
{currentHighlight.metricLabel}
</div>
</div>
)}
</div>
{/* Highlights Visual Feature Block */}
<div className="relative flex justify-center items-center min-h-[260px] rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md">
<div className="relative w-48 h-48 md:w-60 md:h-60 transition-transform duration-500 hover:rotate-6">
<Image
src={mainImage}
alt={currentHighlight.title}
fill
className="object-contain drop-shadow-[0_20px_35px_rgba(0,0,0,0.7)]"
/>
</div>
</div>
</div>
{/* Quick Card Nav Pill */}
<div className="mt-8 flex flex-wrap gap-2 pt-6 border-t border-white/10">
{HIGHLIGHTS.map((item, idx) => (
<button
key={item.id}
type="button"
onClick={() => {
setActiveHighlight(idx)
setProgress(0)
}}
className={`rounded-xl px-4 py-2 text-xs font-bold transition-all ${
activeHighlight === idx
? 'bg-white text-slate-950 shadow-md'
: 'bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white'
}`}
>
{item.tag}
</button>
))}
</div>
</div>
</div>
</section>
{/* ---------------- 3. FINISHES & SWATCH EXPLORER ("มาดูกันใกล้ๆ") ---------------- */}
<section className="relative px-6 py-24 md:px-12 lg:px-20 border-b border-white/10 bg-[#0c0f17]">
<div className="max-w-6xl mx-auto space-y-12">
<div className="text-center space-y-3">
<p className="text-xs font-bold uppercase tracking-widest text-amber-400">FINISHES & MATERIALS</p>
<h2 className="text-3xl sm:text-5xl md:text-6xl font-black tracking-tight text-white">
มาดูกันใกล้ๆ
</h2>
<p className="text-sm sm:text-base text-slate-400 max-w-xl mx-auto">
เลือกชมรายละเอียดพื้นผิวและวัสดุเกรดแข่งขันที่ได้รับการออกแบบมาอย่างประณีต
</p>
</div>
{/* Central Swatch Viewer Canvas */}
<div className="relative rounded-3xl border border-white/10 bg-gradient-to-b from-white/5 to-transparent p-8 md:p-14 backdrop-blur-xl">
{/* Ambient Reactive Dynamic Glow */}
<div
className="pointer-events-none absolute inset-0 blur-3xl opacity-30 transition-all duration-700 rounded-3xl"
style={{ background: currentFinish.glow }}
/>
<div className="relative z-10 grid gap-12 lg:grid-cols-[1.1fr_0.9fr] items-center">
{/* Product Visual Centerpiece */}
<div className="relative flex items-center justify-center min-h-[380px] sm:min-h-[460px]">
<div className="relative w-72 h-72 sm:w-96 sm:h-96 transition-all duration-700 ease-out transform">
<Image
src={mainImage}
alt={currentFinish.name}
fill
sizes="(min-width: 1024px) 450px, 80vw"
className="object-contain drop-shadow-[0_30px_50px_rgba(0,0,0,0.8)]"
/>
</div>
</div>
{/* Swatch Selector Controls */}
<div className="space-y-6">
<div>
<span className="text-xs font-bold uppercase tracking-widest text-slate-400">สีและวัสดุตัวเรือน</span>
<h3 className="text-2xl sm:text-3xl font-black text-white mt-1">
{currentFinish.labelTh}
</h3>
<p className="text-xs uppercase tracking-wider text-slate-500 font-mono mt-0.5">
{currentFinish.name}
</p>
</div>
<p className="text-sm sm:text-base text-slate-300 leading-relaxed border-l-2 border-white/20 pl-4 py-1">
{currentFinish.desc}
</p>
{/* Apple-style Round Swatches Grid */}
<div className="space-y-3 pt-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-slate-400">
เลือกเฉดสี ({activeFinish + 1} / {FINISHES.length}):
</label>
<div className="flex flex-wrap items-center gap-3">
{FINISHES.map((finish, idx) => (
<button
key={finish.id}
type="button"
onClick={() => setActiveFinish(idx)}
className={`relative w-10 h-10 rounded-full transition-all duration-200 flex items-center justify-center ${
activeFinish === idx
? 'scale-110 ring-2 ring-offset-2 ring-offset-[#0c0f17] shadow-lg'
: 'hover:scale-105 opacity-80 hover:opacity-100'
}`}
style={{
backgroundColor: finish.color,
boxShadow: activeFinish === idx ? `0 0 16px ${finish.glow}` : undefined,
}}
title={finish.labelTh}
aria-label={finish.labelTh}
>
{activeFinish === idx && (
<span
className="w-3.5 h-3.5 rounded-full border-2 border-white/80"
style={{ borderColor: finish.ringBorder }}
/>
)}
</button>
))}
</div>
</div>
{/* Paddle Navigation Buttons */}
<div className="flex items-center gap-3 pt-4">
<button
type="button"
onClick={() => setActiveFinish((prev) => (prev > 0 ? prev - 1 : FINISHES.length - 1))}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-white/10 transition-all"
>
← ก่อนหน้า
</button>
<button
type="button"
onClick={() => setActiveFinish((prev) => (prev < FINISHES.length - 1 ? prev + 1 : 0))}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-white/10 transition-all"
>
ถัดไป →
</button>
</div>
</div>
</div>
</div>
</div>
</section>
{/* ---------------- 4. ENGINEERING BENTO DEEP-DIVE ---------------- */}
<section className="relative px-6 py-20 md:px-12 lg:px-20 border-b border-white/10 bg-[#090c13]">
<div className="max-w-6xl mx-auto space-y-12">
<div className="text-center space-y-2">
<p className="text-xs font-bold uppercase tracking-widest text-emerald-400">TECHNICAL INNOVATION</p>
<h2 className="text-3xl sm:text-5xl font-black tracking-tight text-white">
เจาะลึกนวัตกรรม
</h2>
<p className="text-sm sm:text-base text-slate-400">
ทุกชิ้นส่วนถูกออกแบบมาเพื่อความแม่นยำและความปลอดภัยในชีวิตจริง
</p>
</div>
{/* Interactive Feature Accordion Pills */}
<div className="grid gap-4 md:grid-cols-2">
{DEEP_FEATURES.map((feat) => {
const isOpen = activeDeepFeature === feat.id
return (
<div
key={feat.id}
onClick={() => setActiveDeepFeature(isOpen ? null : feat.id)}
className={`cursor-pointer rounded-2xl border p-6 transition-all duration-300 ${
isOpen
? 'border-sky-500/50 bg-sky-950/20 shadow-xl shadow-sky-500/5'
: 'border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/[0.07]'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-sky-400">
{feat.badge}
</span>
<h4 className="text-lg sm:text-xl font-bold text-white">
{feat.title}
</h4>
</div>
<span className="text-xl text-slate-400 font-bold transition-transform duration-300">
{isOpen ? '−' : '+'}
</span>
</div>
{isOpen && (
<div className="mt-4 pt-4 border-t border-white/10 space-y-3 animate-fadeIn">
<p className="text-sm text-slate-300 leading-relaxed">
{feat.description}
</p>
<div className="inline-block rounded-lg bg-white/10 px-3 py-1 text-xs font-semibold text-white">
⚡ {feat.stats}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
</section>
{/* ---------------- 5. CONVERSION DOCK & FOOTER CTA ---------------- */}
<section className="relative px-6 py-24 md:px-12 lg:px-20 bg-gradient-to-t from-[#05070c] to-[#0c0f17] text-center">
<div className="max-w-3xl mx-auto space-y-6">
<p className="text-xs font-bold uppercase tracking-widest text-slate-500">READY TO UPGRADE</p>
<h2 className="text-4xl sm:text-6xl font-black tracking-tight text-white">
อัปเกรดระบบเบรก<br />
สู่มาตรฐานระดับโลก
</h2>
<p className="text-base sm:text-lg text-slate-400 leading-relaxed">
พร้อมรับประกันคุณภาพและความเข้ากันได้กับรถของคุณ สั่งซื้อวันนี้เพื่อสัมผัสความมั่นใจในทุกระยะหยุด
</p>
<div className="pt-4">
<button
type="button"
onClick={scrollToPurchase}
className="inline-flex items-center justify-center rounded-2xl bg-white px-10 py-4 text-base font-black text-slate-950 hover:bg-slate-200 active:scale-95 transition-all shadow-2xl shadow-white/20"
>
เลือกซื้อรุ่นของคุณ (Select Model) ↑
</button>
</div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,393 @@
'use client'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import Image from 'next/image'
import type { Product } from '@homaroma/types'
import { resolveLocalized } from '../lib/localization'
export interface StoryScene {
id: string
kicker?: string
title?: string
description?: string
metric?: string
chips?: string[]
theme?: 'hero' | 'material' | 'heat' | 'friction' | 'control' | 'durability' | 'fitment' | string
imageUrl?: string
}
interface Props {
product: Product
heroImage?: string
scenes?: StoryScene[]
lang: string
endCtaTitle?: string
endCtaButtonText?: string
}
const DEFAULT_SCENES: StoryScene[] = [
{
id: 'hero',
kicker: '01 / PRODUCT STORY',
title: 'STOP WITH\nCONFIDENCE.',
description: 'พัฒนาขึ้นสำหรับการแข่งขันและการขับขี่สมรรถนะสูง ให้สินค้ากลายเป็นพระเอกก่อนพาลงลึกไปทีละมิติของประสิทธิภาพ',
theme: 'hero',
},
{
id: 'material',
kicker: '02 / MATERIAL',
title: 'CERAMIC\nCOMPOUND.',
description: 'สูตรผสมเนื้อเซรามิกเกรดพรีเมียม สึกหรอน้อย เสียงเบียบเงียบ ไร้แรงสั่นสะเทือน และลดฝุ่นผงจับจานเบรกอย่างเห็นได้ชัด',
chips: ['Low Noise', 'Low Vibration', 'Cleaner Braking'],
theme: 'material',
},
{
id: 'heat',
kicker: '03 / HEAT CONTROL',
title: 'HIGH-HEAT\nCONTROL.',
metric: '300°C',
description: 'ทนต่อความร้อนสูงสะสม ป้องกันอาการเบรกเฟด (Brake Fade) ให้ระยะหยุดสม่ำเสมอแม้ใช้งานต่อเนื่องบนสนามแข่งหรือทางลาดชัน',
theme: 'heat',
},
{
id: 'friction',
kicker: '04 / PERFORMANCE',
title: 'STABLE\nFRICTION.',
description: 'ค่าสัมประสิทธิ์แรงเสียดทานคงที่ในทุกช่วงอุณหภูมิ ให้การจับตัวนุ่มนวลแต่แน่นหนา มั่นใจในทุกโค้ง',
theme: 'friction',
},
{
id: 'control',
kicker: '05 / CONTROL',
title: 'SMOOTH\nENGAGEMENT.',
description: 'โครงสร้างแม่นยำมาตรฐานสูง สัมผัสก้านเบรกตอบสนองเป็นเส้นตรง (Linear Modulation) คุมน้ำหนักเบรกได้ละเอียดดั่งใจ',
theme: 'control',
},
{
id: 'durability',
kicker: '06 / DURABILITY',
title: 'BUILT\nTO LAST.',
description: 'เนื้อผ้าเบรกถนอมจานเบรก ไม่กินจาน ยืดอายุการใช้งานยาวนาน คุ้มค่าสำหรับการขับขี่ทั้งในชีวิตประจำวันและทริปเดินทางไกล',
chips: ['Durable', 'Rotor Friendly', 'Everyday Use'],
theme: 'durability',
},
{
id: 'fitment',
kicker: '07 / FITMENT',
title: 'MADE FOR\nYOUR BIKE.',
description: 'ออกแบบตรงรุ่นพร้อมติดตั้งแบบ Plug & Play ครอบคลุมรถจักรยานยนต์ยอดนิยมทุกค่าย',
theme: 'fitment',
},
]
export default function CinematicStoryScroll({
product,
heroImage,
scenes,
lang,
endCtaTitle,
endCtaButtonText,
}: Props) {
const [activeScene, setActiveScene] = useState<string>('hero')
const [isStoryVisible, setIsStoryVisible] = useState(false)
const storyRef = useRef<HTMLDivElement | null>(null)
const activeScenes = useMemo(() => {
if (scenes && scenes.length > 0) return scenes
return DEFAULT_SCENES
}, [scenes])
const resolvedProductName = useMemo(() => {
return resolveLocalized(product.name, lang) || 'Product Story'
}, [product.name, lang])
const mainShowcaseImage = useMemo(() => {
if (heroImage) return heroImage
if (product.images && product.images.length > 0) return product.images[0].url
return ''
}, [heroImage, product.images])
const fitmentModels = useMemo(() => {
if (product.options && product.options.length > 0) {
const opt1 = product.options[0]
if (opt1 && Array.isArray(opt1.values)) {
return opt1.values.map(v => {
if (typeof v === 'string') return v
return resolveLocalized(v, lang) || Object.values(v)[0]
})
}
}
return ['NVX155', 'LC135', 'Vario125', 'Vario150', 'PCX150', 'Beat110', 'Wave110-I', 'R15']
}, [product.options, lang])
useEffect(() => {
const storyEl = storyRef.current
if (!storyEl) return
const triggers = storyEl.querySelectorAll<HTMLElement>('.story-trigger')
if (triggers.length === 0) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const sceneId = entry.target.getAttribute('data-scene')
if (sceneId) setActiveScene(sceneId)
}
})
},
{ threshold: 0.55 }
)
triggers.forEach((t) => observer.observe(t))
const visibilityObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsStoryVisible(entry.isIntersecting)
})
},
{ threshold: 0.05 }
)
visibilityObserver.observe(storyEl)
return () => {
observer.disconnect()
visibilityObserver.disconnect()
}
}, [activeScenes])
const scrollToPurchase = () => {
const el = document.getElementById('product-purchase-top')
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// Active scene index
const activeIndex = Math.max(0, activeScenes.findIndex(s => s.id === activeScene))
return (
<div ref={storyRef} className="relative w-full bg-[#0c1320] text-white overflow-hidden transition-colors duration-700 select-none">
{/* 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-none ${
isStoryVisible ? 'opacity-100' : 'opacity-0'
}`}
>
{activeScenes.map((s, idx) => (
<div
key={s.id}
className={`w-2 rounded-full transition-all duration-300 ${
activeIndex === idx
? 'h-7 bg-white shadow-lg shadow-white/30'
: 'h-2 bg-white/20'
}`}
/>
))}
</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">
{/* Ambient Reactive Glow Backdrops */}
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === '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%)',
}}
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === '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%)',
}}
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === '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)',
backgroundSize: '40px 40px',
}}
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === '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%)',
}}
/>
<div
className={`absolute inset-0 transition-opacity duration-700 pointer-events-none ${
activeScene === '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%)',
}}
/>
{/* 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">
<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 ${
activeScene === 'heat'
? 'scale-105 border-[16px] border-orange-500 shadow-[0_0_90px_rgba(255,102,0,0.6)]'
: activeScene === 'material'
? 'scale-110 border-[16px] border-blue-500 shadow-[0_0_80px_rgba(20,103,255,0.5)]'
: activeScene === 'control'
? 'scale-95 -rotate-12 border-[14px] border-cyan-400 shadow-[0_0_70px_rgba(34,211,238,0.4)]'
: activeScene === '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)]'
}`}
style={{
background: 'radial-gradient(circle, rgba(12, 19, 32, 0.95) 45%, rgba(6, 10, 18, 0.98) 100%)',
}}
>
{/* Product Image */}
{mainShowcaseImage ? (
<div className="relative w-4/5 h-4/5 p-4 flex items-center justify-center">
<Image
src={mainShowcaseImage}
alt={resolvedProductName}
fill
sizes="(max-width: 1024px) 320px, 440px"
className="object-contain drop-shadow-[0_20px_35px_rgba(0,0,0,0.7)] transition-transform duration-700"
priority
/>
</div>
) : (
<div className="text-center p-6 space-y-2">
<span className="text-4xl">🏎️</span>
<p className="text-xs uppercase tracking-widest text-slate-400 font-bold">Premium Racing Spec</p>
</div>
)}
{/* Pulsing Core Ring */}
<div
className="absolute inset-4 rounded-full border border-white/10 pointer-events-none animate-spin-slow"
style={{ animationDuration: '40s' }}
/>
</div>
</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
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 ${
isActive
? 'opacity-100 translate-y-[-50%] pointer-events-auto'
: 'opacity-0 translate-y-[-40%] pointer-events-none'
}`}
>
{/* Kicker */}
<div className="text-xs md:text-sm font-black tracking-[0.2em] uppercase text-cyan-300">
{scene.kicker}
</div>
{/* Headline */}
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight leading-[0.95] whitespace-pre-line text-white">
{scene.title}
</h2>
{/* Metric Highlight (e.g. 300°C) */}
{scene.metric && (
<div className="text-5xl sm:text-6xl md:text-7xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-orange-400 via-amber-300 to-yellow-200">
{scene.metric}
</div>
)}
{/* Description */}
<p className="text-sm md:text-base text-slate-300 leading-relaxed max-w-md">
{scene.description}
</p>
{/* Feature Chips */}
{scene.chips && scene.chips.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{scene.chips.map((chip, idx) => (
<span
key={idx}
className="px-3.5 py-1.5 rounded-full text-xs font-semibold bg-white/10 border border-white/20 text-slate-200 backdrop-blur-sm"
>
{chip}
</span>
))}
</div>
)}
{/* Fitment Grid for Fitment Scene */}
{scene.id === 'fitment' && fitmentModels.length > 0 && (
<div className="pt-2">
<p className="text-xs uppercase font-bold tracking-wider text-slate-400 mb-2">
รุ่นรถยอดนิยมที่รองรับ ({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) => (
<span
key={idx}
className="px-3 py-2 rounded-xl text-center text-xs font-bold bg-white/10 border border-white/15 text-white truncate"
title={model}
>
{model}
</span>
))}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
{/* Scroll Trigger Sections (each 90vh tall to drive IntersectionObserver) */}
{activeScenes.map((scene) => (
<div
key={scene.id}
data-scene={scene.id}
className="story-trigger h-[90vh] w-full pointer-events-none opacity-0"
/>
))}
</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 bg-gradient-to-b from-[#0c1320] via-[#09101a] to-[#060a12] border-t border-white/10 space-y-5">
<div className="text-xs uppercase font-black tracking-[0.25em] text-cyan-400">
END OF PRODUCT STORY
</div>
<h3 className="text-3xl sm:text-5xl md:text-6xl font-black text-white tracking-tight">
{endCtaTitle || 'SELECT YOUR MODEL.'}
</h3>
<p className="text-sm md:text-base text-slate-400 max-w-md mx-auto leading-relaxed">
พร้อมยกระดับสมรรถนะการเบรกให้มอเตอร์ไซค์ของคุณ สั่งซื้อได้ทันทีตรงรุ่น
</p>
<button
type="button"
onClick={scrollToPurchase}
className="mt-4 px-8 py-4 rounded-2xl font-black text-sm uppercase tracking-wider bg-gradient-to-r from-cyan-400 to-blue-500 hover:from-cyan-300 hover:to-blue-400 text-night-950 shadow-xl shadow-cyan-500/25 transition-all transform hover:scale-105 active:scale-95 cursor-pointer"
>
{endCtaButtonText || 'Choose Your Bike / สั่งซื้อรุ่นของคุณ'}
</button>
</section>
</div>
)
}
+17 -1
View File
@@ -1101,17 +1101,33 @@ export const ProductCarouselSectionPayloadSchema = z.object({
}).strict();
export type ProductCarouselSectionPayload = z.infer<typeof ProductCarouselSectionPayloadSchema>;
export const StorySceneSchema = z.object({
id: z.string(),
kicker: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
metric: z.string().optional(),
chips: z.array(z.string()).optional(),
theme: z.string().optional(),
imageUrl: z.string().optional(),
}).passthrough();
export type StoryScene = z.infer<typeof StorySceneSchema>;
export const ProductLayoutSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
subtitle: LocalizedStringSchema.optional(),
showHeading: z.boolean().optional(),
showViewAllLink: z.boolean().optional(),
layoutMode: z.enum(['standard', 'sticky_gallery']).optional(),
layoutMode: z.enum(['standard', 'sticky_gallery', 'story_scroll', 'apple_watch_tour', 'apple_keynote']).optional(),
galleryBehavior: z.enum(['carousel', 'stacked']).optional(),
purchaseCardSticky: z.boolean().optional(),
showStickyPurchaseBarAfterMain: z.boolean().optional(),
showBreadcrumbs: z.boolean().optional(),
showBrandMeta: z.boolean().optional(),
storyHeroImage: z.string().optional(),
storyScenes: z.array(StorySceneSchema).optional(),
endCtaTitle: z.string().optional(),
endCtaButtonText: z.string().optional(),
}).strict();
export type ProductLayoutSectionPayload = z.infer<typeof ProductLayoutSectionPayloadSchema>;
+449
View File
@@ -0,0 +1,449 @@
<!doctype html>
<html lang="th">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>TOBAKI Story Scroll Mockup</title>
<style>
:root{
--bg:#f6f3ea;
--ink:#111;
--muted:#6d6a62;
--blue:#1267ff;
--gold:#d8b35a;
--dark:#0c1320;
--panel:#ffffff;
--line:#d8d3c8;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;
font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color:var(--ink);
background:var(--bg);
}
/* ---------- top product block ---------- */
.product-top{
min-height:92vh;
display:grid;
grid-template-columns:1.05fr 1fr;
gap:4vw;
align-items:center;
padding:8vh 8vw;
background:#f7f4ec;
border-bottom:1px solid #e5e0d6;
}
.gallery{
display:grid;
grid-template-columns:72px 1fr;
gap:20px;
align-items:center;
}
.thumbs{display:flex;flex-direction:column;gap:12px}
.thumb{
aspect-ratio:1;
border:1px solid #ddd5c9;
background:#fff;
border-radius:14px;
display:grid;
place-items:center;
color:#8c877e;
font-size:11px;
}
.thumb.active{border:2px solid #d8b35a}
.product-stage{
min-height:520px;
border-radius:28px;
background:#fff;
border:1px solid #e5dfd4;
display:grid;
place-items:center;
box-shadow:0 24px 60px rgba(0,0,0,.05);
position:relative;
overflow:hidden;
}
.fake-pack{
width:44%;
height:64%;
background:linear-gradient(180deg,#151515 0 18%,#d9b35a 18% 100%);
border-radius:18px;
box-shadow:0 30px 50px rgba(0,0,0,.14);
position:absolute;
left:18%;
}
.fake-pad{
width:28%;
height:14%;
background:#c79b43;
border-radius:50px 26px 26px 50px;
position:absolute;
right:14%;
top:44%;
transform:rotate(-18deg);
box-shadow:0 16px 24px rgba(0,0,0,.12);
}
.product-info h1{font-size:clamp(36px,4vw,64px);line-height:.98;margin:0 0 18px}
.eyebrow{font-size:12px;letter-spacing:.18em;font-weight:800}
.desc{color:var(--muted);font-size:17px;line-height:1.7;max-width:620px}
.price{font-size:52px;font-weight:900;margin:34px 0 10px}
.selector,.buyrow{
display:flex;gap:14px;align-items:center;margin-top:18px
}
.selectbox{
flex:1;padding:18px 20px;border-radius:16px;background:#fff;border:1px solid #e0dbd1
}
.qty{padding:18px 22px;border-radius:16px;background:#fff;border:1px solid #e0dbd1}
.cart{
flex:1;padding:19px 26px;border-radius:16px;background:#171a18;color:#fff;text-align:center;font-weight:800
}
/* ---------- story ---------- */
.story-wrap{
position:relative;
background:var(--dark);
color:#fff;
}
.story-progress{
position:fixed;
right:24px;
top:50%;
transform:translateY(-50%);
z-index:50;
display:flex;
flex-direction:column;
gap:10px;
opacity:0;
pointer-events:none;
transition:.25s ease;
}
.story-progress.show{opacity:1}
.dot{width:8px;height:8px;border-radius:99px;background:rgba(255,255,255,.28)}
.dot.active{height:26px;background:#fff}
.story{
position:relative;
}
.sticky-stage{
position:sticky;
top:0;
height:100vh;
overflow:hidden;
display:grid;
place-items:center;
background:
radial-gradient(circle at 34% 50%, rgba(20,103,255,.23), transparent 32%),
linear-gradient(180deg,#0c1320 0%, #0a0f18 100%);
}
.product-core{
position:absolute;
left:11vw;
top:50%;
transform:translateY(-50%);
width:min(42vw,640px);
aspect-ratio:1;
border-radius:50%;
border:28px solid #1467ff;
box-shadow:
inset -30px -15px 0 #050a10,
0 0 60px rgba(18,103,255,.32);
transition:all .5s ease;
}
.product-core:after{
content:"";
position:absolute;
inset:12%;
border-radius:50%;
border:22px solid #16181b;
}
.product-core:before{
content:"";
position:absolute;
left:-14%; right:-14%; bottom:-12%;
height:18%;
filter:blur(18px);
background:rgba(0,0,0,.45);
border-radius:50%;
}
.scene-copy{
position:absolute;
left:56vw;
top:50%;
transform:translateY(-50%);
width:35vw;
max-width:650px;
opacity:0;
transition:opacity .35s ease, transform .35s ease;
}
.scene-copy.active{opacity:1;transform:translateY(-50%)}
.scene-copy .kicker{
color:#9fbfff;font-size:13px;letter-spacing:.18em;font-weight:800;margin-bottom:16px
}
.scene-copy h2{
font-size:clamp(44px,5vw,88px);
line-height:.92;
margin:0 0 18px;
letter-spacing:-.03em;
}
.scene-copy p{font-size:18px;line-height:1.65;color:#cdd4df;max-width:560px}
.metric{
font-size:clamp(64px,8vw,120px);
font-weight:900;
color:#fff;
letter-spacing:-.05em;
margin-top:20px;
}
.feature-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}
.chip{
padding:10px 14px;border:1px solid rgba(255,255,255,.18);
border-radius:999px;background:rgba(255,255,255,.05);
color:#e9edf4;font-size:13px
}
.trigger{
height:100vh;
position:relative;
}
/* visual states */
.story-wrap[data-scene="material"] .sticky-stage{
background:
radial-gradient(circle at 34% 50%, rgba(44,119,255,.26), transparent 30%),
linear-gradient(180deg,#0e1728 0%,#09101a 100%);
}
.story-wrap[data-scene="material"] .product-core{
transform:translateY(-50%) scale(1.18);
border-width:34px;
}
.story-wrap[data-scene="heat"] .sticky-stage{
background:
radial-gradient(circle at 30% 50%, rgba(255,94,0,.42), transparent 35%),
linear-gradient(110deg,#160c08,#111522 70%);
}
.story-wrap[data-scene="heat"] .product-core{
border-color:#ff7a00;
box-shadow:inset -30px -15px 0 #050a10,0 0 80px rgba(255,122,0,.55);
}
.story-wrap[data-scene="friction"] .sticky-stage{
background:
linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(180deg,#0b1018,#080c13);
background-size:40px 40px,40px 40px,auto;
}
.story-wrap[data-scene="control"] .product-core{
transform:translateY(-50%) rotate(-18deg) scale(.93);
}
.story-wrap[data-scene="durability"] .sticky-stage{
background:
radial-gradient(circle at 75% 70%, rgba(216,179,90,.2), transparent 28%),
linear-gradient(120deg,#101722,#0a0e15);
}
.story-wrap[data-scene="fitment"] .sticky-stage{
background:linear-gradient(180deg,#f6f3ea,#ece7dc);
color:#111;
}
.story-wrap[data-scene="fitment"] .scene-copy p{color:#5d5a54}
.story-wrap[data-scene="fitment"] .product-core{
left:8vw;
transform:translateY(-50%) scale(.82);
}
.fit-grid{
display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:26px
}
.fit-grid span{
padding:13px 14px;border-radius:12px;background:#fff;color:#111;text-align:center;
border:1px solid #d7d1c6;font-size:13px;font-weight:700
}
.end-cta{
min-height:65vh;
display:grid;
place-items:center;
text-align:center;
padding:10vh 8vw;
background:#f6f3ea;
}
.end-cta h3{font-size:clamp(44px,5vw,80px);margin:0}
.end-cta p{color:#6f6a61;font-size:18px}
.end-cta .btn{
display:inline-block;margin-top:20px;background:#171a18;color:#fff;
padding:16px 26px;border-radius:14px;font-weight:800
}
.note{
position:fixed;left:20px;bottom:20px;z-index:60;
background:rgba(255,255,255,.9);backdrop-filter:blur(10px);
color:#111;padding:10px 13px;border-radius:12px;font-size:12px;
box-shadow:0 12px 30px rgba(0,0,0,.12)
}
@media(max-width:900px){
.product-top{grid-template-columns:1fr;padding:6vh 5vw}
.gallery{grid-template-columns:1fr}
.thumbs{flex-direction:row}
.thumb{width:64px}
.product-stage{min-height:390px}
.product-core{left:50%;top:31%;transform:translate(-50%,-50%);width:70vw}
.scene-copy{left:8vw;top:auto;bottom:8vh;transform:none;width:84vw}
.scene-copy.active{transform:none}
.scene-copy h2{font-size:48px}
.story-wrap[data-scene="material"] .product-core,
.story-wrap[data-scene="control"] .product-core,
.story-wrap[data-scene="fitment"] .product-core{left:50%;transform:translate(-50%,-50%) scale(.9)}
}
</style>
</head>
<body>
<section class="product-top">
<div class="gallery">
<div class="thumbs">
<div class="thumb active">IMG 01</div>
<div class="thumb">IMG 02</div>
<div class="thumb">IMG 03</div>
<div class="thumb">DETAIL</div>
<div class="thumb">TEXTURE</div>
</div>
<div class="product-stage">
<div class="fake-pack"></div>
<div class="fake-pad"></div>
</div>
</div>
<div class="product-info">
<div class="eyebrow">TOBAKI / BRAKING SYSTEM</div>
<h1>X Ceramic Brake Pad</h1>
<p class="desc">ช่วงแรกของหน้าเป็น Product Detail แบบปกติ — ดูภาพ, เลือกรุ่น, ราคา, สต๊อก และสั่งซื้อได้ทันที</p>
<div class="price">฿3</div>
<div class="selector">
<div class="selectbox">SELECT MODEL: <b>ADV150 / ADV160 (F)</b></div>
</div>
<div class="buyrow">
<div class="qty">− &nbsp; 1 &nbsp; +</div>
<div class="cart">Add to Cart</div>
</div>
</div>
</section>
<section class="story-wrap" id="story" data-scene="hero">
<div class="story-progress" id="progress">
<div class="dot active"></div><div class="dot"></div><div class="dot"></div>
<div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="dot"></div>
</div>
<div class="story">
<div class="sticky-stage">
<div class="product-core" aria-hidden="true"></div>
<div class="scene-copy active" data-copy="hero">
<div class="kicker">01 / PRODUCT STORY</div>
<h2>STOP WITH<br>CONFIDENCE.</h2>
<p>เริ่ม Story แบบภาพใหญ่เต็มจอ ให้สินค้ากลายเป็นพระเอก ก่อนจะพาคนดูลงลึกไปทีละประเด็น</p>
</div>
<div class="scene-copy" data-copy="material">
<div class="kicker">02 / MATERIAL</div>
<h2>CERAMIC<br>COMPOUND.</h2>
<p>ซูมเข้าเนื้อผ้าเบรก แล้วอธิบายเรื่อง Low Noise, Low Vibration และ Brake Dust ต่ำลง</p>
<div class="feature-row">
<span class="chip">Low Noise</span><span class="chip">Low Vibration</span><span class="chip">Cleaner Braking</span>
</div>
</div>
<div class="scene-copy" data-copy="heat">
<div class="kicker">03 / HEAT CONTROL</div>
<h2>HIGH-HEAT<br>CONTROL.</h2>
<div class="metric">300°C</div>
<p>ฉากเปลี่ยนจาก Blue → Orange/Heat เพื่อสื่อ Brake Fade Resistance แบบเห็นความต่างทันที</p>
</div>
<div class="scene-copy" data-copy="friction">
<div class="kicker">04 / PERFORMANCE</div>
<h2>STABLE<br>FRICTION.</h2>
<p>พื้นหลังเปลี่ยนเป็น Technical Grid / Data Scene เพื่อเล่าเรื่องแรงเสียดทานคงที่ โดยไม่ต้องทำ Dashboard หนัก ๆ</p>
</div>
<div class="scene-copy" data-copy="control">
<div class="kicker">05 / CONTROL</div>
<h2>SMOOTH<br>ENGAGEMENT.</h2>
<p>สินค้าเริ่มหมุน/เปลี่ยนมุม มองเห็นโครงสร้างและสปริง สื่อถึง Predictable Modulation และการควบคุมที่นุ่มนวล</p>
</div>
<div class="scene-copy" data-copy="durability">
<div class="kicker">06 / DURABILITY</div>
<h2>BUILT<br>TO LAST.</h2>
<p>เปลี่ยน mood เป็นการใช้งานระยะยาว และเล่าจุดขายเรื่อง Durable Lifespan, Rotor Friendly และ Everyday Confidence</p>
<div class="feature-row">
<span class="chip">Durable</span><span class="chip">Rotor Friendly</span><span class="chip">Everyday Use</span>
</div>
</div>
<div class="scene-copy" data-copy="fitment">
<div class="kicker">07 / FITMENT</div>
<h2>MADE FOR<br>YOUR BIKE.</h2>
<p>Story ปิดท้ายด้วยรุ่นรถที่รองรับ ก่อนส่งผู้ใช้กลับเข้าสู่ flow การเลือก Model และซื้อสินค้า</p>
<div class="fit-grid">
<span>NVX155</span><span>LC135</span><span>Vario125</span>
<span>Vario150</span><span>PCX150</span><span>Beat110 FI</span>
</div>
</div>
</div>
<div class="trigger" data-scene="hero"></div>
<div class="trigger" data-scene="material"></div>
<div class="trigger" data-scene="heat"></div>
<div class="trigger" data-scene="friction"></div>
<div class="trigger" data-scene="control"></div>
<div class="trigger" data-scene="durability"></div>
<div class="trigger" data-scene="fitment"></div>
</div>
</section>
<section class="end-cta">
<div>
<div class="eyebrow">END OF PRODUCT STORY</div>
<h3>SELECT YOUR MODEL.</h3>
<p>หลังจบ Story ค่อยพากลับมาที่ Conversion อีกครั้ง ไม่ต้องให้ animation แย่งกับการซื้อสินค้า</p>
<div class="btn">Choose Your Bike</div>
</div>
</section>
<div class="note">Scroll ลงเพื่อดู Story UX Mockup</div>
<script>
const story = document.getElementById('story');
const progress = document.getElementById('progress');
const triggers = [...document.querySelectorAll('.trigger')];
const copies = [...document.querySelectorAll('.scene-copy')];
const dots = [...document.querySelectorAll('.dot')];
const observer = new IntersectionObserver((entries)=>{
entries.forEach(entry=>{
if(entry.isIntersecting){
const scene = entry.target.dataset.scene;
story.dataset.scene = scene;
copies.forEach(c=>c.classList.toggle('active', c.dataset.copy === scene));
const i = triggers.indexOf(entry.target);
dots.forEach((d,idx)=>d.classList.toggle('active',idx===i));
}
});
},{threshold:.55});
triggers.forEach(t=>observer.observe(t));
const visibilityObserver = new IntersectionObserver((entries)=>{
entries.forEach(e=>progress.classList.toggle('show', e.isIntersecting));
},{threshold:.1});
visibilityObserver.observe(story);
</script>
</body>
</html>