diff --git a/apps/admin/src/components/page-builder/AddSectionModal.tsx b/apps/admin/src/components/page-builder/AddSectionModal.tsx index de860c8..e271a95 100644 --- a/apps/admin/src/components/page-builder/AddSectionModal.tsx +++ b/apps/admin/src/components/page-builder/AddSectionModal.tsx @@ -143,7 +143,36 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS ) }, - // 3. ANNOUNCEMENT_BAR + // 2.9 APPLE_SHOWCASE + { + type: 'APPLE_SHOWCASE', + category: 'hero', + title: 'APPLE KEYNOTE SHOWCASE', + description: 'Apple Watch Series 12 inspired keynote showcase featuring live swatches tour, titanium/aluminum badges, spec metrics callouts, and conversion CTA.', + badge: 'Apple Style · Hot', + renderPreview: () => ( +
+
+
+ + KEYNOTE SHOWCASE + +
+ PRECISION CRAFTED. +
+
+ + + +
+ + Order Now → + +
+
+ ) + }, +// 3. ANNOUNCEMENT_BAR { type: 'ANNOUNCEMENT_BAR', category: 'hero', @@ -372,7 +401,33 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
) }, - // 10. EDITORIAL_TILE + // 9.5 CINEMATIC_STORY_SCROLL + { + type: 'CINEMATIC_STORY_SCROLL', + category: 'editorial', + title: 'CINEMATIC STORY SCROLL (Scrollytelling)', + description: 'Full-bleed immersive storytelling canvas with dynamic multi-scene narrative, pinned product visual, and auto-scrolling studio background (Tobaki Style).', + badge: 'Tobaki · Apple Style', + renderPreview: () => ( +
+
+ + 01 / SCROLLYTELLING + +
+ STOP WITH CONFIDENCE. +
+

+ Pinned 3D product view with progressive narrative scenes. +

+
+
+ +
+
+ ) + }, +// 10. EDITORIAL_TILE { type: 'EDITORIAL_TILE', category: 'editorial', diff --git a/apps/admin/src/components/page-builder/AppleShowcaseForm.tsx b/apps/admin/src/components/page-builder/AppleShowcaseForm.tsx new file mode 100644 index 0000000..db871de --- /dev/null +++ b/apps/admin/src/components/page-builder/AppleShowcaseForm.tsx @@ -0,0 +1,216 @@ +import { useState, useEffect } from 'react' +import { + Sparkles, + Upload, + Image as ImageIcon, + X, + Package, + ExternalLink, + RotateCcw, +} from 'lucide-react' +import { API_BASE } from '@homaroma/types' + +interface Props { + payload: Record + updatePayloadValue: (field: string, value: unknown) => void + onBrowseMedia?: (onSelect: (url: string) => void) => void +} + +export default function AppleShowcaseForm({ + payload, + updatePayloadValue, + onBrowseMedia, +}: Props) { + const [productsList, setProductsList] = useState([]) + const [loadingProducts, setLoadingProducts] = useState(false) + + const heroImage = (payload.heroImage as string) || (payload.storyHeroImage as string) || '' + 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 AppleShowcaseForm', err) + } finally { + if (isMounted) setLoadingProducts(false) + } + } + fetchCatalog() + return () => { isMounted = false } + }, []) + + const handleSelectProduct = (productIdStr: string) => { + if (!productIdStr) { + updatePayloadValue('productId', null) + return + } + const pid = Number(productIdStr) + updatePayloadValue('productId', pid) + + const found = productsList.find(p => p.id === pid) + if (found) { + if (!heroImage && found.images?.[0]?.url) { + updatePayloadValue('heroImage', found.images[0].url) + } + if (!endCtaLink) { + updatePayloadValue('endCtaLink', `/products/${found.slug || found.id}`) + } + } + } + + const handleResetToMockup = () => { + updatePayloadValue('endCtaTitle', 'อัปเกรดระบบเบรกสู่มาตรฐานระดับโลก') + updatePayloadValue('endCtaButtonText', 'เลือกซื้อรุ่นของคุณ (Select Model) ↑') + } + + return ( +
+ {/* Intro Banner */} +
+
+ +
+

+ Apple Watch Keynote & Swatch Showcase +

+

+ การนำเสนอแบบ Keynote เปิดตัวระดับ Apple: จานหมุน 360° Color Swatches, ไฮไลท์บาร์แบบตั้งเวลาเล่นอัตโนมัติ, Bento Grid นวัตกรรม และ Accordion เจาะลึกวิศวกรรม +

