feat(cms): add Interactive 360 Turntable and Macro Zoom Showcase block

This commit is contained in:
Your Name committed 2026-10-17 09:25:16 +07:00
1 parent 8d9f2fc587
commit 64f7ecf729
12 files changed
+1185 -3

No files matched your search

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