This commit is contained in:
Your Name committed 2026-10-17 08:11:33 +07:00
1 parent 34be2599fa
commit 3f9db48df4
8 files changed
+2037 -4

No files matched your search

@@ -0,0 +1,390 @@
import { useState, useEffect } from 'react'
import {
Sparkles,
Upload,
Image as ImageIcon,
X,
Layers,
CheckCircle,
RotateCcw,
} from 'lucide-react'
export interface StorySceneItem {
id: string
kicker: string
title: string
description: string
metric?: string
chips?: string[]
theme: string
imageUrl?: string
}
const DEFAULT_SCENES: StorySceneItem[] = [
{
id: 'hero',
kicker: '01 / PRODUCT STORY',
title: 'STOP WITH\nCONFIDENCE.',
description: 'พัฒนาขึ้นสำหรับการแข่งขันและการขับขี่สมรรถนะสูง ให้สินค้ากลายเป็นพระเอกก่อนพาลงลึกไปทีละมิติของประสิทธิภาพ',
theme: 'hero',
},
{
id: 'material',
kicker: '02 / MATERIAL',
title: 'CERAMIC\nCOMPOUND.',
description: 'สูตรผสมเนื้อเซรามิกเกรดพรีเมียม สึกหรอน้อย เสียงเบียบเงียบ ไร้แรงสั่นสะเทือน และลดฝุ่นผงจับจานเบรกอย่างเห็นได้ชัด',
chips: ['Low Noise', 'Low Vibration', 'Cleaner Braking'],
theme: 'material',
},
{
id: 'heat',
kicker: '03 / HEAT CONTROL',
title: 'HIGH-HEAT\nCONTROL.',
metric: '300°C',
description: 'ทนต่อความร้อนสูงสะสม ป้องกันอาการเบรกเฟด (Brake Fade) ให้ระยะหยุดสม่ำเสมอแม้ใช้งานต่อเนื่องบนสนามแข่งหรือทางลาดชัน',
theme: 'heat',
},
{
id: 'friction',
kicker: '04 / PERFORMANCE',
title: 'STABLE\nFRICTION.',
description: 'ค่าสัมประสิทธิ์แรงเสียดทานคงที่ในทุกช่วงอุณหภูมิ ให้การจับตัวนุ่มนวลแต่แน่นหนา มั่นใจในทุกโค้ง',
theme: 'friction',
},
{
id: 'control',
kicker: '05 / CONTROL',
title: 'SMOOTH\nENGAGEMENT.',
description: 'โครงสร้างแม่นยำมาตรฐานสูง สัมผัสก้านเบรกตอบสนองเป็นเส้นตรง (Linear Modulation) คุมน้ำหนักเบรกได้ละเอียดดั่งใจ',
theme: 'control',
},
{
id: 'durability',
kicker: '06 / DURABILITY',
title: 'BUILT\nTO LAST.',
description: 'เนื้อผ้าเบรกถนอมจานเบรก ไม่กินจาน ยืดอายุการใช้งานยาวนาน คุ้มค่าสำหรับการขับขี่ทั้งในชีวิตประจำวันและทริปเดินทางไกล',
chips: ['Durable', 'Rotor Friendly', 'Everyday Use'],
theme: 'durability',
},
{
id: 'fitment',
kicker: '07 / FITMENT',
title: 'MADE FOR\nYOUR BIKE.',
description: 'ออกแบบตรงรุ่นพร้อมติดตั้งแบบ Plug & Play ครอบคลุมรถจักรยานยนต์ยอดนิยมทุกค่าย',
theme: 'fitment',
},
]
interface Props {
payload: Record<string, unknown>
onChange: (nextPayload: Record<string, unknown>) => void
onBrowseMedia: (onSelect: (url: string) => void) => void
}
export default function ProductStoryScrollForm({ payload, onChange, onBrowseMedia }: Props) {
const [selectedSceneIndex, setSelectedSceneIndex] = useState(0)
const heroImage = (payload.storyHeroImage as string) || ''
const endCtaTitle = (payload.endCtaTitle as string) || ''
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
// Auto-seed default mockup scenes on first open if empty
useEffect(() => {
if (!payload.storyScenes || !Array.isArray(payload.storyScenes) || payload.storyScenes.length === 0) {
onChange({
...payload,
storyScenes: DEFAULT_SCENES,
endCtaTitle: payload.endCtaTitle || 'SELECT YOUR MODEL.',
endCtaButtonText: payload.endCtaButtonText || 'Choose Your Bike',
})
}
}, [])
const rawScenes = payload.storyScenes
const scenes: StorySceneItem[] = Array.isArray(rawScenes) && rawScenes.length > 0
? (rawScenes as any[]).map((s, idx) => ({
...DEFAULT_SCENES[idx % DEFAULT_SCENES.length],
...s,
}))
: DEFAULT_SCENES
const updateScenes = (newScenes: StorySceneItem[]) => {
onChange({
...payload,
storyScenes: newScenes,
})
}
const handleResetToMockup = () => {
onChange({
...payload,
storyScenes: DEFAULT_SCENES,
endCtaTitle: 'SELECT YOUR MODEL.',
endCtaButtonText: 'Choose Your Bike',
})
}
const updateSelectedScene = (field: keyof StorySceneItem, val: any) => {
const updated = [...scenes]
updated[selectedSceneIndex] = {
...updated[selectedSceneIndex],
[field]: val,
}
updateScenes(updated)
}
const currentScene = scenes[selectedSceneIndex] || scenes[0]
return (
<div className="space-y-6 pt-4 border-t border-cyan-glow/20">
{/* Banner Intro */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-cyan-glow/[0.05] border border-cyan-glow/20 text-cyan-glow">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">
โหมดนำเสนอพิเศษ: Cinematic Story Scroll (Apple Style)
</p>
<p className="text-xs text-slate-400 leading-relaxed">
เมื่อเปิดโหมดนี้ หน้าสินค้าบนจอ PC Wide จะแสดงส่วน Scrollytelling เต็มจอ 100vh เล่าเรื่องเทคโนโลยี 7 ฉากแบบภาพยนตร์ไฮเทค พร้อมปุ่มสั่งซื้อที่ด้านล่างสุด
</p>
</div>
</div>
<button
type="button"
onClick={handleResetToMockup}
className="shrink-0 flex items-center gap-2 px-3 py-2 rounded-xl bg-cyan-glow/10 border border-cyan-glow/30 hover:bg-cyan-glow/20 text-cyan-glow text-xs font-bold transition-all shadow-sm"
>
<RotateCcw className="w-3.5 h-3.5" />
โหลด Mockup Data ทั้งหมด
</button>
</div>
{/* 1. Core Hero Showcase Image */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
ภาพสินค้าหลักสำหรับโชว์ใน Story Stage
</h4>
<p className="text-xs text-slate-500">
แนะนำใช้ภาพสินค้าไดคัทพื้นหลังโปร่งใส (Transparent PNG หรือ WebP) เพื่อให้หมุนและเรืองแสงได้เต็มตา (หากไม่ระบุ จะดึงภาพแรกของสินค้าให้อัตโนมัติ)
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative w-24 h-24 rounded-2xl bg-night-900 border border-white/15 overflow-hidden flex items-center justify-center shrink-0">
{heroImage ? (
<img src={heroImage} alt="Story Hero" className="w-full h-full object-contain p-2" />
) : (
<span className="text-xs text-slate-600 font-semibold text-center px-2">
ใช้ภาพแรกอัตโนมัติ
</span>
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onBrowseMedia((url) => onChange({ ...payload, storyHeroImage: url }))}
className="px-4 py-2 rounded-xl bg-cyan-glow/15 hover:bg-cyan-glow/25 text-cyan-glow text-xs font-bold flex items-center gap-2 transition-all cursor-pointer border border-cyan-glow/30"
>
<Upload className="w-3.5 h-3.5" />
<span>{heroImage ? 'เปลี่ยนรูปภาพ' : 'อัปโหลด / เลือกรูปภาพ'}</span>
</button>
{heroImage && (
<button
type="button"
onClick={() => onChange({ ...payload, storyHeroImage: '' })}
className="px-3 py-2 rounded-xl bg-white/5 hover:bg-rose-500/10 text-slate-400 hover:text-rose-400 text-xs font-semibold transition-all cursor-pointer"
>
ล้างรูปภาพ
</button>
)}
</div>
<p className="text-[11px] text-slate-500">
{heroImage ? `URL: ${heroImage.slice(0, 50)}...` : '* ดึงภาพสินค้าที่มีอยู่แล้วมาแสดงอัตโนมัติ'}
</p>
</div>
</div>
</div>
{/* 2. Story Scenes Manager (7 Scenes Tabs) */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-5">
<div>
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<Layers className="w-4 h-4 text-cyan-glow" />
ปรับแต่งฉาก Story Scenes ทั้ง 7 ตอน
</h4>
<p className="text-xs text-slate-500 mt-1">
คลิกเลือกตอนที่ต้องการปรับเปลี่ยนข้อความ ตัวเลข หรือใส่รูปภาพเฉพาะฉาก
</p>
</div>
{/* Scene Tabs */}
<div className="flex gap-2 overflow-x-auto pb-2 custom-scrollbar">
{scenes.map((s, idx) => (
<button
key={s.id}
type="button"
onClick={() => setSelectedSceneIndex(idx)}
className={`px-3.5 py-2 rounded-xl text-xs font-bold whitespace-nowrap transition-all cursor-pointer border ${
selectedSceneIndex === idx
? 'bg-cyan-glow/20 text-cyan-glow border-cyan-glow/40 shadow-sm'
: 'bg-white/5 text-slate-400 border-white/5 hover:bg-white/10 hover:text-white'
}`}
>
{`Scene ${idx + 1}: ${s.kicker.split('/')[1]?.trim() || s.id}`}
</button>
))}
</div>
{/* Selected Scene Editor */}
{currentScene && (
<div className="p-4 rounded-2xl bg-night-900 border border-white/10 space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-white/5">
<span className="text-xs uppercase font-mono font-bold tracking-wider text-cyan-glow">
แก้ไขฉากที่ {selectedSceneIndex + 1} ({currentScene.id.toUpperCase()})
</span>
<span className="text-[11px] text-slate-500">
ธีมแสง: {currentScene.theme}
</span>
</div>
{/* Custom Image for this Scene */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
รูปภาพเฉพาะของฉากนี้ (ถ้ามี เช่น รูปซูมเนื้อผิว / กราฟความร้อน)
</label>
<div className="flex items-center gap-3">
<div className="relative w-16 h-16 rounded-xl bg-night-950 border border-white/10 overflow-hidden flex items-center justify-center shrink-0">
{currentScene.imageUrl ? (
<img src={currentScene.imageUrl} alt={currentScene.title} className="w-full h-full object-cover" />
) : (
<span className="text-[10px] text-slate-600 text-center">ไม่มีรูป</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onBrowseMedia((url) => updateSelectedScene('imageUrl', url))}
className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/15 text-slate-200 text-xs font-semibold flex items-center gap-1.5 cursor-pointer"
>
<Upload className="w-3.5 h-3.5" />
<span>{currentScene.imageUrl ? 'เปลี่ยนรูป' : 'อัปโหลดรูป'}</span>
</button>
{currentScene.imageUrl && (
<button
type="button"
onClick={() => updateSelectedScene('imageUrl', '')}
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-400"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
{/* Kicker & Title */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Kicker</label>
<input
type="text"
value={currentScene.kicker || ''}
onChange={(e) => updateSelectedScene('kicker', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. 02 / MATERIAL"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Metric ตัวเลขเด่น (ถ้ามี)</label>
<input
type="text"
value={currentScene.metric || ''}
onChange={(e) => updateSelectedScene('metric', e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. 300°C หรือ 100%"
/>
</div>
</div>
{/* Headline */}
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Headline (ขึ้นบรรทัดใหม่ได้)</label>
<textarea
value={currentScene.title || ''}
onChange={(e) => updateSelectedScene('title', e.target.value)}
rows={2}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40 resize-none font-bold"
placeholder="e.g. CERAMIC\nCOMPOUND."
/>
</div>
{/* Description */}
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">คำอธิบาย</label>
<textarea
value={currentScene.description || ''}
onChange={(e) => updateSelectedScene('description', e.target.value)}
rows={2}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-slate-300 outline-none focus:border-cyan-glow/40 resize-none leading-relaxed"
placeholder="คำอธิบายรายละเอียดทางเทคนิคของฉากนี้..."
/>
</div>
{/* Chips */}
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Feature Chips (คั่นด้วยจุลภาค ,)</label>
<input
type="text"
value={(currentScene.chips || []).join(', ')}
onChange={(e) => {
const items = e.target.value.split(',').map(s => s.trim()).filter(Boolean)
updateSelectedScene('chips', items)
}}
className="w-full px-3 py-2 rounded-xl bg-night-950 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. Low Noise, Low Vibration, Cleaner Braking"
/>
</div>
</div>
)}
</div>
{/* 3. End CTA Settings */}
<div className="p-4 rounded-2xl bg-night-950 border border-white/10 space-y-4">
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-400" />
ข้อความส่วนท้าย (End CTA)
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">หัวข้อส่วนท้าย</label>
<input
type="text"
value={endCtaTitle}
onChange={(e) => onChange({ ...payload, endCtaTitle: e.target.value })}
className="w-full px-3 py-2 rounded-xl bg-night-900 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. SELECT YOUR MODEL."
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">ข้อความบนปุ่มสั่งซื้อ</label>
<input
type="text"
value={endCtaButtonText}
onChange={(e) => onChange({ ...payload, endCtaButtonText: e.target.value })}
className="w-full px-3 py-2 rounded-xl bg-night-900 border border-white/10 text-xs text-white outline-none focus:border-cyan-glow/40"
placeholder="e.g. Choose Your Bike / สั่งซื้อรุ่นของคุณ"
/>
</div>
</div>
</div>
</div>
)
}
+43 -1
View File
@@ -36,6 +36,7 @@ import FocalPointPicker from '../components/page-builder/FocalPointPicker'
import ProductPickerModal from '../components/page-builder/ProductPickerModal'
import ProductHeroCarouselBlockForm from '../components/products/ProductHeroCarouselBlockForm'
import ProductDividerBlockForm from '../components/products/ProductDividerBlockForm'
import ProductStoryScrollForm from '../components/products/ProductStoryScrollForm'
import { validateProductHeroCarouselPayload } from '../components/products/productHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization'
import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal'
@@ -898,11 +899,38 @@ export default function ProductCmsPage() {
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Layout Mode</label>
<select
value={typeof sectionPayload.layoutMode === 'string' ? sectionPayload.layoutMode : 'standard'}
onChange={(event) => syncNativePayload('layoutMode', event.target.value)}
onChange={(event) => {
const val = event.target.value
if (val === 'story_scroll') {
const next = {
...sectionPayload,
layoutMode: 'story_scroll',
storyScenes: Array.isArray(sectionPayload.storyScenes) && sectionPayload.storyScenes.length > 0
? sectionPayload.storyScenes
: [
{ id: 'hero', kicker: '01 / PRODUCT STORY', title: 'STOP WITH\nCONFIDENCE.', description: 'พัฒนาขึ้นสำหรับการแข่งขันและการขับขี่สมรรถนะสูง ให้สินค้ากลายเป็นพระเอกก่อนพาลงลึกไปทีละมิติของประสิทธิภาพ', theme: 'hero' },
{ id: 'material', kicker: '02 / MATERIAL', title: 'CERAMIC\nCOMPOUND.', description: 'สูตรผสมเนื้อเซรามิกเกรดพรีเมียม สึกหรอน้อย เสียงเบียบเงียบ ไร้แรงสั่นสะเทือน และลดฝุ่นผงจับจานเบรกอย่างเห็นได้ชัด', chips: ['Low Noise', 'Low Vibration', 'Cleaner Braking'], theme: 'material' },
{ id: 'heat', kicker: '03 / HEAT CONTROL', title: 'HIGH-HEAT\nCONTROL.', metric: '300°C', description: 'ทนต่อความร้อนสูงสะสม ป้องกันอาการเบรกเฟด (Brake Fade) ให้ระยะหยุดสม่ำเสมอแม้ใช้งานต่อเนื่องบนสนามแข่งหรือทางลาดชัน', theme: 'heat' },
{ id: 'friction', kicker: '04 / PERFORMANCE', title: 'STABLE\nFRICTION.', description: 'ค่าสัมประสิทธิ์แรงเสียดทานคงที่ในทุกช่วงอุณหภูมิ ให้การจับตัวนุ่มนวลแต่แน่นหนา มั่นใจในทุกโค้ง', theme: 'friction' },
{ id: 'control', kicker: '05 / CONTROL', title: 'SMOOTH\nENGAGEMENT.', description: 'โครงสร้างแม่นยำมาตรฐานสูง สัมผัสก้านเบรกตอบสนองเป็นเส้นตรง (Linear Modulation) คุมน้ำหนักเบรกได้ละเอียดดั่งใจ', theme: 'control' },
{ id: 'durability', kicker: '06 / DURABILITY', title: 'BUILT\nTO LAST.', description: 'เนื้อผ้าเบรกถนอมจานเบรก ไม่กินจาน ยืดอายุการใช้งานยาวนาน คุ้มค่าสำหรับการขับขี่ทั้งในชีวิตประจำวันและทริปเดินทางไกล', chips: ['Durable', 'Rotor Friendly', 'Everyday Use'], theme: 'durability' },
{ id: 'fitment', kicker: '07 / FITMENT', title: 'MADE FOR\nYOUR BIKE.', description: 'ออกแบบตรงรุ่นพร้อมติดตั้งแบบ Plug & Play ครอบคลุมรถจักรยานยนต์ยอดนิยมทุกค่าย', theme: 'fitment' },
],
endCtaTitle: (sectionPayload.endCtaTitle as string) || 'SELECT YOUR MODEL.',
endCtaButtonText: (sectionPayload.endCtaButtonText as string) || 'Choose Your Bike',
}
setSectionPayload(next)
setSectionJson(JSON.stringify(next, null, 2))
} else {
syncNativePayload('layoutMode', val)
}
}}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="standard">Standard</option>
<option value="sticky_gallery">Sticky Gallery</option>
<option value="story_scroll">Cinematic Story Scroll (Apple Style)</option>
<option value="apple_watch_tour">Apple Watch Keynote & Swatch Tour</option>
</select>
</div>
<div className="space-y-2">
@@ -936,6 +964,20 @@ export default function ProductCmsPage() {
<span className="text-xs font-bold text-slate-300">Show brand/category meta</span>
</label>
</div>
{(sectionPayload.layoutMode === 'story_scroll' || sectionPayload.layoutMode === 'apple_watch_tour') && (
<ProductStoryScrollForm
payload={sectionPayload}
onChange={(nextPayload) => {
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
onBrowseMedia={(cb) => {
setOnSelectMedia(() => cb)
setIsMediaModalOpen(true)
}}
/>
)}
</div>
)}
{sectionType === 'PRODUCT_REVIEWS' && (