feat(cms): add VDO Frame Scrubber block with isolated sequence library

This commit is contained in:
Your Name committed 2026-10-17 11:23:08 +07:00
1 parent 64f7ecf729
commit 0bf544720b
877 files changed
+1628 -2

No files matched your search

@@ -142,6 +142,33 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
<div className="relative z-20 w-4 h-4 rounded-full bg-black/50 text-white flex items-center justify-center text-[8px]">›</div>
</div>
)
},
// 2.96 VDO_FRAME_SCRUBBER
{
type: 'VDO_FRAME_SCRUBBER',
category: 'hero',
title: 'VDO FRAME SCRUBBER (Scrollytelling Video)',
description: 'Cinema-quality video frame sequence (432 frames) with scroll scrub, direct drag, range slider, and play/pause at 24fps without memory leaks.',
badge: 'Video Frames · 4K',
renderPreview: () => (
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0e0e0d] flex items-center justify-between p-3 border border-[#d5ff63]/30">
<div className="space-y-1 max-w-[65%] z-10">
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63] animate-ping" /> FRAME SCRUBBER
</span>
<h5 className="text-[10px] font-black text-white leading-tight">
FROM VIDEO TO INTERACTIVE
</h5>
<div className="flex items-center gap-2 text-[6px] text-slate-400 font-mono">
<span className="px-1.5 py-0.5 rounded bg-white/10 text-white">432 FRAMES</span>
<span>00:18.0s @ 24FPS</span>
</div>
</div>
<div className="w-12 h-12 rounded-2xl bg-black/80 border border-[#d5ff63]/40 flex items-center justify-center text-[#d5ff63] shadow-xl shrink-0">
<Play className="w-5 h-5 fill-current ml-0.5" />
</div>
</div>
)
},
// 2.95 INTERACTIVE_360_SHOWCASE
{
@@ -0,0 +1,365 @@
import { useState, useEffect } from 'react'
import {
X,
Upload,
Film,
CheckCircle,
Loader2,
Trash2,
FolderOpen,
} from 'lucide-react'
import { API_BASE } from '@homaroma/types'
export interface FrameSequenceItem {
id: string
name: string
slug: string
folderPath: string
pattern: string
padding: number
totalFrames: number
fps: number
durationSeconds: number
posterUrl: string
createdAt: string
}
interface Props {
isOpen: boolean
onClose: () => void
onSelectSequence: (seq: FrameSequenceItem) => void
selectedSlug?: string
}
export default function FrameSequenceModal({
isOpen,
onClose,
onSelectSequence,
selectedSlug,
}: Props) {
const [sequences, setSequences] = useState<FrameSequenceItem[]>([])
const [loading, setLoading] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
// Upload Form State
const [showUploadForm, setShowUploadForm] = useState(false)
const [uploadName, setUploadName] = useState('')
const [uploadFps, setUploadFps] = useState('24')
const [zipFile, setZipFile] = useState<File | null>(null)
const fetchSequences = async () => {
setLoading(true)
try {
const res = await fetch(`${API_BASE}/frame-sequences`)
if (!res.ok) return
const data = await res.json()
setSequences(data.sequences || [])
} catch (err) {
console.error('Failed to load frame sequences', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isOpen) {
fetchSequences()
setShowUploadForm(false)
setUploadError(null)
}
}, [isOpen])
if (!isOpen) return null
const handleUploadZip = async (e: React.FormEvent) => {
e.preventDefault()
if (!zipFile) {
setUploadError('กรุณาเลือกไฟล์ .ZIP ที่มีรูปเฟรม')
return
}
setIsUploading(true)
setUploadError(null)
const formData = new FormData()
formData.append('file', zipFile)
if (uploadName) formData.append('name', uploadName)
formData.append('fps', uploadFps)
try {
const res = await fetch(`${API_BASE}/frame-sequences/upload`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok || data.error) {
throw new Error(data.error || 'Upload failed')
}
await fetchSequences()
setShowUploadForm(false)
setZipFile(null)
setUploadName('')
if (data.sequence) {
onSelectSequence(data.sequence)
onClose()
}
} catch (err: any) {
setUploadError(err.message || 'เกิดข้อผิดพลาดในการอัปโหลด ZIP')
} finally {
setIsUploading(false)
}
}
const handleDelete = async (slug: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบชุดเฟรม "${slug}"?`)) return
try {
const res = await fetch(`${API_BASE}/frame-sequences/${slug}`, {
method: 'DELETE',
})
if (res.ok) {
setSequences((prev) => prev.filter((s) => s.slug !== slug))
}
} catch (err) {
console.error('Failed to delete sequence', err)
}
}
return (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-fade-in">
<div className="bg-[#121211] border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl flex flex-col max-h-[85vh] overflow-hidden text-slate-200">
{/* Modal Header */}
<div className="p-6 border-b border-white/5 flex items-center justify-between shrink-0 bg-black/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-[#d5ff63]/10 border border-[#d5ff63]/30 flex items-center justify-center text-[#d5ff63]">
<Film className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<span>คลังชุดวิดีโอเฟรม (Frame Sequence Library)</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[#d5ff63]/20 text-[#d5ff63] font-mono">
แยกจาก Media ปกติ
</span>
</h3>
<p className="text-xs text-slate-400">
เลือกชุดเฟรมภาพแบบ 360° / Scrollytelling โดยไม่ปะปนกับคลังภาพสินค้าทั่วไป
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowUploadForm(!showUploadForm)}
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-bold text-white flex items-center gap-2 transition-all cursor-pointer"
>
<Upload className="w-3.5 h-3.5 text-[#d5ff63]" />
<span>{showUploadForm ? 'ดูรายการที่มีอยู่' : 'อัปโหลด ZIP ชุดใหม่'}</span>
</button>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Modal Body */}
<div className="p-6 flex-1 overflow-y-auto space-y-6">
{/* Upload Form Accordion */}
{showUploadForm && (
<form
onSubmit={handleUploadZip}
className="p-5 rounded-2xl bg-black/60 border border-[#d5ff63]/30 space-y-4 animate-fade-in"
>
<div className="flex items-center gap-2 text-xs font-bold text-[#d5ff63] uppercase tracking-wider">
<Upload className="w-4 h-4" />
<span>อัปโหลดชุดเฟรมภาพด้วยไฟล์ .ZIP</span>
</div>
<p className="text-xs text-slate-400">
บีบอัดโฟลเดอร์ภาพ เช่น <code className="text-slate-200">frame-001.webp</code> ถึง{' '}
<code className="text-slate-200">frame-432.webp</code> เป็นไฟล์ .zip แล้วอัปโหลด ระบบจะคลายไฟล์ให้อัตโนมัติ
</p>
{uploadError && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-xs text-red-400">
{uploadError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
ชื่อชุดวิดีโอ (Name)
</label>
<input
type="text"
value={uploadName}
onChange={(e) => setUploadName(e.target.value)}
placeholder="เช่น Chrome Sprocket 18s Spin"
className="admin-input w-full text-xs"
required
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
ความเร็วเฟรมต่อวินาที (FPS)
</label>
<select
value={uploadFps}
onChange={(e) => setUploadFps(e.target.value)}
className="admin-input w-full text-xs"
>
<option value="24">24 FPS (Cinematic Video - ค่ามาตรฐาน)</option>
<option value="30">30 FPS (Standard Video)</option>
<option value="60">60 FPS (Ultra Smooth 3D)</option>
<option value="12">12 FPS (Stop Motion)</option>
</select>
</div>
</div>
<div>
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
ไฟล์ .ZIP ที่มีรูปเฟรม
</label>
<input
type="file"
accept=".zip"
onChange={(e) => setZipFile(e.target.files?.[0] || null)}
className="admin-input w-full text-xs"
required
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowUploadForm(false)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-xs text-slate-300"
>
ยกเลิก
</button>
<button
type="submit"
disabled={isUploading || !zipFile}
className="px-6 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] disabled:opacity-50 flex items-center gap-2 cursor-pointer shadow-lg"
>
{isUploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>กำลังแตกไฟล์และจัดเก็บ...</span>
</>
) : (
<>
<Upload className="w-3.5 h-3.5" />
<span>เริ่มอัปโหลดและประมวลผล ZIP</span>
</>
)}
</button>
</div>
</form>
)}
{/* Sequence Cards Grid */}
{loading ? (
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-[#d5ff63]" />
<span className="text-xs">กำลังโหลดชุดวิดีโอเฟรม...</span>
</div>
) : sequences.length === 0 ? (
<div className="py-20 text-center space-y-3">
<FolderOpen className="w-12 h-12 text-slate-600 mx-auto" />
<div className="text-sm font-bold text-slate-300">ยังไม่มีชุด Frame Sequence ในระบบ</div>
<p className="text-xs text-slate-500 max-w-sm mx-auto">
คุณสามารถกดปุ่ม "อัปโหลด ZIP ชุดใหม่" ด้านบนเพื่อนำชุดเฟรมภาพเข้ามาใช้งานได้ทันที
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{sequences.map((seq) => {
const isSelected = selectedSlug === seq.slug
return (
<div
key={seq.id}
onClick={() => {
onSelectSequence(seq)
onClose()
}}
className={`relative group rounded-2xl bg-black/40 border transition-all p-3 space-y-3 cursor-pointer ${
isSelected
? 'border-[#d5ff63] ring-1 ring-[#d5ff63] shadow-lg shadow-[#d5ff63]/10'
: 'border-white/10 hover:border-white/20'
}`}
>
{/* Poster Thumbnail */}
<div className="relative aspect-video rounded-xl bg-slate-900 overflow-hidden border border-white/5">
<img
src={seq.posterUrl}
alt={seq.name}
className="w-full h-full object-contain p-2"
/>
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/80 backdrop-blur-md text-[9px] font-mono text-white">
{seq.totalFrames} FRAMES • {seq.durationSeconds}s
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-[#d5ff63] text-black flex items-center justify-center shadow-lg">
<CheckCircle className="w-4 h-4 fill-black text-[#d5ff63]" />
</div>
)}
</div>
{/* Metadata */}
<div className="space-y-1">
<h4 className="text-sm font-bold text-white group-hover:text-[#d5ff63] transition-colors truncate">
{seq.name}
</h4>
<p className="text-[10px] font-mono text-slate-500 truncate">
{seq.folderPath}
</p>
</div>
{/* Action Bar */}
<div className="flex items-center justify-between pt-1 border-t border-white/5 text-[10px]">
<span className="font-mono text-slate-400">
{seq.fps} FPS
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => handleDelete(seq.slug, e)}
title="ลบชุดนี้"
className="p-1 rounded hover:bg-red-500/20 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="px-3 py-1 rounded-lg bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e]"
>
เลือกชุดนี้
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,434 @@
import { useState, useEffect } from 'react'
import {
Film,
Package,
ExternalLink,
RotateCcw,
CheckCircle,
} from 'lucide-react'
import { API_BASE } from '@homaroma/types'
import FrameSequenceModal, { type FrameSequenceItem } from './FrameSequenceModal'
interface Props {
payload: Record<string, unknown>
updatePayloadValue: (field: string, value: unknown) => void
}
export default function VdoFrameScrubberForm({
payload,
updatePayloadValue,
}: Props) {
const [productsList, setProductsList] = useState<any[]>([])
const [loadingProducts, setLoadingProducts] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const folderPath = (payload.folderPath as string) || '/frame-sequences/chrome-sprocket-428h'
const pattern = (payload.pattern as string) || 'frame-{index}.webp'
const totalFrames = Number(payload.totalFrames) || 432
const fps = Number(payload.fps) || 24
const durationSeconds = Number(payload.durationSeconds) || 18
const eyebrow = (payload.eyebrow as string) || ''
const title = (payload.title as string) || ''
const subtitle = (payload.subtitle as string) || ''
const instruction = (payload.instruction 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) : ''
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 VdoFrameScrubberForm', err)
} finally {
if (isMounted) setLoadingProducts(false)
}
}
fetchCatalog()
return () => { isMounted = false }
}, [])
// Auto-seed defaults on brand new block
useEffect(() => {
if (!payload.folderPath) updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
if (!payload.pattern) updatePayloadValue('pattern', 'frame-{index}.webp')
if (!payload.padding) updatePayloadValue('padding', 3)
if (!payload.totalFrames) updatePayloadValue('totalFrames', 432)
if (!payload.fps) updatePayloadValue('fps', 24)
if (!payload.durationSeconds) updatePayloadValue('durationSeconds', 18)
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
if (!payload.title) updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
if (!payload.subtitle) updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
if (!payload.instruction) updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
if (!payload.productName) updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
if (!payload.productBadge) updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
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('folderPath', '/frame-sequences/chrome-sprocket-428h')
updatePayloadValue('pattern', 'frame-{index}.webp')
updatePayloadValue('padding', 3)
updatePayloadValue('totalFrames', 432)
updatePayloadValue('fps', 24)
updatePayloadValue('durationSeconds', 18)
updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
updatePayloadValue('theme', 'dark')
updatePayloadValue('accentColor', '#d5ff63')
}
const handleSequenceSelected = (seq: FrameSequenceItem) => {
updatePayloadValue('folderPath', seq.folderPath)
updatePayloadValue('pattern', seq.pattern || 'frame-{index}.webp')
updatePayloadValue('padding', seq.padding || 3)
updatePayloadValue('totalFrames', seq.totalFrames)
updatePayloadValue('fps', seq.fps || 24)
updatePayloadValue('durationSeconds', seq.durationSeconds || parseFloat((seq.totalFrames / 24).toFixed(1)))
updatePayloadValue('productBadge', `${seq.durationSeconds}S / ${seq.totalFrames} FRAMES`)
}
return (
<div className="space-y-6 text-slate-200">
{/* ── Preset Banner ── */}
<div className="p-4 rounded-2xl bg-gradient-to-r from-[#d5ff63]/10 via-slate-900/40 to-transparent border border-[#d5ff63]/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-[#d5ff63] font-bold text-xs uppercase tracking-wider">
<Film className="w-4 h-4 text-[#d5ff63]" />
<span>VDO Frame-by-Frame Interactive Scrubber</span>
</div>
<p className="text-xs text-slate-400">
แสดงผลวิดีโอแบบ Frame Sequence ควบคุมเฟรมตามการเลื่อนหน้าเว็บ (Scroll) และการลาก (Drag Scrub)
</p>
</div>
<button
type="button"
onClick={handleApplyPreset}
className="px-4 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>รีเซ็ต / โหลดตัวอย่าง Chrome Sprocket (432 เฟรม)</span>
</button>
</div>
{/* ── Sequence Collection Selector (Isolated Library) ── */}
<div className="p-5 rounded-2xl bg-slate-900/70 border border-white/5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
<Film className="w-4 h-4 text-[#d5ff63]" />
<span>ชุดวิดีโอเฟรมที่เลือกใช้งาน (Frame Sequence Collection)</span>
</div>
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="px-4 py-1.5 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 cursor-pointer shadow-md"
>
<Film className="w-3.5 h-3.5" />
<span>เลือก / อัปโหลดชุด ZIP ใหม่</span>
</button>
</div>
{/* Selected Sequence Card */}
<div className="p-4 rounded-xl bg-black/50 border border-white/10 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-20 aspect-video rounded-lg bg-slate-800 overflow-hidden border border-white/10 shrink-0">
<img
src={`${folderPath}/frame-001.webp`}
alt="Sequence Poster"
className="w-full h-full object-contain p-1"
onError={(e) => {
// Fallback if not yet loaded
(e.target as HTMLImageElement).src = '/images/3d/sprocket-detail.png'
}}
/>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-white">
{folderPath.split('/').pop()?.replace(/[-_]/g, ' ').toUpperCase() || 'FRAME SEQUENCE'}
</span>
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-[9px] font-mono flex items-center gap-1">
<CheckCircle className="w-2.5 h-2.5" /> พร้อมใช้งาน
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400 font-mono">
<span>{totalFrames} FRAMES</span>
<span>•</span>
<span>{fps} FPS</span>
<span>•</span>
<span>{durationSeconds} วินาที</span>
</div>
<p className="text-[10px] text-slate-500 font-mono">
โฟลเดอร์: {folderPath} (รูปแบบ: {pattern})
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="text-xs text-[#d5ff63] hover:underline font-mono"
>
เปลี่ยนชุด &rarr;
</button>
</div>
</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-[#d5ff63]" />
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
</div>
{selectedProductId && (
<span className="text-[10px] text-[#d5ff63] 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>
</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>
{/* ── Headline & Header 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 & Story Intro)
</div>
<div className="grid grid-cols-1 md:grid-cols-4 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="FROM VIDEO TO INTERACTIVE"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
ป้ายซีรีส์ (Badge / Tag)
</label>
<input
type="text"
value={productBadge}
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
className="admin-input w-full text-xs font-mono"
placeholder="18 SEC / 432 FRAMES"
/>
</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="SPROCKET / FRAME SCRUBBER"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
คำแนะนำการเลื่อน (Instruction Cue)
</label>
<input
type="text"
value={instruction}
onChange={(e) => updatePayloadValue('instruction', e.target.value)}
className="admin-input w-full text-xs"
placeholder="ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ"
/>
</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="รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์"
/>
</div>
</div>
{/* ── Theme, Accent, and Conversion CTA ── */}
<div className="grid grid-cols-1 md:grid-cols-4 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">
โทนพื้นหลัง (Theme)
</label>
<select
value={theme}
onChange={(e) => updatePayloadValue('theme', e.target.value)}
className="admin-input w-full text-xs"
>
<option value="dark">Dark Theater (#0e0e0d - แนะนำ)</option>
<option value="light">Light Studio (#f8f9fa)</option>
</select>
</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>
{/* Modal for Frame Sequence Collection */}
<FrameSequenceModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSelectSequence={handleSequenceSelected}
selectedSlug={folderPath.split('/').pop()}
/>
</div>
)
}
+9
View File
@@ -34,6 +34,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 VdoFrameScrubberForm from '../components/page-builder/VdoFrameScrubberForm';
import HeroForm from '../components/page-builder/HeroForm';
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
import NewsletterForm from '../components/page-builder/NewsletterForm';
@@ -1470,6 +1471,7 @@ export default function PageBuilderPage() {
onChange={(e) => handleTypeChange(e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="VDO_FRAME_SCRUBBER">🎬 VDO FRAME SCRUBBER (432 Frames Video Scrub)</option>
<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>
@@ -1595,6 +1597,13 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'VDO_FRAME_SCRUBBER' && (
<VdoFrameScrubberForm
payload={editingSection.payload || {}}
updatePayloadValue={updatePayloadValue}
/>
)}
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
<Interactive360ShowcaseForm
payload={editingSection.payload || {}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Loaded 100 of 877 files, more files were not shown because too many files have changed in this diff. Show more