x
This commit is contained in:
1 parent
235cf6047d
commit
88feee666f
7 files changed
+1309
-2
No files matched your search
@@ -165,6 +165,105 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 5.1 HERO_MINIMAL
|
||||
{
|
||||
type: 'HERO_MINIMAL',
|
||||
category: 'hero',
|
||||
title: 'HERO MINIMAL (Zen Cover)',
|
||||
description: 'Fullscreen mood cover with micro-tagline and statement typography (Pure visual, No CTA buttons).',
|
||||
badge: 'Minimal',
|
||||
renderPreview: () => (
|
||||
<div className="w-full aspect-[21/9] rounded-xl overflow-hidden bg-slate-900 flex items-center justify-center p-4 text-center relative"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), 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-[7px] uppercase font-bold text-white/70 tracking-[0.25em]">AUTUMN EQUINOX</span>
|
||||
<h4 className="text-[11px] font-serif font-light text-white tracking-wide">The scent of damp earth & cedar</h4>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 5.2 HERO_SPLIT_MINIMAL
|
||||
{
|
||||
type: 'HERO_SPLIT_MINIMAL',
|
||||
category: 'hero',
|
||||
title: 'HERO SPLIT MINIMAL (Editorial)',
|
||||
description: 'Magazine-style asymmetric layout with quiet philosophical copy and vertical editorial image.',
|
||||
badge: 'Minimal',
|
||||
renderPreview: () => (
|
||||
<div className="grid grid-cols-2 gap-2 bg-slate-900/40 p-2.5 rounded-xl items-center border border-white/5">
|
||||
<div className="space-y-1 pr-1">
|
||||
<h5 className="text-[9px] font-serif font-medium text-white leading-tight">Formulated in silence, crafted for peace.</h5>
|
||||
<p className="text-[6px] text-slate-400">Pure organic distillates.</p>
|
||||
</div>
|
||||
<div className="aspect-[3/4] 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="editorial" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 5.3 HERO_VIDEO_MINIMAL
|
||||
{
|
||||
type: 'HERO_VIDEO_MINIMAL',
|
||||
category: 'hero',
|
||||
title: 'HERO VIDEO MINIMAL (Motion)',
|
||||
description: 'Atmospheric slow-moving ambient video background with floating quiet typography.',
|
||||
badge: 'Minimal',
|
||||
renderPreview: () => (
|
||||
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-slate-950 flex items-center justify-center p-3 text-center border border-white/5">
|
||||
<div className="absolute inset-0 bg-black/40 z-10"></div>
|
||||
<img src="https://images.unsplash.com/photo-1540555700478-4be289fbecef?auto=format&fit=crop&w=400&q=80" alt="motion" className="absolute inset-0 w-full h-full object-cover opacity-60" />
|
||||
<div className="relative z-20 space-y-0.5">
|
||||
<span className="text-[6px] uppercase font-bold text-cyan-glow/80 tracking-widest">LIVING ARTWORK</span>
|
||||
<h5 className="text-[10px] font-light text-white tracking-wider font-serif">Breathe with Intention</h5>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 5.4 BANNER_MINIMAL
|
||||
{
|
||||
type: 'BANNER_MINIMAL',
|
||||
category: 'hero',
|
||||
title: 'BANNER MINIMAL (Quiet Divider)',
|
||||
description: 'Slim horizontal divider with single statement copy. Ideal for mood transitions between grids.',
|
||||
badge: 'Minimal',
|
||||
renderPreview: () => (
|
||||
<div className="w-full py-3 px-4 rounded-xl bg-slate-800/80 border border-white/10 text-center space-y-0.5">
|
||||
<h5 className="text-[8px] font-serif font-medium text-slate-200">100% pure botanical distillates, harvested with intention.</h5>
|
||||
<p className="text-[6px] text-slate-400">Carbon-neutral delivery on all orders</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 5.5 ANNOUNCEMENT_BAR_MINIMAL
|
||||
{
|
||||
type: 'ANNOUNCEMENT_BAR_MINIMAL',
|
||||
category: 'hero',
|
||||
title: 'ANNOUNCEMENT MINIMAL',
|
||||
description: 'Ultra-thin, gentle top announcement ticker without emojis or loud badges.',
|
||||
badge: 'Minimal',
|
||||
renderPreview: () => (
|
||||
<div className="w-full bg-[#111915] text-[#DCE6E0] py-1.5 px-3 text-center text-[8px] tracking-[0.15em] font-medium rounded-lg border border-white/5">
|
||||
COMPLIMENTARY BOTANICAL SAMPLE WITH EVERY ORDER
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 5.6 USP_MINIMAL
|
||||
{
|
||||
type: 'USP_MINIMAL',
|
||||
category: 'hero',
|
||||
title: 'USP MINIMAL (Typography)',
|
||||
description: 'Pure typographic pillars separated by clean dots or lines. Sophisticated and icon-free.',
|
||||
badge: 'Minimal',
|
||||
renderPreview: () => (
|
||||
<div className="w-full bg-slate-900/50 border border-white/5 py-2 px-3 rounded-xl text-center">
|
||||
<p className="text-[7px] font-medium text-slate-300 tracking-wider">
|
||||
Ethically Sourced <span className="text-cyan-glow mx-1">·</span> No Synthetics <span className="text-cyan-glow mx-1">·</span> Hand-Poured <span className="text-cyan-glow mx-1">·</span> Cruelty-Free
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 6. PRODUCTS
|
||||
{
|
||||
type: 'PRODUCTS',
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
import React from 'react';
|
||||
import { UploadCloud, Image as ImageIcon, Plus, Trash2, Sparkles, Video } from 'lucide-react';
|
||||
import { type PageSection } from '@homaroma/types';
|
||||
import FocalPointPicker from './FocalPointPicker';
|
||||
|
||||
interface CommonProps {
|
||||
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'; index?: number } | null) => void;
|
||||
setIsMediaModalOpen?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// 1. HERO_MINIMAL FORM
|
||||
export function HeroMinimalForm({
|
||||
editingSection,
|
||||
getLocalizedValue,
|
||||
updateLocalizedValue,
|
||||
updatePayloadValue,
|
||||
setUploadTarget,
|
||||
setIsMediaModalOpen,
|
||||
}: CommonProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 p-3 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow text-xs font-semibold">
|
||||
<Sparkles className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Minimal Mode: Full-screen atmospheric cover with pure typography (No CTA buttons).</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Cover Image</label>
|
||||
<div
|
||||
className="relative h-44 w-full rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
|
||||
onClick={() => {
|
||||
setUploadTarget?.({ type: 'main' });
|
||||
setIsMediaModalOpen?.(true);
|
||||
}}
|
||||
>
|
||||
{editingSection.payload?.imageUrl ? (
|
||||
<>
|
||||
<img src={editingSection.payload.imageUrl} className="w-full h-full object-cover" alt="Hero" />
|
||||
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
||||
<UploadCloud className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center space-y-2">
|
||||
<ImageIcon className="w-10 h-10 text-slate-700 mx-auto" />
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Select Cover Image</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingSection.payload?.imageUrl && (
|
||||
<div className="md:col-span-2">
|
||||
<FocalPointPicker
|
||||
imageUrl={editingSection.payload.imageUrl}
|
||||
focalPoint={editingSection.payload.focalPoint}
|
||||
onChange={(focalPoint) => updatePayloadValue('focalPoint', focalPoint)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Tagline (Micro-copy)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'tagline')}
|
||||
onChange={(e) => updateLocalizedValue('tagline', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. AUTUMN EQUINOX · BOTANICAL ESSENCE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Text Alignment</label>
|
||||
<select
|
||||
value={editingSection.payload?.contentAlignment || 'center'}
|
||||
onChange={(e) => updatePayloadValue('contentAlignment', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
>
|
||||
<option value="center">Center</option>
|
||||
<option value="left">Left</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title / Poetic Statement</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. The scent of damp earth and cedar [sanctuary]"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500">Tip: Use [brackets] to highlight words in accent color.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Overlay Opacity (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={80}
|
||||
value={editingSection.payload?.overlayOpacity ?? 20}
|
||||
onChange={(e) => updatePayloadValue('overlayOpacity', Number(e.target.value))}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. HERO_SPLIT_MINIMAL FORM
|
||||
export function HeroSplitMinimalForm({
|
||||
editingSection,
|
||||
getLocalizedValue,
|
||||
updateLocalizedValue,
|
||||
updatePayloadValue,
|
||||
setUploadTarget,
|
||||
setIsMediaModalOpen,
|
||||
}: CommonProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 p-3 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow text-xs font-semibold">
|
||||
<Sparkles className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Minimal Split: Magazine-style layout with asymmetric whitespace and statement copy.</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Editorial Image</label>
|
||||
<div
|
||||
className="relative h-44 w-full rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
|
||||
onClick={() => {
|
||||
setUploadTarget?.({ type: 'main' });
|
||||
setIsMediaModalOpen?.(true);
|
||||
}}
|
||||
>
|
||||
{editingSection.payload?.imageUrl ? (
|
||||
<>
|
||||
<img src={editingSection.payload.imageUrl} className="w-full h-full object-cover" alt="Split" />
|
||||
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
||||
<UploadCloud className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center space-y-2">
|
||||
<ImageIcon className="w-10 h-10 text-slate-700 mx-auto" />
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Select Editorial Image</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingSection.payload?.imageUrl && (
|
||||
<div className="md:col-span-2">
|
||||
<FocalPointPicker
|
||||
imageUrl={editingSection.payload.imageUrl}
|
||||
focalPoint={editingSection.payload.focalPoint}
|
||||
onChange={(focalPoint) => updatePayloadValue('focalPoint', focalPoint)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Image Position</label>
|
||||
<select
|
||||
value={editingSection.payload?.imagePosition || 'right'}
|
||||
onChange={(e) => updatePayloadValue('imagePosition', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
>
|
||||
<option value="right">Image on Right / Text on Left</option>
|
||||
<option value="left">Image on Left / Text on Right</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.backgroundColor || ''}
|
||||
onChange={(e) => updatePayloadValue('backgroundColor', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. #F8F9F5 or empty for default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Main Statement / Title</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full resize-none"
|
||||
placeholder="e.g. Formulated in silence, crafted for quiet moments."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtitle / Philosophical Note (Optional)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={getLocalizedValue(editingSection.payload, 'subtitle')}
|
||||
onChange={(e) => updateLocalizedValue('subtitle', e.target.value)}
|
||||
className="admin-input w-full resize-none"
|
||||
placeholder="e.g. Certified organic extraction from alpine botanicals."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. HERO_VIDEO_MINIMAL FORM
|
||||
export function HeroVideoMinimalForm({
|
||||
editingSection,
|
||||
getLocalizedValue,
|
||||
updateLocalizedValue,
|
||||
updatePayloadValue,
|
||||
setUploadTarget,
|
||||
setIsMediaModalOpen,
|
||||
}: CommonProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 p-3 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow text-xs font-semibold">
|
||||
<Video className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Minimal Motion: Ambient slow-moving video background with floating quiet typography.</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Video URL (MP4 / WebM)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.videoUrl || ''}
|
||||
onChange={(e) => updatePayloadValue('videoUrl', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="https://.../ambient-mist.mp4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Poster Fallback Image</label>
|
||||
<div
|
||||
className="relative h-36 w-full rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
|
||||
onClick={() => {
|
||||
setUploadTarget?.({ type: 'main' });
|
||||
setIsMediaModalOpen?.(true);
|
||||
}}
|
||||
>
|
||||
{editingSection.payload?.posterImageUrl ? (
|
||||
<>
|
||||
<img src={editingSection.payload.posterImageUrl} className="w-full h-full object-cover" alt="Poster" />
|
||||
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
||||
<UploadCloud className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center space-y-2">
|
||||
<ImageIcon className="w-8 h-8 text-slate-700 mx-auto" />
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Select Poster Image</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Tagline</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'tagline')}
|
||||
onChange={(e) => updateLocalizedValue('tagline', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. LIVING ARTWORK"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Height</label>
|
||||
<select
|
||||
value={editingSection.payload?.height || 'large'}
|
||||
onChange={(e) => updatePayloadValue('height', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
>
|
||||
<option value="large">Large (~85vh)</option>
|
||||
<option value="medium">Medium (~60vh)</option>
|
||||
<option value="full">Full Screen (100vh)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtle Title / Headline</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Breathe with Intention"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Overlay Opacity (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={80}
|
||||
value={editingSection.payload?.overlayOpacity ?? 25}
|
||||
onChange={(e) => updatePayloadValue('overlayOpacity', Number(e.target.value))}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. BANNER_MINIMAL FORM
|
||||
export function BannerMinimalForm({
|
||||
editingSection,
|
||||
getLocalizedValue,
|
||||
updateLocalizedValue,
|
||||
updatePayloadValue,
|
||||
setUploadTarget,
|
||||
setIsMediaModalOpen,
|
||||
}: CommonProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 p-3 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow text-xs font-semibold">
|
||||
<Sparkles className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Minimal Banner: Slim mood divider between page content (No CTA button, entire band optional clickable).</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Texture Image (Optional)</label>
|
||||
<div
|
||||
className="relative h-32 w-full rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
|
||||
onClick={() => {
|
||||
setUploadTarget?.({ type: 'main' });
|
||||
setIsMediaModalOpen?.(true);
|
||||
}}
|
||||
>
|
||||
{editingSection.payload?.imageUrl ? (
|
||||
<>
|
||||
<img src={editingSection.payload.imageUrl} className="w-full h-full object-cover" alt="Banner" />
|
||||
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
||||
<UploadCloud className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center space-y-1">
|
||||
<ImageIcon className="w-6 h-6 text-slate-700 mx-auto" />
|
||||
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Select Image or leave empty for solid color</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.backgroundColor || ''}
|
||||
onChange={(e) => updatePayloadValue('backgroundColor', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. #1E2822 or var(--surface-card)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Text Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.textColor || ''}
|
||||
onChange={(e) => updatePayloadValue('textColor', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. #FDFDF9 or empty for default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Banner Statement (Title)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. 100% pure botanical distillates, harvested with intention."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtitle / Supporting Note (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'subtitle')}
|
||||
onChange={(e) => updateLocalizedValue('subtitle', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Free carbon-neutral delivery on all orders over ฿1,500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Optional Destination URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.linkUrl || ''}
|
||||
onChange={(e) => updatePayloadValue('linkUrl', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. /shop or leave empty"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. ANNOUNCEMENT_BAR_MINIMAL FORM
|
||||
export function AnnouncementBarMinimalForm({
|
||||
editingSection,
|
||||
setEditingSection,
|
||||
editingLang,
|
||||
}: {
|
||||
editingSection: Partial<PageSection>;
|
||||
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
|
||||
editingLang: string;
|
||||
}) {
|
||||
const slides = Array.isArray(editingSection.payload?.slides) ? editingSection.payload.slides : [''];
|
||||
|
||||
const handleUpdateSlide = (idx: number, val: string) => {
|
||||
const nextSlides = [...slides];
|
||||
const current = nextSlides[idx];
|
||||
let newVal;
|
||||
if (typeof current === 'string') {
|
||||
newVal = { th: current, [editingLang]: val };
|
||||
} else {
|
||||
newVal = { ...(current || {}), [editingLang]: val };
|
||||
}
|
||||
nextSlides[idx] = newVal;
|
||||
setEditingSection((prev) => (prev ? { ...prev, payload: { ...(prev.payload || {}), slides: nextSlides } } : prev));
|
||||
};
|
||||
|
||||
const handleAddSlide = () => {
|
||||
const nextSlides = [...slides, { th: '', [editingLang]: '' }];
|
||||
setEditingSection((prev) => (prev ? { ...prev, payload: { ...(prev.payload || {}), slides: nextSlides } } : prev));
|
||||
};
|
||||
|
||||
const handleRemoveSlide = (idx: number) => {
|
||||
if (slides.length <= 1) return;
|
||||
const nextSlides = slides.filter((_: unknown, i: number) => i !== idx);
|
||||
setEditingSection((prev) => (prev ? { ...prev, payload: { ...(prev.payload || {}), slides: nextSlides } } : prev));
|
||||
};
|
||||
|
||||
const getSlideVal = (slide: any) => {
|
||||
if (typeof slide === 'string') return editingLang === 'th' ? slide : '';
|
||||
return slide?.[editingLang] || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 p-3 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow text-xs font-semibold">
|
||||
<Sparkles className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Minimal Announcement: Ultra-thin gentle message ticker with subtle transitions.</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.backgroundColor || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection((prev) =>
|
||||
prev ? { ...prev, payload: { ...(prev.payload || {}), backgroundColor: e.target.value } } : prev
|
||||
)
|
||||
}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. #0F1713 or default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Text Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.textColor || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection((prev) =>
|
||||
prev ? { ...prev, payload: { ...(prev.payload || {}), textColor: e.target.value } } : prev
|
||||
)
|
||||
}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. #E0E8E3 or default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Transition Delay (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={2000}
|
||||
step={500}
|
||||
value={editingSection.payload?.speed || 4000}
|
||||
onChange={(e) =>
|
||||
setEditingSection((prev) =>
|
||||
prev ? { ...prev, payload: { ...(prev.payload || {}), speed: Number(e.target.value) } } : prev
|
||||
)
|
||||
}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Announcement Messages</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSlide}
|
||||
className="flex items-center gap-1 text-xs text-cyan-glow hover:underline font-semibold"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{slides.map((slide: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center gap-3 bg-night-950/40 p-2.5 rounded-xl border border-white/5">
|
||||
<input
|
||||
type="text"
|
||||
value={getSlideVal(slide)}
|
||||
onChange={(e) => handleUpdateSlide(idx, e.target.value)}
|
||||
className="admin-input flex-1"
|
||||
placeholder={`Message #${idx + 1} (e.g. Complimentary botanical sample with every order)`}
|
||||
/>
|
||||
{slides.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveSlide(idx)}
|
||||
className="p-2 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 6. USP_MINIMAL FORM
|
||||
export function UspMinimalForm({
|
||||
editingSection,
|
||||
setEditingSection,
|
||||
editingLang,
|
||||
}: {
|
||||
editingSection: Partial<PageSection>;
|
||||
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
|
||||
editingLang: string;
|
||||
}) {
|
||||
const items = Array.isArray(editingSection.payload?.items) ? editingSection.payload.items : ['100% Organic', 'Hand-Poured'];
|
||||
|
||||
const handleUpdateItem = (idx: number, val: string) => {
|
||||
const nextItems = [...items];
|
||||
const current = nextItems[idx];
|
||||
let newVal;
|
||||
if (typeof current === 'string') {
|
||||
newVal = { th: current, [editingLang]: val };
|
||||
} else {
|
||||
newVal = { ...(current || {}), [editingLang]: val };
|
||||
}
|
||||
nextItems[idx] = newVal;
|
||||
setEditingSection((prev) => (prev ? { ...prev, payload: { ...(prev.payload || {}), items: nextItems } } : prev));
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const nextItems = [...items, { th: '', [editingLang]: '' }];
|
||||
setEditingSection((prev) => (prev ? { ...prev, payload: { ...(prev.payload || {}), items: nextItems } } : prev));
|
||||
};
|
||||
|
||||
const handleRemoveItem = (idx: number) => {
|
||||
if (items.length <= 1) return;
|
||||
const nextItems = items.filter((_: unknown, i: number) => i !== idx);
|
||||
setEditingSection((prev) => (prev ? { ...prev, payload: { ...(prev.payload || {}), items: nextItems } } : prev));
|
||||
};
|
||||
|
||||
const getItemVal = (item: any) => {
|
||||
if (typeof item === 'string') return editingLang === 'th' ? item : '';
|
||||
return item?.[editingLang] || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 p-3 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow text-xs font-semibold">
|
||||
<Sparkles className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Minimal USP: Pure typographic pillars separated by clean dots or lines (No heavy icons).</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Separator Style</label>
|
||||
<select
|
||||
value={editingSection.payload?.separator || 'dot'}
|
||||
onChange={(e) =>
|
||||
setEditingSection((prev) =>
|
||||
prev ? { ...prev, payload: { ...(prev.payload || {}), separator: e.target.value } } : prev
|
||||
)
|
||||
}
|
||||
className="admin-input w-full"
|
||||
>
|
||||
<option value="dot">Middle Dot ( · )</option>
|
||||
<option value="slash">Slash ( / )</option>
|
||||
<option value="line">Vertical Line ( | )</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="text"
|
||||
value={editingSection.payload?.backgroundColor || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection((prev) =>
|
||||
prev ? { ...prev, payload: { ...(prev.payload || {}), backgroundColor: e.target.value } } : prev
|
||||
)
|
||||
}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. #F8F9F5 or transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Text Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.textColor || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection((prev) =>
|
||||
prev ? { ...prev, payload: { ...(prev.payload || {}), textColor: e.target.value } } : prev
|
||||
)
|
||||
}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. #2C3E35 or default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Pillar Statements</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddItem}
|
||||
className="flex items-center gap-1 text-xs text-cyan-glow hover:underline font-semibold"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Pillar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.map((item: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center gap-3 bg-night-950/40 p-2.5 rounded-xl border border-white/5">
|
||||
<input
|
||||
type="text"
|
||||
value={getItemVal(item)}
|
||||
onChange={(e) => handleUpdateItem(idx, e.target.value)}
|
||||
className="admin-input flex-1"
|
||||
placeholder={`Pillar #${idx + 1} (e.g. Ethically Sourced, Zero Synthetics, Hand-Poured)`}
|
||||
/>
|
||||
{items.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(idx)}
|
||||
className="p-2 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,14 @@ import ProductLayoutBlockForm from '../components/page-builder/ProductLayoutBloc
|
||||
import AddSectionModal from '../components/page-builder/AddSectionModal';
|
||||
import FeaturedTilesForm from '../components/page-builder/FeaturedTilesForm';
|
||||
import HeroVideoForm from '../components/page-builder/HeroVideoForm';
|
||||
import {
|
||||
HeroMinimalForm,
|
||||
HeroSplitMinimalForm,
|
||||
HeroVideoMinimalForm,
|
||||
BannerMinimalForm,
|
||||
AnnouncementBarMinimalForm,
|
||||
UspMinimalForm,
|
||||
} from '../components/page-builder/MinimalSectionForms';
|
||||
|
||||
const PRODUCT_LAYOUT_BLOCK_TYPES = new Set([
|
||||
'PRODUCT_MAIN',
|
||||
@@ -663,6 +671,59 @@ export default function PageBuilderPage() {
|
||||
buttonLink: '/shop'
|
||||
};
|
||||
break;
|
||||
case 'HERO_MINIMAL':
|
||||
defaultPayload = {
|
||||
imageUrl: '/images/hero.png',
|
||||
tagline: 'AUTUMN EQUINOX · BOTANICAL ESSENCE',
|
||||
title: 'The scent of damp earth and cedar [sanctuary]',
|
||||
contentAlignment: 'center',
|
||||
overlayOpacity: 20
|
||||
};
|
||||
break;
|
||||
case 'HERO_SPLIT_MINIMAL':
|
||||
defaultPayload = {
|
||||
imageUrl: '/images/oil_ozone.png',
|
||||
title: 'Formulated in silence, crafted for quiet moments.',
|
||||
subtitle: 'Certified organic extraction from alpine botanicals.',
|
||||
imagePosition: 'right',
|
||||
backgroundColor: ''
|
||||
};
|
||||
break;
|
||||
case 'HERO_VIDEO_MINIMAL':
|
||||
defaultPayload = {
|
||||
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',
|
||||
tagline: 'LIVING ARTWORK',
|
||||
title: 'Breathe with Intention',
|
||||
height: 'large',
|
||||
overlayOpacity: 25
|
||||
};
|
||||
break;
|
||||
case 'BANNER_MINIMAL':
|
||||
defaultPayload = {
|
||||
title: '100% pure botanical distillates, harvested with intention.',
|
||||
subtitle: 'Free carbon-neutral delivery on all orders over ฿1,500',
|
||||
backgroundColor: '',
|
||||
textColor: '',
|
||||
linkUrl: '/shop'
|
||||
};
|
||||
break;
|
||||
case 'ANNOUNCEMENT_BAR_MINIMAL':
|
||||
defaultPayload = {
|
||||
slides: ['COMPLIMENTARY BOTANICAL SAMPLE WITH EVERY ORDER', 'HAND-POURED IN SMALL BATCHES'],
|
||||
backgroundColor: '#0F1713',
|
||||
textColor: '#DCE6E0',
|
||||
speed: 4000
|
||||
};
|
||||
break;
|
||||
case 'USP_MINIMAL':
|
||||
defaultPayload = {
|
||||
items: ['Ethically Sourced', 'Zero Synthetics', 'Hand-Poured', 'Cruelty-Free'],
|
||||
separator: 'dot',
|
||||
backgroundColor: '',
|
||||
textColor: ''
|
||||
};
|
||||
break;
|
||||
case 'CATEGORIES':
|
||||
defaultPayload = {
|
||||
title: 'Shop by Collection',
|
||||
@@ -1266,11 +1327,81 @@ export default function PageBuilderPage() {
|
||||
<option value="HERO_V2">HERO V2 (Split Layout Hero)</option>
|
||||
<option value="USP_BAR">USP BAR (Features / Icons)</option>
|
||||
<option value="CUSTOM">CUSTOM (Registered component)</option>
|
||||
<option value="HERO_MINIMAL">HERO MINIMAL (Zen Cover, No CTA)</option>
|
||||
<option value="HERO_SPLIT_MINIMAL">HERO SPLIT MINIMAL (Editorial, No CTA)</option>
|
||||
<option value="HERO_VIDEO_MINIMAL">HERO VIDEO MINIMAL (Ambient Motion, No CTA)</option>
|
||||
<option value="BANNER_MINIMAL">BANNER MINIMAL (Quiet Divider, No CTA)</option>
|
||||
<option value="ANNOUNCEMENT_BAR_MINIMAL">ANNOUNCEMENT MINIMAL (Gentle Ticker)</option>
|
||||
<option value="USP_MINIMAL">USP MINIMAL (Typography Pillars)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Forms based on Type */}
|
||||
{editingSection.type?.toUpperCase() === 'HERO_MINIMAL' && (
|
||||
<HeroMinimalForm
|
||||
editingSection={editingSection}
|
||||
editingLang={editingLang}
|
||||
getLocalizedValue={getLocalizedValue}
|
||||
updateLocalizedValue={updateLocalizedValue}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
setUploadTarget={setUploadTarget}
|
||||
setIsMediaModalOpen={setIsMediaModalOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'HERO_SPLIT_MINIMAL' && (
|
||||
<HeroSplitMinimalForm
|
||||
editingSection={editingSection}
|
||||
editingLang={editingLang}
|
||||
getLocalizedValue={getLocalizedValue}
|
||||
updateLocalizedValue={updateLocalizedValue}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
setUploadTarget={setUploadTarget}
|
||||
setIsMediaModalOpen={setIsMediaModalOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'HERO_VIDEO_MINIMAL' && (
|
||||
<HeroVideoMinimalForm
|
||||
editingSection={editingSection}
|
||||
editingLang={editingLang}
|
||||
getLocalizedValue={getLocalizedValue}
|
||||
updateLocalizedValue={updateLocalizedValue}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
setUploadTarget={setUploadTarget}
|
||||
setIsMediaModalOpen={setIsMediaModalOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'BANNER_MINIMAL' && (
|
||||
<BannerMinimalForm
|
||||
editingSection={editingSection}
|
||||
editingLang={editingLang}
|
||||
getLocalizedValue={getLocalizedValue}
|
||||
updateLocalizedValue={updateLocalizedValue}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
setUploadTarget={setUploadTarget}
|
||||
setIsMediaModalOpen={setIsMediaModalOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'ANNOUNCEMENT_BAR_MINIMAL' && (
|
||||
<AnnouncementBarMinimalForm
|
||||
editingSection={editingSection}
|
||||
setEditingSection={setEditingSection}
|
||||
editingLang={editingLang}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'USP_MINIMAL' && (
|
||||
<UspMinimalForm
|
||||
editingSection={editingSection}
|
||||
setEditingSection={setEditingSection}
|
||||
editingLang={editingLang}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'HERO' && (
|
||||
<HeroForm
|
||||
editingSection={editingSection}
|
||||
|
||||
@@ -34,6 +34,12 @@ import {
|
||||
HeroVideoSectionPayloadSchema,
|
||||
UspBarSectionPayloadSchema,
|
||||
CustomSectionPayloadSchema,
|
||||
HeroMinimalSectionPayloadSchema,
|
||||
HeroSplitMinimalSectionPayloadSchema,
|
||||
HeroVideoMinimalSectionPayloadSchema,
|
||||
BannerMinimalSectionPayloadSchema,
|
||||
AnnouncementBarMinimalSectionPayloadSchema,
|
||||
UspMinimalSectionPayloadSchema,
|
||||
PAGE_SECTION_LOCALIZED_PATHS,
|
||||
type SectionType,
|
||||
} from "@homaroma/types";
|
||||
@@ -1176,6 +1182,24 @@ export function validateSectionPayload(
|
||||
case "CUSTOM":
|
||||
schema = CustomSectionPayloadSchema;
|
||||
break;
|
||||
case "HERO_MINIMAL":
|
||||
schema = HeroMinimalSectionPayloadSchema;
|
||||
break;
|
||||
case "HERO_SPLIT_MINIMAL":
|
||||
schema = HeroSplitMinimalSectionPayloadSchema;
|
||||
break;
|
||||
case "HERO_VIDEO_MINIMAL":
|
||||
schema = HeroVideoMinimalSectionPayloadSchema;
|
||||
break;
|
||||
case "BANNER_MINIMAL":
|
||||
schema = BannerMinimalSectionPayloadSchema;
|
||||
break;
|
||||
case "ANNOUNCEMENT_BAR_MINIMAL":
|
||||
schema = AnnouncementBarMinimalSectionPayloadSchema;
|
||||
break;
|
||||
case "USP_MINIMAL":
|
||||
schema = UspMinimalSectionPayloadSchema;
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -310,6 +310,268 @@ function UspBarSection({ payload, lang }: { payload: Record<string, unknown>, la
|
||||
)
|
||||
}
|
||||
|
||||
function AnnouncementBarMinimal({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
const slides = Array.isArray(payload.slides) ? payload.slides : []
|
||||
const backgroundColor = typeof payload.backgroundColor === 'string' && payload.backgroundColor ? payload.backgroundColor : '#0F1713'
|
||||
const textColor = typeof payload.textColor === 'string' && payload.textColor ? payload.textColor : '#DCE6E0'
|
||||
const speed = typeof payload.speed === 'number' ? payload.speed : 4000
|
||||
|
||||
if (!slides.length) return null
|
||||
|
||||
return (
|
||||
<section
|
||||
className="announcement-bar-minimal absolute left-0 top-0 z-[60] w-full overflow-hidden px-4 py-2 text-center text-[11px] md:text-xs font-medium tracking-[0.2em] uppercase select-none transition-colors"
|
||||
style={{ backgroundColor, color: textColor }}
|
||||
>
|
||||
<Swiper
|
||||
modules={[Autoplay]}
|
||||
autoplay={{ delay: speed, disableOnInteraction: false }}
|
||||
loop={slides.length > 1}
|
||||
className="w-full"
|
||||
>
|
||||
{slides.map((slide, index) => (
|
||||
<SwiperSlide key={index} className="flex items-center justify-center">
|
||||
<span className="inline-block truncate max-w-full">
|
||||
{localizedFromRecord({ slide }, 'slide', resolvedLang)}
|
||||
</span>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroMinimalSection({ 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 imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
|
||||
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 20
|
||||
const align = typeof payload.contentAlignment === 'string' ? payload.contentAlignment : 'center'
|
||||
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
|
||||
|
||||
const alignClass = align === 'left' ? 'text-left items-start' : align === 'right' ? 'text-right items-end ml-auto' : 'text-center items-center mx-auto'
|
||||
|
||||
return (
|
||||
<section className="relative w-full h-[75vh] min-h-[500px] md:h-[85vh] bg-surface-page overflow-hidden flex items-center justify-center">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title || 'Hero'}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black transition-opacity pointer-events-none" style={{ opacity: overlayOpacity / 100 }} />
|
||||
<div className="relative z-10 w-full max-w-5xl mx-auto px-6 py-12 flex flex-col justify-center">
|
||||
<div className={`flex flex-col gap-4 max-w-3xl ${alignClass}`}>
|
||||
{tagline && (
|
||||
<span className="text-xs md:text-sm font-medium uppercase tracking-[0.3em] text-[#EAEFEA] drop-shadow-md">
|
||||
{tagline}
|
||||
</span>
|
||||
)}
|
||||
{title && (
|
||||
<h1 className="font-serif text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-light text-white leading-tight tracking-wide drop-shadow-md">
|
||||
{renderAccentTitle(title, 'text-[#EAEFEA] font-medium')}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroSplitMinimalSection({ 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 imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/oil_ozone.png'
|
||||
const imagePosition = payload.imagePosition === 'left' ? 'left' : 'right'
|
||||
const backgroundColor = typeof payload.backgroundColor === 'string' && payload.backgroundColor ? payload.backgroundColor : undefined
|
||||
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
|
||||
|
||||
return (
|
||||
<section className="w-full py-16 md:py-24 bg-surface-page" style={{ backgroundColor }}>
|
||||
<div className="max-w-6xl mx-auto px-6 md:px-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-16 items-center">
|
||||
<div className={`flex flex-col justify-center gap-6 ${imagePosition === 'left' ? 'md:order-2' : 'md:order-1'}`}>
|
||||
{title && (
|
||||
<h2 className="font-serif text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-light text-text-main leading-snug tracking-wide">
|
||||
{renderAccentTitle(title, 'text-brand-primary font-normal')}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm md:text-base text-muted-text font-light leading-relaxed max-w-md">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`relative aspect-[3/4] w-full max-w-md mx-auto overflow-hidden rounded-sm bg-surface-card ${imagePosition === 'left' ? 'md:order-1' : 'md:order-2'}`}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title || 'Editorial'}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroVideoMinimalSection({ 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 videoUrl = typeof payload.videoUrl === 'string' ? payload.videoUrl : ''
|
||||
const posterImageUrl = typeof payload.posterImageUrl === 'string' ? payload.posterImageUrl : undefined
|
||||
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 25
|
||||
const height = typeof payload.height === 'string' ? payload.height : 'large'
|
||||
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
|
||||
|
||||
const getHeightClass = (h: string) => {
|
||||
switch (h) {
|
||||
case 'full':
|
||||
return 'h-screen'
|
||||
case 'medium':
|
||||
return 'h-[55vh] min-h-[400px]'
|
||||
case 'large':
|
||||
default:
|
||||
return 'h-[80vh] min-h-[500px]'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex items-center justify-center bg-[#0B0F17] text-white`}>
|
||||
{videoUrl ? (
|
||||
<video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
poster={posterImageUrl}
|
||||
className="absolute inset-0 w-full h-full object-cover z-0"
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<source src={videoUrl} type="video/webm" />
|
||||
</video>
|
||||
) : posterImageUrl ? (
|
||||
<Image
|
||||
src={posterImageUrl}
|
||||
alt="Hero Poster"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover z-0"
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="absolute inset-0 z-10 bg-black transition-opacity pointer-events-none" style={{ opacity: overlayOpacity / 100 }} />
|
||||
|
||||
<div className="relative z-20 mx-auto px-6 py-12 w-full max-w-4xl text-center flex flex-col items-center justify-center gap-4">
|
||||
{tagline && (
|
||||
<p className="text-xs md:text-sm font-medium uppercase tracking-[0.35em] text-white/80 drop-shadow-md">
|
||||
{tagline}
|
||||
</p>
|
||||
)}
|
||||
{title && (
|
||||
<h1 className="font-serif text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-light leading-tight tracking-wide drop-shadow-md text-white">
|
||||
{renderAccentTitle(title)}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BannerMinimalSection({ 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 imageUrl = typeof payload.imageUrl === 'string' && payload.imageUrl ? payload.imageUrl : undefined
|
||||
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : ''
|
||||
const backgroundColor = typeof payload.backgroundColor === 'string' && payload.backgroundColor ? payload.backgroundColor : undefined
|
||||
const textColor = typeof payload.textColor === 'string' && payload.textColor ? payload.textColor : undefined
|
||||
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
className={`relative w-full py-12 md:py-16 px-6 text-center overflow-hidden flex flex-col items-center justify-center gap-2 ${!imageUrl && !backgroundColor ? 'bg-surface-card' : ''}`}
|
||||
style={{ backgroundColor, color: textColor }}
|
||||
>
|
||||
{imageUrl && (
|
||||
<>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title || 'Banner'}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover z-0 opacity-40"
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20 z-0" />
|
||||
</>
|
||||
)}
|
||||
<div className="relative z-10 max-w-3xl mx-auto space-y-2">
|
||||
{title && (
|
||||
<h3 className="font-serif text-xl sm:text-2xl md:text-3xl font-light tracking-wide text-inherit">
|
||||
{renderAccentTitle(title)}
|
||||
</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs sm:text-sm opacity-80 font-light tracking-wider max-w-xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="w-full bg-surface-page">
|
||||
{linkUrl ? <Link href={linkUrl} className="block group">{inner}</Link> : inner}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function UspMinimalSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
const items = Array.isArray(payload.items) ? payload.items : []
|
||||
const separator = typeof payload.separator === 'string' ? payload.separator : 'dot'
|
||||
const backgroundColor = typeof payload.backgroundColor === 'string' && payload.backgroundColor ? payload.backgroundColor : undefined
|
||||
const textColor = typeof payload.textColor === 'string' && payload.textColor ? payload.textColor : undefined
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const sepSymbol = separator === 'slash' ? '/' : separator === 'line' ? '|' : '·'
|
||||
|
||||
return (
|
||||
<section className="w-full py-6 border-t border-b border-line-primary/10 bg-surface-page" style={{ backgroundColor, color: textColor }}>
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs sm:text-sm font-medium uppercase tracking-widest text-inherit/90">
|
||||
{items.map((item: any, idx: number) => (
|
||||
<span key={idx} className="inline-flex items-center gap-x-4">
|
||||
{idx > 0 && <span className="opacity-40 font-light select-none">{sepSymbol}</span>}
|
||||
<span>{localizedFromRecord({ item }, 'item', resolvedLang)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroV2Section({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
@@ -1675,6 +1937,34 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
)
|
||||
case 'HERO_VIDEO':
|
||||
return <HeroVideoSection key={section.id} payload={payload} lang={lang} />
|
||||
case 'HERO_MINIMAL':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
<HeroMinimalSection payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'HERO_SPLIT_MINIMAL':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
<HeroSplitMinimalSection payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'HERO_VIDEO_MINIMAL':
|
||||
return <HeroVideoMinimalSection key={section.id} payload={payload} lang={lang} />
|
||||
case 'BANNER_MINIMAL':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
<BannerMinimalSection payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'ANNOUNCEMENT_BAR_MINIMAL':
|
||||
return <AnnouncementBarMinimal key={section.id} payload={payload} lang={lang} />
|
||||
case 'USP_MINIMAL':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
<UspMinimalSection payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'USP_BAR':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageSection } from '@homaroma/types'
|
||||
|
||||
const announcementTypes = new Set(['ANNOUNCEMENT_BAR'])
|
||||
const announcementTypes = new Set(['ANNOUNCEMENT_BAR', 'ANNOUNCEMENT_BAR_MINIMAL'])
|
||||
const heroTypes = new Set(['HERO', 'BANNER'])
|
||||
|
||||
function sectionType(section: PageSection) {
|
||||
|
||||
Reference in new issue
Block a user