This commit is contained in:
Your Name committed 2026-07-28 00:13:57 -04:00
1 parent 406cad801b
commit a2da091b15
21 files changed
+2436 -45

No files matched your search

Binary file not shown.
+38 -12
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { API_BASE } from '@homaroma/types';
import { X, Upload, Loader2, Image as ImageIcon } from 'lucide-react';
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
interface MediaLibraryModalProps {
isOpen: boolean;
@@ -12,6 +12,7 @@ interface MediaItem {
id?: string;
url: string;
filename?: string;
mimeType?: string;
}
export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLibraryModalProps) {
@@ -57,19 +58,28 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
const data = await res.json();
await fetchMedia(); // Refresh list
if (data.url) {
// Auto-select uploaded image (optional, or let user pick from list)
onSelect(data.url);
onClose();
}
} else {
const err = await res.json();
alert(`Upload failed: ${err.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Failed to upload', error);
alert('Failed to upload media file.');
} finally {
setUploading(false);
e.target.value = ''; // Reset input
}
};
const isVideoFile = (item: MediaItem) => {
if (item.mimeType?.startsWith('video/')) return true;
const fn = (item.filename || item.url).toLowerCase();
return fn.endsWith('.mp4') || fn.endsWith('.webm') || fn.endsWith('.mov') || fn.endsWith('.ogg');
};
if (!isOpen) return null;
return (
@@ -82,7 +92,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
<ImageIcon className="w-5 h-5 text-cyan-glow" />
Media Library
</h2>
<p className="text-sm text-slate-400 mt-1">Select or upload images for your content.</p>
<p className="text-sm text-slate-400 mt-1">Select or upload images and videos (.mp4, .webm) for your content.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-slate-400 hover:text-white">
<X className="w-6 h-6" />
@@ -95,9 +105,9 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
{uploading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading...</>
) : (
<><Upload className="w-4 h-4" /> Upload New Image</>
<><Upload className="w-4 h-4" /> Upload New File</>
)}
<input type="file" className="hidden" accept="image/*" onChange={handleUpload} disabled={uploading} />
<input type="file" className="hidden" accept="image/*,video/*" onChange={handleUpload} disabled={uploading} />
</label>
<div className="text-sm text-slate-500 font-mono">
{media.length} items found
@@ -113,7 +123,10 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{media.map((item, idx) => (
{media.map((item, idx) => {
const isVid = isVideoFile(item);
return (
<div
key={item.id || idx}
className="group relative border-2 border-white/5 bg-night-950 rounded-xl overflow-hidden cursor-pointer hover:border-cyan-glow transition-all hover:shadow-[0_0_15px_rgba(34,211,238,0.2)] aspect-square flex flex-col"
@@ -122,25 +135,38 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
onClose();
}}
>
<div className="flex-1 overflow-hidden relative bg-black/50">
<div className="flex-1 overflow-hidden relative bg-black/50 flex items-center justify-center">
{isVid ? (
<>
<video src={item.url} className="w-full h-full object-cover opacity-80" muted preload="metadata" />
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
<div className="p-2 bg-black/60 rounded-full border border-white/20">
<Video className="w-5 h-5 text-cyan-glow" />
</div>
</div>
</>
) : (
<img
src={item.url}
alt={item.filename || 'media'}
className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-300"
/>
)}
</div>
{item.filename && (
<div className="p-2.5 text-[10px] uppercase tracking-widest font-mono truncate bg-night-900 border-t border-white/5 text-slate-400 group-hover:text-cyan-glow transition-colors text-center" title={item.filename}>
{item.filename}
<div className="p-2.5 text-[10px] uppercase tracking-widest font-mono truncate bg-night-900 border-t border-white/5 text-slate-400 group-hover:text-cyan-glow transition-colors text-center flex items-center justify-center gap-1" title={item.filename}>
{isVid && <Video className="w-3 h-3 text-cyan-glow shrink-0" />}
<span className="truncate">{item.filename}</span>
</div>
)}
</div>
))}
);
})}
{media.length === 0 && !loading && (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-slate-500">
<ImageIcon className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg font-bold">No images found</p>
<p className="text-sm mt-2">Upload your first image to get started.</p>
<p className="text-lg font-bold">No media found</p>
<p className="text-sm mt-2">Upload your first image or video to get started.</p>
</div>
)}
</div>
@@ -0,0 +1,728 @@
import React, { useState } from 'react';
import {
X,
Search,
Sparkles,
Layers,
Mail,
MapPin,
Play,
Download,
Info,
ChevronDown,
Plus
} from 'lucide-react';
interface AddSectionModalProps {
isOpen: boolean;
onClose: () => void;
onSelectBlock: (blockType: string) => void;
}
interface BlockDefinition {
type: string;
category: string;
title: string;
description: string;
badge?: string;
renderPreview: () => React.ReactNode;
}
const CATEGORIES = [
{ id: 'all', name: 'All Blocks' },
{ id: 'hero', name: 'Banner & Hero' },
{ id: 'products', name: 'Products & Shop' },
{ id: 'editorial', name: 'Story & Editorial' },
{ id: 'articles', name: 'Article Elements' },
{ id: 'forms', name: 'Forms & Info' },
{ id: 'custom', name: 'Product & Custom' }
];
export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddSectionModalProps) {
const [activeCategory, setActiveCategory] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
if (!isOpen) return null;
const blocks: BlockDefinition[] = [
// 1. HERO
{
type: 'HERO',
category: 'hero',
title: 'HERO (Full Banner)',
description: 'Premium full-screen cover with headline, background image, tagline, and CTA button.',
badge: 'Popular',
renderPreview: () => (
<div className="w-full aspect-[21/9] rounded-xl overflow-hidden bg-slate-800 flex items-center justify-center p-4 text-center relative"
style={{
backgroundImage: 'linear-gradient(rgba(0,0,0,0.6), rgba(0,0,0,0.6)), url(https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=400&q=80)',
backgroundSize: 'cover'
}}>
<div className="space-y-1">
<span className="text-[8px] uppercase font-bold text-cyan-glow tracking-widest">Vibrant Elegance</span>
<h4 className="text-xs font-extrabold text-white">New Scent <span className="text-cyan-glow">[Sanctuary]</span></h4>
<span className="inline-block px-2 py-0.5 bg-cyan-glow text-slate-900 font-bold rounded text-[8px] mt-1">Explore</span>
</div>
</div>
)
},
// 2. HERO_V2
{
type: 'HERO_V2',
category: 'hero',
title: 'HERO V2 (Split Layout)',
description: 'Split Layout Hero featuring prominent typography on one side and a product card on the other.',
renderPreview: () => (
<div className="grid grid-cols-2 gap-2 bg-slate-900/60 p-3 rounded-xl items-center border border-white/5">
<div className="space-y-1">
<span className="text-[7px] uppercase font-bold text-cyan-glow">Pure Wellness</span>
<h5 className="text-[10px] font-bold text-white leading-tight">The Organic [Diffuser]</h5>
<span className="inline-block px-2 py-0.5 bg-white text-slate-900 font-bold rounded text-[7px]">Shop Now</span>
</div>
<div className="aspect-[4/3] rounded-lg overflow-hidden bg-slate-800">
<img src="https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=200&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
</div>
)
},
// 2.5 HERO_VIDEO
{
type: 'HERO_VIDEO',
category: 'hero',
title: 'HERO VIDEO (Motion Cover)',
description: 'Cinema-quality full background video banner with animated overlay typography and dual CTA buttons.',
badge: 'Hot',
renderPreview: () => (
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-slate-950 flex items-center justify-center p-4 text-center border border-cyan-glow/20">
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-black/80 z-10"></div>
<img src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=400&q=80" alt="video preview" className="absolute inset-0 w-full h-full object-cover opacity-60" />
<div className="relative z-20 space-y-1">
<span className="text-[7px] uppercase font-bold text-cyan-glow tracking-widest flex items-center justify-center gap-1">
<Play className="w-2 h-2 fill-cyan-glow" /> Cinema of Scents
</span>
<h5 className="text-[10px] font-extrabold text-white">Pure Botanical <span className="text-cyan-glow">[Sanctuary]</span></h5>
<div className="flex gap-1 justify-center pt-0.5">
<span className="px-2 py-0.5 bg-cyan-glow text-slate-900 font-bold rounded text-[6px]">Discover</span>
<span className="px-2 py-0.5 bg-white/10 text-white font-bold rounded text-[6px] border border-white/20">Watch</span>
</div>
</div>
</div>
)
},
// 3. ANNOUNCEMENT_BAR
{
type: 'ANNOUNCEMENT_BAR',
category: 'hero',
title: 'ANNOUNCEMENT BAR',
description: 'Top notice bar with custom background, text colors, and animated promotional slide items.',
renderPreview: () => (
<div className="w-full bg-[#1F3D2D] text-[#FDFDF9] py-2 px-3 text-center text-[9px] font-bold tracking-wider rounded-lg border border-white/10">
✨ [FREE SHIPPING] on orders over ฿1,500
</div>
)
},
// 4. USP_BAR
{
type: 'USP_BAR',
category: 'hero',
title: 'USP BAR (Icon Highlights)',
description: 'Unique Selling Proposition Bar highlighting product perks (Icons & Sub-labels).',
renderPreview: () => (
<div className="grid grid-cols-3 gap-2 bg-slate-900/60 border border-white/5 p-2 rounded-xl text-center">
<div className="space-y-0.5">
<Sparkles className="w-3 h-3 text-cyan-glow mx-auto" />
<div className="text-[8px] font-bold text-slate-200">100% Organic</div>
</div>
<div className="space-y-0.5 border-x border-white/5">
<Sparkles className="w-3 h-3 text-cyan-glow mx-auto" />
<div className="text-[8px] font-bold text-slate-200">Free Shipping</div>
</div>
<div className="space-y-0.5">
<Sparkles className="w-3 h-3 text-cyan-glow mx-auto" />
<div className="text-[8px] font-bold text-slate-200">Easy Returns</div>
</div>
</div>
)
},
// 5. BANNER
{
type: 'BANNER',
category: 'hero',
title: 'BANNER (Campaign Call-to-action)',
description: 'Clickable promotional block with headline overlay, ideal for mid-page campaigns.',
renderPreview: () => (
<div className="relative w-full aspect-[16/6] rounded-xl overflow-hidden bg-slate-800 p-3 flex items-center"
style={{
backgroundImage: 'linear-gradient(90deg, rgba(0,0,0,0.7) 40%, rgba(0,0,0,0.1) 100%), url(https://images.unsplash.com/photo-1540555700478-4be289fbecef?auto=format&fit=crop&w=400&q=80)',
backgroundSize: 'cover'
}}>
<div className="space-y-1">
<h5 className="text-[10px] font-bold text-white">Mid-Year Sale</h5>
<span className="inline-block px-2 py-0.5 bg-fuchsia-500 text-white font-bold rounded text-[7px]">Shop Sale</span>
</div>
</div>
)
},
// 6. PRODUCTS
{
type: 'PRODUCTS',
category: 'products',
title: 'PRODUCTS (Showcase Grid / Carousel)',
description: 'Featured product list with filter choices by Category, Brand, or Custom ID picker.',
badge: 'Essential',
renderPreview: () => (
<div className="grid grid-cols-3 gap-2">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-slate-900/60 p-1.5 border border-white/5 rounded-lg text-center space-y-1">
<div className="aspect-square bg-slate-800 rounded overflow-hidden">
<img src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=100&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<div className="text-[7px] font-bold text-slate-200 truncate">Product #{i}</div>
<div className="text-[7px] text-cyan-glow font-bold">฿850</div>
</div>
))}
</div>
)
},
// 7. PRODUCT_CAROUSEL
{
type: 'PRODUCT_CAROUSEL',
category: 'products',
title: 'PRODUCT CAROUSEL',
description: 'Swipeable horizontal card carousel displaying specific product collections.',
renderPreview: () => (
<div className="flex gap-2 overflow-hidden">
{[1, 2, 3].map((i) => (
<div key={i} className="min-w-[70px] bg-slate-900/60 p-1 border border-white/5 rounded-lg text-center space-y-0.5">
<div className="aspect-square bg-slate-800 rounded overflow-hidden">
<img src="https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=100&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<div className="text-[7px] text-slate-200 truncate">Item {i}</div>
</div>
))}
</div>
)
},
// 8. CATEGORIES
{
type: 'CATEGORIES',
category: 'products',
title: 'CATEGORIES (Collection Grid)',
description: 'Visual category cards to guide users to specific store collections.',
renderPreview: () => (
<div className="grid grid-cols-3 gap-2">
{['Diffusers', 'Oils', 'Sprays'].map((cat, i) => (
<div key={i} className="relative aspect-square rounded-lg overflow-hidden bg-slate-800 flex items-end p-1">
<img src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=100&q=80" alt="mock" className="absolute inset-0 w-full h-full object-cover opacity-60" />
<span className="relative z-10 text-[7px] font-bold text-white">{cat}</span>
</div>
))}
</div>
)
},
// 9. RELATED_PRODUCTS
{
type: 'RELATED_PRODUCTS',
category: 'products',
title: 'RELATED PRODUCTS',
description: 'Dynamic block recommending items from the same category on product pages.',
renderPreview: () => (
<div className="grid grid-cols-2 gap-2">
{[1, 2].map((i) => (
<div key={i} className="flex gap-1 bg-slate-900/40 p-1 border border-white/5 rounded items-center">
<div className="w-6 h-6 bg-slate-800 rounded overflow-hidden shrink-0">
<img src="https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=60&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<div className="text-[7px] text-slate-300 font-bold truncate">Related #{i}</div>
</div>
))}
</div>
)
},
// 10. EDITORIAL_TILE
{
type: 'EDITORIAL_TILE',
category: 'editorial',
title: 'EDITORIAL TILE (50:50 Story)',
description: 'Stunning 50:50 split block presenting storytelling text alongside a photo with flexible aspect ratios.',
badge: 'Updated',
renderPreview: () => (
<div className="grid grid-cols-2 bg-slate-900/60 rounded-xl overflow-hidden border border-white/5">
<div className="aspect-square bg-slate-800">
<img src="https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?auto=format&fit=crop&w=150&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<div className="p-2 space-y-1 flex flex-col justify-center">
<h5 className="text-[9px] font-bold text-white">Moodboard [IRL]</h5>
<p className="text-[7px] text-slate-400 line-clamp-2">An expressive mix of texture and shape.</p>
<span className="text-[6px] text-cyan-glow font-bold">Shop the looks &rarr;</span>
</div>
</div>
)
},
// 10.5 FEATURED_TILES
{
type: 'FEATURED_TILES',
category: 'editorial',
title: 'FEATURED TILES (Campaign Grid)',
description: 'Side-by-side promo cards featuring image, centered title, and CTA button (Jo Malone style).',
badge: 'New',
renderPreview: () => (
<div className="grid grid-cols-2 gap-2 bg-slate-900/60 p-2 rounded-xl border border-white/5 text-center">
<div className="space-y-1">
<div className="aspect-square bg-slate-800 rounded-lg overflow-hidden border border-white/10">
<img src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=120&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<div className="text-[7px] font-bold text-white">Here Comes The Sun</div>
<span className="inline-block px-1.5 py-0.5 bg-slate-800 text-[6px] text-slate-200 font-bold border border-white/10 rounded">Shop Beach</span>
</div>
<div className="space-y-1">
<div className="aspect-square bg-slate-800 rounded-lg overflow-hidden border border-white/10">
<img src="https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=120&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<div className="text-[7px] font-bold text-white">New Adventure</div>
<span className="inline-block px-1.5 py-0.5 bg-slate-800 text-[6px] text-slate-200 font-bold border border-white/10 rounded">First To Know</span>
</div>
</div>
)
},
// 11. EDITORIAL_CAROUSEL
{
type: 'EDITORIAL_CAROUSEL',
category: 'editorial',
title: 'EDITORIAL CAROUSEL',
description: 'Stories and values slider displaying image slideshows side-by-side with editorial statements.',
renderPreview: () => (
<div className="bg-slate-900/40 p-2 border border-white/5 rounded-xl space-y-1">
<div className="text-[8px] font-bold text-center text-slate-200">The Wellness Philosophy</div>
<div className="grid grid-cols-12 gap-1 items-center">
<div className="col-span-5 aspect-square rounded bg-slate-800 overflow-hidden">
<img src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=100&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<div className="col-span-7 text-[7px] text-slate-300">
<strong className="block text-white">Rooted in Nature</strong>
<p className="line-clamp-2 text-slate-400">Sustainably sourced organic growers.</p>
</div>
</div>
</div>
)
},
// 12. SCENT_NOTES_BREAKDOWN
{
type: 'SCENT_NOTES_BREAKDOWN',
category: 'editorial',
title: 'SCENT NOTES BREAKDOWN',
description: 'Fragrance profile breakdowns containing Top, Heart/Middle, and Base notes lists.',
renderPreview: () => (
<div className="bg-slate-900/60 p-2 border border-white/5 rounded-xl space-y-1 text-[7px]">
<div className="font-bold text-slate-200">Scent Notes</div>
<div className="flex gap-1 items-center"><span className="text-cyan-glow font-bold">Top:</span> Citrus, Bergamot</div>
<div className="flex gap-1 items-center"><span className="text-fuchsia-400 font-bold">Heart:</span> Jasmine, Rose</div>
<div className="flex gap-1 items-center"><span className="text-amber-400 font-bold">Base:</span> Amber, Musk</div>
</div>
)
},
// 13. QUOTE_CALLOUT
{
type: 'QUOTE_CALLOUT',
category: 'editorial',
title: 'QUOTE CALLOUT',
description: 'Elegant text banner presenting callout quotations or customer testimonials.',
renderPreview: () => (
<div className="bg-white/[0.02] border border-white/5 p-3 rounded-xl text-center italic space-y-1">
<p className="text-[8px] text-slate-200 font-serif">"Fragrance is the invisible accessory."</p>
<div className="text-[6px] text-slate-400 not-italic font-bold">— Coco Chanel</div>
</div>
)
},
// 14. TEXT
{
type: 'TEXT',
category: 'editorial',
title: 'TEXT (Plain Heading & Copy)',
description: 'Clean text component for section titles, centered paragraphs, or brand statements.',
renderPreview: () => (
<div className="text-center p-2 space-y-1">
<div className="text-[9px] font-bold text-slate-200">Our Commitment</div>
<p className="text-[7px] text-slate-400">100% natural, botanically derived ingredients.</p>
</div>
)
},
// 15. RICH_TEXT
{
type: 'RICH_TEXT',
category: 'editorial',
title: 'RICH TEXT (Markdown / HTML)',
description: 'Styled block designed for long-form brand stories or custom formatted HTML text.',
renderPreview: () => (
<div className="bg-slate-900/40 p-2 border border-white/5 rounded-xl space-y-1 text-[7px] text-slate-300">
<div className="font-bold text-slate-100">Brand Story</div>
<p>Tell your story with <strong>bold</strong> text and custom lists.</p>
</div>
)
},
// 16. HERO_ARTICLE
{
type: 'HERO_ARTICLE',
category: 'articles',
title: 'HERO ARTICLE (Editorial Cover)',
description: 'Editorial article cover header with background image, date, author, and category overlays.',
renderPreview: () => (
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-slate-800 p-2 flex items-end"
style={{
backgroundImage: 'linear-gradient(rgba(0,0,0,0.2), rgba(0,0,0,0.8)), url(https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=300&q=80)',
backgroundSize: 'cover'
}}>
<div className="space-y-0.5">
<span className="text-[6px] text-cyan-glow font-bold uppercase">Insights • 2026</span>
<h5 className="text-[9px] font-bold text-white">Mindfulness & Fragrance</h5>
</div>
</div>
)
},
// 17. ARTICLE_HEADER
{
type: 'ARTICLE_HEADER',
category: 'articles',
title: 'ARTICLE HEADER',
description: 'Introductory page header for standard text articles.',
renderPreview: () => (
<div className="border-l-2 border-cyan-glow bg-slate-900/60 p-2 rounded-r space-y-0.5">
<div className="text-[8px] font-bold text-slate-200">The Chemistry of Volatility</div>
<p className="text-[7px] text-slate-400">Understanding evaporation rates of pure oils.</p>
</div>
)
},
// 18. ARTICLE_MEDIA
{
type: 'ARTICLE_MEDIA',
category: 'articles',
title: 'ARTICLE MEDIA (Paragraph + Image)',
description: 'Article paragraph paired with a side image (left/right alignment).',
renderPreview: () => (
<div className="flex gap-2 items-center bg-slate-900/40 p-2 border border-white/5 rounded-lg">
<div className="flex-1 space-y-0.5">
<div className="text-[8px] font-bold text-slate-200">Sourcing Lavender</div>
<p className="text-[7px] text-slate-400 line-clamp-2">Harvested at dawn in Provence when oil concentration is peak.</p>
</div>
<div className="w-8 h-8 bg-slate-800 rounded overflow-hidden shrink-0">
<img src="https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=60&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
</div>
)
},
// 19. ARTICLE_GALLERY
{
type: 'ARTICLE_GALLERY',
category: 'articles',
title: 'ARTICLE GALLERY',
description: 'Photo collection grids to show off multiple illustrations or steps.',
renderPreview: () => (
<div className="grid grid-cols-2 gap-2">
<div className="aspect-[4/3] rounded bg-slate-800 overflow-hidden">
<img src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=100&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<div className="aspect-[4/3] rounded bg-slate-800 overflow-hidden">
<img src="https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=100&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
</div>
)
},
// 20. ARTICLE_FACTS
{
type: 'ARTICLE_FACTS',
category: 'articles',
title: 'ARTICLE FACTS',
description: 'Quick summaries or key takeaway bullets for editorial pieces.',
renderPreview: () => (
<div className="bg-slate-900/60 p-2 border border-white/5 rounded-lg space-y-1">
<div className="text-[8px] font-bold text-slate-200 flex items-center gap-1">
<Info className="w-3 h-3 text-cyan-glow" /> Key Facts
</div>
<p className="text-[7px] text-slate-400">• Relaxation: lowers cortisol levels.</p>
</div>
)
},
// 21. ARTICLE_VIDEO
{
type: 'ARTICLE_VIDEO',
category: 'articles',
title: 'ARTICLE VIDEO',
description: 'Embedded video player wrapper for YouTube / Vimeo videos.',
renderPreview: () => (
<div className="w-full aspect-[16/9] rounded-lg overflow-hidden bg-black flex flex-col justify-center items-center text-slate-400 relative">
<div className="w-8 h-8 rounded-full bg-red-600 flex items-center justify-center text-white">
<Play className="w-4 h-4 fill-white ml-0.5" />
</div>
</div>
)
},
// 22. ARTICLE_REFERENCES
{
type: 'ARTICLE_REFERENCES',
category: 'articles',
title: 'ARTICLE REFERENCES',
description: 'Citation list or scientific reference cards.',
renderPreview: () => (
<div className="bg-slate-900/40 p-2 border border-white/5 rounded space-y-0.5 text-[7px]">
<div className="font-bold text-slate-400">Sources</div>
<span className="text-cyan-glow underline">Journal of Aromatherapy Research</span>
</div>
)
},
// 23. ARTICLE_TABLE
{
type: 'ARTICLE_TABLE',
category: 'articles',
title: 'ARTICLE TABLE',
description: 'Comparison table displaying technical parameters or specs.',
renderPreview: () => (
<div className="border border-white/5 rounded overflow-hidden text-[7px]">
<div className="bg-white/5 p-1 font-bold text-slate-300">Technical Specs</div>
<div className="p-1 text-slate-400 border-t border-white/5">Volatile Oils vs Carrier</div>
</div>
)
},
// 24. ARTICLE_DOWNLOAD
{
type: 'ARTICLE_DOWNLOAD',
category: 'articles',
title: 'ARTICLE DOWNLOAD',
description: 'File attachments download cards for PDF guides or catalogues.',
renderPreview: () => (
<div className="flex items-center justify-between p-2 bg-slate-900/60 border border-white/5 rounded-lg text-[7px]">
<div>
<div className="font-bold text-slate-200">Scent Catalogue.pdf</div>
<div className="text-slate-500">4.8 MB</div>
</div>
<Download className="w-3 h-3 text-cyan-glow" />
</div>
)
},
// 25. ARTICLE_CTA
{
type: 'ARTICLE_CTA',
category: 'articles',
title: 'ARTICLE CTA',
description: 'Call-to-action blocks prompting next-step user clicks.',
renderPreview: () => (
<div className="bg-gradient-to-r from-cyan-glow/10 to-transparent border border-cyan-glow/20 rounded-xl p-3 text-center space-y-1">
<div className="text-[8px] font-bold text-slate-200">Ready to explore?</div>
<span className="inline-block px-3 py-1 bg-cyan-glow text-slate-900 font-bold rounded text-[7px]">Shop Now</span>
</div>
)
},
// 26. NEWSLETTER
{
type: 'NEWSLETTER',
category: 'forms',
title: 'NEWSLETTER',
description: 'Email subscription block to attract newsletter sign-ups.',
renderPreview: () => (
<div className="bg-slate-900/60 p-3 border border-white/5 rounded-xl space-y-1.5 text-center">
<div className="text-[8px] font-bold text-slate-200">Begin Your Wellness Journey</div>
<div className="flex gap-1 max-w-[160px] mx-auto">
<input type="email" placeholder="Email" className="admin-input flex-1 text-[7px] py-0.5 px-1" readOnly />
<button className="px-2 py-0.5 bg-white text-slate-900 font-bold rounded text-[7px]">Join</button>
</div>
</div>
)
},
// 27. ACCORDION
{
type: 'ACCORDION',
category: 'forms',
title: 'ACCORDION (FAQ Block)',
description: 'Question-answer accordions for FAQs, divided by custom categories.',
renderPreview: () => (
<div className="bg-slate-900/40 p-2 border border-white/5 rounded-xl space-y-1 text-[7px]">
<div className="font-bold text-slate-200 flex items-center justify-between">
<span>How long do Reed Diffusers last?</span>
<ChevronDown className="w-2.5 h-2.5 text-slate-400" />
</div>
<p className="text-slate-400">Evaporates over 4 to 6 weeks.</p>
</div>
)
},
// 28. CONTACT_INFO
{
type: 'CONTACT_INFO',
category: 'forms',
title: 'CONTACT INFO',
description: 'Information box showing address, telephone, email, and social details.',
renderPreview: () => (
<div className="bg-slate-900/60 p-2 border border-white/5 rounded-xl space-y-1 text-[7px] text-slate-400">
<div className="font-bold text-slate-200 uppercase">Contact Info</div>
<div className="flex items-center gap-1"><MapPin className="w-2.5 h-2.5 text-cyan-glow" /> Sukhumvit Rd, Bangkok</div>
<div className="flex items-center gap-1"><Mail className="w-2.5 h-2.5 text-cyan-glow" /> hello@homaroma.com</div>
</div>
)
},
// 29. CONTACT_FORM
{
type: 'CONTACT_FORM',
category: 'forms',
title: 'CONTACT FORM',
description: 'General support email inquiry contact form.',
renderPreview: () => (
<div className="bg-slate-900/60 p-2 border border-white/5 rounded-xl space-y-1 text-[7px]">
<div className="font-bold text-slate-200">Get In Touch</div>
<div className="space-y-1">
<input type="text" placeholder="Name" className="admin-input w-full py-0.5 text-[6px]" readOnly />
<button className="w-full py-0.5 bg-cyan-glow text-slate-900 font-bold rounded text-[6px]">Send</button>
</div>
</div>
)
},
// 30. CUSTOM
{
type: 'CUSTOM',
category: 'custom',
title: 'CUSTOM BLOCK',
description: 'Registered custom layout components (e.g., Bakery Categories).',
renderPreview: () => (
<div className="bg-slate-900/40 p-2 border border-dashed border-cyan-glow/30 rounded-xl text-center space-y-1">
<span className="text-[8px] font-bold text-cyan-glow font-mono">CUSTOM COMPONENT</span>
<p className="text-[7px] text-slate-400">Dynamic registered widget</p>
</div>
)
}
];
const filteredBlocks = blocks.filter((block) => {
const matchesCategory = activeCategory === 'all' || block.category === activeCategory;
const matchesSearch = searchQuery === '' ||
block.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
block.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
block.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
{/* Modal Header */}
<div className="p-6 border-b border-white/5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-night-950/60 shrink-0">
<div>
<h3 className="text-xl font-extrabold flex items-center gap-3 text-white">
<Sparkles className="w-6 h-6 text-cyan-glow animate-pulse" />
Add Block Section
</h3>
<p className="text-xs text-slate-400 mt-1">
Select a visual block component to insert into your page layout.
</p>
</div>
<div className="flex items-center gap-3">
{/* Search input */}
<div className="relative min-w-[220px]">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search blocks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-900/80 border border-white/10 text-white rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-cyan-glow/50 transition-all"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<button
onClick={onClose}
className="p-2 rounded-xl hover:bg-white/10 text-slate-400 hover:text-white transition-all cursor-pointer"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* Category Filter Tabs */}
<div className="px-6 py-3 border-b border-white/5 bg-night-950/40 flex gap-2 overflow-x-auto hide-scrollbar shrink-0">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setActiveCategory(cat.id)}
className={`px-4 py-2 text-xs font-bold transition-all rounded-xl whitespace-nowrap cursor-pointer ${
activeCategory === cat.id
? 'bg-cyan-glow text-night-900 shadow-md shadow-cyan-glow/20 font-extrabold'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{cat.name}
</button>
))}
</div>
{/* Ultra-Wide Block Cards Grid */}
<div className="flex-1 overflow-y-auto p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 custom-scrollbar">
{filteredBlocks.length === 0 ? (
<div className="col-span-full py-20 text-center text-slate-500 space-y-3">
<Layers className="w-12 h-12 mx-auto text-slate-700" />
<p className="text-sm font-bold">No blocks found matching "{searchQuery}".</p>
</div>
) : (
filteredBlocks.map((block) => (
<div
key={block.type}
onClick={() => {
onSelectBlock(block.type);
onClose();
}}
className="group relative bg-slate-900/50 hover:bg-slate-900 border border-white/5 hover:border-cyan-glow/40 rounded-2xl p-4 transition-all duration-200 cursor-pointer flex flex-col justify-between hover:shadow-xl hover:shadow-cyan-glow/5"
>
<div className="space-y-3">
{/* Card Top Info */}
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="text-sm font-bold text-slate-100 group-hover:text-cyan-glow transition-colors flex items-center gap-2">
{block.title}
</h4>
<span className="text-[9px] font-mono text-slate-500 font-semibold uppercase tracking-wider">
{block.type}
</span>
</div>
{block.badge && (
<span className="px-2 py-0.5 text-[9px] font-extrabold uppercase tracking-wider bg-fuchsia-500/20 text-fuchsia-400 border border-fuchsia-500/30 rounded-md shrink-0">
{block.badge}
</span>
)}
</div>
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed">
{block.description}
</p>
{/* Visual Preview Container */}
<div className="bg-black/30 p-2.5 rounded-xl border border-white/5 group-hover:border-white/10 transition-colors">
{block.renderPreview()}
</div>
</div>
{/* Hover Selection Footer */}
<div className="mt-4 pt-3 border-t border-white/5 flex items-center justify-between">
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500 group-hover:text-slate-300">
Category: {block.category}
</span>
<button
type="button"
className="px-3 py-1 bg-cyan-glow/10 group-hover:bg-cyan-glow text-cyan-glow group-hover:text-night-900 text-xs font-bold rounded-lg transition-all flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" />
Select Block
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,234 @@
import React from 'react';
import { UploadCloud, Image as ImageIcon, Plus, Trash2 } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
interface FeaturedTilesFormProps {
editingSection: Partial<PageSection>;
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
editingLang: string;
getLocalizedValue: (payload: any, field: string) => string;
updateLocalizedValue: (field: string, value: string) => void;
updatePayloadValue: (field: string, value: any) => void;
setUploadTarget: (target: { type: 'main' | 'slide' | 'gallery' | 'tile'; index?: number } | null) => void;
setIsMediaModalOpen: (open: boolean) => void;
}
export default function FeaturedTilesForm({
editingSection,
setEditingSection: _setEditingSection,
editingLang,
getLocalizedValue,
updateLocalizedValue,
updatePayloadValue,
setUploadTarget,
setIsMediaModalOpen,
}: FeaturedTilesFormProps) {
const payload = editingSection.payload || {};
const tiles = Array.isArray(payload.tiles) ? payload.tiles : [];
const handleAddTile = () => {
const newTiles = [
...tiles,
{
title: { th: 'New Tile Title', en: 'New Tile Title' },
description: { th: '', en: '' },
imageUrl: 'https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=600&q=80',
imageAspectRatio: 'square',
buttonText: { th: 'Shop Now', en: 'Shop Now' },
buttonLink: '/shop'
}
];
updatePayloadValue('tiles', newTiles);
};
const handleRemoveTile = (index: number) => {
const newTiles = tiles.filter((_: any, i: number) => i !== index);
updatePayloadValue('tiles', newTiles);
};
const updateTileField = (index: number, field: string, value: any) => {
const newTiles = [...tiles];
newTiles[index] = { ...(newTiles[index] || {}), [field]: value };
updatePayloadValue('tiles', newTiles);
};
const updateTileLocalizedField = (index: number, field: string, value: string) => {
const newTiles = [...tiles];
const tile = newTiles[index] || {};
const currentVal = tile[field];
let newVal;
if (typeof currentVal === 'string') {
newVal = { th: currentVal, [editingLang]: value };
} else {
newVal = { ...(currentVal || {}), [editingLang]: value };
}
newTiles[index] = { ...tile, [field]: newVal };
updatePayloadValue('tiles', newTiles);
};
const getTileLocalizedField = (tile: any, field: string) => {
if (!tile || tile[field] === undefined) return '';
const val = tile[field];
if (typeof val === 'string') return editingLang === 'th' ? val : '';
return val[editingLang] || '';
};
return (
<div className="space-y-6">
{/* Section Header Controls */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 bg-night-950/40 p-4 rounded-2xl border border-white/5">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Section Title (Optional)</label>
<input
type="text"
value={getLocalizedValue(payload, 'title')}
onChange={(e) => updateLocalizedValue('title', e.target.value)}
className="admin-input w-full"
placeholder="e.g. Summer Campaign"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Grid Columns</label>
<select
value={payload.columns || 2}
onChange={(e) => updatePayloadValue('columns', parseInt(e.target.value))}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value={2}>2 Columns (Side-by-side)</option>
<option value={3}>3 Columns</option>
<option value={4}>4 Columns</option>
</select>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
<input
type="color"
value={payload.backgroundColor || '#FDFDF9'}
onChange={(e) => updatePayloadValue('backgroundColor', e.target.value)}
className="admin-input w-full h-10 p-1 cursor-pointer"
/>
</div>
</div>
{/* Tiles List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs uppercase font-bold tracking-widest text-slate-300">Campaign Cards ({tiles.length})</h4>
<button
type="button"
onClick={handleAddTile}
className="px-3 py-1.5 bg-cyan-glow text-night-900 font-bold rounded-xl text-xs flex items-center gap-1.5 hover:bg-cyan-glow/85 transition-all cursor-pointer"
>
<Plus className="w-4 h-4" /> Add Card
</button>
</div>
{tiles.length === 0 ? (
<div className="p-8 text-center border border-dashed border-white/10 rounded-2xl text-slate-500 text-xs">
No campaign cards added yet. Click "Add Card" above to insert cards.
</div>
) : (
tiles.map((tile: any, idx: number) => (
<div key={idx} className="bg-slate-900/60 border border-white/10 p-5 rounded-2xl space-y-4 relative">
<div className="flex items-center justify-between border-b border-white/5 pb-2">
<span className="text-xs font-bold text-cyan-glow uppercase tracking-wider">Card #{idx + 1}</span>
<button
type="button"
onClick={() => handleRemoveTile(idx)}
className="p-1 text-slate-400 hover:text-rose-500 transition-colors"
title="Remove Card"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Image selector */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Card Image</label>
<div
className="relative h-28 w-full rounded-xl overflow-hidden border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-cyan-glow/30 transition-all"
onClick={() => {
setUploadTarget({ type: 'tile', index: idx });
setIsMediaModalOpen(true);
}}
>
{tile.imageUrl ? (
<>
<img src={tile.imageUrl} className="w-full h-full object-cover" alt="Tile" />
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
<UploadCloud className="w-8 h-8 text-white" />
</div>
</>
) : (
<div className="text-center space-y-1">
<ImageIcon className="w-6 h-6 text-slate-600 mx-auto" />
<span className="text-[9px] font-bold text-slate-500 uppercase">Select Image</span>
</div>
)}
</div>
<input
type="text"
value={tile.imageUrl || ''}
onChange={(e) => updateTileField(idx, 'imageUrl', e.target.value)}
className="admin-input w-full text-xs"
placeholder="Or paste Image URL..."
/>
</div>
<div className="space-y-3">
<div>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
<input
type="text"
required
value={getTileLocalizedField(tile, 'title')}
onChange={(e) => updateTileLocalizedField(idx, 'title', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. Here Comes The Sun"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Image Aspect Ratio</label>
<select
value={tile.imageAspectRatio || 'square'}
onChange={(e) => updateTileField(idx, 'imageAspectRatio', e.target.value)}
className="admin-input w-full text-xs bg-slate-900 border-white/10"
>
<option value="square">Square 1:1 (จัตุรัส)</option>
<option value="portrait">Portrait 3:4 (แนวสูง 3:4)</option>
<option value="tall">Tall Fashion 2:3 (แนวสูง 2:3)</option>
<option value="auto">Natural / Original (รูปต้นฉบับ)</option>
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Text</label>
<input
type="text"
value={getTileLocalizedField(tile, 'buttonText')}
onChange={(e) => updateTileLocalizedField(idx, 'buttonText', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. Shop Beach Blossom"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Link URL</label>
<input
type="text"
value={tile.buttonLink || ''}
onChange={(e) => updateTileField(idx, 'buttonLink', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. /shop/beach-blossom"
/>
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,241 @@
import React from 'react';
import { UploadCloud, Image as ImageIcon, Video } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
interface HeroVideoFormProps {
editingSection: Partial<PageSection>;
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
editingLang: string;
getLocalizedValue: (payload: any, field: string) => string;
updateLocalizedValue: (field: string, value: string) => void;
updatePayloadValue: (field: string, value: any) => void;
setUploadTarget: (target: { type: 'main' | 'slide' | 'gallery' | 'tile'; index?: number } | null) => void;
setIsMediaModalOpen: (open: boolean) => void;
}
export default function HeroVideoForm({
editingSection,
setEditingSection: _setEditingSection,
editingLang: _editingLang,
getLocalizedValue,
updateLocalizedValue,
updatePayloadValue,
setUploadTarget,
setIsMediaModalOpen,
}: HeroVideoFormProps) {
const payload = editingSection.payload || {};
return (
<div className="space-y-6">
{/* Video Source & Poster */}
<div className="bg-night-950/40 p-5 rounded-2xl border border-white/5 space-y-4">
<h4 className="text-xs uppercase font-bold tracking-widest text-cyan-glow flex items-center gap-2">
<Video className="w-4 h-4" /> Video Media Configuration
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Video URL Input */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Video File URL (.mp4 / .webm) <span className="text-rose-400">*</span>
</label>
<div className="flex gap-2">
<input
type="text"
required
value={payload.videoUrl || ''}
onChange={(e) => updatePayloadValue('videoUrl', e.target.value)}
className="admin-input flex-1 text-xs font-mono"
placeholder="https://example.com/brand-hero-video.mp4"
/>
<button
type="button"
onClick={() => {
setUploadTarget({ type: 'main' });
setIsMediaModalOpen(true);
}}
className="px-3 py-2 bg-white/10 hover:bg-white/20 text-white rounded-xl text-xs font-bold transition-all shrink-0 flex items-center gap-1 cursor-pointer"
>
<UploadCloud className="w-3.5 h-3.5" /> Media
</button>
</div>
<p className="text-[9px] text-slate-500 font-medium">Direct MP4/WebM video URL. Recommended size under 15MB for fast loading.</p>
</div>
{/* Poster Image */}
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Poster Cover Image (Fallback)
</label>
<div className="flex gap-2">
<input
type="text"
value={payload.posterImageUrl || ''}
onChange={(e) => updatePayloadValue('posterImageUrl', e.target.value)}
className="admin-input flex-1 text-xs"
placeholder="https://example.com/poster.jpg"
/>
<button
type="button"
onClick={() => {
setUploadTarget({ type: 'main' });
setIsMediaModalOpen(true);
}}
className="px-3 py-2 bg-white/10 hover:bg-white/20 text-white rounded-xl text-xs font-bold transition-all shrink-0 flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3.5 h-3.5" /> Pick
</button>
</div>
</div>
</div>
{/* Video Settings: Opacity & Height */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-2 border-t border-white/5">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
Dark Overlay Opacity: {payload.overlayOpacity ?? 40}%
</label>
<input
type="range"
min={0}
max={90}
step={5}
value={payload.overlayOpacity ?? 40}
onChange={(e) => updatePayloadValue('overlayOpacity', parseInt(e.target.value))}
className="w-full h-2 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-cyan-glow"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Hero Section Height</label>
<select
value={payload.height || 'large'}
onChange={(e) => updatePayloadValue('height', e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10 text-xs"
>
<option value="full">Full Screen (100vh)</option>
<option value="large">Large Banner (80vh)</option>
<option value="medium">Medium Banner (60vh)</option>
</select>
</div>
<div className="space-y-2 flex flex-col justify-end">
<label className="flex items-center gap-2 cursor-pointer text-xs text-slate-300">
<input
type="checkbox"
checked={payload.autoplay !== false}
onChange={(e) => updatePayloadValue('autoplay', e.target.checked)}
className="rounded bg-slate-900 border-white/10 accent-cyan-glow"
/>
Autoplay Muted Video
</label>
<label className="flex items-center gap-2 cursor-pointer text-xs text-slate-300">
<input
type="checkbox"
checked={payload.loop !== false}
onChange={(e) => updatePayloadValue('loop', e.target.checked)}
className="rounded bg-slate-900 border-white/10 accent-cyan-glow"
/>
Loop Continuously
</label>
</div>
</div>
</div>
{/* Localized Content */}
<div className="bg-night-950/40 p-5 rounded-2xl border border-white/5 space-y-4">
<h4 className="text-xs uppercase font-bold tracking-widest text-slate-300">Hero Text Content</h4>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Tagline / Sub-header</label>
<input
type="text"
value={getLocalizedValue(payload, 'tagline')}
onChange={(e) => updateLocalizedValue('tagline', e.target.value)}
className="admin-input w-full"
placeholder="e.g. Cinema of Scents"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Main Title</label>
<input
type="text"
required
value={getLocalizedValue(payload, 'title')}
onChange={(e) => updateLocalizedValue('title', e.target.value)}
className="admin-input w-full text-base font-bold"
placeholder="e.g. Pure Botanical [Sanctuary]"
/>
<p className="text-[9px] text-slate-500 font-medium">Use [brackets] for accent colored text (e.g. Pure Botanical [Sanctuary]).</p>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Description Copy</label>
<textarea
rows={3}
value={getLocalizedValue(payload, 'description')}
onChange={(e) => updateLocalizedValue('description', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. Immerse yourself in moving sensory atmospheres..."
/>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-night-950/40 p-5 rounded-2xl border border-white/5">
{/* Primary CTA */}
<div className="space-y-3">
<h5 className="text-xs font-bold text-slate-200">Primary Button (CTA)</h5>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Text</label>
<input
type="text"
value={getLocalizedValue(payload, 'buttonText')}
onChange={(e) => updateLocalizedValue('buttonText', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. Discover Collection"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Link URL</label>
<input
type="text"
value={payload.buttonLink || ''}
onChange={(e) => updatePayloadValue('buttonLink', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. /shop"
/>
</div>
</div>
{/* Secondary CTA */}
<div className="space-y-3">
<h5 className="text-xs font-bold text-slate-200">Secondary Button (Optional)</h5>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Text</label>
<input
type="text"
value={getLocalizedValue(payload, 'secondaryButtonText')}
onChange={(e) => updateLocalizedValue('secondaryButtonText', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. Watch Story"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Link URL</label>
<input
type="text"
value={payload.secondaryButtonLink || ''}
onChange={(e) => updatePayloadValue('secondaryButtonLink', e.target.value)}
className="admin-input w-full text-xs"
placeholder="e.g. /about"
/>
</div>
</div>
</div>
</div>
);
}
+89
View File
@@ -124,6 +124,45 @@ export default function CmsMockupPage() {
</div>
)
},
// 2.5 HERO_VIDEO
{
type: 'HERO_VIDEO',
category: 'hero',
description: 'Cinema-quality full background video banner with animated overlay typography and dual CTA buttons.',
payload: {
tagline: 'Cinema of Scents',
title: 'Pure Botanical [Sanctuary]',
description: 'Immerse yourself in moving sensory atmospheres hand-blended for quiet luxury.',
videoUrl: 'https://cdn.coverr.co/videos/coverr-essential-oils-and-diffuser-5374/1080p.mp4',
posterImageUrl: 'https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=1200&q=80',
buttonText: 'Discover Collection',
buttonLink: '/shop',
secondaryButtonText: 'Watch Story',
secondaryButtonLink: '/about',
overlayOpacity: 40,
height: 'large',
autoplay: true,
loop: true,
muted: true
},
renderPreview: () => (
<div className="relative w-full aspect-[21/9] rounded-2xl overflow-hidden bg-slate-950 flex items-center justify-center p-6 text-center border border-cyan-glow/20 shadow-2xl">
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-black/80 z-10"></div>
<img src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=800&q=80" alt="mock video" className="absolute inset-0 w-full h-full object-cover opacity-60" />
<div className="relative z-20 space-y-2 max-w-lg">
<span className="text-[9px] uppercase font-bold text-cyan-glow tracking-widest flex items-center justify-center gap-1.5">
<Play className="w-3 h-3 fill-cyan-glow" /> Cinema of Scents
</span>
<h4 className="text-xl font-extrabold text-white">Pure Botanical <span className="text-cyan-glow">[Sanctuary]</span></h4>
<p className="text-xs text-slate-300 line-clamp-2">Immerse yourself in moving sensory atmospheres hand-blended for quiet luxury.</p>
<div className="flex gap-2 justify-center pt-2">
<span className="px-4 py-1.5 bg-cyan-glow text-slate-900 font-bold rounded-lg text-xs">Discover Collection</span>
<span className="px-4 py-1.5 bg-white/10 text-white font-bold rounded-lg text-xs border border-white/20">Watch Story</span>
</div>
</div>
</div>
)
},
// 4. USP_BAR
{
type: 'USP_BAR',
@@ -375,6 +414,56 @@ export default function CmsMockupPage() {
</div>
)
},
// 10.5 FEATURED_TILES
{
type: 'FEATURED_TILES',
category: 'editorial',
description: 'Side-by-side campaign grid featuring square or tall images with centered titles and outline CTA buttons (Jo Malone aesthetic).',
payload: {
title: 'Summer Campaign',
columns: 2,
backgroundColor: '#FDFDF9',
textColor: '#1F3D2D',
tiles: [
{
title: 'Here Comes The Sun',
description: '',
imageUrl: 'https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=600&q=80',
buttonText: 'Shop Beach Blossom',
buttonLink: '/shop'
},
{
title: 'The Scent Of A New Adventure',
description: '',
imageUrl: 'https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=600&q=80',
buttonText: 'Be The First To Know',
buttonLink: '/shop'
}
]
},
renderPreview: () => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-[#FDFDF9] p-6 rounded-2xl border border-white/10 text-center">
<div className="space-y-3 flex flex-col items-center">
<div className="aspect-square w-full bg-slate-200 rounded-xl overflow-hidden shadow-md">
<img src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=400&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<h5 className="text-sm font-serif font-semibold text-slate-800">Here Comes The Sun</h5>
<button className="px-4 py-1.5 bg-slate-900 text-white rounded text-xs font-bold uppercase tracking-wider">
Shop Beach Blossom
</button>
</div>
<div className="space-y-3 flex flex-col items-center">
<div className="aspect-square w-full bg-slate-200 rounded-xl overflow-hidden shadow-md">
<img src="https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=400&q=80" alt="mock" className="w-full h-full object-cover" />
</div>
<h5 className="text-sm font-serif font-semibold text-slate-800">The Scent Of A New Adventure</h5>
<button className="px-4 py-1.5 bg-slate-900 text-white rounded text-xs font-bold uppercase tracking-wider">
Be The First To Know
</button>
</div>
</div>
)
},
// 11. EDITORIAL_CAROUSEL
{
type: 'EDITORIAL_CAROUSEL',
+119 -14
View File
@@ -15,7 +15,8 @@ import {
CheckCircle2,
ExternalLink,
Copy,
Check
Check,
Sparkles
} from 'lucide-react';
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
import { getAdminDisplayString } from '../utils/localization';
@@ -53,6 +54,9 @@ import HeroV2Form from '../components/page-builder/HeroV2Form';
import UspBarForm from '../components/page-builder/UspBarForm';
import CustomBlockForm from '../components/page-builder/CustomBlockForm';
import ProductLayoutBlockForm from '../components/page-builder/ProductLayoutBlockForm';
import AddSectionModal from '../components/page-builder/AddSectionModal';
import FeaturedTilesForm from '../components/page-builder/FeaturedTilesForm';
import HeroVideoForm from '../components/page-builder/HeroVideoForm';
const PRODUCT_LAYOUT_BLOCK_TYPES = new Set([
'PRODUCT_MAIN',
@@ -83,16 +87,25 @@ export default function PageBuilderPage() {
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery'; index?: number } | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery' | 'tile'; index?: number } | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [isReorderModalOpen, setIsReorderModalOpen] = useState(false);
const [isAddBlockModalOpen, setIsAddBlockModalOpen] = useState(false);
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
const handleMediaSelect = (url: string) => {
if (uploadTarget?.type === 'slide' && typeof uploadTarget.index === 'number') {
if (uploadTarget?.type === 'tile' && typeof uploadTarget.index === 'number') {
const idx = uploadTarget.index;
setEditingSection(prev => {
if (!prev) return null;
const currentTiles = [...(prev.payload?.tiles || [])];
currentTiles[idx] = { ...(currentTiles[idx] || {}), imageUrl: url };
return { ...prev, payload: { ...(prev.payload || {}), tiles: currentTiles } };
});
} else if (uploadTarget?.type === 'slide' && typeof uploadTarget.index === 'number') {
const idx = uploadTarget.index;
setEditingSection(prev => {
if (!prev) return null;
@@ -519,18 +532,16 @@ export default function PageBuilderPage() {
};
const openAddModal = () => {
setIsAddBlockModalOpen(true);
};
const handleSelectBlockFromModal = (type: string) => {
const defaultPayload = getDefaultPayloadForType(type);
setEditingSection({
type: 'HERO',
type,
order: sections.length + 1,
isActive: true,
payload: {
tagline: 'Vibrant Elegance',
title: 'New Scent [Sanctuary]',
description: 'Transform your room into a peaceful escape.',
imageUrl: '/images/hero.png',
buttonText: 'Explore',
buttonLink: '/shop'
}
payload: defaultPayload
});
window.scrollTo({ top: 0, behavior: 'smooth' });
};
@@ -540,9 +551,27 @@ export default function PageBuilderPage() {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleTypeChange = (type: string) => {
const getDefaultPayloadForType = (type: string) => {
let defaultPayload = {};
switch (type) {
case 'HERO_VIDEO':
defaultPayload = {
tagline: 'Cinema of Scents',
title: 'Pure Botanical [Sanctuary]',
description: 'Immerse yourself in moving sensory atmospheres hand-blended for quiet luxury.',
videoUrl: 'https://cdn.coverr.co/videos/coverr-essential-oils-and-diffuser-5374/1080p.mp4',
posterImageUrl: 'https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=1200&q=80',
buttonText: 'Discover Collection',
buttonLink: '/shop',
secondaryButtonText: 'Watch Story',
secondaryButtonLink: '/about',
overlayOpacity: 40,
height: 'large',
autoplay: true,
loop: true,
muted: true
};
break;
case 'ANNOUNCEMENT_BAR':
defaultPayload = {
slides: ['[FREE SHIPPING] on orders over ฿1,500', 'New Premium Collection is out now!'],
@@ -573,6 +602,32 @@ export default function PageBuilderPage() {
textColor: '#1F3D2D'
};
break;
case 'FEATURED_TILES':
defaultPayload = {
title: 'Featured Campaigns',
columns: 2,
backgroundColor: '#FDFDF9',
textColor: '#1F3D2D',
tiles: [
{
title: { th: 'Here Comes The Sun', en: 'Here Comes The Sun' },
description: { th: '', en: '' },
imageUrl: 'https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=600&q=80',
imageAspectRatio: 'square',
buttonText: { th: 'Shop Beach Blossom', en: 'Shop Beach Blossom' },
buttonLink: '/shop'
},
{
title: { th: 'The Scent Of A New Adventure', en: 'The Scent Of A New Adventure' },
description: { th: '', en: '' },
imageUrl: 'https://images.unsplash.com/photo-1547887537-6158d64c35b3?auto=format&fit=crop&w=600&q=80',
imageAspectRatio: 'square',
buttonText: { th: 'Be The First To Know', en: 'Be The First To Know' },
buttonLink: '/shop'
}
]
};
break;
case 'EDITORIAL_CAROUSEL':
defaultPayload = {
title: 'The Wellness [Philosophy]',
@@ -837,6 +892,11 @@ export default function PageBuilderPage() {
break;
}
return defaultPayload;
};
const handleTypeChange = (type: string) => {
const defaultPayload = getDefaultPayloadForType(type);
setEditingSection(prev => {
if (!prev) return null;
return {
@@ -1150,17 +1210,30 @@ export default function PageBuilderPage() {
<form onSubmit={handleFormSubmit} className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto p-8 space-y-6">
{!editingSection.id && (
<div className="space-y-2">
<div className="space-y-2 bg-night-950/40 p-4 rounded-2xl border border-white/5">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Block Type</label>
<button
type="button"
onClick={() => setIsAddBlockModalOpen(true)}
className="text-xs font-bold text-cyan-glow hover:underline flex items-center gap-1.5 cursor-pointer"
>
<Sparkles className="w-3.5 h-3.5 text-cyan-glow" />
Browse Visual Gallery
</button>
</div>
<select
value={editingSection.type || 'HERO'}
onChange={(e) => handleTypeChange(e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="HERO">HERO (Campaign Intro)</option>
<option value="HERO_V2">HERO V2 (Split Layout Hero)</option>
<option value="HERO_VIDEO">HERO VIDEO (Video Full Cover Background)</option>
<option value="ANNOUNCEMENT_BAR">ANNOUNCEMENT BAR (Premium Slides)</option>
<option value="NEWSLETTER">NEWSLETTER (Email Subscription)</option>
<option value="EDITORIAL_TILE">EDITORIAL TILE (Split 50:50 Story)</option>
<option value="FEATURED_TILES">FEATURED TILES (Multi-Card Campaign Grid)</option>
<option value="EDITORIAL_CAROUSEL">EDITORIAL CAROUSEL (Story Slider)</option>
<option value="CATEGORIES">CATEGORIES (Collections list)</option>
<option value="PRODUCTS">PRODUCTS (Product list Showcase)</option>
@@ -1220,6 +1293,19 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'HERO_VIDEO' && (
<HeroVideoForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
setUploadTarget={setUploadTarget}
setIsMediaModalOpen={setIsMediaModalOpen}
/>
)}
{editingSection.type?.toUpperCase() === 'USP_BAR' && (
<UspBarForm
editingSection={editingSection}
@@ -1261,6 +1347,19 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'FEATURED_TILES' && (
<FeaturedTilesForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
setUploadTarget={setUploadTarget}
setIsMediaModalOpen={setIsMediaModalOpen}
/>
)}
{editingSection.type?.toUpperCase() === 'EDITORIAL_CAROUSEL' && (
<EditorialCarouselForm
editingSection={editingSection}
@@ -1759,6 +1858,12 @@ export default function PageBuilderPage() {
onClose={() => setIsMediaModalOpen(false)}
onSelect={handleMediaSelect}
/>
<AddSectionModal
isOpen={isAddBlockModalOpen}
onClose={() => setIsAddBlockModalOpen(false)}
onSelectBlock={handleSelectBlockFromModal}
/>
</div>
);
}
+9 -3
View File
@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { LocalizedStringSchema, HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticleSectionPayloadSchema, RichTextSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema, QuoteCalloutSectionPayloadSchema, ProductCarouselSectionPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema, } from "@homaroma/types";
import { LocalizedStringSchema, HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, FeaturedTilesSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticleSectionPayloadSchema, RichTextSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema, QuoteCalloutSectionPayloadSchema, ProductCarouselSectionPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema, HeroV2SectionPayloadSchema, HeroVideoSectionPayloadSchema, UspBarSectionPayloadSchema, CustomSectionPayloadSchema } from "@homaroma/types";
import { z } from "zod";
import { adminOnly } from "../middleware/auth";
const RESERVED_SLUGS = [
@@ -487,6 +487,9 @@ function validateSectionPayload(type, payload) {
case "EDITORIAL_TILE":
schema = EditorialTileSectionPayloadSchema;
break;
case "FEATURED_TILES":
schema = FeaturedTilesSectionPayloadSchema;
break;
case "EDITORIAL_CAROUSEL":
schema = EditorialCarouselSectionPayloadSchema;
break;
@@ -508,8 +511,11 @@ function validateSectionPayload(type, payload) {
case "ACCORDION":
schema = AccordionSectionPayloadSchema;
break;
case "CONTACT_INFO":
schema = ContactInfoSectionPayloadSchema;
case "HERO_V2":
schema = HeroV2SectionPayloadSchema;
break;
case "HERO_VIDEO":
schema = HeroVideoSectionPayloadSchema;
break;
case "CONTACT_FORM":
schema = ContactFormSectionPayloadSchema;
+8
View File
@@ -10,6 +10,7 @@ import {
AnnouncementBarSectionPayloadSchema,
NewsletterSectionPayloadSchema,
EditorialTileSectionPayloadSchema,
FeaturedTilesSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema,
@@ -30,6 +31,7 @@ import {
ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema,
HeroV2SectionPayloadSchema,
HeroVideoSectionPayloadSchema,
UspBarSectionPayloadSchema,
CustomSectionPayloadSchema,
PAGE_SECTION_LOCALIZED_PATHS,
@@ -1098,6 +1100,9 @@ export function validateSectionPayload(
case "EDITORIAL_TILE":
schema = EditorialTileSectionPayloadSchema;
break;
case "FEATURED_TILES":
schema = FeaturedTilesSectionPayloadSchema;
break;
case "EDITORIAL_CAROUSEL":
schema = EditorialCarouselSectionPayloadSchema;
break;
@@ -1162,6 +1167,9 @@ export function validateSectionPayload(
case "HERO_V2":
schema = HeroV2SectionPayloadSchema;
break;
case "HERO_VIDEO":
schema = HeroVideoSectionPayloadSchema;
break;
case "USP_BAR":
schema = UspBarSectionPayloadSchema;
break;
+2 -2
View File
@@ -11,9 +11,9 @@ export const uploadRoute = new Elysia({ prefix: "/upload" })
return { error: "No file uploaded" };
}
// Validate file type
if (!file.type.startsWith("image/")) {
if (!file.type.startsWith("image/") && !file.type.startsWith("video/")) {
set.status = 400;
return { error: "Only image files are allowed" };
return { error: "Only image and video files are allowed" };
}
console.log(`[Upload] Processing: ${file.name} (${(file.size / 1024).toFixed(2)} KB, ${file.type})`);
// Generate unique filename
+8 -5
View File
@@ -14,9 +14,9 @@ export const uploadRoute = new Elysia({ prefix: "/upload" })
}
// Validate file type
if (!file.type.startsWith("image/")) {
if (!file.type.startsWith("image/") && !file.type.startsWith("video/")) {
set.status = 400;
return { error: "Only image files are allowed" };
return { error: "Only image and video files are allowed" };
}
console.log(`[Upload] Processing: ${file.name} (${(file.size / 1024).toFixed(2)} KB, ${file.type})`);
@@ -66,12 +66,15 @@ export const uploadRoute = new Elysia({ prefix: "/upload" })
}, {
body: t.Object({
file: t.File({
maxSize: '5m',
type: ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
maxSize: '100m',
type: [
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'
]
})
}),
detail: {
summary: "Upload product image",
summary: "Upload product image or video",
tags: ["upload"]
}
});
+52
View File
@@ -111,6 +111,26 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
focalPoint: { x: "50%", y: "50%" },
},
},
HERO_VIDEO: {
type: "HERO_VIDEO",
requiredFields: ["videoUrl", "title"],
optionalFields: ["tagline", "description", "posterImageUrl", "buttonText", "buttonLink", "secondaryButtonText", "secondaryButtonLink", "overlayOpacity", "height", "autoplay", "loop", "muted", "backgroundColor", "textColor"],
renderedFields: ["tagline", "title", "description", "videoUrl", "posterImageUrl", "buttonText", "buttonLink", "secondaryButtonText", "secondaryButtonLink", "overlayOpacity", "height", "autoplay", "loop", "muted", "backgroundColor", "textColor"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["videoUrl must be a direct MP4 or WebM video link."],
minimalPayload: {
title: localizedObjectExample,
videoUrl: "https://cdn.coverr.co/videos/coverr-essential-oils-and-diffuser-5374/1080p.mp4"
},
recommendedPayload: {
tagline: { th: "ภาพยนตร์แห่งกลิ่นหอม", en: "Cinema of Scents" },
title: { th: "วิหารแห่งกลิ่นหอม", en: "Pure Botanical Sanctuary" },
videoUrl: "https://cdn.coverr.co/videos/coverr-essential-oils-and-diffuser-5374/1080p.mp4",
buttonText: { th: "สำรวจคอลเลกชัน", en: "Discover Collection" },
buttonLink: "/shop"
}
},
ANNOUNCEMENT_BAR: {
type: "ANNOUNCEMENT_BAR",
requiredFields: ["slides"],
@@ -175,6 +195,38 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
buttonLink: "/about",
},
},
FEATURED_TILES: {
type: "FEATURED_TILES",
requiredFields: ["tiles[].title", "tiles[].imageUrl"],
optionalFields: ["title", "subtitle", "columns", "backgroundColor", "textColor", "tiles[].description", "tiles[].buttonText", "tiles[].buttonLink", "tiles[].imageAspectRatio"],
renderedFields: ["title", "subtitle", "columns", "backgroundColor", "textColor", "tiles[].title", "tiles[].description", "tiles[].imageUrl", "tiles[].buttonText", "tiles[].buttonLink", "tiles[].imageAspectRatio"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["columns should be 2, 3, or 4."],
minimalPayload: {
tiles: [
{ title: localizedObjectExample, imageUrl: imageUrlExample }
]
},
recommendedPayload: {
title: { th: "แคมเปญฤดูร้อน", en: "Summer Campaign" },
columns: 2,
tiles: [
{
title: { th: "Here Comes The Sun", en: "Here Comes The Sun" },
imageUrl: imageUrlExample,
buttonText: { th: "ช้อปเลย", en: "Shop Beach Blossom" },
buttonLink: "/shop"
},
{
title: { th: "The Scent Of A New Adventure", en: "The Scent Of A New Adventure" },
imageUrl: imageUrlExample,
buttonText: { th: "ติดตามข่าวสาร", en: "Be The First To Know" },
buttonLink: "/shop"
}
]
}
},
EDITORIAL_CAROUSEL: {
type: "EDITORIAL_CAROUSEL",
requiredFields: ["title", "slides[].category", "slides[].title", "slides[].description", "slides[].imageUrl"],
@@ -0,0 +1,140 @@
import { Link } from 'react-router-dom';
import type { FeaturedTilesSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicFeaturedTilesProps {
payload: FeaturedTilesSectionPayload;
}
export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesProps) {
const { i18n } = useTranslation();
const lang = i18n.language;
const tiles = Array.isArray(payload.tiles) ? payload.tiles : [];
const columns = payload.columns || 2;
const sectionTitle = getLocalizedField(payload.title, lang);
const sectionSubtitle = getLocalizedField(payload.subtitle, lang);
const getGridColsClass = (cols: number) => {
switch (cols) {
case 3:
return 'grid-cols-1 md:grid-cols-3';
case 4:
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
case 2:
default:
return 'grid-cols-1 md:grid-cols-2';
}
};
const getAspectClass = (ratio?: string) => {
switch (ratio) {
case 'portrait':
return 'aspect-[3/4]';
case 'tall':
return 'aspect-[2/3]';
case 'auto':
return 'aspect-auto';
case 'square':
default:
return 'aspect-square';
}
};
return (
<section
className="ui-section-band w-full overflow-hidden"
style={{ backgroundColor: payload.backgroundColor, color: payload.textColor }}
>
<div className="ui-section-inner space-y-12">
{/* Optional Section Header */}
{(sectionTitle || sectionSubtitle) && (
<div className="text-center space-y-3 max-w-2xl mx-auto">
{sectionTitle && (
<h2 className="font-serif text-3xl md:text-4xl font-light tracking-wide" style={{ color: payload.textColor }}>
{sectionTitle}
</h2>
)}
{sectionSubtitle && (
<p className="text-sm md:text-base opacity-80 font-light" style={{ color: payload.textColor }}>
{sectionSubtitle}
</p>
)}
<div className="w-12 h-[1px] bg-pop-pink mx-auto"></div>
</div>
)}
{/* Campaign Tiles Grid */}
<div className={`grid gap-8 md:gap-12 ${getGridColsClass(columns)}`}>
{tiles.map((tile, idx) => {
const tileTitle = getLocalizedField(tile.title, lang);
const tileDescription = getLocalizedField(tile.description, lang);
const buttonText = getLocalizedField(tile.buttonText, lang);
const cardContent = (
<>
{/* Image Container */}
<div className={`relative ${getAspectClass(tile.imageAspectRatio)} w-full overflow-hidden rounded-2xl bg-neutral-100 shadow-md`}>
{tile.imageUrl ? (
<img
src={tile.imageUrl}
alt={tileTitle}
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full bg-neutral-200 flex items-center justify-center text-neutral-400 text-xs">
No Image
</div>
)}
</div>
{/* Content Details */}
<div className="space-y-2 max-w-md px-2 flex-1 flex flex-col justify-between w-full">
<div className="space-y-2">
{tileTitle && (
<h3 className="font-serif text-xl md:text-2xl font-light tracking-wide text-neutral-800 group-hover:text-pop-pink transition-colors" style={{ color: payload.textColor }}>
{tileTitle}
</h3>
)}
{tileDescription && (
<p className="text-xs md:text-sm font-light leading-relaxed opacity-80" style={{ color: payload.textColor }}>
{tileDescription}
</p>
)}
</div>
{/* CTA Button */}
{buttonText && tile.buttonLink && (
<div className="pt-3">
<span className="inline-block px-7 py-3 text-xs font-medium tracking-wider uppercase transition-all duration-300 bg-neutral-900 text-white group-hover:bg-neutral-800 rounded-sm shadow-sm">
{buttonText}
</span>
</div>
)}
</div>
</>
);
return (
<div key={idx} className="flex flex-col items-center text-center group w-full">
{tile.buttonLink ? (
<Link to={tile.buttonLink} className="w-full flex flex-col items-center text-center space-y-5">
{cardContent}
</Link>
) : (
<div className="w-full flex flex-col items-center text-center space-y-5">
{cardContent}
</div>
)}
</div>
);
})}
</div>
</div>
</section>
);
}
@@ -0,0 +1,127 @@
import { Link } from 'react-router-dom';
import type { HeroVideoSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicHeroVideoProps {
payload: HeroVideoSectionPayload;
}
export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const tagline = getLocalizedField(payload.tagline, lang);
const title = getLocalizedField(payload.title, lang);
const description = getLocalizedField(payload.description, lang);
const buttonText = getLocalizedField(payload.buttonText, lang);
const secondaryButtonText = getLocalizedField(payload.secondaryButtonText, lang);
const videoUrl = payload.videoUrl;
const posterImageUrl = payload.posterImageUrl;
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 40;
const height = payload.height || 'large';
const getHeightClass = (h: string) => {
switch (h) {
case 'full':
return 'h-screen';
case 'medium':
return 'h-[60vh] min-h-[400px]';
case 'large':
default:
return 'h-[85vh] min-h-[550px]';
}
};
const renderTitle = (text: string) => {
if (!text) return null;
return text.split('[').map((part, i) => {
if (part.includes(']')) {
const [accent, rest] = part.split(']');
return (
<span key={i}>
<span className="text-pop-pink font-serif italic font-normal">
{accent}
</span>
{rest}
</span>
);
}
return <span key={i}>{part}</span>;
});
};
return (
<section
className={`relative w-full ${getHeightClass(height)} overflow-hidden flex items-center justify-center`}
style={{ backgroundColor: payload.backgroundColor || '#0B0F17', color: payload.textColor || '#FFFFFF' }}
>
{/* Background Video */}
{videoUrl ? (
<video
autoPlay={payload.autoplay !== false}
muted={payload.muted !== false}
loop={payload.loop !== false}
playsInline
poster={posterImageUrl}
className="absolute inset-0 w-full h-full object-cover z-0"
>
<source src={videoUrl} type="video/mp4" />
<source src={videoUrl} type="video/webm" />
{posterImageUrl && <img src={posterImageUrl} alt="Hero fallback" className="w-full h-full object-cover" />}
</video>
) : posterImageUrl ? (
<img src={posterImageUrl} alt="Hero Poster" className="absolute inset-0 w-full h-full object-cover z-0" />
) : null}
{/* Customizable Overlay Opacity */}
<div
className="absolute inset-0 z-10 bg-black transition-opacity"
style={{ opacity: overlayOpacity / 100 }}
></div>
{/* Overlay Typography Content */}
<div className="relative z-20 mx-auto px-6 py-16 w-full max-w-5xl text-center space-y-6 flex flex-col items-center justify-center animate-fade-in">
{tagline && (
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-pop-pink/90 drop-shadow-md">
{tagline}
</p>
)}
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white">
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
</h1>
{description && (
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow">
{description}
</p>
)}
{/* Dual Action Buttons */}
{(buttonText || secondaryButtonText) && (
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
{buttonText && payload.buttonLink && (
<Link
to={payload.buttonLink}
className="px-8 py-3.5 bg-white text-neutral-900 font-medium text-xs uppercase tracking-widest hover:bg-pop-pink hover:text-white transition-all shadow-lg rounded-sm"
>
{buttonText}
</Link>
)}
{secondaryButtonText && payload.secondaryButtonLink && (
<Link
to={payload.secondaryButtonLink}
className="px-8 py-3.5 bg-transparent border border-white/40 text-white font-medium text-xs uppercase tracking-widest hover:bg-white/10 hover:border-white transition-all rounded-sm backdrop-blur-sm"
>
{secondaryButtonText}
</Link>
)}
</div>
)}
</div>
</section>
);
}
@@ -1,6 +1,7 @@
import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload, CustomSectionPayload } from '@homaroma/types';
import DynamicHero from './DynamicHero';
import DynamicHeroV2 from './DynamicHeroV2';
import DynamicHeroVideo from './DynamicHeroVideo';
import DynamicUspBar from './DynamicUspBar';
import DynamicCategories from './DynamicCategories';
import DynamicProducts from './DynamicProducts';
@@ -9,6 +10,7 @@ import DynamicText from './DynamicText';
import DynamicAnnouncementBar from './DynamicAnnouncementBar';
import DynamicNewsletter from './DynamicNewsletter';
import DynamicEditorialTile from './DynamicEditorialTile';
import DynamicFeaturedTiles from './DynamicFeaturedTiles';
import DynamicEditorialCarousel from './DynamicEditorialCarousel';
import DynamicHeroArticle from './DynamicHeroArticle';
import DynamicScentNotesBreakdown from './DynamicScentNotesBreakdown';
@@ -21,6 +23,8 @@ import ContactFormSection from './sections/ContactFormSection';
import ScrollReveal from './ScrollReveal';
import { customRegistry } from '../config/customRegistry';
import { useEffect } from 'react';
interface DynamicPageRendererProps {
sections: PageSection[];
categories: Category[];
@@ -32,6 +36,22 @@ export default function DynamicPageRenderer({
categories,
categoriesLoading
}: DynamicPageRendererProps) {
useEffect(() => {
const HERO_TYPES = new Set(['HERO', 'HERO_V2', 'HERO_VIDEO']);
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
const isHero = Boolean(firstActiveSection && HERO_TYPES.has(firstActiveSection.type.toUpperCase()));
if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false';
}
return () => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = 'false';
}
};
}, [sections]);
return (
<div className="flex flex-col w-full">
{sections.map((section) => {
@@ -45,6 +65,8 @@ export default function DynamicPageRenderer({
return <DynamicHero key={section.id} payload={section.payload} />;
case 'HERO_V2':
return <DynamicHeroV2 key={section.id} payload={section.payload} />;
case 'HERO_VIDEO':
return <DynamicHeroVideo key={section.id} payload={section.payload} />;
case 'USP_BAR':
return <DynamicUspBar key={section.id} payload={section.payload} />;
@@ -78,6 +100,8 @@ export default function DynamicPageRenderer({
return <ScrollReveal key={section.id}><DynamicRichText section={section} /></ScrollReveal>;
case 'EDITORIAL_TILE':
return <ScrollReveal key={section.id}><DynamicEditorialTile payload={section.payload} /></ScrollReveal>;
case 'FEATURED_TILES':
return <ScrollReveal key={section.id}><DynamicFeaturedTiles payload={section.payload} /></ScrollReveal>;
case 'EDITORIAL_CAROUSEL':
return <ScrollReveal key={section.id}><DynamicEditorialCarousel payload={section.payload} /></ScrollReveal>;
case 'HERO_ARTICLE':
+14 -1
View File
@@ -150,7 +150,20 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
const logoUrl = settings?.logoUrl || ''
const shouldBeTransparent = isTransparent && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
const [isHeroFirst, setIsHeroFirst] = useState(false);
useEffect(() => {
const updateHeroFirst = () => {
const isHero = document.documentElement.dataset.heroFirst === 'true';
setIsHeroFirst(isHero);
};
updateHeroFirst();
const observer = new MutationObserver(updateHeroFirst);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-hero-first'] });
return () => observer.disconnect();
}, [location.pathname]);
const shouldBeTransparent = (isTransparent || isHeroFirst) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
return (
<>
+5 -2
View File
@@ -23,6 +23,9 @@ export default function CustomPage() {
(section) => !(section.isActive && section.type.toUpperCase() === 'ANNOUNCEMENT_BAR')
);
const HERO_TYPES = new Set(['HERO', 'HERO_V2', 'HERO_VIDEO']);
const hasHeroFirst = contentSections.length > 0 && HERO_TYPES.has(contentSections[0].type.toUpperCase());
useEffect(() => {
const fetchCategories = async () => {
try {
@@ -74,7 +77,7 @@ export default function CustomPage() {
categoriesLoading={categoriesLoading}
/>
))}
<Navbar categories={categories} topOffsetClassName={topOffsetClassName} />
<Navbar categories={categories} topOffsetClassName={topOffsetClassName} isTransparent={hasHeroFirst} />
{loading ? (
<div className="min-h-[60vh] flex items-center justify-center">
@@ -83,7 +86,7 @@ export default function CustomPage() {
) : error ? (
<NotFoundPage />
) : (
<div className={`animate-fade-in ${announcementSections.length > 0 ? 'pt-[32px] md:pt-[40px]' : ''}`}>
<div className={`animate-fade-in ${announcementSections.length > 0 ? (hasHeroFirst ? 'pt-[32px] md:pt-[40px]' : 'pt-[110px] md:pt-[128px]') : (hasHeroFirst ? '' : 'pt-[70px] md:pt-[80px]')}`}>
<DynamicPageRenderer
sections={contentSections}
categories={categories}
+18 -2
View File
@@ -22,6 +22,7 @@ interface NavbarProps {
categories?: Category[]
initialMenu?: MegaMenuPayload[]
settings?: StoreSettings | null
isTransparentPage?: boolean
}
const LANGUAGES = [
@@ -43,7 +44,7 @@ function setLanguageCookie(code: string) {
export default function Navbar({ categories = [], initialMenu = [], settings = null }: NavbarProps) {
export default function Navbar({ categories = [], initialMenu = [], settings = null, isTransparentPage }: NavbarProps) {
const [searchOpen, setSearchOpen] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [langSheetOpen, setLangSheetOpen] = useState(false)
@@ -194,8 +195,23 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
return () => window.removeEventListener('product-sticky-change', handleStickyChange)
}, [])
const [isHeroFirst, setIsHeroFirst] = useState(false)
useEffect(() => {
const checkHeroFirst = () => {
const isHero = typeof document !== 'undefined' && document.documentElement.dataset.heroFirst === 'true'
setIsHeroFirst(isHero)
}
checkHeroFirst()
const observer = new MutationObserver(checkHeroFirst)
if (typeof document !== 'undefined') {
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-hero-first'] })
}
return () => observer.disconnect()
}, [pathname])
const isHome = getPathWithoutLocale(pathname) === '/'
const isTransparent = isHome && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
const isTransparent = (isHome || isHeroFirst || Boolean(isTransparentPage)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
const pathWithoutLocale = getPathWithoutLocale(pathname)
const isCommercePath = ['/shop', '/products', '/category', '/cart', '/checkout', '/wishlist', '/profile', '/order', '/track']
.some((route) => pathWithoutLocale === route || pathWithoutLocale.startsWith(`${route}/`))
@@ -155,6 +155,95 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
)
}
function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : ''
const secondaryButtonText = localizedFromRecord(payload, 'secondaryButtonText', resolvedLang)
const secondaryButtonLink = typeof payload.secondaryButtonLink === 'string' ? payload.secondaryButtonLink : ''
const videoUrl = typeof payload.videoUrl === 'string' ? payload.videoUrl : ''
const posterImageUrl = typeof payload.posterImageUrl === 'string' ? payload.posterImageUrl : undefined
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 40
const height = typeof payload.height === 'string' ? payload.height : 'large'
const getHeightClass = (h: string) => {
switch (h) {
case 'full':
return 'h-screen'
case 'medium':
return 'h-[60vh] min-h-[400px]'
case 'large':
default:
return 'h-[85vh] min-h-[550px]'
}
}
return (
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex items-center justify-center bg-[#0B0F17] text-white`}>
{videoUrl ? (
<video
autoPlay={payload.autoplay !== false}
muted={payload.muted !== false}
loop={payload.loop !== false}
playsInline
poster={posterImageUrl}
className="absolute inset-0 w-full h-full object-cover z-0"
>
<source src={videoUrl} type="video/mp4" />
<source src={videoUrl} type="video/webm" />
{posterImageUrl && <Image src={posterImageUrl} alt="Hero fallback" fill unoptimized className="object-cover" />}
</video>
) : posterImageUrl ? (
<Image src={posterImageUrl} alt="Hero Poster" fill unoptimized className="object-cover z-0" />
) : null}
<div className="absolute inset-0 z-10 bg-black transition-opacity" style={{ opacity: overlayOpacity / 100 }}></div>
<div className="relative z-20 mx-auto px-6 py-16 w-full max-w-5xl text-center space-y-6 flex flex-col items-center justify-center">
{tagline && (
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-[#EAEFEA] drop-shadow-md">
{tagline}
</p>
)}
{title && (
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white">
{renderAccentTitle(title)}
</h1>
)}
{description && (
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow">
{description}
</p>
)}
{(buttonText || secondaryButtonText) && (
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
{buttonText && buttonLink && (
<Link
href={buttonLink}
className="px-8 py-3.5 bg-white text-neutral-900 font-medium text-xs uppercase tracking-widest hover:bg-brand-primary hover:text-white transition-all shadow-lg rounded-sm"
>
{buttonText}
</Link>
)}
{secondaryButtonText && secondaryButtonLink && (
<Link
href={secondaryButtonLink}
className="px-8 py-3.5 bg-transparent border border-white/40 text-white font-medium text-xs uppercase tracking-widest hover:bg-white/10 hover:border-white transition-all rounded-sm backdrop-blur-sm"
>
{secondaryButtonText}
</Link>
)}
</div>
)}
</div>
</section>
)
}
const ICON_MAP: Record<string, React.ElementType> = {
leaf: Leaf,
droplet: Droplet,
@@ -944,6 +1033,110 @@ function EditorialTileSection({ payload, lang }: { payload: Record<string, unkno
)
}
function FeaturedTilesSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const columns = typeof payload.columns === 'number' ? payload.columns : 2
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const tiles = Array.isArray(payload.tiles) ? payload.tiles.map(toRecord) : []
const getColsClass = (cols: number) => {
switch (cols) {
case 3:
return 'grid-cols-1 md:grid-cols-3'
case 4:
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
case 2:
default:
return 'grid-cols-1 md:grid-cols-2'
}
}
const getAspectClass = (ratio?: string) => {
switch (ratio) {
case 'portrait':
return 'aspect-[3/4]'
case 'tall':
return 'aspect-[2/3]'
case 'auto':
return 'aspect-auto'
case 'square':
default:
return 'aspect-square'
}
}
return (
<section className="overflow-hidden bg-surface-page ui-section-band" style={{ backgroundColor, color: textColor }}>
<div className="mx-auto max-w-7xl space-y-12">
{(title || subtitle) && (
<div className="mx-auto max-w-2xl text-center space-y-3">
{title && (
<h2 className="text-3xl font-semibold leading-tight text-text-main md:text-5xl" style={{ color: textColor }}>
{renderAccentTitle(title)}
</h2>
)}
{subtitle && <p className="text-base leading-relaxed text-muted-text md:text-lg" style={{ color: textColor }}>{subtitle}</p>}
<div className="mx-auto h-px w-14 bg-brand-primary" />
</div>
)}
<div className={`grid gap-8 md:gap-12 ${getColsClass(columns)}`}>
{tiles.map((tile, idx) => {
const tileTitle = localizedFromRecord(tile, 'title', resolvedLang)
const tileDesc = localizedFromRecord(tile, 'description', resolvedLang)
const buttonText = localizedFromRecord(tile, 'buttonText', resolvedLang)
const buttonLink = typeof tile.buttonLink === 'string' ? tile.buttonLink : ''
const imageUrl = typeof tile.imageUrl === 'string' ? tile.imageUrl : ''
const imageAspectRatio = typeof tile.imageAspectRatio === 'string' ? tile.imageAspectRatio : 'square'
return (
<div key={idx} className="group flex flex-col items-center text-center space-y-5">
{imageUrl && (
<div className={`relative ${getAspectClass(imageAspectRatio)} w-full overflow-hidden rounded-3xl shadow-card`}>
<Image
src={imageUrl}
alt={tileTitle || 'Campaign Card'}
fill
unoptimized
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
</div>
)}
<div className="space-y-3">
{tileTitle && (
<h3 className="text-xl font-semibold leading-snug text-text-main md:text-2xl" style={{ color: textColor }}>
{tileTitle}
</h3>
)}
{tileDesc && (
<p className="text-sm leading-relaxed text-muted-text md:text-base" style={{ color: textColor }}>
{tileDesc}
</p>
)}
{buttonText && buttonLink && (
<div className="pt-2">
<Link
href={buttonLink}
className="inline-flex rounded-full border border-current px-8 py-3 text-xs font-bold uppercase tracking-[0.22em] transition hover:bg-brand-primary hover:text-surface-card"
>
{buttonText}
</Link>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
</section>
)
}
function EditorialCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
@@ -1415,12 +1608,30 @@ function ContactFormSection({ payload, lang }: { payload: Record<string, unknown
)
}
import { useEffect } from 'react'
import ScrollReveal from '../ScrollReveal'
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
const i18nLang = useLang()
// Use i18n.language reactively — switches immediately when user changes language
const lang = langProp ?? i18nLang
useEffect(() => {
const HERO_TYPES = new Set(['HERO', 'HERO_V2', 'HERO_VIDEO'])
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR')
const isHero = Boolean(firstActiveSection && HERO_TYPES.has(firstActiveSection.type.toUpperCase()))
if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false'
}
return () => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = 'false'
}
}
}, [sections])
return (
<div className="flex w-full flex-col">
{sections.map((section) => {
@@ -1444,6 +1655,8 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
<HeroV2Section payload={payload} lang={lang} />
</ScrollReveal>
)
case 'HERO_VIDEO':
return <HeroVideoSection key={section.id} payload={payload} lang={lang} />
case 'USP_BAR':
return (
<ScrollReveal key={section.id}>
@@ -1492,6 +1705,12 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
<EditorialTileSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'FEATURED_TILES':
return (
<ScrollReveal key={section.id}>
<FeaturedTilesSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'EDITORIAL_CAROUSEL':
return (
<ScrollReveal key={section.id}>
+314
View File
@@ -0,0 +1,314 @@
ได้ครับ และควรกำหนดเป็น **Creative Direction แบบข้อความกลาง** ก่อน แล้วค่อยแตกเป็น Prompt สำหรับภาพและ UI เพราะถ้าใช้ Prompt รายภาพอย่างเดียว งานจะค่อย ๆ หลุดโทนได้ง่าย
## 1. Project Mood & Tone หลัก
ผมแนะนำให้กำหนดทิศทางของโปรเจกต์นี้ว่า:
> **Modern Thai Botanical Wellness**
> ความเป็นธรรมชาติแบบไทยร่วมสมัย สงบ อบอุ่น สะอาด และเข้าถึงง่าย
> สื่อถึงผลิตภัณฑ์น้ำมันหอมระเหยที่มีคุณภาพ โดยไม่ดูเป็นยา ไม่ลึกลับ และไม่หรูหราจนห่างเหิน
### คีย์เวิร์ดหลัก
**Natural / Calm / Warm / Refined / Sensory / Trustworthy**
หรือภาษาไทย:
**ธรรมชาติ — สงบ — อบอุ่น — ละเมียดละไม — สัมผัสได้ — น่าเชื่อถือ**
### บุคลิกของแบรนด์
ลองจินตนาการว่าแบรนด์เป็นคนหนึ่งคน:
> เป็นผู้หญิงหรือผู้ชายวัยประมาณ 30–45 ปี สุภาพ รสนิยมดี รักธรรมชาติ ดูแลตัวเอง แต่ไม่พูดภาษาวิชาการเกินไป บ้านสะอาด มีแสงธรรมชาติ และเลือกใช้ของที่เรียบง่ายแต่มีคุณภาพ
ข้อความนี้ช่วยให้ทั้งนักออกแบบ นักเขียน และ AI เข้าใจ “บุคลิกเดียวกัน”
---
## 2. Brand Statement ที่ควรวางไว้บนสุดของทุกบรีฟ
ใช้ข้อความนี้เป็นส่วนเปิดของทุก Prompt:
> Hom Aroma is a modern Thai botanical wellness brand specializing in essential oils and home fragrance products. The brand combines natural Thai ingredients, contemporary simplicity, sensory comfort, and everyday wellbeing. The visual identity should feel warm, calm, refined, trustworthy, and naturally premium—never clinical, overly luxurious, mystical, or mass-market.
เวอร์ชันภาษาไทย:
> Hom Aroma คือแบรนด์ผลิตภัณฑ์กลิ่นหอมและน้ำมันหอมระเหยจากธรรมชาติในบริบทไทยร่วมสมัย แบรนด์ให้ความรู้สึกสงบ อบอุ่น สะอาด ละเมียดละไม และน่าเชื่อถือ มีความพรีเมียมอย่างเป็นธรรมชาติ แต่ไม่หรูหราจนห่างเหิน ไม่ดูเป็นผลิตภัณฑ์ทางการแพทย์ และไม่ใช้ภาพลักษณ์ลึกลับเชิงไสยศาสตร์
---
## 3. Visual Direction
### สีหลัก
ใช้โทนสีจากธรรมชาติแบบลดความอิ่มสีลงเล็กน้อย:
* Warm ivory — พื้นหลังหลัก
* Oat beige — พื้นรองและการ์ด
* Sandstone — วัสดุและองค์ประกอบตกแต่ง
* Botanical green — สีเน้นหลัก
* Muted terracotta — สีเน้นรอง
* Amber brown — สื่อถึงน้ำมันและขวดแก้ว
* Charcoal brown — สีข้อความแทนดำสนิท
ควรหลีกเลี่ยง:
* สีขาวบริสุทธิ์ทั่วทั้งหน้า
* ดำสนิทที่ contrast แข็งเกินไป
* เขียวสดแบบสินค้าออร์แกนิกทั่วไป
* ทองเงาแบบ luxury cosmetics
* สีพาสเทลหวานแบบผลิตภัณฑ์วัยรุ่น
### ตัวอย่าง Color Palette
```text
Warm Ivory #F6F1E9
Soft Cream #EDE4D7
Oat Beige #D8C8B5
Sandstone #BFA98F
Botanical Green #455B48
Sage Green #87917C
Muted Terracotta #B9785E
Amber Brown #8A633F
Charcoal Brown #352E29
```
ไม่จำเป็นต้องใช้ครบทุกสีในหนึ่งหน้าจอ ควรให้ Warm Ivory, Cream และ Charcoal เป็นฐาน แล้วใช้เขียวหรือ Terracotta เป็น accent ประมาณ 5–15%
---
## 4. Photography Direction
ภาพสินค้าควรดูเหมือนอยู่ใน “พื้นที่แห่งความสงบ” ไม่ใช่ภาพโฆษณาเครื่องสำอางทั่วไป
### สิ่งที่ควรมี
* แสงธรรมชาติอ่อนจากด้านข้าง
* เงาใบไม้ที่นุ่มและไม่รก
* พื้นผิวหินปูน ปูนปลาสเตอร์ ไม้สีอ่อน หรือผ้าลินิน
* ขวดแก้วสีชาและสีใส
* สมุนไพร ดอกไม้ เปลือกผลไม้ หรือวัตถุดิบจริง
* ไอน้ำหรือหมอกบาง ๆ เมื่อมีเครื่องกระจายกลิ่น
* Negative space สำหรับวางข้อความ
* องค์ประกอบไม่สมมาตรเล็กน้อย เพื่อให้ดูเป็นธรรมชาติ
* Depth of field บาง ๆ แต่ฉลากสินค้าต้องอ่านชัด
### สิ่งที่ไม่ควรมี
* พร็อพจำนวนมากจนแย่งสินค้า
* ดอกไม้สีจัดหรือฉากสวนแฟนตาซี
* ควันหนาเหมือนธูป
* แสง neon หรือแสงสตูดิโอแข็ง
* หินคริสตัล ไพ่ทาโรต์ หรือสัญลักษณ์ลึกลับ
* ฉากสปาแบบ cliché เช่น ผ้าขนหนูม้วน ก้อนหินดำ และกล้วยไม้
* ผิวภาพส้ม–น้ำตาลทั้งภาพจนสินค้าดูเก่า
* การสร้างฉลากหรือโลโก้ขึ้นใหม่ด้วย AI
---
## 5. Master Prompt สำหรับสร้างภาพสินค้า
ใช้โครงสร้างนี้เป็น Prompt กลาง แล้วเปลี่ยนเฉพาะสินค้า วัตถุดิบ และขนาดภาพ
```text
Create a refined lifestyle product photograph for Hom Aroma, a modern Thai botanical wellness brand.
Feature [PRODUCT] as the clear hero object, placed in a calm contemporary interior setting inspired by natural Thai materials. Use warm ivory, soft beige, sandstone, muted botanical green, and subtle amber tones.
Lighting should be soft natural daylight coming from one side, with delicate botanical shadows and a warm but neutral white balance. Use tactile materials such as limestone, light plaster, pale wood, linen, handmade ceramic, or translucent glass.
Include a restrained arrangement of [BOTANICAL INGREDIENTS] to communicate the fragrance naturally. The composition should feel clean, sensory, balanced, and naturally premium, with generous negative space for website text.
Editorial commercial photography, realistic materials, soft depth of field, understated styling, calm atmosphere, refined details, accurate product proportions.
The mood is natural, warm, peaceful, contemporary, trustworthy, and accessible.
Avoid clinical laboratory styling, dark luxury cosmetics, excessive gold, mystical elements, spa clichés, saturated colors, visual clutter, artificial flowers, heavy smoke, dramatic contrast, distorted bottles, unreadable labels, invented logos, and text generated inside the image.
```
### ส่วนที่เปลี่ยนได้
```text
[PRODUCT]
a clear glass lavender essential oil bottle beside a compact ceramic aroma diffuser
[BOTANICAL INGREDIENTS]
dried lavender, small white flowers, eucalyptus leaves, and a few natural stones
```
---
## 6. Prompt สำหรับภาพแทนรูปผู้หญิงใน Section นั้น
ภาพตำแหน่งเดิมมีพื้นที่แนวตั้งและอยู่คู่กับข้อความ จึงควรเป็นภาพที่ให้ความรู้สึก editorial แต่ยังเกี่ยวข้องกับสินค้า
```text
Create a vertical editorial lifestyle photograph for Hom Aroma, a modern Thai botanical wellness brand.
Show a quiet sensory ritual in a warm minimalist interior: a ceramic aroma diffuser releasing a very light stream of mist beside an amber essential oil bottle, placed on a sculptural limestone pedestal. Add a restrained arrangement of dried lavender, eucalyptus leaves, soft linen, and handmade ceramic objects.
Use warm ivory plaster walls, subtle natural imperfections, soft side daylight, delicate plant shadows, and a calm neutral color grade. The composition should feel artistic and fashion-editorial, but the essential oil and diffuser must remain clearly relevant.
Use an asymmetrical composition with visual interest concentrated in the lower and middle areas. Leave clean negative space around the upper-right area for accompanying website text.
Natural, refined, sensory, contemporary, warm, peaceful, tactile, understated premium.
No people, no spa towels, no candles dominating the scene, no mystical symbolism, no dark background, no excessive props, no saturated colors, no heavy smoke, no generated typography, no invented logo.
```
---
## 7. UI Direction
### Layout
* ใช้พื้นที่ว่างมาก แต่ไม่โล่งจนดู unfinished
* Section ควรมีจังหวะสลับระหว่างพื้น Cream และ White
* Grid เรียบง่าย 12 columns
* เนื้อหาหลักกว้างประมาณ 1180–1280 px
* ระยะห่างระหว่าง Section เด่นชัด
* ใช้ภาพขนาดใหญ่สลับกับ Product Card เพื่อสร้างจังหวะ
* ขอบมนไม่ควรมากเกินไป ประมาณ 8–16 px
* หลีกเลี่ยง Pill shape กับทุกองค์ประกอบ
### Product Card
Product Card ควรให้ความรู้สึก “quiet commerce”:
* พื้นหลังสี Warm White
* เส้นขอบบางมาก หรือใช้เงาอ่อน
* ภาพสินค้ากินพื้นที่มากที่สุด
* ชื่อสินค้าไม่เกิน 2 บรรทัด
* ราคาอ่านง่าย
* ปุ่มสีน้ำตาลเข้มหรือเขียวเข้ม
* Hover ใช้การยกตัวเล็กน้อย ไม่ใช้ animation รุนแรง
* Badge ใช้เฉพาะเมื่อมีความหมายจริง เช่น ใหม่ หรือขายดี
### Typography
ควรใช้บุคลิกตัวอักษรสองแบบ:
* **Heading:** มีความเป็น editorial เล็กน้อย อาจเป็น Serif หรือ Sans ที่รูปทรงนุ่ม
* **Body/UI:** Sans-serif ที่อ่านภาษาไทยง่าย สะอาด และเป็นกลาง
หลักการสำคัญ:
> Heading สร้างอารมณ์ ส่วน Body สร้างความน่าเชื่อถือ
ไม่ควรใช้ฟอนต์บางมาก เพราะภาษาไทยจะอ่านยาก โดยเฉพาะบนมือถือ
---
## 8. Master Prompt สำหรับออกแบบ UI
```text
Design a responsive e-commerce interface for Hom Aroma, a modern Thai botanical wellness and essential-oil brand.
The interface should express calm, warm, natural, refined, sensory, and trustworthy qualities. Use a warm ivory and soft cream foundation, charcoal-brown typography, muted botanical green accents, subtle amber details, and occasional muted terracotta.
Create a clean editorial layout with generous but intentional whitespace, a clear visual hierarchy, large sensory product photography, understated product cards, and balanced asymmetrical compositions.
Use soft natural textures sparingly, such as limestone, handmade paper, pale wood, linen, and botanical shadows. Keep interface controls simple, readable, and commercially practical.
Buttons should feel grounded and tactile, using dark botanical green or charcoal brown with restrained rounded corners. Cards should use subtle borders or very soft shadows, not floating glass effects.
The user experience must feel premium but approachable, with strong product clarity, readable Thai typography, intuitive navigation, and clear calls to action.
Avoid generic luxury cosmetics, clinical wellness styling, mystical aesthetics, excessive beige monotony, glassmorphism, neon colors, overly rounded components, heavy gradients, crowded botanical decoration, thin unreadable typography, and template-like Shopify layouts.
```
---
## 9. Prompt สำหรับตรวจว่างานหลุดโทนหรือไม่
หลังได้ภาพหรือ UI แล้ว สามารถใช้ Prompt นี้ให้ AI วิจารณ์งานก่อนส่งต่อ:
```text
Evaluate this design against the Hom Aroma creative direction.
The intended brand qualities are:
natural, calm, warm, refined, sensory, trustworthy, contemporary Thai, and naturally premium.
Assess:
1. Color palette
2. Lighting and image treatment
3. Material and botanical styling
4. Typography
5. Spacing and composition
6. Product prominence
7. Commercial usability
8. Brand consistency
Identify anything that feels:
clinical, mystical, overly luxurious, generic, mass-market, cluttered, saturated, cold, artificial, or unrelated to essential oils and home fragrance.
Give each category a score from 1–10 and provide precise corrections. Do not redesign the entire work unless necessary.
```
---
## 10. กฎสั้น ๆ สำหรับทีม
สามารถสรุปเป็น Checklist ก่อนอนุมัติงานทุกชิ้น:
> **Warm, not yellow.**
> **Natural, not rustic.**
> **Premium, not luxurious.**
> **Calm, not empty.**
> **Botanical, not decorative.**
> **Thai, not traditional costume.**
> **Sensory, not mystical.**
> **Commercial, not template-like.**
ประโยคชุดนี้สำคัญมาก เพราะช่วยกำหนด “เส้นแบ่ง” ได้ชัดกว่าการบอกเพียงว่าให้ดูอบอุ่นและเป็นธรรมชาติ
## รูปแบบบรีฟมาตรฐานที่แนะนำ
ทุกครั้งที่สั่งงานภาพหรือ UI ให้เรียงข้อมูลดังนี้:
```text
PROJECT:
Hom Aroma
BRAND DIRECTION:
Modern Thai Botanical Wellness
OBJECTIVE:
ภาพหรือหน้าจอนี้ต้องช่วยอะไร
CORE MESSAGE:
ผู้ชมควรรู้สึกหรือเข้าใจอะไรเป็นอันดับแรก
FORMAT:
ขนาดภาพ อัตราส่วน และตำแหน่งใช้งาน
HERO SUBJECT:
สินค้า หรือองค์ประกอบที่สำคัญที่สุด
MOOD:
Natural, calm, warm, refined, sensory, trustworthy
COLOR:
Warm ivory, soft cream, botanical green, amber brown
LIGHTING:
Soft natural side light, neutral-warm white balance
MATERIALS:
Limestone, plaster, pale wood, linen, ceramic, glass
COMPOSITION:
ตำแหน่งสินค้าและพื้นที่ว่างสำหรับข้อความ
MUST INCLUDE:
สิ่งที่ต้องมี
MUST AVOID:
สิ่งที่ห้ามมี
REFERENCE CONSISTENCY:
Match the approved Hom Aroma visual system; do not introduce a new visual language.
```
การใช้เอกสารชุดเดียวเป็น **Project Visual Constitution** แล้วนำส่วนที่เกี่ยวข้องไปแปะไว้ต้น Prompt ทุกครั้ง จะช่วยให้ทั้งภาพ, UI, Banner, Product Card และ Campaign อยู่ในโลกเดียวกันครับ
+47 -4
View File
@@ -410,6 +410,43 @@ export const EditorialTileSectionPayloadSchema = z.object({
});
export type EditorialTileSectionPayload = z.infer<typeof EditorialTileSectionPayloadSchema>;
export const FeaturedTilesSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
subtitle: LocalizedStringSchema.optional(),
columns: z.number().optional().default(2),
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
tiles: z.array(z.object({
title: LocalizedStringSchema,
description: LocalizedStringSchema.optional(),
imageUrl: z.string().min(1),
imageAspectRatio: z.enum(['square', 'portrait', 'tall', 'auto']).optional(),
buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(),
})).min(1)
});
export type FeaturedTilesSectionPayload = z.infer<typeof FeaturedTilesSectionPayloadSchema>;
export const HeroVideoSectionPayloadSchema = z.object({
tagline: LocalizedStringSchema.optional(),
title: LocalizedStringSchema.optional(),
description: LocalizedStringSchema.optional(),
videoUrl: z.string().min(1),
posterImageUrl: z.string().optional(),
buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(),
secondaryButtonText: LocalizedStringSchema.optional(),
secondaryButtonLink: z.string().optional(),
overlayOpacity: z.number().optional().default(40),
height: z.enum(['full', 'large', 'medium']).optional().default('large'),
muted: z.boolean().optional().default(true),
autoplay: z.boolean().optional().default(true),
loop: z.boolean().optional().default(true),
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
});
export type HeroVideoSectionPayload = z.infer<typeof HeroVideoSectionPayloadSchema>;
export const EditorialCarouselSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
backgroundColor: z.string().optional(),
@@ -765,22 +802,25 @@ export const CustomSectionPayloadSchema = z.object({
export type CustomSectionPayload = z.infer<typeof CustomSectionPayloadSchema>;
export const PAGE_SECTION_TYPES = [
'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE',
'HERO', 'HERO_V2', 'HERO_VIDEO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE', 'FEATURED_TILES',
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'ARTICLE_HEADER', 'ARTICLE_MEDIA', 'ARTICLE_GALLERY',
'ARTICLE_FACTS', 'ARTICLE_VIDEO', 'ARTICLE_REFERENCES', 'ARTICLE_TABLE', 'ARTICLE_DOWNLOAD',
'ARTICLE_CTA', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
'HERO_V2', 'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS',
'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS',
'PRODUCT_REVIEWS', 'RELATED_PRODUCTS', 'CUSTOM'
] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number];
export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]> = {
HERO: ['tagline', 'title', 'description', 'buttonText'],
HERO_V2: ['tagline', 'title', 'description', 'primaryButtonText', 'secondaryButtonText'],
HERO_VIDEO: ['tagline', 'title', 'description', 'buttonText', 'secondaryButtonText'],
ANNOUNCEMENT_BAR: ['slides[]'],
NEWSLETTER: ['title', 'description', 'buttonText', 'placeholderText', 'privacyText'],
EDITORIAL_TILE: ['title', 'description', 'buttonText'],
FEATURED_TILES: ['title', 'subtitle', 'tiles[].title', 'tiles[].description', 'tiles[].buttonText'],
EDITORIAL_CAROUSEL: ['title', 'slides[].category', 'slides[].title', 'slides[].description', 'slides[].buttonText'],
PRODUCTS: ['title', 'subtitle'],
CATEGORIES: ['title', 'subtitle'],
@@ -803,7 +843,6 @@ export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]
ACCORDION: ['title', 'categories[].name', 'categories[].questions[].q', 'categories[].questions[].a'],
CONTACT_INFO: ['title', 'address', 'hours'],
CONTACT_FORM: ['title', 'subtitle'],
HERO_V2: ['tagline', 'title', 'description', 'primaryButtonText', 'secondaryButtonText'],
USP_BAR: ['items[].title', 'items[].subtitle'],
PRODUCT_MAIN: ['title', 'subtitle'],
PRODUCT_DETAILS: ['title', 'subtitle'],
@@ -815,9 +854,12 @@ export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]
export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
HERO: HeroSectionPayloadSchema,
HERO_V2: HeroV2SectionPayloadSchema,
HERO_VIDEO: HeroVideoSectionPayloadSchema,
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,
NEWSLETTER: NewsletterSectionPayloadSchema,
EDITORIAL_TILE: EditorialTileSectionPayloadSchema,
FEATURED_TILES: FeaturedTilesSectionPayloadSchema,
EDITORIAL_CAROUSEL: EditorialCarouselSectionPayloadSchema,
PRODUCTS: ProductsSectionPayloadSchema,
CATEGORIES: CategoriesSectionPayloadSchema,
@@ -840,7 +882,6 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
ACCORDION: AccordionSectionPayloadSchema,
CONTACT_INFO: ContactInfoSectionPayloadSchema,
CONTACT_FORM: ContactFormSectionPayloadSchema,
HERO_V2: HeroV2SectionPayloadSchema,
USP_BAR: UspBarSectionPayloadSchema,
PRODUCT_MAIN: ProductLayoutSectionPayloadSchema,
PRODUCT_DETAILS: ProductLayoutSectionPayloadSchema,
@@ -854,6 +895,7 @@ export const PRODUCT_DETAIL_SECTION_TYPES = [
'PRODUCT_MAIN',
'HERO',
'HERO_V2',
'HERO_VIDEO',
'PRODUCT_DETAILS',
'PRODUCT_SPECS',
'PRODUCT_REVIEWS',
@@ -863,6 +905,7 @@ export const PRODUCT_DETAIL_SECTION_TYPES = [
'BANNER',
'PRODUCT_CAROUSEL',
'EDITORIAL_TILE',
'FEATURED_TILES',
'QUOTE_CALLOUT',
'CUSTOM',
] as const;