x
This commit is contained in:
1 parent
406cad801b
commit
a2da091b15
21 files changed
+2454
-63
No files matched your search
@@ -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 →</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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
});
|
||||
@@ -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':
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user