feat(cms): add VDO Frame Scrubber block with isolated sequence library
This commit is contained in:
1 parent
64f7ecf729
commit
0bf544720b
877 files changed
+1632
-6
No files matched your search
@@ -0,0 +1,474 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
ArrowRight,
|
||||
Maximize2,
|
||||
Sliders,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface VdoFrameScrubberProps {
|
||||
folderPath?: string
|
||||
pattern?: string
|
||||
padding?: number
|
||||
totalFrames?: number
|
||||
fps?: number
|
||||
durationSeconds?: number
|
||||
eyebrow?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
instruction?: string
|
||||
productName?: string
|
||||
productBadge?: string
|
||||
endCtaTitle?: string
|
||||
endCtaButtonText?: string
|
||||
endCtaLink?: string
|
||||
theme?: 'dark' | 'light'
|
||||
accentColor?: string
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export default function VdoFrameScrubber({
|
||||
folderPath = '/frame-sequences/chrome-sprocket-428h',
|
||||
pattern = 'frame-{index}.webp',
|
||||
padding = 3,
|
||||
totalFrames = 432,
|
||||
durationSeconds = 18,
|
||||
eyebrow = 'FROM VIDEO TO INTERACTIVE',
|
||||
title = 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม',
|
||||
subtitle = 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์',
|
||||
instruction = 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ',
|
||||
productName = 'SPROCKET / FRAME SCRUBBER',
|
||||
productBadge = '18 SEC / 432 EXTRACTED FRAMES',
|
||||
endCtaTitle = 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่',
|
||||
endCtaButtonText = 'สั่งซื้อสินค้าตอนนี้',
|
||||
endCtaLink,
|
||||
theme = 'dark',
|
||||
accentColor = '#d5ff63',
|
||||
}: VdoFrameScrubberProps) {
|
||||
const isDark = theme !== 'light'
|
||||
|
||||
const scrollSectionRef = useRef<HTMLDivElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const [currentFrame, setCurrentFrame] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [scrollProgress, setScrollProgress] = useState(0)
|
||||
|
||||
// LRU cache for decoded frames (max 16 frames in memory to prevent mobile memory leaks)
|
||||
const cacheRef = useRef<Map<number, HTMLImageElement>>(new Map())
|
||||
const pendingRef = useRef<Map<number, Promise<HTMLImageElement | null>>>(new Map())
|
||||
|
||||
// Internal state ref for event handlers & animation loops
|
||||
const stateRef = useRef({
|
||||
current: 0,
|
||||
dragging: false,
|
||||
pointerStart: 0,
|
||||
frameStart: 0,
|
||||
playing: false,
|
||||
playTimer: null as any,
|
||||
})
|
||||
|
||||
// Format path to frame image
|
||||
const getFrameUrl = useCallback(
|
||||
(index: number) => {
|
||||
const idx = ((Math.round(index) % totalFrames) + totalFrames) % totalFrames
|
||||
const pad = String(idx + 1).padStart(padding, '0')
|
||||
const base = folderPath.replace(/\/$/, '')
|
||||
return `${base}/${pattern.replace('{index}', pad)}`
|
||||
},
|
||||
[folderPath, pattern, padding, totalFrames]
|
||||
)
|
||||
|
||||
// Preload & Cache a single frame
|
||||
const loadFrame = useCallback(
|
||||
(index: number): Promise<HTMLImageElement | null> => {
|
||||
const idx = ((Math.round(index) % totalFrames) + totalFrames) % totalFrames
|
||||
const cache = cacheRef.current
|
||||
const pending = pendingRef.current
|
||||
|
||||
if (cache.has(idx)) {
|
||||
const img = cache.get(idx)!
|
||||
cache.delete(idx)
|
||||
cache.set(idx, img)
|
||||
return Promise.resolve(img)
|
||||
}
|
||||
|
||||
if (pending.has(idx)) {
|
||||
return pending.get(idx)!
|
||||
}
|
||||
|
||||
const p = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = reject
|
||||
img.src = getFrameUrl(idx)
|
||||
})
|
||||
.then((img) => {
|
||||
pending.delete(idx)
|
||||
cache.set(idx, img)
|
||||
while (cache.size > 16) {
|
||||
cache.delete(cache.keys().next().value!)
|
||||
}
|
||||
return img
|
||||
})
|
||||
.catch(() => {
|
||||
pending.delete(idx)
|
||||
return null
|
||||
})
|
||||
|
||||
pending.set(idx, p)
|
||||
return p
|
||||
},
|
||||
[getFrameUrl, totalFrames]
|
||||
)
|
||||
|
||||
// Paint image onto Canvas with smooth aspect scaling
|
||||
const paintCanvas = useCallback((img: HTMLImageElement | null) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !img || !img.naturalWidth || !canvas.width) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const scale = Math.min(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight)
|
||||
const dw = img.naturalWidth * scale
|
||||
const dh = img.naturalHeight * scale
|
||||
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.drawImage(img, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh)
|
||||
}, [])
|
||||
|
||||
// Show specific frame & preload neighbors [-2, -1, 1, 2]
|
||||
const showFrame = useCallback(
|
||||
(val: number) => {
|
||||
const idx = ((Math.round(val) % totalFrames) + totalFrames) % totalFrames
|
||||
stateRef.current.current = idx
|
||||
setCurrentFrame(idx)
|
||||
|
||||
const prog = totalFrames > 1 ? idx / (totalFrames - 1) : 0
|
||||
setScrollProgress(prog)
|
||||
|
||||
loadFrame(idx).then((img) => {
|
||||
if (img && stateRef.current.current === idx) {
|
||||
paintCanvas(img)
|
||||
}
|
||||
})
|
||||
|
||||
// Lookahead predictive preload
|
||||
for (const offset of [-2, -1, 1, 2]) {
|
||||
loadFrame((idx + offset + totalFrames) % totalFrames)
|
||||
}
|
||||
},
|
||||
[loadFrame, paintCanvas, totalFrames]
|
||||
)
|
||||
|
||||
// Resize canvas according to device pixel ratio
|
||||
const resizeCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = Math.round(rect.width * dpr)
|
||||
canvas.height = Math.round(rect.height * dpr)
|
||||
loadFrame(stateRef.current.current).then(paintCanvas)
|
||||
}, [loadFrame, paintCanvas])
|
||||
|
||||
useEffect(() => {
|
||||
resizeCanvas()
|
||||
showFrame(0)
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
return () => window.removeEventListener('resize', resizeCanvas)
|
||||
}, [resizeCanvas, showFrame])
|
||||
|
||||
// Scroll-driven scrubbing
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const s = stateRef.current
|
||||
if (s.dragging || s.playing) return
|
||||
const section = scrollSectionRef.current
|
||||
if (!section) return
|
||||
|
||||
const rect = section.getBoundingClientRect()
|
||||
if (rect.top >= window.innerHeight || rect.bottom <= 0) return
|
||||
|
||||
const scrollable = Math.max(1, section.offsetHeight - window.innerHeight)
|
||||
const fraction = Math.max(0, Math.min(1, -rect.top / scrollable))
|
||||
showFrame(fraction * (totalFrames - 1))
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [showFrame, totalFrames])
|
||||
|
||||
// Play / Pause loop at native 24fps
|
||||
const togglePlay = () => {
|
||||
const s = stateRef.current
|
||||
const nextState = !s.playing
|
||||
s.playing = nextState
|
||||
setIsPlaying(nextState)
|
||||
|
||||
if (s.playTimer) clearInterval(s.playTimer)
|
||||
if (nextState) {
|
||||
const intervalMs = (durationSeconds * 1000) / totalFrames
|
||||
s.playTimer = setInterval(() => {
|
||||
showFrame((stateRef.current.current + 1) % totalFrames)
|
||||
}, intervalMs)
|
||||
} else {
|
||||
s.playTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Pointer drag scrubbing 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
|
||||
if (s.playing) {
|
||||
s.playing = false
|
||||
setIsPlaying(false)
|
||||
if (s.playTimer) clearInterval(s.playTimer)
|
||||
}
|
||||
|
||||
s.pointerStart = e.clientX
|
||||
s.frameStart = s.current
|
||||
stage.setPointerCapture(e.pointerId)
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
const s = stateRef.current
|
||||
if (!s.dragging) return
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
|
||||
const delta = ((e.clientX - s.pointerStart) / Math.max(1, stage.clientWidth)) * totalFrames
|
||||
showFrame(s.frameStart + delta)
|
||||
}
|
||||
|
||||
const stopDragging = () => {
|
||||
stateRef.current.dragging = false
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
// Keyboard controls (Arrow Left / Right)
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes((e.target as HTMLElement)?.tagName)) return
|
||||
if (e.key === 'ArrowLeft') {
|
||||
showFrame(stateRef.current.current - 1)
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
showFrame(stateRef.current.current + 1)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [showFrame])
|
||||
|
||||
// Calculate live timecode seconds
|
||||
const currentSeconds = (currentFrame / totalFrames) * durationSeconds
|
||||
const formattedTimecode = `00:${currentSeconds.toFixed(1).padStart(4, '0')} / 00:${durationSeconds.toFixed(1)}`
|
||||
|
||||
const handleCtaClick = () => {
|
||||
if (endCtaLink) {
|
||||
window.location.href = endCtaLink
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`relative w-full ${
|
||||
isDark ? 'bg-[#0e0e0d] 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>FRAME BY FRAME SCRUBBER</span>
|
||||
<span style={{ color: accentColor }}>{totalFrames} FRAMES / {durationSeconds}S</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scroll Section with Sticky Stage (250vh) ── */}
|
||||
<div
|
||||
ref={scrollSectionRef}
|
||||
className="relative w-full"
|
||||
style={{ height: '260vh' }}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Instruction Cue */}
|
||||
<div className="hidden sm: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>{instruction}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Stage: Canvas + Timecode Overlay */}
|
||||
<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' }}
|
||||
>
|
||||
{/* Stage Ambient Glow */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none rounded-full blur-3xl opacity-20 transform scale-75"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${accentColor} 0%, rgba(0,0,0,0) 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Video Canvas */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="relative z-10 w-full h-[65vh] max-h-[580px] object-contain rounded-xl shadow-2xl"
|
||||
/>
|
||||
|
||||
{/* Stage Timecode Pill */}
|
||||
<div className="absolute left-4 sm:left-8 bottom-6 z-20 px-3 py-1.5 rounded-lg bg-black/70 backdrop-blur-md border border-white/10 text-[11px] font-mono text-slate-300 flex items-center gap-2 pointer-events-none">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: accentColor }} />
|
||||
<span>{formattedTimecode}</span>
|
||||
</div>
|
||||
|
||||
{/* Drag Tip Overlay */}
|
||||
{!isDragging && !isPlaying && scrollProgress < 0.02 && (
|
||||
<div className="absolute bottom-6 right-4 sm:right-8 z-20 px-3.5 py-1.5 rounded-lg bg-black/70 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>ลากเพื่อ Scrub เฟรม</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Scrub Controls Bar */}
|
||||
<div className="w-full max-w-7xl mx-auto flex flex-col gap-3 z-20">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
{/* Play/Pause Button */}
|
||||
<div className="flex items-center gap-4 w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="px-5 py-2.5 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 shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||
<span>หยุดวิดีโอ (Pause)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-3.5 h-3.5 fill-current" />
|
||||
<span>เล่นวิดีโอ (Play)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Range Scrubber Slider */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={totalFrames - 1}
|
||||
value={currentFrame}
|
||||
onChange={(e) => {
|
||||
if (stateRef.current.playing) {
|
||||
stateRef.current.playing = false
|
||||
setIsPlaying(false)
|
||||
if (stateRef.current.playTimer) clearInterval(stateRef.current.playTimer)
|
||||
}
|
||||
showFrame(Number(e.target.value))
|
||||
}}
|
||||
className="w-full sm:w-80 h-1.5 bg-white/20 rounded-lg appearance-none cursor-pointer accent-[#d5ff63]"
|
||||
style={{ accentColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frame Counter Display */}
|
||||
<div className="text-xs font-mono text-slate-400 shrink-0">
|
||||
<span className="font-bold text-white text-sm" style={{ color: accentColor }}>
|
||||
{String(currentFrame + 1).padStart(padding, '0')}
|
||||
</span>
|
||||
<span className="mx-1.5 text-slate-600">/</span>
|
||||
<span>{totalFrames} FRAMES</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Progress Bar at very bottom */}
|
||||
<div className="w-full 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>
|
||||
|
||||
{/* ── Final Conversion CTA Banner ── */}
|
||||
<div className="relative py-20 px-6 sm:px-12 max-w-6xl mx-auto border-t border-white/10">
|
||||
<div className="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 }}>
|
||||
FRAME BY FRAME PRECISION
|
||||
</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 shrink-0"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
<span>{endCtaButtonText}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import PageDivider from '../PageDivider'
|
||||
import CinematicStoryScroll from '../CinematicStoryScroll'
|
||||
import AppleWatchShowcase from '../AppleWatchShowcase'
|
||||
import Interactive360Showcase from '../Interactive360Showcase'
|
||||
import VdoFrameScrubber from '../VdoFrameScrubber'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/pagination'
|
||||
import 'swiper/css/navigation'
|
||||
@@ -2840,7 +2841,36 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
)
|
||||
}
|
||||
|
||||
case 'INTERACTIVE_360_SHOWCASE':
|
||||
case 'VDO_FRAME_SCRUBBER':
|
||||
case 'INTERACTIVE_VDO_CANVAS':
|
||||
case 'VIDEO_SCRUBBER': {
|
||||
return (
|
||||
<div key={section.id} className="w-full">
|
||||
<VdoFrameScrubber
|
||||
folderPath={(payload.folderPath as string) || '/frame-sequences/chrome-sprocket-428h'}
|
||||
pattern={(payload.pattern as string) || 'frame-{index}.webp'}
|
||||
padding={Number(payload.padding) || 3}
|
||||
totalFrames={Number(payload.totalFrames) || 432}
|
||||
fps={Number(payload.fps) || 24}
|
||||
durationSeconds={Number(payload.durationSeconds) || 18}
|
||||
eyebrow={payload.eyebrow as string | undefined}
|
||||
title={payload.title as string | undefined}
|
||||
subtitle={payload.subtitle as string | undefined}
|
||||
instruction={payload.instruction as string | undefined}
|
||||
productName={payload.productName as string | undefined}
|
||||
productBadge={payload.productBadge as string | 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 'INTERACTIVE_360_SHOWCASE':
|
||||
case 'TURNTABLE_3D_SHOWCASE':
|
||||
case 'TURNTABLE_360': {
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user