This commit is contained in:
Your Name committed 2026-09-17 07:06:49 +07:00
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>
);
}
+131
View File
@@ -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}
+24
View File
@@ -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 -1
View File
@@ -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) {