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

No files matched your search

+55 -29
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,34 +123,50 @@ 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) => (
<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"
onClick={() => {
onSelect(item.url);
onClose();
}}
>
<div className="flex-1 overflow-hidden relative bg-black/50">
<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}
{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"
onClick={() => {
onSelect(item.url);
onClose();
}}
>
<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>
)}
</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 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',
+120 -15
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">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Block Type</label>
<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}>