+
+
+ +
+ + {/* Product Binding Selector */} +
+ +
+ + {loadingProducts && กำลังโหลดสินค้า...} +
+
+ + {/* Hero Showcase Image */} +
+ +
+ updatePayloadValue('heroImage', e.target.value)} + placeholder="https://... หรือเลือกจาก Media Library" + className="admin-input flex-1 bg-slate-900 border-white/10 text-xs" + /> + {onBrowseMedia && ( + + )} +
+ {heroImage && ( +
+ Hero Preview + +
+ )} +
+ + {/* Conversion End CTA */} +
+ +
+
+ + updatePayloadValue('endCtaTitle', e.target.value)} + placeholder="อัปเกรดระบบเบรกสู่มาตรฐานระดับโลก" + className="admin-input w-full bg-slate-900 border-white/10 text-xs" + /> +
+ +
+ + updatePayloadValue('endCtaButtonText', e.target.value)} + placeholder="เลือกซื้อรุ่นของคุณ (Select Model) ↑" + className="admin-input w-full bg-slate-900 border-white/10 text-xs" + /> +
+
+ +
+ + updatePayloadValue('endCtaLink', e.target.value)} + placeholder="เว้นว่างไว้เพื่อเลื่อนขึ้นด้านบน หรือใส่ /products/... หรือ /shop" + className="admin-input w-full bg-slate-900 border-white/10 text-xs" + /> +
+
+
+ ) +} diff --git a/apps/admin/src/components/page-builder/CinematicStoryScrollForm.tsx b/apps/admin/src/components/page-builder/CinematicStoryScrollForm.tsx new file mode 100644 index 0000000..e1ca565 --- /dev/null +++ b/apps/admin/src/components/page-builder/CinematicStoryScrollForm.tsx @@ -0,0 +1,490 @@ +import { useState, useEffect } from 'react' +import { + Sparkles, + Upload, + Image as ImageIcon, + X, + Layers, + CheckCircle, + RotateCcw, + Package, + ExternalLink, +} from 'lucide-react' +import { API_BASE } from '@homaroma/types' + +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 + updatePayloadValue: (field: string, value: unknown) => void + onBrowseMedia?: (onSelect: (url: string) => void) => void +} + +export default function CinematicStoryScrollForm({ + payload, + updatePayloadValue, + onBrowseMedia, +}: Props) { + const [selectedSceneIndex, setSelectedSceneIndex] = useState(0) + const [productsList, setProductsList] = useState([]) + const [loadingProducts, setLoadingProducts] = useState(false) + + const heroImage = (payload.storyHeroImage as string) || (payload.heroImage as string) || '' + const storyTheme = (payload.storyTheme as string) || 'light' + 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) : '' + + // Fetch product list for convenient 1-click binding + 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 CinematicStoryScrollForm', err) + } finally { + if (isMounted) setLoadingProducts(false) + } + } + fetchCatalog() + return () => { isMounted = false } + }, []) + + // Auto-seed default mockup scenes on first mount if empty + useEffect(() => { + if (!payload.storyScenes || !Array.isArray(payload.storyScenes) || payload.storyScenes.length === 0) { + updatePayloadValue('storyScenes', DEFAULT_SCENES) + if (!payload.storyTheme) updatePayloadValue('storyTheme', 'light') + if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'SELECT YOUR MODEL.') + if (!payload.endCtaButtonText) updatePayloadValue('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[]) => { + updatePayloadValue('storyScenes', newScenes) + } + + const handleResetToMockup = () => { + updatePayloadValue('storyScenes', DEFAULT_SCENES) + updatePayloadValue('storyTheme', 'light') + updatePayloadValue('endCtaTitle', 'SELECT YOUR MODEL.') + updatePayloadValue('endCtaButtonText', 'Choose Your Bike') + } + + const updateSelectedScene = (field: keyof StorySceneItem, val: any) => { + const updated = [...scenes] + updated[selectedSceneIndex] = { + ...updated[selectedSceneIndex], + [field]: val, + } + updateScenes(updated) + } + + const handleSelectProduct = (productIdStr: string) => { + if (!productIdStr) { + updatePayloadValue('productId', null) + return + } + const pid = Number(productIdStr) + updatePayloadValue('productId', pid) + + const found = productsList.find(p => p.id === pid) + if (found) { + if (!heroImage && found.images?.[0]?.url) { + updatePayloadValue('storyHeroImage', found.images[0].url) + } + if (!endCtaLink) { + updatePayloadValue('endCtaLink', `/products/${found.slug || found.id}`) + } + } + } + + const currentScene = scenes[selectedSceneIndex] || scenes[0] + + return ( +
+ {/* Intro Banner */} +
+
+ +
+

+ Cinematic Story Scroll (Apple / Tobaki Style) +

+

+ แสดงการเล่าเรื่องเทคโนโลยี 7 ฉากแบบ Scrollytelling เต็มจอ 100vh เหมาะสำหรับทำหน้า Landing Page เปิดตัวสินค้าเรือธงหรือใส่ลงในหน้า CMS +

+
+
+ +
+ + {/* Product Binding Selector */} +
+ +
+ + {loadingProducts && กำลังโหลดสินค้า...} +
+
+ + {/* Visual Theme Selector (Light Luxury vs Dark Theater) */} +
+ +
+ + + +
+
+ + {/* Hero Showcase Image */} +
+ +
+ updatePayloadValue('storyHeroImage', e.target.value)} + placeholder="https://... หรือเลือกจาก Media Library" + className="admin-input flex-1 bg-slate-900 border-white/10 text-xs" + /> + {onBrowseMedia && ( + + )} +
+ {heroImage && ( +
+ Hero Preview + +
+ )} +
+ + {/* Scenes Editor Tabs */} +
+
+ + + ฉาก {selectedSceneIndex + 1} / {scenes.length} + +
+ + {/* Scene Tabs */} +
+ {scenes.map((scene, idx) => ( + + ))} +
+ + {/* Selected Scene Fields */} +
+
+
+ + updateSelectedScene('kicker', e.target.value)} + className="admin-input w-full bg-slate-900 border-white/10 text-xs" + /> +
+ +
+ + +
+
+ +
+ + updateSelectedScene('title', e.target.value)} + className="admin-input w-full bg-slate-900 border-white/10 text-xs font-bold" + /> +
+ +
+ + updateSelectedScene('metric', e.target.value)} + placeholder="เช่น 300°C หรือ 0.42 μ" + className="admin-input w-full bg-slate-900 border-white/10 text-xs" + /> +
+ +
+ +