feat(cms): add Cinematic Story Scroll and Apple Showcase blocks to Page Builder

This commit is contained in:
Your Name committed 2026-10-17 09:06:44 +07:00
1 parent 25cbe3549e
commit 8d9f2fc587
10 files changed
+969 -6

No files matched your search

@@ -143,6 +143,35 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
</div>
)
},
// 2.9 APPLE_SHOWCASE
{
type: 'APPLE_SHOWCASE',
category: 'hero',
title: 'APPLE KEYNOTE SHOWCASE',
description: 'Apple Watch Series 12 inspired keynote showcase featuring live swatches tour, titanium/aluminum badges, spec metrics callouts, and conversion CTA.',
badge: 'Apple Style · Hot',
renderPreview: () => (
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-black flex flex-col items-center justify-center p-3 text-center border border-amber-500/30">
<div className="absolute inset-0 bg-gradient-to-t from-amber-500/10 via-black to-black opacity-80" />
<div className="relative z-10 space-y-1">
<span className="text-[7px] uppercase font-mono font-bold tracking-widest text-amber-400">
KEYNOTE SHOWCASE
</span>
<h5 className="text-[11px] font-extrabold text-white tracking-tight">
PRECISION CRAFTED.
</h5>
<div className="flex items-center justify-center gap-1.5 pt-0.5">
<span className="w-2.5 h-2.5 rounded-full bg-slate-400 border border-white/40 ring-1 ring-cyan-glow" />
<span className="w-2.5 h-2.5 rounded-full bg-amber-600 border border-white/40" />
<span className="w-2.5 h-2.5 rounded-full bg-slate-800 border border-white/40" />
</div>
<span className="inline-block px-2.5 py-0.5 bg-white text-black font-extrabold rounded-full text-[7px] mt-1 shadow-lg">
Order Now &rarr;
</span>
</div>
</div>
)
},
// 3. ANNOUNCEMENT_BAR
{
type: 'ANNOUNCEMENT_BAR',
@@ -372,6 +401,32 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
</div>
)
},
// 9.5 CINEMATIC_STORY_SCROLL
{
type: 'CINEMATIC_STORY_SCROLL',
category: 'editorial',
title: 'CINEMATIC STORY SCROLL (Scrollytelling)',
description: 'Full-bleed immersive storytelling canvas with dynamic multi-scene narrative, pinned product visual, and auto-scrolling studio background (Tobaki Style).',
badge: 'Tobaki · Apple Style',
renderPreview: () => (
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-slate-950 via-slate-900 to-black p-3 flex items-center justify-between border border-cyan-glow/30">
<div className="space-y-1 max-w-[55%] z-10">
<span className="text-[6px] uppercase font-mono font-bold text-cyan-glow tracking-widest">
01 / SCROLLYTELLING
</span>
<h5 className="text-[10px] font-black text-white leading-tight">
STOP WITH CONFIDENCE.
</h5>
<p className="text-[7px] text-slate-400 line-clamp-1">
Pinned 3D product view with progressive narrative scenes.
</p>
</div>
<div className="w-14 h-14 rounded-xl bg-slate-800/80 border border-cyan-glow/40 flex items-center justify-center p-1 shadow-xl shrink-0">
<Sparkles className="w-6 h-6 text-cyan-glow animate-pulse" />
</div>
</div>
)
},
// 10. EDITORIAL_TILE
{
type: 'EDITORIAL_TILE',
@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react'
import {
Sparkles,
Upload,
Image as ImageIcon,
X,
Package,
ExternalLink,
RotateCcw,
} from 'lucide-react'
import { API_BASE } from '@homaroma/types'
interface Props {
payload: Record<string, unknown>
updatePayloadValue: (field: string, value: unknown) => void
onBrowseMedia?: (onSelect: (url: string) => void) => void
}
export default function AppleShowcaseForm({
payload,
updatePayloadValue,
onBrowseMedia,
}: Props) {
const [productsList, setProductsList] = useState<any[]>([])
const [loadingProducts, setLoadingProducts] = useState(false)
const heroImage = (payload.heroImage as string) || (payload.storyHeroImage as string) || ''
const endCtaTitle = (payload.endCtaTitle as string) || ''
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
const endCtaLink = (payload.endCtaLink as string) || ''
const selectedProductId = payload.productId ? String(payload.productId) : ''
useEffect(() => {
let isMounted = true
const fetchCatalog = async () => {
setLoadingProducts(true)
try {
const res = await fetch(`${API_BASE}/products?limit=100`)
if (!res.ok) return
const data = await res.json()
const items = data.data || data.products || (Array.isArray(data) ? data : [])
if (isMounted) setProductsList(items)
} catch (err) {
console.error('Failed to load products in AppleShowcaseForm', err)
} finally {
if (isMounted) setLoadingProducts(false)
}
}
fetchCatalog()
return () => { isMounted = false }
}, [])
const handleSelectProduct = (productIdStr: string) => {
if (!productIdStr) {
updatePayloadValue('productId', null)
return
}
const pid = Number(productIdStr)
updatePayloadValue('productId', pid)
const found = productsList.find(p => p.id === pid)
if (found) {
if (!heroImage && found.images?.[0]?.url) {
updatePayloadValue('heroImage', found.images[0].url)
}
if (!endCtaLink) {
updatePayloadValue('endCtaLink', `/products/${found.slug || found.id}`)
}
}
}
const handleResetToMockup = () => {
updatePayloadValue('endCtaTitle', 'อัปเกรดระบบเบรกสู่มาตรฐานระดับโลก')
updatePayloadValue('endCtaButtonText', 'เลือกซื้อรุ่นของคุณ (Select Model) ↑')
}
return (
<div className="space-y-6">
{/* Intro Banner */}
<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">
Apple Watch Keynote & Swatch Showcase
</p>
<p className="text-xs text-slate-400 leading-relaxed">
การนำเสนอแบบ Keynote เปิดตัวระดับ Apple: จานหมุน 360° Color Swatches, ไฮไลท์บาร์แบบตั้งเวลาเล่นอัตโนมัติ, Bento Grid นวัตกรรม และ Accordion เจาะลึกวิศวกรรม
</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 cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
โหลด Mockup Data
</button>
</div>
{/* Product Binding Selector */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-3">
<label className="text-xs font-bold uppercase tracking-widest text-slate-300 flex items-center gap-2">
<Package className="w-4 h-4 text-cyan-glow" />
ผูกข้อมูลกับสินค้าในระบบ (Optional Product Link)
</label>
<div className="flex flex-col sm:flex-row items-center gap-3">
<select
value={selectedProductId}
onChange={(e) => handleSelectProduct(e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
>
<option value="">-- ไม่ผูกสินค้า (ใช้ค่า Default สำหรับนำเสนอสินค้าเรือธง) --</option>
{productsList.map((p) => {
const pName = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || `Product #${p.id}`
return (
<option key={p.id} value={p.id}>
#{p.id} - {pName}
</option>
)
})}
</select>
{loadingProducts && <span className="text-xs text-slate-500 animate-pulse">กำลังโหลดสินค้า...</span>}
</div>
</div>
{/* Hero Showcase Image */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-3">
<label className="text-xs font-bold uppercase tracking-widest text-slate-300 flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
รูปสินค้าหลัก (Hero Keynote Image)
</label>
<div className="flex items-center gap-3">
<input
type="text"
value={heroImage}
onChange={(e) => updatePayloadValue('heroImage', e.target.value)}
placeholder="https://... หรือเลือกจาก Media Library"
className="admin-input flex-1 bg-slate-900 border-white/10 text-xs"
/>
{onBrowseMedia && (
<button
type="button"
onClick={() => onBrowseMedia((url) => updatePayloadValue('heroImage', url))}
className="px-4 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 border border-cyan-glow/30 text-cyan-glow text-xs font-bold transition-all flex items-center gap-2 shrink-0 cursor-pointer"
>
<Upload className="w-3.5 h-3.5" />
เลือกรูป
</button>
)}
</div>
{heroImage && (
<div className="relative w-28 h-28 rounded-xl overflow-hidden border border-white/10 bg-slate-900 p-2 flex items-center justify-center">
<img src={heroImage} alt="Hero Preview" className="max-w-full max-h-full object-contain" />
<button
type="button"
onClick={() => updatePayloadValue('heroImage', '')}
className="absolute top-1 right-1 p-1 rounded-full bg-black/60 text-white hover:bg-black"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
{/* Conversion End CTA */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-4">
<label className="text-xs font-bold uppercase tracking-widest text-slate-300 flex items-center gap-2">
<ExternalLink className="w-4 h-4 text-cyan-glow" />
ปุ่ม Action ตอนท้ายหน้า (Conversion Dock)
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Headline ตอนจบ
</label>
<input
type="text"
value={endCtaTitle}
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
placeholder="อัปเกรดระบบเบรกสู่มาตรฐานระดับโลก"
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
ข้อความบนปุ่ม
</label>
<input
type="text"
value={endCtaButtonText}
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
placeholder="เลือกซื้อรุ่นของคุณ (Select Model) ↑"
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
ลิงก์ปลายทางเมื่อกดปุ่ม (Target Link)
</label>
<input
type="text"
value={endCtaLink}
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
placeholder="เว้นว่างไว้เพื่อเลื่อนขึ้นด้านบน หรือใส่ /products/... หรือ /shop"
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,490 @@
import { useState, useEffect } from 'react'
import {
Sparkles,
Upload,
Image as ImageIcon,
X,
Layers,
CheckCircle,
RotateCcw,
Package,
ExternalLink,
} from 'lucide-react'
import { API_BASE } from '@homaroma/types'
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>
updatePayloadValue: (field: string, value: unknown) => void
onBrowseMedia?: (onSelect: (url: string) => void) => void
}
export default function CinematicStoryScrollForm({
payload,
updatePayloadValue,
onBrowseMedia,
}: Props) {
const [selectedSceneIndex, setSelectedSceneIndex] = useState(0)
const [productsList, setProductsList] = useState<any[]>([])
const [loadingProducts, setLoadingProducts] = useState(false)
const heroImage = (payload.storyHeroImage as string) || (payload.heroImage as string) || ''
const storyTheme = (payload.storyTheme as string) || 'light'
const endCtaTitle = (payload.endCtaTitle as string) || ''
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
const endCtaLink = (payload.endCtaLink as string) || ''
const selectedProductId = payload.productId ? String(payload.productId) : ''
// Fetch product list for convenient 1-click binding
useEffect(() => {
let isMounted = true
const fetchCatalog = async () => {
setLoadingProducts(true)
try {
const res = await fetch(`${API_BASE}/products?limit=100`)
if (!res.ok) return
const data = await res.json()
const items = data.data || data.products || (Array.isArray(data) ? data : [])
if (isMounted) setProductsList(items)
} catch (err) {
console.error('Failed to load products in CinematicStoryScrollForm', err)
} finally {
if (isMounted) setLoadingProducts(false)
}
}
fetchCatalog()
return () => { isMounted = false }
}, [])
// Auto-seed default mockup scenes on first mount if empty
useEffect(() => {
if (!payload.storyScenes || !Array.isArray(payload.storyScenes) || payload.storyScenes.length === 0) {
updatePayloadValue('storyScenes', DEFAULT_SCENES)
if (!payload.storyTheme) updatePayloadValue('storyTheme', 'light')
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'SELECT YOUR MODEL.')
if (!payload.endCtaButtonText) updatePayloadValue('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[]) => {
updatePayloadValue('storyScenes', newScenes)
}
const handleResetToMockup = () => {
updatePayloadValue('storyScenes', DEFAULT_SCENES)
updatePayloadValue('storyTheme', 'light')
updatePayloadValue('endCtaTitle', 'SELECT YOUR MODEL.')
updatePayloadValue('endCtaButtonText', 'Choose Your Bike')
}
const updateSelectedScene = (field: keyof StorySceneItem, val: any) => {
const updated = [...scenes]
updated[selectedSceneIndex] = {
...updated[selectedSceneIndex],
[field]: val,
}
updateScenes(updated)
}
const handleSelectProduct = (productIdStr: string) => {
if (!productIdStr) {
updatePayloadValue('productId', null)
return
}
const pid = Number(productIdStr)
updatePayloadValue('productId', pid)
const found = productsList.find(p => p.id === pid)
if (found) {
if (!heroImage && found.images?.[0]?.url) {
updatePayloadValue('storyHeroImage', found.images[0].url)
}
if (!endCtaLink) {
updatePayloadValue('endCtaLink', `/products/${found.slug || found.id}`)
}
}
}
const currentScene = scenes[selectedSceneIndex] || scenes[0]
return (
<div className="space-y-6">
{/* Intro Banner */}
<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 / Tobaki Style)
</p>
<p className="text-xs text-slate-400 leading-relaxed">
แสดงการเล่าเรื่องเทคโนโลยี 7 ฉากแบบ Scrollytelling เต็มจอ 100vh เหมาะสำหรับทำหน้า Landing Page เปิดตัวสินค้าเรือธงหรือใส่ลงในหน้า CMS
</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 cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
โหลด Mockup Data ทั้งหมด
</button>
</div>
{/* Product Binding Selector */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-3">
<label className="text-xs font-bold uppercase tracking-widest text-slate-300 flex items-center gap-2">
<Package className="w-4 h-4 text-cyan-glow" />
ผูกข้อมูลกับสินค้าในระบบ (Optional Product Link)
</label>
<div className="flex flex-col sm:flex-row items-center gap-3">
<select
value={selectedProductId}
onChange={(e) => handleSelectProduct(e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
>
<option value="">-- ไม่ผูกสินค้า (ใช้ภาพและข้อความ Custom ด้านล่าง) --</option>
{productsList.map((p) => {
const pName = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || `Product #${p.id}`
return (
<option key={p.id} value={p.id}>
#{p.id} - {pName}
</option>
)
})}
</select>
{loadingProducts && <span className="text-xs text-slate-500 animate-pulse">กำลังโหลดสินค้า...</span>}
</div>
</div>
{/* Visual Theme Selector (Light Luxury vs Dark Theater) */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-3">
<label className="text-xs font-bold uppercase tracking-widest text-slate-300 flex items-center gap-2">
<Layers className="w-4 h-4 text-cyan-glow" />
ธีมการแสดงผลของ Story (Visual Theme)
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => updatePayloadValue('storyTheme', 'light')}
className={`p-3.5 rounded-xl border text-left transition-all cursor-pointer ${
storyTheme === 'light'
? 'bg-cyan-glow/10 border-cyan-glow/50 text-white ring-1 ring-cyan-glow/30'
: 'bg-white/5 border-white/10 text-slate-400 hover:text-white'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-black uppercase tracking-wider">Light Studio (Clean Luxury)</span>
{storyTheme === 'light' && <CheckCircle className="w-4 h-4 text-cyan-glow" />}
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
พื้นหลังโทนอุ่น Cream/Off-white กลมกลืนกับธีมเว็บ DDC และ HomAroma
</p>
</button>
<button
type="button"
onClick={() => updatePayloadValue('storyTheme', 'dark')}
className={`p-3.5 rounded-xl border text-left transition-all cursor-pointer ${
storyTheme === 'dark'
? 'bg-cyan-glow/10 border-cyan-glow/50 text-white ring-1 ring-cyan-glow/30'
: 'bg-white/5 border-white/10 text-slate-400 hover:text-white'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-black uppercase tracking-wider">Dark Theater (Apple Pro)</span>
{storyTheme === 'dark' && <CheckCircle className="w-4 h-4 text-cyan-glow" />}
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
ฉากมืดสไตล์โรงภาพยนตร์ แสงนีออนและ Radial Glow เรืองแสงรอบสินค้า
</p>
</button>
</div>
</div>
{/* Hero Showcase Image */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-3">
<label className="text-xs font-bold uppercase tracking-widest text-slate-300 flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
รูปสินค้าพระเอกตรงกลาง (Showcase Image)
</label>
<div className="flex items-center gap-3">
<input
type="text"
value={heroImage}
onChange={(e) => updatePayloadValue('storyHeroImage', e.target.value)}
placeholder="https://... หรือเลือกจาก Media Library"
className="admin-input flex-1 bg-slate-900 border-white/10 text-xs"
/>
{onBrowseMedia && (
<button
type="button"
onClick={() => onBrowseMedia((url) => updatePayloadValue('storyHeroImage', url))}
className="px-4 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 border border-cyan-glow/30 text-cyan-glow text-xs font-bold transition-all flex items-center gap-2 shrink-0 cursor-pointer"
>
<Upload className="w-3.5 h-3.5" />
เลือกรูป
</button>
)}
</div>
{heroImage && (
<div className="relative w-28 h-28 rounded-xl overflow-hidden border border-white/10 bg-slate-900 p-2 flex items-center justify-center">
<img src={heroImage} alt="Hero Preview" className="max-w-full max-h-full object-contain" />
<button
type="button"
onClick={() => updatePayloadValue('storyHeroImage', '')}
className="absolute top-1 right-1 p-1 rounded-full bg-black/60 text-white hover:bg-black"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
{/* Scenes Editor Tabs */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-4">
<div className="flex items-center justify-between">
<label className="text-xs font-bold uppercase tracking-widest text-slate-300 flex items-center gap-2">
<Layers className="w-4 h-4 text-cyan-glow" />
ฉากเรื่องราว 7 มิติ (Story Scenes)
</label>
<span className="text-xs text-slate-500">
ฉาก {selectedSceneIndex + 1} / {scenes.length}
</span>
</div>
{/* Scene Tabs */}
<div className="flex items-center gap-1.5 overflow-x-auto custom-scrollbar pb-2">
{scenes.map((scene, idx) => (
<button
key={scene.id || idx}
type="button"
onClick={() => setSelectedSceneIndex(idx)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold whitespace-nowrap transition-all cursor-pointer ${
selectedSceneIndex === idx
? 'bg-cyan-glow text-slate-950 shadow-md shadow-cyan-glow/20'
: 'bg-white/5 text-slate-400 hover:text-white hover:bg-white/10'
}`}
>
{scene.kicker?.split('/')[1]?.trim() || `0${idx + 1}`}
</button>
))}
</div>
{/* Selected Scene Fields */}
<div className="p-4 rounded-xl bg-slate-900/60 border border-white/5 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Kicker (หัวข้อย่อยด้านบน เช่น 03 / HEAT CONTROL)
</label>
<input
type="text"
value={currentScene.kicker || ''}
onChange={(e) => updateSelectedScene('kicker', e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Visual Dynamic Theme
</label>
<select
value={currentScene.theme || 'hero'}
onChange={(e) => updateSelectedScene('theme', e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
>
<option value="hero">Hero (Titanium / Blue Electric)</option>
<option value="material">Material (Ceramic Blue Glow)</option>
<option value="heat">Heat (Flame Orange Glow / High-Temp)</option>
<option value="friction">Friction (Technical Grid)</option>
<option value="control">Control (Modulation Tilt)</option>
<option value="durability">Durability (Gold Luxury)</option>
<option value="fitment">Fitment (Model Compatibility)</option>
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Headline Title (พาดหัวตัวใหญ่ เช่น CERAMIC COMPOUND.)
</label>
<input
type="text"
value={currentScene.title || ''}
onChange={(e) => updateSelectedScene('title', e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10 text-xs font-bold"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Metric Highlight (ตัวเลขสถิติเด่น เช่น 300°C - ใส่เฉพาะฉากที่ต้องการ)
</label>
<input
type="text"
value={currentScene.metric || ''}
onChange={(e) => updateSelectedScene('metric', e.target.value)}
placeholder="เช่น 300°C หรือ 0.42 μ"
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Description (คำบรรยาย)
</label>
<textarea
rows={3}
value={currentScene.description || ''}
onChange={(e) => updateSelectedScene('description', e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10 text-xs leading-relaxed"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Feature Chips (แท็กจุดเด่น คั่นด้วยเครื่องหมายจุลภาค ,)
</label>
<input
type="text"
value={currentScene.chips?.join(', ') || ''}
onChange={(e) => {
const chipsArr = e.target.value
.split(',')
.map((c) => c.trim())
.filter(Boolean)
updateSelectedScene('chips', chipsArr)
}}
placeholder="Low Noise, Low Vibration, Cleaner Braking"
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
</div>
</div>
{/* End Call-To-Action (Conversion Footer) */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-4">
<label className="text-xs font-bold uppercase tracking-widest text-slate-300 flex items-center gap-2">
<ExternalLink className="w-4 h-4 text-cyan-glow" />
ปุ่ม Action ตอนจบของ Story (Conversion Dock)
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Headline ตอนจบ
</label>
<input
type="text"
value={endCtaTitle}
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
placeholder="SELECT YOUR MODEL."
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
ข้อความบนปุ่มกด
</label>
<input
type="text"
value={endCtaButtonText}
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
placeholder="Choose Your Bike / สั่งซื้อรุ่นของคุณ"
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
ลิงก์ปลายทางเมื่อกดปุ่ม (Target Link เช่น /products/... หรือ /checkout)
</label>
<input
type="text"
value={endCtaLink}
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
placeholder="เว้นว่างไว้หากต้องการให้เลื่อนขึ้นด้านบนสุด หรือใส่ URL ปลายทาง"
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
/>
</div>
</div>
</div>
)
}
+33
View File
@@ -31,6 +31,8 @@ import BlockTranslationModal, { isTranslatableBlock } from '../components/page-b
import { useStoreSettings } from '../contexts/StoreSettingsContext';
import { getAuthHeaders } from '../utils/auth';
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
import HeroForm from '../components/page-builder/HeroForm';
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
import NewsletterForm from '../components/page-builder/NewsletterForm';
@@ -156,8 +158,15 @@ export default function PageBuilderPage() {
const [isSeoModalOpen, setIsSeoModalOpen] = useState(false);
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
const [customMediaCallback, setCustomMediaCallback] = useState<((url: string) => void) | null>(null);
const handleMediaSelect = (url: string) => {
if (customMediaCallback) {
customMediaCallback(url);
setCustomMediaCallback(null);
setIsMediaModalOpen(false);
return;
}
if (uploadTarget?.type === 'seoOgImage') {
setPage(prev => (prev ? { ...prev, ogImageUrl: url } : prev));
} else if (uploadTarget?.type === 'mobileImage') {
@@ -1460,6 +1469,8 @@ export default function PageBuilderPage() {
onChange={(e) => handleTypeChange(e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
<option value="HERO">HERO (Campaign Intro)</option>
<option value="HERO_V2">HERO V2 (Split Layout Hero)</option>
<option value="HERO_VIDEO">HERO VIDEO (Video Full Cover Background)</option>
@@ -1582,6 +1593,28 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'CINEMATIC_STORY_SCROLL' && (
<CinematicStoryScrollForm
payload={editingSection.payload || {}}
updatePayloadValue={updatePayloadValue}
onBrowseMedia={(cb) => {
setCustomMediaCallback(() => cb);
setIsMediaModalOpen(true);
}}
/>
)}
{editingSection.type?.toUpperCase() === 'APPLE_SHOWCASE' && (
<AppleShowcaseForm
payload={editingSection.payload || {}}
updatePayloadValue={updatePayloadValue}
onBrowseMedia={(cb) => {
setCustomMediaCallback(() => cb);
setIsMediaModalOpen(true);
}}
/>
)}
{editingSection.type?.toUpperCase() === 'HERO' && (
<HeroForm
editingSection={editingSection}
+10 -1
View File
@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { LocalizedStringSchema, HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, FeaturedTilesSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticleSectionPayloadSchema, RichTextSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema, QuoteCalloutSectionPayloadSchema, ProductCarouselSectionPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema, HeroV2SectionPayloadSchema, HeroVideoSectionPayloadSchema, UspBarSectionPayloadSchema, CustomSectionPayloadSchema } from "@homaroma/types";
import { LocalizedStringSchema, HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, FeaturedTilesSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticleSectionPayloadSchema, RichTextSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema, QuoteCalloutSectionPayloadSchema, ProductCarouselSectionPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema, HeroV2SectionPayloadSchema, HeroVideoSectionPayloadSchema, UspBarSectionPayloadSchema, CustomSectionPayloadSchema, CinematicStoryScrollSectionPayloadSchema, AppleShowcaseSectionPayloadSchema } from "@homaroma/types";
import { z } from "zod";
import { adminOnly } from "../middleware/auth";
const RESERVED_SLUGS = [
@@ -520,6 +520,15 @@ function validateSectionPayload(type, payload) {
case "CONTACT_FORM":
schema = ContactFormSectionPayloadSchema;
break;
case "CINEMATIC_STORY_SCROLL":
case "STORY_SCROLL":
schema = CinematicStoryScrollSectionPayloadSchema;
break;
case "APPLE_SHOWCASE":
case "APPLE_KEYNOTE_SHOWCASE":
case "APPLE_WATCH_TOUR":
schema = AppleShowcaseSectionPayloadSchema;
break;
default:
return {
success: false,
+11
View File
@@ -43,6 +43,8 @@ import {
BannerMinimalSectionPayloadSchema,
AnnouncementBarMinimalSectionPayloadSchema,
UspMinimalSectionPayloadSchema,
CinematicStoryScrollSectionPayloadSchema,
AppleShowcaseSectionPayloadSchema,
SectionSpacingPayloadSchema,
PAGE_SECTION_LOCALIZED_PATHS,
type SectionType,
@@ -1428,6 +1430,15 @@ export function validateSectionPayload(
case "USP_MINIMAL":
schema = UspMinimalSectionPayloadSchema;
break;
case "CINEMATIC_STORY_SCROLL":
case "STORY_SCROLL":
schema = CinematicStoryScrollSectionPayloadSchema;
break;
case "APPLE_SHOWCASE":
case "APPLE_KEYNOTE_SHOWCASE":
case "APPLE_WATCH_TOUR":
schema = AppleShowcaseSectionPayloadSchema;
break;
default:
return {
success: false,
@@ -9,6 +9,9 @@ interface Props {
product: Product
heroImage?: string
lang?: string
endCtaTitle?: string
endCtaButtonText?: string
endCtaLink?: string
}
interface HighlightCard {
@@ -184,7 +187,14 @@ const DEEP_FEATURES: DeepFeature[] = [
},
]
export default function AppleWatchShowcase({ product, heroImage, lang = 'th' }: Props) {
export default function AppleWatchShowcase({
product,
heroImage,
lang = 'th',
endCtaTitle,
endCtaButtonText,
endCtaLink,
}: Props) {
const productName = resolveLocalized(product.name, lang)
const mainImage = heroImage || product.images?.[0]?.url || '/placeholder.jpg'
@@ -231,9 +241,15 @@ export default function AppleWatchShowcase({ product, heroImage, lang = 'th' }:
const currentHighlight = HIGHLIGHTS[activeHighlight]
const scrollToPurchase = () => {
if (endCtaLink) {
window.location.href = endCtaLink
return
}
const el = document.getElementById('product-purchase-top')
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
@@ -619,8 +635,7 @@ export default function AppleWatchShowcase({ product, heroImage, lang = 'th' }:
<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 />
สู่มาตรฐานระดับโลก
{endCtaTitle ? endCtaTitle : <>อัปเกรดระบบเบรก<br />สู่มาตรฐานระดับโลก</>}
</h2>
<p className="text-base sm:text-lg text-slate-400 leading-relaxed">
พร้อมรับประกันคุณภาพและความเข้ากันได้กับรถของคุณ สั่งซื้อวันนี้เพื่อสัมผัสความมั่นใจในทุกระยะหยุด
@@ -632,7 +647,7 @@ export default function AppleWatchShowcase({ product, heroImage, lang = 'th' }:
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) ↑
{endCtaButtonText || 'เลือกซื้อรุ่นของคุณ (Select Model) ↑'}
</button>
</div>
</div>
@@ -24,6 +24,7 @@ interface Props {
lang: string
endCtaTitle?: string
endCtaButtonText?: string
endCtaLink?: string
storyTheme?: 'dark' | 'light' | string
}
@@ -89,6 +90,7 @@ export default function CinematicStoryScroll({
lang,
endCtaTitle,
endCtaButtonText,
endCtaLink,
storyTheme = 'light',
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null)
@@ -212,6 +214,10 @@ export default function CinematicStoryScroll({
}, [activeIndex, activeScenes.length, scrollToScene])
const scrollToPurchase = () => {
if (endCtaLink) {
window.location.href = endCtaLink
return
}
const el = document.getElementById('product-purchase-top')
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
@@ -16,6 +16,8 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
import ScrollReveal from '../ScrollReveal'
import PageDivider from '../PageDivider'
import CinematicStoryScroll from '../CinematicStoryScroll'
import AppleWatchShowcase from '../AppleWatchShowcase'
import 'swiper/css'
import 'swiper/css/pagination'
import 'swiper/css/navigation'
@@ -2600,6 +2602,11 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
'HERO_SPLIT_MINIMAL',
'HERO_VIDEO_MINIMAL',
'HERO_CAROUSEL',
'CINEMATIC_STORY_SCROLL',
'STORY_SCROLL',
'APPLE_SHOWCASE',
'APPLE_KEYNOTE_SHOWCASE',
'APPLE_WATCH_TOUR',
]), [])
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR_MINIMAL')
@@ -2831,6 +2838,96 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
</ScrollReveal>
)
}
case 'CINEMATIC_STORY_SCROLL':
case 'STORY_SCROLL': {
const rawTheme = payload.storyTheme as string | undefined
const storyTheme = rawTheme === 'dark' ? 'dark' : 'light'
const storyHeroImage = (payload.storyHeroImage as string) || (payload.heroImage 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
const endCtaLink = (payload.endCtaLink as string) || undefined
const targetProductId = Number(payload.productId) || 0
const foundProduct = targetProductId
? (categories.flatMap(c => (c as any).products || []) as Product[]).find(p => p.id === targetProductId)
: undefined
const mockProduct: Product = foundProduct || {
id: targetProductId || 1,
name: { en: 'Product Story', th: 'การนำเสนอผลิตภัณฑ์' },
description: { en: 'High Performance Story', th: 'การขับขี่สมรรถนะสูง' },
details: null,
price: 0,
brandId: 1,
categoryId: 1,
images: storyHeroImage ? [{ id: 1, url: storyHeroImage, order: 0 }] : [],
variants: [],
options: [
{
id: 1,
name: { en: 'Model', th: 'รุ่น' },
values: ['NVX155', 'LC135', 'Vario125', 'Vario150', 'PCX150', 'Beat110', 'Wave110-I', 'R15'] as any
}
] as any,
category: { id: 1, name: 'Braking' } as any,
brand: { id: 1, name: 'Brand Story' } as any,
}
return (
<div key={section.id} className="w-full">
<CinematicStoryScroll
product={mockProduct}
heroImage={storyHeroImage}
scenes={storyScenes}
lang={lang}
endCtaTitle={endCtaTitle}
endCtaButtonText={endCtaButtonText}
endCtaLink={endCtaLink}
storyTheme={storyTheme}
/>
</div>
)
}
case 'APPLE_SHOWCASE':
case 'APPLE_KEYNOTE_SHOWCASE':
case 'APPLE_WATCH_TOUR': {
const storyHeroImage = (payload.heroImage as string) || (payload.storyHeroImage as string) || undefined
const targetProductId = Number(payload.productId) || 0
const foundProduct = targetProductId
? (categories.flatMap(c => (c as any).products || []) as Product[]).find(p => p.id === targetProductId)
: undefined
const mockProduct: Product = foundProduct || {
id: targetProductId || 1,
name: { en: 'Keynote Showcase', th: 'การนำเสนอผลิตภัณฑ์' },
description: { en: 'Next-Gen Performance', th: 'นวัตกรรมมาตรฐานระดับโลก' },
details: null,
price: 0,
brandId: 1,
categoryId: 1,
images: storyHeroImage ? [{ id: 1, url: storyHeroImage, order: 0 }] : [],
variants: [],
category: { id: 1, name: 'Technology' } as any,
brand: { id: 1, name: 'Keynote' } as any,
}
return (
<div key={section.id} className="w-full">
<AppleWatchShowcase
product={mockProduct}
heroImage={storyHeroImage}
lang={lang}
endCtaTitle={payload.endCtaTitle as string | undefined}
endCtaButtonText={payload.endCtaButtonText as string | undefined}
endCtaLink={payload.endCtaLink as string | undefined}
/>
</div>
)
}
default:
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
+32 -1
View File
@@ -1324,6 +1324,32 @@ export const UspMinimalSectionPayloadSchema = z.object({
});
export type UspMinimalSectionPayload = z.infer<typeof UspMinimalSectionPayloadSchema>;
export const CinematicStoryScrollSectionPayloadSchema = z.object({
productId: z.union([z.number(), z.string(), z.null()]).optional(),
storyHeroImage: z.string().optional(),
storyTheme: z.enum(['dark', 'light']).optional().default('light'),
storyScenes: z.array(StorySceneSchema).optional(),
endCtaTitle: z.string().optional(),
endCtaButtonText: z.string().optional(),
endCtaLink: z.string().optional(),
paddingTop: z.string().optional(),
paddingBottom: z.string().optional(),
}).passthrough();
export type CinematicStoryScrollSectionPayload = z.infer<typeof CinematicStoryScrollSectionPayloadSchema>;
export const AppleShowcaseSectionPayloadSchema = z.object({
productId: z.union([z.number(), z.string(), z.null()]).optional(),
heroImage: z.string().optional(),
storyHeroImage: z.string().optional(),
storyTheme: z.enum(['dark', 'light']).optional().default('dark'),
endCtaTitle: z.string().optional(),
endCtaButtonText: z.string().optional(),
endCtaLink: z.string().optional(),
paddingTop: z.string().optional(),
paddingBottom: z.string().optional(),
}).passthrough();
export type AppleShowcaseSectionPayload = z.infer<typeof AppleShowcaseSectionPayloadSchema>;
export const PAGE_SECTION_TYPES = [
'HERO', 'HERO_V2', 'HERO_VIDEO', 'HERO_CAROUSEL', 'DIVIDER', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE', 'FEATURED_TILES',
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
@@ -1333,7 +1359,8 @@ export const PAGE_SECTION_TYPES = [
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS',
'PRODUCT_REVIEWS', 'RELATED_PRODUCTS', 'CUSTOM',
'HERO_MINIMAL', 'HERO_SPLIT_MINIMAL', 'HERO_VIDEO_MINIMAL', 'BANNER_MINIMAL', 'ANNOUNCEMENT_BAR_MINIMAL', 'USP_MINIMAL'
'HERO_MINIMAL', 'HERO_SPLIT_MINIMAL', 'HERO_VIDEO_MINIMAL', 'BANNER_MINIMAL', 'ANNOUNCEMENT_BAR_MINIMAL', 'USP_MINIMAL',
'CINEMATIC_STORY_SCROLL', 'APPLE_SHOWCASE'
] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number];
@@ -1382,6 +1409,8 @@ export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]
BANNER_MINIMAL: ['title', 'subtitle'],
ANNOUNCEMENT_BAR_MINIMAL: ['slides[]'],
USP_MINIMAL: ['items[]'],
CINEMATIC_STORY_SCROLL: ['endCtaTitle', 'endCtaButtonText'],
APPLE_SHOWCASE: ['endCtaTitle', 'endCtaButtonText'],
};
export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
@@ -1429,6 +1458,8 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
BANNER_MINIMAL: BannerMinimalSectionPayloadSchema,
ANNOUNCEMENT_BAR_MINIMAL: AnnouncementBarMinimalSectionPayloadSchema,
USP_MINIMAL: UspMinimalSectionPayloadSchema,
CINEMATIC_STORY_SCROLL: CinematicStoryScrollSectionPayloadSchema,
APPLE_SHOWCASE: AppleShowcaseSectionPayloadSchema,
};
export const PRODUCT_DETAIL_SECTION_TYPES = [