feat(cms): add Interactive 360 Turntable and Macro Zoom Showcase block
This commit is contained in:
1 parent
8d9f2fc587
commit
64f7ecf729
12 files changed
+1185
-3
No files matched your search
@@ -143,7 +143,34 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 2.9 APPLE_SHOWCASE
|
||||
// 2.95 INTERACTIVE_360_SHOWCASE
|
||||
{
|
||||
type: 'INTERACTIVE_360_SHOWCASE',
|
||||
category: 'hero',
|
||||
title: 'INTERACTIVE 360° TURNTABLE SHOWCASE',
|
||||
description: 'Ultra-fast 2.5D Sprite Sheet 360° turntable, scroll-driven rotation, interactive drag-to-spin, and seamless macro detail zoom transition.',
|
||||
badge: '3D Web · Hot',
|
||||
renderPreview: () => (
|
||||
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0f0f0e] flex items-center justify-between p-3 border border-[#d5ff63]/30">
|
||||
<div className="space-y-1 max-w-[60%] z-10">
|
||||
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest">
|
||||
360° TURNTABLE + MACRO ZOOM
|
||||
</span>
|
||||
<h5 className="text-[10px] font-black text-white leading-tight">
|
||||
PRECISION TO EVERY ANGLE
|
||||
</h5>
|
||||
<div className="flex items-center gap-1 text-[6px] text-slate-400 font-mono">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63]" />
|
||||
<span>16-Angle Turntable Scrub</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-14 h-14 rounded-full bg-black/60 border border-[#d5ff63]/40 flex items-center justify-center p-1 shadow-2xl shrink-0">
|
||||
<span className="text-[9px] font-bold font-mono text-[#d5ff63]">360°</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 2.9 APPLE_SHOWCASE
|
||||
{
|
||||
type: 'APPLE_SHOWCASE',
|
||||
category: 'hero',
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Sparkles,
|
||||
Upload,
|
||||
Package,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
|
||||
interface SpecItem {
|
||||
number?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
payload: Record<string, unknown>
|
||||
updatePayloadValue: (field: string, value: unknown) => void
|
||||
onBrowseMedia?: (onSelect: (url: string) => void) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SPECS: SpecItem[] = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'โครเมียมขัดเงาระดับพรีเมียม',
|
||||
subtitle: 'POLISHED CHROME FINISH',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'เจาะรูกลึงลดน้ำหนักตามหลักอากาศพลศาสตร์',
|
||||
subtitle: 'LIGHTWEIGHT PRECISION CUTOUTS',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'โปรไฟล์ฟันเฟือง 428H แม่นยำระดับไมครอน',
|
||||
subtitle: '428H PRECISION TOOTH PROFILE',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Interactive360ShowcaseForm({
|
||||
payload,
|
||||
updatePayloadValue,
|
||||
onBrowseMedia,
|
||||
}: Props) {
|
||||
const [productsList, setProductsList] = useState<any[]>([])
|
||||
const [loadingProducts, setLoadingProducts] = useState(false)
|
||||
|
||||
const spriteSheetUrl = (payload.spriteSheetUrl as string) || '/images/3d/sprocket-views.png'
|
||||
const detailImageUrl = (payload.detailImageUrl as string) || '/images/3d/sprocket-detail.png'
|
||||
const eyebrow = (payload.eyebrow as string) || ''
|
||||
const title = (payload.title as string) || ''
|
||||
const subtitle = (payload.subtitle as string) || ''
|
||||
const detailKicker = (payload.detailKicker as string) || ''
|
||||
const detailTitle = (payload.detailTitle as string) || ''
|
||||
const detailCaption = (payload.detailCaption as string) || ''
|
||||
const productName = (payload.productName as string) || ''
|
||||
const productBadge = (payload.productBadge as string) || ''
|
||||
const accentColor = (payload.accentColor as string) || '#d5ff63'
|
||||
const theme = (payload.theme as string) || 'dark'
|
||||
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) : ''
|
||||
|
||||
const rawSpecs = Array.isArray(payload.specs) ? (payload.specs as SpecItem[]) : DEFAULT_SPECS
|
||||
|
||||
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 Interactive360ShowcaseForm', err)
|
||||
} finally {
|
||||
if (isMounted) setLoadingProducts(false)
|
||||
}
|
||||
}
|
||||
fetchCatalog()
|
||||
return () => { isMounted = false }
|
||||
}, [])
|
||||
|
||||
// Auto-seed defaults if brand new block
|
||||
useEffect(() => {
|
||||
if (!payload.spriteSheetUrl) updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
|
||||
if (!payload.detailImageUrl) updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
|
||||
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
|
||||
if (!payload.title) updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
|
||||
if (!payload.subtitle) updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
|
||||
if (!payload.detailKicker) updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
|
||||
if (!payload.detailTitle) updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
|
||||
if (!payload.detailCaption) updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
|
||||
if (!payload.productName) updatePayloadValue('productName', 'Chrome Sprocket 428H')
|
||||
if (!payload.productBadge) updatePayloadValue('productBadge', 'MONO / 01')
|
||||
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
|
||||
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||
if (!payload.specs) updatePayloadValue('specs', DEFAULT_SPECS)
|
||||
if (!payload.theme) updatePayloadValue('theme', 'dark')
|
||||
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
|
||||
}, [])
|
||||
|
||||
const handleSelectProduct = (productIdStr: string) => {
|
||||
if (!productIdStr) {
|
||||
updatePayloadValue('productId', null)
|
||||
return
|
||||
}
|
||||
const pid = Number(productIdStr)
|
||||
updatePayloadValue('productId', pid)
|
||||
|
||||
const prod = productsList.find((p) => p.id === pid)
|
||||
if (prod) {
|
||||
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
|
||||
if (prodName) updatePayloadValue('productName', prodName)
|
||||
const slug = prod.slug || `product-${pid}`
|
||||
updatePayloadValue('endCtaLink', `/products/${slug}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyPreset = () => {
|
||||
updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
|
||||
updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
|
||||
updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
|
||||
updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
|
||||
updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
|
||||
updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
|
||||
updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
|
||||
updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
|
||||
updatePayloadValue('productName', 'Chrome Sprocket 428H')
|
||||
updatePayloadValue('productBadge', 'MONO / 01')
|
||||
updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
|
||||
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||
updatePayloadValue('specs', DEFAULT_SPECS)
|
||||
updatePayloadValue('theme', 'dark')
|
||||
updatePayloadValue('accentColor', '#d5ff63')
|
||||
}
|
||||
|
||||
const handleUpdateSpec = (idx: number, field: keyof SpecItem, val: string) => {
|
||||
const updated = [...rawSpecs]
|
||||
updated[idx] = { ...updated[idx], [field]: val }
|
||||
updatePayloadValue('specs', updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-slate-200">
|
||||
{/* ── Preset & Binding Banner ── */}
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-r from-lime-500/10 via-slate-900/40 to-transparent border border-lime-500/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-lime-400 font-bold text-xs uppercase tracking-wider">
|
||||
<Sparkles className="w-4 h-4 text-lime-400" />
|
||||
<span>3D Interactive Turntable & Macro Zoom Showcase</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
แสดงผลสเตอร์/ชิ้นส่วน 360 องศาด้วย Sprite Sheet + Scroll Scrollytelling + ซูม Macro Detail อัตโนมัติ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyPreset}
|
||||
className="px-4 py-2 rounded-xl bg-lime-400 text-slate-950 font-bold text-xs hover:bg-lime-300 transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>รีเซ็ต / โหลด Mockup สเตอร์โครเมียม</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 1-Click Product Linking ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||
<Package className="w-4 h-4 text-lime-400" />
|
||||
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
|
||||
</div>
|
||||
{selectedProductId && (
|
||||
<span className="text-[10px] text-lime-400 font-mono">
|
||||
Product ID: #{selectedProductId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<select
|
||||
value={selectedProductId}
|
||||
onChange={(e) => handleSelectProduct(e.target.value)}
|
||||
disabled={loadingProducts}
|
||||
className="admin-input w-full text-xs"
|
||||
>
|
||||
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
|
||||
{productsList.map((p) => {
|
||||
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
|
||||
return (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{p.id} - {nameStr}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
เมื่อเลือกสินค้า ระบบจะดึงชื่อและตั้งค่าลิงก์ปุ่มสั่งซื้ออัตโนมัติ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaLink}
|
||||
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
|
||||
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
|
||||
className="admin-input flex-1 text-xs"
|
||||
/>
|
||||
{endCtaLink && (
|
||||
<a
|
||||
href={endCtaLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Visual Media Uploads (Sprite Sheet + Macro Detail) ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Sprite Sheet 16-Angles */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
|
||||
1. Sprite Sheet 360° (ภาพรวม 16 มุมมอง 4x4 Grid)
|
||||
</label>
|
||||
{spriteSheetUrl ? (
|
||||
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
|
||||
<img
|
||||
src={spriteSheetUrl}
|
||||
alt="360 Sprite Sheet"
|
||||
className="w-full h-full object-contain p-2"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
|
||||
{onBrowseMedia && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
|
||||
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
|
||||
>
|
||||
เปลี่ยนรูป
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
|
||||
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
|
||||
>
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Sprite Sheet</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={spriteSheetUrl}
|
||||
onChange={(e) => updatePayloadValue('spriteSheetUrl', e.target.value)}
|
||||
placeholder="/images/3d/sprocket-views.png"
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Macro Detail Shot */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
|
||||
2. Macro Detail Shot (ภาพซูมระยะประชิดระดับ 4K)
|
||||
</label>
|
||||
{detailImageUrl ? (
|
||||
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
|
||||
<img
|
||||
src={detailImageUrl}
|
||||
alt="Macro Detail"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
|
||||
{onBrowseMedia && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
|
||||
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
|
||||
>
|
||||
เปลี่ยนรูป
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
|
||||
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
|
||||
>
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Macro Detail</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={detailImageUrl}
|
||||
onChange={(e) => updatePayloadValue('detailImageUrl', e.target.value)}
|
||||
placeholder="/images/3d/sprocket-detail.png"
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Headline & Intro Texts ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||
ข้อความหัวเรื่อง (Header & Intro)
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หมวด/ป้ายกำกับ (Eyebrow Tag)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={eyebrow}
|
||||
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
placeholder="ENGINEERED TO MOVE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ชื่อสินค้า (Product Name)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productName}
|
||||
onChange={(e) => updatePayloadValue('productName', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="Chrome Sprocket 428H"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ซีรีส์สินค้า (Badge / Series)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productBadge}
|
||||
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
placeholder="MONO / 01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หัวเรื่องหลัก (Main Title)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => updatePayloadValue('title', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
คำบรรยายเกริ่นนำ (Intro Subtitle)
|
||||
</label>
|
||||
<textarea
|
||||
value={subtitle}
|
||||
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
|
||||
rows={2}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Macro Zoom Detail Overlay Texts ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||
<div className="text-xs font-bold text-lime-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>ข้อความส่วนซูม Macro Detail (แสดงเมื่อเลื่อนจอลงถึงช่วงท้าย)</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
Kicker Tag
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detailKicker}
|
||||
onChange={(e) => updatePayloadValue('detailKicker', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
placeholder="FINISHED TO THE EDGE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
Headline ตอนซูม
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detailTitle}
|
||||
onChange={(e) => updatePayloadValue('detailTitle', e.target.value)}
|
||||
className="admin-input w-full text-xs font-bold"
|
||||
placeholder="ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
คำอธิบายรายละเอียด
|
||||
</label>
|
||||
<textarea
|
||||
value={detailCaption}
|
||||
onChange={(e) => updatePayloadValue('detailCaption', e.target.value)}
|
||||
rows={2}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3-Spec Highlights Grid ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||
จุดเด่นด้านสมรรถนะ 3 หัวข้อ (Technical Pillars)
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{rawSpecs.map((spec, idx) => (
|
||||
<div key={idx} className="p-3 rounded-xl bg-black/40 border border-white/10 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono font-bold text-lime-400">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={spec.number || `0${idx + 1}`}
|
||||
onChange={(e) => handleUpdateSpec(idx, 'number', e.target.value)}
|
||||
className="admin-input w-16 text-center text-xs font-mono"
|
||||
placeholder={`0${idx + 1}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={spec.title}
|
||||
onChange={(e) => handleUpdateSpec(idx, 'title', e.target.value)}
|
||||
className="admin-input w-full text-xs font-bold"
|
||||
placeholder="หัวข้อสเปก"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={spec.subtitle || ''}
|
||||
onChange={(e) => handleUpdateSpec(idx, 'subtitle', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono text-slate-400"
|
||||
placeholder="SUBTITLE EN"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Theme & CTA Config ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
สี Accent Highlight
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={accentColor}
|
||||
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||
className="admin-input flex-1 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaTitle}
|
||||
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="พร้อมยกระดับสมรรถนะการขับขี่?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ข้อความปุ่มสั่งซื้อ (CTA Button)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaButtonText}
|
||||
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="สั่งซื้อสินค้าตอนนี้"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
โทนพื้นหลัง (Theme)
|
||||
</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => updatePayloadValue('theme', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
>
|
||||
<option value="dark">Dark Theater (#0f0f0e - แนะนำ)</option>
|
||||
<option value="light">Light Studio (#f8f9fa)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { getAuthHeaders } from '../utils/auth';
|
||||
|
||||
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
|
||||
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
||||
import Interactive360ShowcaseForm from '../components/page-builder/Interactive360ShowcaseForm';
|
||||
import HeroForm from '../components/page-builder/HeroForm';
|
||||
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
||||
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
||||
@@ -1469,6 +1470,7 @@ export default function PageBuilderPage() {
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
className="admin-input w-full bg-slate-900 border-white/10"
|
||||
>
|
||||
<option value="INTERACTIVE_360_SHOWCASE">⚡ INTERACTIVE 360° TURNTABLE (3D Web Sprocket)</option>
|
||||
<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>
|
||||
@@ -1593,6 +1595,17 @@ export default function PageBuilderPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
|
||||
<Interactive360ShowcaseForm
|
||||
payload={editingSection.payload || {}}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
onBrowseMedia={(cb) => {
|
||||
setCustomMediaCallback(() => cb);
|
||||
setIsMediaModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'CINEMATIC_STORY_SCROLL' && (
|
||||
<CinematicStoryScrollForm
|
||||
payload={editingSection.payload || {}}
|
||||
|
||||
@@ -524,7 +524,12 @@ function validateSectionPayload(type, payload) {
|
||||
case "STORY_SCROLL":
|
||||
schema = CinematicStoryScrollSectionPayloadSchema;
|
||||
break;
|
||||
case "APPLE_SHOWCASE":
|
||||
case "INTERACTIVE_360_SHOWCASE":
|
||||
case "TURNTABLE_3D_SHOWCASE":
|
||||
case "TURNTABLE_360":
|
||||
schema = Interactive360ShowcaseSectionPayloadSchema;
|
||||
break;
|
||||
case "APPLE_SHOWCASE":
|
||||
case "APPLE_KEYNOTE_SHOWCASE":
|
||||
case "APPLE_WATCH_TOUR":
|
||||
schema = AppleShowcaseSectionPayloadSchema;
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
UspMinimalSectionPayloadSchema,
|
||||
CinematicStoryScrollSectionPayloadSchema,
|
||||
AppleShowcaseSectionPayloadSchema,
|
||||
Interactive360ShowcaseSectionPayloadSchema,
|
||||
SectionSpacingPayloadSchema,
|
||||
PAGE_SECTION_LOCALIZED_PATHS,
|
||||
type SectionType,
|
||||
@@ -1434,6 +1435,11 @@ export function validateSectionPayload(
|
||||
case "STORY_SCROLL":
|
||||
schema = CinematicStoryScrollSectionPayloadSchema;
|
||||
break;
|
||||
case "INTERACTIVE_360_SHOWCASE":
|
||||
case "TURNTABLE_3D_SHOWCASE":
|
||||
case "TURNTABLE_360":
|
||||
schema = Interactive360ShowcaseSectionPayloadSchema;
|
||||
break;
|
||||
case "APPLE_SHOWCASE":
|
||||
case "APPLE_KEYNOTE_SHOWCASE":
|
||||
case "APPLE_WATCH_TOUR":
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,521 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
ArrowRight,
|
||||
Maximize2,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface SpecItem {
|
||||
number?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export interface Interactive360Props {
|
||||
spriteSheetUrl?: string
|
||||
detailImageUrl?: string
|
||||
columns?: number
|
||||
rows?: number
|
||||
eyebrow?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
detailKicker?: string
|
||||
detailTitle?: string
|
||||
detailCaption?: string
|
||||
productName?: string
|
||||
productBadge?: string
|
||||
specs?: SpecItem[]
|
||||
endCtaTitle?: string
|
||||
endCtaButtonText?: string
|
||||
endCtaLink?: string
|
||||
theme?: 'dark' | 'light'
|
||||
accentColor?: string
|
||||
lang?: string
|
||||
}
|
||||
|
||||
const DEFAULT_SPECS: SpecItem[] = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'โครเมียมขัดเงาระดับพรีเมียม',
|
||||
subtitle: 'POLISHED CHROME FINISH',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'เจาะรูกลึงลดน้ำหนักตามหลักอากาศพลศาสตร์',
|
||||
subtitle: 'LIGHTWEIGHT PRECISION CUTOUTS',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'โปรไฟล์ฟันเฟือง 428H แม่นยำระดับไมครอน',
|
||||
subtitle: '428H PRECISION TOOTH PROFILE',
|
||||
},
|
||||
]
|
||||
|
||||
const VIEW_NAMES = [
|
||||
'มุมมองด้านหน้า (Front View)',
|
||||
'มุมมองเฉียง 45° (Quarter Angle)',
|
||||
'มุมมองด้านข้าง (Side Profile)',
|
||||
'มุมมองด้านหลัง (Rear View)',
|
||||
]
|
||||
|
||||
export default function Interactive360Showcase({
|
||||
spriteSheetUrl = '/images/3d/sprocket-views.png',
|
||||
detailImageUrl = '/images/3d/sprocket-detail.png',
|
||||
columns = 4,
|
||||
rows = 4,
|
||||
eyebrow = 'ENGINEERED TO MOVE',
|
||||
title = 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา',
|
||||
subtitle = 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro',
|
||||
detailKicker = 'FINISHED TO THE EDGE',
|
||||
detailTitle = 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ',
|
||||
detailCaption = 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน',
|
||||
productName = 'Chrome Sprocket 428H',
|
||||
productBadge = 'MONO / 01',
|
||||
specs = DEFAULT_SPECS,
|
||||
endCtaTitle = 'พร้อมยกระดับสมรรถนะการขับขี่?',
|
||||
endCtaButtonText = 'สั่งซื้อสินค้าตอนนี้',
|
||||
endCtaLink,
|
||||
theme = 'dark',
|
||||
accentColor = '#d5ff63',
|
||||
}: Interactive360Props) {
|
||||
const isDark = theme !== 'light'
|
||||
const totalFrames = columns * rows
|
||||
|
||||
const scrollSectionRef = useRef<HTMLDivElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const spriteRef = useRef<HTMLImageElement | null>(null)
|
||||
|
||||
const [frameIndex, setFrameIndex] = useState(0)
|
||||
const [currentViewLabel, setCurrentViewLabel] = useState(VIEW_NAMES[0])
|
||||
const [scrollProgress, setScrollProgress] = useState(0)
|
||||
const [zoomAmount, setZoomAmount] = useState(0)
|
||||
const [isAutoRotate, setIsAutoRotate] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// Internal animation state refs
|
||||
const stateRef = useRef({
|
||||
frame: 0,
|
||||
targetFrame: 0,
|
||||
dragging: false,
|
||||
startX: 0,
|
||||
startFrame: 0,
|
||||
autoRotate: false,
|
||||
lastTick: 0,
|
||||
scrollDriven: false,
|
||||
zoom: 0,
|
||||
targetZoom: 0,
|
||||
})
|
||||
|
||||
// 1. Load Sprite Sheet Image
|
||||
useEffect(() => {
|
||||
const img = new Image()
|
||||
img.src = spriteSheetUrl
|
||||
img.onload = () => {
|
||||
spriteRef.current = img
|
||||
renderCanvas(0)
|
||||
}
|
||||
}, [spriteSheetUrl])
|
||||
|
||||
// 2. Render Frame onto Canvas
|
||||
const renderCanvas = useCallback(
|
||||
(currentFrameFloat: number) => {
|
||||
const canvas = canvasRef.current
|
||||
const sprite = spriteRef.current
|
||||
if (!canvas || !sprite || !sprite.naturalWidth || !canvas.width) return
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true })
|
||||
if (!ctx) return
|
||||
|
||||
const index = ((Math.round(currentFrameFloat) % totalFrames) + totalFrames) % totalFrames
|
||||
const col = index % columns
|
||||
const row = Math.floor(index / columns)
|
||||
|
||||
const sourceCellW = sprite.naturalWidth / columns
|
||||
const sourceCellH = sprite.naturalHeight / rows
|
||||
const sx = col * sourceCellW
|
||||
const sy = row * sourceCellH
|
||||
const sw = Math.min(sourceCellW, sprite.naturalWidth - sx)
|
||||
const sh = Math.min(sourceCellH, sprite.naturalHeight - sy)
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const scale = Math.min(canvas.width / sw, canvas.height / sh)
|
||||
const dw = sw * scale
|
||||
const dh = sh * scale
|
||||
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
sx,
|
||||
sy,
|
||||
sw,
|
||||
sh,
|
||||
(canvas.width - dw) / 2,
|
||||
(canvas.height - dh) / 2,
|
||||
dw,
|
||||
dh
|
||||
)
|
||||
|
||||
setFrameIndex(index)
|
||||
const viewSector = Math.min(3, Math.floor(index / 4))
|
||||
setCurrentViewLabel(VIEW_NAMES[viewSector])
|
||||
},
|
||||
[columns, rows, totalFrames]
|
||||
)
|
||||
|
||||
// 3. Resize Canvas according to Device Pixel Ratio
|
||||
const resizeCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr))
|
||||
canvas.height = Math.max(1, Math.round(rect.height * dpr))
|
||||
renderCanvas(stateRef.current.frame)
|
||||
}, [renderCanvas])
|
||||
|
||||
useEffect(() => {
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
return () => window.removeEventListener('resize', resizeCanvas)
|
||||
}, [resizeCanvas])
|
||||
|
||||
// 4. Scroll Listener
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const s = stateRef.current
|
||||
if (s.dragging || s.autoRotate) return
|
||||
const section = scrollSectionRef.current
|
||||
if (!section) return
|
||||
|
||||
const rect = section.getBoundingClientRect()
|
||||
const scrollable = section.offsetHeight - window.innerHeight
|
||||
if (scrollable <= 0) return
|
||||
|
||||
const progress = Math.max(0, Math.min(1, -rect.top / scrollable))
|
||||
setScrollProgress(progress)
|
||||
|
||||
const active = rect.top < window.innerHeight && rect.bottom > 0
|
||||
if (!active) return
|
||||
|
||||
s.scrollDriven = true
|
||||
const turnProgress = Math.min(1, progress / 0.68)
|
||||
s.targetFrame = turnProgress * (totalFrames - 1)
|
||||
s.targetZoom = Math.max(0, Math.min(1, (progress - 0.68) / 0.32))
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [totalFrames])
|
||||
|
||||
// 5. Main Animation Loop (Smoothing, Zoom, Inertia)
|
||||
useEffect(() => {
|
||||
let animId: number
|
||||
const animate = (time: number) => {
|
||||
const s = stateRef.current
|
||||
|
||||
if (s.autoRotate && time - s.lastTick > 90) {
|
||||
s.targetFrame = (s.targetFrame + 0.15) % totalFrames
|
||||
s.lastTick = time
|
||||
}
|
||||
|
||||
s.zoom += (s.targetZoom - s.zoom) * 0.12
|
||||
const curZoom = s.zoom < 0.005 ? 0 : s.zoom
|
||||
setZoomAmount(curZoom)
|
||||
|
||||
const delta = s.targetFrame - s.frame
|
||||
if (Math.abs(delta) > 0.01) {
|
||||
const step =
|
||||
s.scrollDriven && !s.autoRotate
|
||||
? delta
|
||||
: ((delta + totalFrames * 1.5) % totalFrames) - totalFrames / 2
|
||||
s.frame = (s.frame + step * 0.18 + totalFrames) % totalFrames
|
||||
renderCanvas(s.frame)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate)
|
||||
return () => cancelAnimationFrame(animId)
|
||||
}, [renderCanvas, totalFrames])
|
||||
|
||||
// 6. Interactive Drag Handlers
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
|
||||
const s = stateRef.current
|
||||
s.dragging = true
|
||||
s.startX = e.clientX
|
||||
s.startFrame = s.targetFrame
|
||||
stage.setPointerCapture(e.pointerId)
|
||||
|
||||
s.autoRotate = false
|
||||
s.targetZoom = 0
|
||||
setIsAutoRotate(false)
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
const s = stateRef.current
|
||||
if (!s.dragging) return
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
|
||||
const dx = e.clientX - s.startX
|
||||
s.targetFrame = (s.startFrame + dx / Math.max(12, stage.clientWidth / 18) + totalFrames * 2) % totalFrames
|
||||
const p = (s.targetFrame % totalFrames) / (totalFrames - 1)
|
||||
setScrollProgress(p)
|
||||
}
|
||||
|
||||
const stopDragging = () => {
|
||||
const s = stateRef.current
|
||||
if (!s.dragging) return
|
||||
s.dragging = false
|
||||
s.scrollDriven = false
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
// 7. Toggle Auto-Rotate
|
||||
const toggleAutoRotate = () => {
|
||||
const s = stateRef.current
|
||||
s.autoRotate = !s.autoRotate
|
||||
s.targetZoom = 0
|
||||
setIsAutoRotate(s.autoRotate)
|
||||
}
|
||||
|
||||
const handleCtaClick = () => {
|
||||
if (endCtaLink) {
|
||||
window.location.href = endCtaLink
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`relative w-full ${
|
||||
isDark ? 'bg-[#0f0f0e] text-[#f4f2ec]' : 'bg-[#f8f9fa] text-slate-900'
|
||||
} font-sans selection:bg-[${accentColor}] selection:text-black transition-colors duration-500`}
|
||||
>
|
||||
{/* ── Section Intro Header ── */}
|
||||
<div className="pt-24 sm:pt-32 pb-12 px-6 sm:px-12 max-w-6xl mx-auto flex flex-col sm:flex-row items-start sm:items-end justify-between gap-6 border-b border-white/5">
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-white/5 border border-white/10 text-[10px] tracking-widest font-mono uppercase" style={{ color: accentColor }}>
|
||||
<span className="w-1.5 h-1.5 rounded-full animate-ping" style={{ backgroundColor: accentColor }} />
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-5xl md:text-6xl font-extrabold tracking-tight leading-[1.1]">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm sm:text-base text-slate-400 font-light leading-relaxed">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex flex-col items-end gap-1 text-[11px] font-mono text-slate-500 uppercase tracking-widest">
|
||||
<span>INTERACTIVE 360° TURNTABLE</span>
|
||||
<span style={{ color: accentColor }}>{totalFrames} ANGLE CAPTURE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scroll Section with Sticky Stage (240vh) ── */}
|
||||
<div
|
||||
ref={scrollSectionRef}
|
||||
className="relative w-full"
|
||||
style={{ height: '240vh' }}
|
||||
>
|
||||
<div className="sticky top-0 h-screen w-full flex flex-col justify-between py-6 sm:py-10 px-4 sm:px-12 overflow-hidden">
|
||||
{/* Top Info Bar inside Sticky Canvas */}
|
||||
<div className="w-full flex items-center justify-between z-20 max-w-7xl mx-auto">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] uppercase font-mono tracking-widest" style={{ color: accentColor }}>
|
||||
{productBadge}
|
||||
</span>
|
||||
<h3 className="text-base sm:text-lg font-bold tracking-tight">
|
||||
{productName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* View Label & Angle Badge */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/40 backdrop-blur-md border border-white/10 text-xs font-mono text-slate-300">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: accentColor }} />
|
||||
<span>{zoomAmount > 0.08 ? 'รายละเอียดฟันเฟือง (Macro Detail)' : currentViewLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Stage: Canvas + Macro Detail Crossfade */}
|
||||
<div
|
||||
ref={stageRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={stopDragging}
|
||||
onPointerCancel={stopDragging}
|
||||
className="relative flex-1 w-full max-w-5xl mx-auto flex items-center justify-center cursor-grab active:cursor-grabbing select-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
{/* Subtle Stage Spotlight Glow */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none rounded-full blur-3xl opacity-30 transform scale-75 transition-opacity"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${accentColor} 0%, rgba(0,0,0,0) 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Turntable Canvas */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="relative z-10 w-full h-[65vh] max-h-[560px] object-contain transition-transform duration-75 ease-out"
|
||||
style={{
|
||||
opacity: 1 - zoomAmount,
|
||||
transform: `scale(${1 + zoomAmount * 0.45})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Macro Detail Image Overlay (Crossfade on zoom) */}
|
||||
{detailImageUrl && (
|
||||
<img
|
||||
src={detailImageUrl}
|
||||
alt="Macro Sprocket Detail"
|
||||
className="absolute inset-0 z-20 w-full h-full object-contain pointer-events-none transition-transform duration-100 ease-out"
|
||||
style={{
|
||||
opacity: zoomAmount,
|
||||
transform: `scale(${1.08 - zoomAmount * 0.08})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Macro Detail Text Callout */}
|
||||
<div
|
||||
className="absolute right-4 sm:right-12 bottom-12 sm:bottom-20 z-30 flex flex-col items-end text-right space-y-2 pointer-events-none transition-all duration-300 max-w-sm"
|
||||
style={{
|
||||
opacity: zoomAmount,
|
||||
transform: `translateY(${16 - zoomAmount * 16}px)`,
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest font-bold" style={{ color: accentColor }}>
|
||||
{detailKicker}
|
||||
</span>
|
||||
<h4 className="text-xl sm:text-3xl font-extrabold tracking-tight drop-shadow-md">
|
||||
{detailTitle}
|
||||
</h4>
|
||||
<p className="text-xs sm:text-sm text-slate-300 leading-relaxed font-light drop-shadow">
|
||||
{detailCaption}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Drag Hint Overlay */}
|
||||
{!isDragging && scrollProgress < 0.03 && !isAutoRotate && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 px-4 py-2 rounded-full bg-black/60 backdrop-blur-md border border-white/10 text-xs font-mono text-slate-300 flex items-center gap-2 pointer-events-none animate-pulse">
|
||||
<Maximize2 className="w-3.5 h-3.5" style={{ color: accentColor }} />
|
||||
<span>ลากซ้าย-ขวาเพื่อหมุนดูรอบทิศ หรือเลื่อนจอลงด้านล่าง</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Control Bar */}
|
||||
<div className="w-full max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4 z-20">
|
||||
{/* Auto-Rotate & Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAutoRotate}
|
||||
className="px-4 py-2 rounded-full bg-white/10 hover:bg-white/20 active:scale-95 border border-white/15 text-xs font-mono flex items-center gap-2 transition-all cursor-pointer"
|
||||
>
|
||||
{isAutoRotate ? (
|
||||
<>
|
||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||
<span>หยุดหมุน (Pause)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-3.5 h-3.5 fill-current" />
|
||||
<span>หมุนอัตโนมัติ (Auto Rotate)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-xs font-mono text-slate-400">
|
||||
<span className="font-bold text-white">
|
||||
{String(frameIndex + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="mx-1 text-slate-600">/</span>
|
||||
<span>{totalFrames} VIEWS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs font-mono text-slate-400">
|
||||
<ChevronDown className="w-4 h-4 animate-bounce" style={{ color: accentColor }} />
|
||||
<span>เลื่อนลงเพื่อดู Macro Zoom รายละเอียดลึก</span>
|
||||
</div>
|
||||
|
||||
{/* Scrub Progress Bar */}
|
||||
<div className="w-full sm:w-48 h-1 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-75"
|
||||
style={{
|
||||
width: `${scrollProgress * 100}%`,
|
||||
backgroundColor: accentColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Specs Grid Section (3 Technical Pillars) ── */}
|
||||
<div className="relative py-20 px-6 sm:px-12 max-w-6xl mx-auto border-t border-white/10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 sm:gap-8">
|
||||
{specs.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-6 rounded-2xl bg-white/[0.02] border border-white/5 space-y-2 hover:border-white/15 transition-all group"
|
||||
>
|
||||
<span className="text-xs font-mono font-bold" style={{ color: accentColor }}>
|
||||
{item.number || `0${idx + 1}`}
|
||||
</span>
|
||||
<h5 className="text-base sm:text-lg font-bold text-slate-100 group-hover:text-white transition-colors">
|
||||
{item.title}
|
||||
</h5>
|
||||
{item.subtitle && (
|
||||
<p className="text-xs text-slate-400 font-mono tracking-wider">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Final Conversion CTA Banner ── */}
|
||||
<div className="mt-16 p-8 sm:p-12 rounded-3xl bg-gradient-to-r from-white/[0.05] via-white/[0.02] to-transparent border border-white/10 flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||
<div className="space-y-1 text-center sm:text-left">
|
||||
<span className="text-[10px] font-mono tracking-widest uppercase" style={{ color: accentColor }}>
|
||||
HIGH PERFORMANCE RACING SPROCKET
|
||||
</span>
|
||||
<h4 className="text-2xl sm:text-3xl font-extrabold tracking-tight">
|
||||
{endCtaTitle}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCtaClick}
|
||||
className="px-8 py-3.5 rounded-full font-bold text-sm tracking-wide text-black hover:opacity-90 active:scale-95 transition-all shadow-xl flex items-center gap-2 cursor-pointer"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
<span>{endCtaButtonText}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import ScrollReveal from '../ScrollReveal'
|
||||
import PageDivider from '../PageDivider'
|
||||
import CinematicStoryScroll from '../CinematicStoryScroll'
|
||||
import AppleWatchShowcase from '../AppleWatchShowcase'
|
||||
import Interactive360Showcase from '../Interactive360Showcase'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/pagination'
|
||||
import 'swiper/css/navigation'
|
||||
@@ -2839,6 +2840,36 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
)
|
||||
}
|
||||
|
||||
case 'INTERACTIVE_360_SHOWCASE':
|
||||
case 'TURNTABLE_3D_SHOWCASE':
|
||||
case 'TURNTABLE_360': {
|
||||
return (
|
||||
<div key={section.id} className="w-full">
|
||||
<Interactive360Showcase
|
||||
spriteSheetUrl={(payload.spriteSheetUrl as string) || '/images/3d/sprocket-views.png'}
|
||||
detailImageUrl={(payload.detailImageUrl as string) || '/images/3d/sprocket-detail.png'}
|
||||
columns={Number(payload.columns) || 4}
|
||||
rows={Number(payload.rows) || 4}
|
||||
eyebrow={payload.eyebrow as string | undefined}
|
||||
title={payload.title as string | undefined}
|
||||
subtitle={payload.subtitle as string | undefined}
|
||||
detailKicker={payload.detailKicker as string | undefined}
|
||||
detailTitle={payload.detailTitle as string | undefined}
|
||||
detailCaption={payload.detailCaption as string | undefined}
|
||||
productName={payload.productName as string | undefined}
|
||||
productBadge={payload.productBadge as string | undefined}
|
||||
specs={Array.isArray(payload.specs) ? (payload.specs as any) : undefined}
|
||||
endCtaTitle={payload.endCtaTitle as string | undefined}
|
||||
endCtaButtonText={payload.endCtaButtonText as string | undefined}
|
||||
endCtaLink={payload.endCtaLink as string | undefined}
|
||||
theme={(payload.theme as 'dark' | 'light') || 'dark'}
|
||||
accentColor={(payload.accentColor as string) || '#d5ff63'}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'CINEMATIC_STORY_SCROLL':
|
||||
case 'STORY_SCROLL': {
|
||||
const rawTheme = payload.storyTheme as string | undefined
|
||||
|
||||
+32
-1
@@ -1337,6 +1337,35 @@ export const CinematicStoryScrollSectionPayloadSchema = z.object({
|
||||
}).passthrough();
|
||||
export type CinematicStoryScrollSectionPayload = z.infer<typeof CinematicStoryScrollSectionPayloadSchema>;
|
||||
|
||||
export const Interactive360ShowcaseSectionPayloadSchema = z.object({
|
||||
spriteSheetUrl: z.string().optional().default('/images/3d/sprocket-views.png'),
|
||||
detailImageUrl: z.string().optional().default('/images/3d/sprocket-detail.png'),
|
||||
columns: z.number().optional().default(4),
|
||||
rows: z.number().optional().default(4),
|
||||
eyebrow: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
detailKicker: z.string().optional(),
|
||||
detailTitle: z.string().optional(),
|
||||
detailCaption: z.string().optional(),
|
||||
productName: z.string().optional(),
|
||||
productBadge: z.string().optional(),
|
||||
specs: z.array(z.object({
|
||||
number: z.string().optional(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
})).optional(),
|
||||
endCtaTitle: z.string().optional(),
|
||||
endCtaButtonText: z.string().optional(),
|
||||
endCtaLink: z.string().optional(),
|
||||
theme: z.enum(['dark', 'light']).optional().default('dark'),
|
||||
accentColor: z.string().optional().default('#d5ff63'),
|
||||
productId: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
paddingTop: z.string().optional(),
|
||||
paddingBottom: z.string().optional(),
|
||||
}).passthrough();
|
||||
export type Interactive360ShowcaseSectionPayload = z.infer<typeof Interactive360ShowcaseSectionPayloadSchema>;
|
||||
|
||||
export const AppleShowcaseSectionPayloadSchema = z.object({
|
||||
productId: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
heroImage: z.string().optional(),
|
||||
@@ -1360,7 +1389,7 @@ export const PAGE_SECTION_TYPES = [
|
||||
'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',
|
||||
'CINEMATIC_STORY_SCROLL', 'APPLE_SHOWCASE'
|
||||
'CINEMATIC_STORY_SCROLL', 'APPLE_SHOWCASE', 'INTERACTIVE_360_SHOWCASE'
|
||||
] as const;
|
||||
export type SectionType = typeof PAGE_SECTION_TYPES[number];
|
||||
|
||||
@@ -1411,6 +1440,7 @@ export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]
|
||||
USP_MINIMAL: ['items[]'],
|
||||
CINEMATIC_STORY_SCROLL: ['endCtaTitle', 'endCtaButtonText'],
|
||||
APPLE_SHOWCASE: ['endCtaTitle', 'endCtaButtonText'],
|
||||
INTERACTIVE_360_SHOWCASE: ['eyebrow', 'title', 'subtitle', 'detailKicker', 'detailTitle', 'detailCaption', 'productName', 'endCtaTitle', 'endCtaButtonText'],
|
||||
};
|
||||
|
||||
export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
|
||||
@@ -1460,6 +1490,7 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
|
||||
USP_MINIMAL: UspMinimalSectionPayloadSchema,
|
||||
CINEMATIC_STORY_SCROLL: CinematicStoryScrollSectionPayloadSchema,
|
||||
APPLE_SHOWCASE: AppleShowcaseSectionPayloadSchema,
|
||||
INTERACTIVE_360_SHOWCASE: Interactive360ShowcaseSectionPayloadSchema,
|
||||
};
|
||||
|
||||
export const PRODUCT_DETAIL_SECTION_TYPES = [
|
||||
|
||||
Reference in new issue
Block a user