x
This commit is contained in:
1 parent
fc252e46cc
commit
ad637fd8b4
27 files changed
+764
-25
No files matched your search
@@ -0,0 +1,159 @@
|
||||
import { UploadCloud, Image as ImageIcon } from 'lucide-react';
|
||||
import { type PageSection } from '@homaroma/types';
|
||||
|
||||
interface HeroV2FormProps {
|
||||
editingSection: Partial<PageSection>;
|
||||
editingLang: string;
|
||||
getLocalizedValue: (payload: any, field: string) => string;
|
||||
updateLocalizedValue: (field: string, value: string) => void;
|
||||
updatePayloadValue: (field: string, value: string | number | boolean) => void;
|
||||
setUploadTarget: (target: { type: 'main' | 'slide'; index?: number } | null) => void;
|
||||
setIsMediaModalOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function HeroV2Form({
|
||||
editingSection,
|
||||
getLocalizedValue,
|
||||
updateLocalizedValue,
|
||||
updatePayloadValue,
|
||||
setUploadTarget,
|
||||
setIsMediaModalOpen,
|
||||
}: HeroV2FormProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Hero image uploading */}
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Image</label>
|
||||
<div
|
||||
className="relative h-40 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 Background" />
|
||||
<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 Image</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Alignment</label>
|
||||
<select
|
||||
value={editingSection.payload?.contentAlignment || 'left'}
|
||||
onChange={(e) => updatePayloadValue('contentAlignment', e.target.value)}
|
||||
className="admin-input w-full bg-slate-900 border-white/10"
|
||||
>
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex items-center gap-2 pt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showOrnament"
|
||||
checked={editingSection.payload?.showOrnament ?? true}
|
||||
onChange={(e) => updatePayloadValue('showOrnament', e.target.checked)}
|
||||
className="rounded bg-slate-900 border-white/10"
|
||||
/>
|
||||
<label htmlFor="showOrnament" className="text-[10px] uppercase font-bold tracking-widest text-slate-400 cursor-pointer">
|
||||
Show Divider Ornament
|
||||
</label>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
<p className="text-[9px] text-slate-500 font-medium">Use [brackets] for golden accent serif text (e.g. Inhale [Serenity]).</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Description</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={getLocalizedValue(editingSection.payload, 'description')}
|
||||
onChange={(e) => updateLocalizedValue('description', e.target.value)}
|
||||
className="admin-input w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-2 p-4 bg-slate-800/30 rounded-lg border border-white/5">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-brand-primary">Primary Button</label>
|
||||
<div className="space-y-3 mt-2">
|
||||
<div>
|
||||
<label className="text-[9px] uppercase font-bold tracking-widest text-slate-400">Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'primaryButtonText')}
|
||||
onChange={(e) => updateLocalizedValue('primaryButtonText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] uppercase font-bold tracking-widest text-slate-400">Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.primaryButtonLink || ''}
|
||||
onChange={(e) => updatePayloadValue('primaryButtonLink', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-4 bg-slate-800/30 rounded-lg border border-white/5">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Secondary Button (Optional)</label>
|
||||
<div className="space-y-3 mt-2">
|
||||
<div>
|
||||
<label className="text-[9px] uppercase font-bold tracking-widest text-slate-400">Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'secondaryButtonText')}
|
||||
onChange={(e) => updateLocalizedValue('secondaryButtonText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] uppercase font-bold tracking-widest text-slate-400">Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.secondaryButtonLink || ''}
|
||||
onChange={(e) => updatePayloadValue('secondaryButtonLink', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { type PageSection } from '@homaroma/types';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
interface UspBarFormProps {
|
||||
editingSection: Partial<PageSection>;
|
||||
editingLang: string;
|
||||
updatePayloadValue: (field: string, value: any) => void;
|
||||
getLocalizedValue: (payload: any, field: string) => string;
|
||||
}
|
||||
|
||||
export default function UspBarForm({
|
||||
editingSection,
|
||||
editingLang,
|
||||
updatePayloadValue,
|
||||
getLocalizedValue
|
||||
}: UspBarFormProps) {
|
||||
|
||||
const items = Array.isArray(editingSection.payload?.items) ? editingSection.payload.items : [];
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (items.length >= 4) return;
|
||||
const newItems = [...items, { icon: 'check', title: '', subtitle: '' }];
|
||||
updatePayloadValue('items', newItems);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
updatePayloadValue('items', newItems);
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: string, value: string) => {
|
||||
const newItems = [...items];
|
||||
if (field === 'icon') {
|
||||
newItems[index] = { ...newItems[index], icon: value };
|
||||
} else {
|
||||
// Handle localized string for array item
|
||||
const currentVal = newItems[index][field] || {};
|
||||
newItems[index] = {
|
||||
...newItems[index],
|
||||
[field]: typeof currentVal === 'string'
|
||||
? (editingLang === 'en' ? value : { en: currentVal, [editingLang]: value })
|
||||
: { ...currentVal, [editingLang]: value }
|
||||
};
|
||||
}
|
||||
updatePayloadValue('items', newItems);
|
||||
};
|
||||
|
||||
const getLocalizedItemValue = (item: any, field: string) => {
|
||||
if (!item || !item[field]) return '';
|
||||
if (typeof item[field] === 'string') {
|
||||
return editingLang === 'en' ? item[field] : '';
|
||||
}
|
||||
return item[field][editingLang] || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-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) => updatePayloadValue('backgroundColor', e.target.value)}
|
||||
placeholder="e.g. #F8F5F0 or var(--surface-page)"
|
||||
className="admin-input w-full md:w-1/2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-brand-primary">Features / USPs ({items.length}/4)</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddItem}
|
||||
disabled={items.length >= 4}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-brand-primary text-night-900 rounded hover:bg-brand-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="p-8 border border-dashed border-white/10 rounded-xl text-center text-slate-500">
|
||||
No items added yet. Click "Add Item" to create the feature bar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{items.map((item: any, idx: number) => (
|
||||
<div key={idx} className="p-4 bg-slate-800/30 rounded-xl border border-white/5 space-y-4 relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(idx)}
|
||||
className="absolute top-3 right-3 text-slate-500 hover:text-red-400 transition-colors"
|
||||
title="Remove item"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[9px] uppercase font-bold tracking-widest text-slate-400">Icon</label>
|
||||
<select
|
||||
value={item.icon || 'check'}
|
||||
onChange={(e) => updateItem(idx, 'icon', e.target.value)}
|
||||
className="admin-input w-full bg-slate-900 border-white/10"
|
||||
>
|
||||
<option value="check">Checkmark</option>
|
||||
<option value="leaf">Leaf (Natural)</option>
|
||||
<option value="droplet">Droplet (Liquid/Oil)</option>
|
||||
<option value="sparkles">Sparkles (Premium)</option>
|
||||
<option value="gift">Gift (Present)</option>
|
||||
<option value="shield">Shield (Safe)</option>
|
||||
<option value="heart">Heart (Care)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[9px] uppercase font-bold tracking-widest text-slate-400">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={getLocalizedItemValue(item, 'title')}
|
||||
onChange={(e) => updateItem(idx, 'title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Premium Ingredients"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[9px] uppercase font-bold tracking-widest text-slate-400">Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedItemValue(item, 'subtitle')}
|
||||
onChange={(e) => updateItem(idx, 'subtitle', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Sourced from the finest..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import ProductCarouselForm from '../components/page-builder/ProductCarouselForm'
|
||||
import AccordionForm from '../components/page-builder/AccordionForm';
|
||||
import ContactInfoForm from '../components/page-builder/ContactInfoForm';
|
||||
import ContactFormForm from '../components/page-builder/ContactFormForm';
|
||||
import HeroV2Form from '../components/page-builder/HeroV2Form';
|
||||
import UspBarForm from '../components/page-builder/UspBarForm';
|
||||
|
||||
interface Brand {
|
||||
id: number;
|
||||
@@ -986,6 +988,8 @@ export default function PageBuilderPage() {
|
||||
<option value="ACCORDION">ACCORDION (FAQ Style)</option>
|
||||
<option value="CONTACT_INFO">CONTACT INFO (Location/Hours)</option>
|
||||
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option>
|
||||
<option value="HERO_V2">HERO V2 (Split Layout Hero)</option>
|
||||
<option value="USP_BAR">USP BAR (Features / Icons)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
@@ -1003,6 +1007,27 @@ export default function PageBuilderPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'HERO_V2' && (
|
||||
<HeroV2Form
|
||||
editingSection={editingSection}
|
||||
editingLang={editingLang}
|
||||
getLocalizedValue={getLocalizedValue}
|
||||
updateLocalizedValue={updateLocalizedValue}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
setUploadTarget={setUploadTarget}
|
||||
setIsMediaModalOpen={setIsMediaModalOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'USP_BAR' && (
|
||||
<UspBarForm
|
||||
editingSection={editingSection}
|
||||
editingLang={editingLang}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
getLocalizedValue={getLocalizedValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'ANNOUNCEMENT_BAR' && (
|
||||
<AnnouncementBarForm
|
||||
editingSection={editingSection}
|
||||
|
||||
@@ -17,10 +17,15 @@ const defaultThemeTokens = {
|
||||
mutedText: '#666666',
|
||||
linePrimary: '#00B900',
|
||||
linePrimaryHover: '#009900',
|
||||
captionText: '#B0A396',
|
||||
borderColor: '#E8E0D8',
|
||||
surfaceMuted: '#EDE6DD',
|
||||
fontBody: '',
|
||||
fontDisplay: '',
|
||||
shadowCard: '0 10px 25px rgba(0,0,0,0.05)',
|
||||
shadowCardHover: '0 20px 40px rgba(0,0,0,0.1)',
|
||||
radiusBase: '8px',
|
||||
radiusCard: '16px',
|
||||
}
|
||||
|
||||
const FONT_PRESETS = {
|
||||
@@ -39,6 +44,11 @@ const FONT_PRESETS = {
|
||||
body: 'var(--font-kanit, "Kanit"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif',
|
||||
display: 'var(--font-kanit, "Kanit"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif',
|
||||
},
|
||||
playfair: {
|
||||
label: 'Playfair + Sarabun (Serif Heading)',
|
||||
body: 'var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif',
|
||||
display: '"Playfair Display", ui-serif, Georgia, serif',
|
||||
},
|
||||
} as const
|
||||
|
||||
type FontPresetKey = keyof typeof FONT_PRESETS
|
||||
@@ -53,6 +63,7 @@ function resolveFontPreset(themeTokens: Record<string, string>) {
|
||||
|
||||
if (fontBody === FONT_PRESETS.sarabun.body && fontDisplay === FONT_PRESETS.sarabun.display) return 'sarabun'
|
||||
if (fontBody === FONT_PRESETS.kanit.body && fontDisplay === FONT_PRESETS.kanit.display) return 'kanit'
|
||||
if (fontBody === FONT_PRESETS.playfair.body && fontDisplay === FONT_PRESETS.playfair.display) return 'playfair'
|
||||
|
||||
return 'custom'
|
||||
}
|
||||
@@ -471,6 +482,9 @@ export default function SystemSettingsPage() {
|
||||
['mutedText', 'Muted Text'],
|
||||
['linePrimary', 'Line Primary'],
|
||||
['linePrimaryHover', 'Line Primary Hover'],
|
||||
['captionText', 'Caption Text'],
|
||||
['borderColor', 'Border Color'],
|
||||
['surfaceMuted', 'Surface Muted'],
|
||||
].map(([key, label]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{label}</label>
|
||||
@@ -490,6 +504,8 @@ export default function SystemSettingsPage() {
|
||||
['fontDisplay', 'Font Display Override'],
|
||||
['shadowCard', 'Shadow Card'],
|
||||
['shadowCardHover', 'Shadow Card Hover'],
|
||||
['radiusBase', 'Radius Base'],
|
||||
['radiusCard', 'Radius Card'],
|
||||
].map(([key, label]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{label}</label>
|
||||
@@ -498,7 +514,7 @@ export default function SystemSettingsPage() {
|
||||
value={themeTokens[key] || ''}
|
||||
onChange={e => setThemeTokens((current) => ({ ...current, [key]: e.target.value }))}
|
||||
className="admin-input w-full"
|
||||
placeholder={key.startsWith('font') ? 'Leave blank to use locale preset' : undefined}
|
||||
placeholder={key.startsWith('font') ? 'Leave blank to use locale preset' : key.startsWith('radius') ? 'e.g. 8px' : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -8,14 +8,20 @@ const defaultThemeTokens = {
|
||||
surfacePage: '#FFF0F5', surfaceNavRest: '#F9F8F5', surfaceCard: '#FFFFFF',
|
||||
textMain: '#333333', mutedText: '#666666',
|
||||
linePrimary: '#00B900', linePrimaryHover: '#009900',
|
||||
captionText: '#B0A396',
|
||||
borderColor: '#E8E0D8',
|
||||
surfaceMuted: '#EDE6DD',
|
||||
fontBody: '', fontDisplay: '',
|
||||
shadowCard: '0 10px 25px rgba(0,0,0,0.05)', shadowCardHover: '0 20px 40px rgba(0,0,0,0.1)',
|
||||
radiusBase: '8px',
|
||||
radiusCard: '16px',
|
||||
}
|
||||
|
||||
const FONT_PRESETS = {
|
||||
locale: { label: 'Locale default', body: '', display: '' },
|
||||
sarabun: { label: 'Sarabun', body: 'var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif', display: 'var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif' },
|
||||
kanit: { label: 'Kanit', body: 'var(--font-kanit, "Kanit"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif', display: 'var(--font-kanit, "Kanit"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif' },
|
||||
playfair: { label: 'Playfair + Sarabun (Serif Heading)', body: 'var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif', display: '"Playfair Display", ui-serif, Georgia, serif' },
|
||||
} as const
|
||||
type FontPresetKey = keyof typeof FONT_PRESETS
|
||||
|
||||
@@ -25,6 +31,7 @@ function resolveFontPreset(tokens: Record<string, string>) {
|
||||
if (!body && !display) return 'locale'
|
||||
if (body === FONT_PRESETS.sarabun.body && display === FONT_PRESETS.sarabun.display) return 'sarabun'
|
||||
if (body === FONT_PRESETS.kanit.body && display === FONT_PRESETS.kanit.display) return 'kanit'
|
||||
if (body === FONT_PRESETS.playfair.body && display === FONT_PRESETS.playfair.display) return 'playfair'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
@@ -102,7 +109,7 @@ export default function ThemeSettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{(['brandPrimary', 'brandPrimaryHover', 'brandAccent', 'surfacePage', 'surfaceCard', 'textMain', 'mutedText', 'linePrimary', 'linePrimaryHover'] as const).map(key => (
|
||||
{(['brandPrimary', 'brandPrimaryHover', 'brandAccent', 'surfacePage', 'surfaceCard', 'textMain', 'mutedText', 'linePrimary', 'linePrimaryHover', 'captionText', 'borderColor', 'surfaceMuted'] as const).map(key => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||
<input type="color" value={themeTokens[key] || '#000000'}
|
||||
@@ -113,13 +120,13 @@ export default function ThemeSettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{(['fontBody', 'fontDisplay', 'shadowCard', 'shadowCardHover'] as const).map(key => (
|
||||
{(['fontBody', 'fontDisplay', 'shadowCard', 'shadowCardHover', 'radiusBase', 'radiusCard'] as const).map(key => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||
<input type="text" value={themeTokens[key] || ''}
|
||||
onChange={e => setThemeTokens(cur => ({ ...cur, [key]: e.target.value }))}
|
||||
className="admin-input w-full"
|
||||
placeholder={key.startsWith('font') ? 'Leave blank to use locale preset' : undefined} />
|
||||
placeholder={key.startsWith('font') ? 'Leave blank to use locale preset' : key.startsWith('radius') ? 'e.g. 8px' : undefined} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,10 @@ import {
|
||||
ScentNotesBreakdownSectionPayloadSchema,
|
||||
QuoteCalloutSectionPayloadSchema,
|
||||
ProductCarouselSectionPayloadSchema,
|
||||
AccordionSectionPayloadSchema,
|
||||
ContactInfoSectionPayloadSchema,
|
||||
ContactFormSectionPayloadSchema,
|
||||
HeroV2SectionPayloadSchema,
|
||||
UspBarSectionPayloadSchema,
|
||||
} from "@homaroma/types";
|
||||
import { z } from "zod";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
@@ -600,6 +601,12 @@ function validateSectionPayload(
|
||||
case "CONTACT_FORM":
|
||||
schema = ContactFormSectionPayloadSchema;
|
||||
break;
|
||||
case "HERO_V2":
|
||||
schema = HeroV2SectionPayloadSchema;
|
||||
break;
|
||||
case "USP_BAR":
|
||||
schema = UspBarSectionPayloadSchema;
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -236,7 +236,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
fontBody: { type: "string" },
|
||||
fontDisplay: { type: "string" },
|
||||
shadowCard: { type: "string" },
|
||||
shadowCardHover: { type: "string" }
|
||||
shadowCardHover: { type: "string" },
|
||||
captionText: { type: "string", description: "Tertiary/caption text color (e.g. #B0A396)" },
|
||||
borderColor: { type: "string", description: "Default border/divider color (e.g. #E8E0D8)" },
|
||||
surfaceMuted: { type: "string", description: "Muted surface background for footer/section bands (e.g. #EDE6DD)" },
|
||||
radiusBase: { type: "string", description: "Base border-radius for UI components (e.g. 8px)" },
|
||||
radiusCard: { type: "string", description: "Border-radius for cards (e.g. 16px)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -942,7 +947,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
"brandCool", "brandSuccess", "brandSoft", "surfacePage",
|
||||
"surfaceNavRest", "surfaceCard", "textMain", "mutedText",
|
||||
"linePrimary", "linePrimaryHover", "fontBody", "fontDisplay",
|
||||
"shadowCard", "shadowCardHover"
|
||||
"shadowCard", "shadowCardHover",
|
||||
// Extended Quiet Luxury tokens
|
||||
"captionText", "borderColor", "surfaceMuted", "radiusBase", "radiusCard"
|
||||
];
|
||||
|
||||
for (const key of validKeys) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
import { Sparkles, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface DynamicHeroV2Props {
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
|
||||
export default function DynamicHeroV2({ payload }: DynamicHeroV2Props) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
|
||||
const tagline = getLocalizedField(payload.tagline, lang);
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
|
||||
const primaryBtnText = getLocalizedField(payload.primaryButtonText, lang);
|
||||
const primaryBtnLink = typeof payload.primaryButtonLink === 'string' ? payload.primaryButtonLink : undefined;
|
||||
const secondaryBtnText = getLocalizedField(payload.secondaryButtonText, lang);
|
||||
const secondaryBtnLink = typeof payload.secondaryButtonLink === 'string' ? payload.secondaryButtonLink : undefined;
|
||||
|
||||
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/assets/hero.png';
|
||||
const align = typeof payload.contentAlignment === 'string' ? payload.contentAlignment : 'left';
|
||||
const showOrnament = typeof payload.showOrnament === 'boolean' ? payload.showOrnament : true;
|
||||
|
||||
const alignClass = align === 'center' ? 'text-center items-center mx-auto' : align === 'right' ? 'text-right items-end ml-auto' : 'text-left items-start';
|
||||
|
||||
const renderTitle = (text: string) => {
|
||||
if (!text) return null;
|
||||
return text.split('[').map((part, i) => {
|
||||
if (part.includes(']')) {
|
||||
const [accent, rest] = part.split(']');
|
||||
return (
|
||||
<span key={i}>
|
||||
<span className="text-pop-pink font-medium italic">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative w-full h-[80vh] min-h-[600px] bg-white flex items-center overflow-hidden">
|
||||
<div className="absolute inset-0 w-full h-full">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title || 'Hero'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white/90 via-white/50 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 md:px-12">
|
||||
<div className={`max-w-xl flex flex-col gap-6 ${alignClass}`}>
|
||||
{tagline && (
|
||||
<div className="flex items-center gap-4">
|
||||
{showOrnament && align !== 'right' && <div className="w-8 h-[1px] bg-pop-pink" />}
|
||||
<span className="text-sm font-semibold tracking-[0.2em] uppercase text-pop-pink">
|
||||
{tagline}
|
||||
</span>
|
||||
{showOrnament && align !== 'left' && <div className="w-8 h-[1px] bg-pop-pink" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-serif text-pop-black leading-tight">
|
||||
{renderTitle(title || '')}
|
||||
</h1>
|
||||
|
||||
{showOrnament && (
|
||||
<div className="flex justify-center items-center text-pop-pink my-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p className="text-base md:text-lg text-pop-black/80 leading-relaxed max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={`flex flex-col sm:flex-row items-center gap-4 mt-4 w-full sm:w-auto ${align === 'center' ? 'justify-center' : align === 'right' ? 'justify-end' : 'justify-start'}`}>
|
||||
{primaryBtnText && primaryBtnLink && (
|
||||
<Link
|
||||
to={primaryBtnLink}
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-pop-pink text-white font-medium rounded-md hover:bg-pop-pink/90 transition-colors w-full sm:w-auto"
|
||||
>
|
||||
{primaryBtnText}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
{secondaryBtnText && secondaryBtnLink && (
|
||||
<Link
|
||||
to={secondaryBtnLink}
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-transparent border border-pop-pink text-pop-pink font-medium rounded-md hover:bg-pop-pink/5 transition-colors w-full sm:w-auto"
|
||||
>
|
||||
{secondaryBtnText}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload } from '@homaroma/types';
|
||||
import DynamicHero from './DynamicHero';
|
||||
import DynamicHeroV2 from './DynamicHeroV2';
|
||||
import DynamicUspBar from './DynamicUspBar';
|
||||
import DynamicCategories from './DynamicCategories';
|
||||
import DynamicProducts from './DynamicProducts';
|
||||
import DynamicBanner from './DynamicBanner';
|
||||
@@ -40,6 +42,10 @@ export default function DynamicPageRenderer({
|
||||
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
||||
case 'HERO':
|
||||
return <DynamicHero key={section.id} payload={section.payload} />;
|
||||
case 'HERO_V2':
|
||||
return <DynamicHeroV2 key={section.id} payload={section.payload} />;
|
||||
case 'USP_BAR':
|
||||
return <DynamicUspBar key={section.id} payload={section.payload} />;
|
||||
|
||||
// animate เมื่อ scroll เข้า viewport
|
||||
case 'NEWSLETTER':
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
import { Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart } from 'lucide-react';
|
||||
|
||||
const ICON_MAP: Record<string, React.ElementType> = {
|
||||
leaf: Leaf,
|
||||
droplet: Droplet,
|
||||
sparkles: Sparkles,
|
||||
gift: Gift,
|
||||
check: CheckCircle,
|
||||
shield: ShieldCheck,
|
||||
heart: Heart,
|
||||
};
|
||||
|
||||
interface DynamicUspBarProps {
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
|
||||
export default function DynamicUspBar({ payload }: DynamicUspBarProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : 'var(--surface-page)';
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full py-8 border-t border-b border-pop-pink/10" style={{ backgroundColor }}>
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 md:grid-cols-${Math.min(items.length, 4)} gap-6 divide-y sm:divide-y-0 sm:divide-x divide-pop-pink/20`}>
|
||||
{items.slice(0, 4).map((item: any, idx: number) => {
|
||||
const Icon = ICON_MAP[item.icon] || CheckCircle;
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-4 px-4 pt-4 sm:pt-0 first:pt-0">
|
||||
<div className="flex-shrink-0 text-pop-pink">
|
||||
<Icon className="w-8 h-8" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm md:text-base font-semibold text-pop-black">
|
||||
{getLocalizedField(item.title, lang)}
|
||||
</h3>
|
||||
{item.subtitle && (
|
||||
<p className="text-xs md:text-sm text-pop-black/70 mt-1">
|
||||
{getLocalizedField(item.subtitle, lang)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,11 @@ export default function ThemeInjector() {
|
||||
'--color-pop-bg': tokens.surfacePage,
|
||||
'--color-pop-black': tokens.textMain,
|
||||
'--color-night-900': tokens.surfaceCard,
|
||||
'--color-pop-caption': tokens.captionText,
|
||||
'--color-pop-border': tokens.borderColor,
|
||||
'--color-pop-muted-bg': tokens.surfaceMuted,
|
||||
'--radius-base': tokens.radiusBase,
|
||||
'--radius-card': tokens.radiusCard,
|
||||
}
|
||||
for (const [key, value] of Object.entries(mapping)) {
|
||||
if (value && typeof value === 'string' && value.trim() !== '') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Kanit, Sarabun } from 'next/font/google';
|
||||
import { Kanit, Sarabun, Playfair_Display } from 'next/font/google';
|
||||
import { notFound } from 'next/navigation';
|
||||
import '../globals.css';
|
||||
import Navbar from '@/components/Navbar';
|
||||
@@ -25,6 +25,13 @@ const kanit = Kanit({
|
||||
variable: '--font-kanit',
|
||||
});
|
||||
|
||||
const playfairDisplay = Playfair_Display({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-playfair',
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const settings = await getStoreSettings(lang);
|
||||
@@ -127,6 +134,11 @@ function buildThemeStyle(settings: StoreSettings | null): CSSProperties | undefi
|
||||
['--font-display-active', tokens.fontDisplay],
|
||||
['--shadow-card', tokens.shadowCard],
|
||||
['--shadow-card-hover', tokens.shadowCardHover],
|
||||
['--color-caption-text', tokens.captionText],
|
||||
['--color-border-color', tokens.borderColor],
|
||||
['--color-surface-muted', tokens.surfaceMuted],
|
||||
['--radius-base', tokens.radiusBase],
|
||||
['--radius-card', tokens.radiusCard],
|
||||
]
|
||||
|
||||
// Add Scroll Reveal Settings
|
||||
@@ -171,7 +183,7 @@ export default async function RootLayout({
|
||||
const themeStyle = buildThemeStyle(settings)
|
||||
|
||||
return (
|
||||
<html lang={lang} className={`${sarabun.variable} ${kanit.variable} scroll-smooth`}>
|
||||
<html lang={lang} className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
|
||||
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased" style={themeStyle}>
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand-primary: #FF6B6B;
|
||||
--color-brand-primary-hover: #FF4500;
|
||||
--color-brand-accent: #FFF200;
|
||||
--color-brand-warm: #FFA07A;
|
||||
--color-brand-cool: #E0FFFF;
|
||||
--color-brand-success: #98FB98;
|
||||
--color-brand-soft: #D8BFD8;
|
||||
--color-surface-page: #FFF0F5;
|
||||
--color-surface-nav-rest: #F9F8F5;
|
||||
/* Quiet Luxury Palette — HOM AROMA Brand Reference */
|
||||
--color-brand-primary: #B8956A;
|
||||
--color-brand-primary-hover: #A07D3A;
|
||||
--color-brand-accent: #D4B97A;
|
||||
--color-brand-warm: #D4785A;
|
||||
--color-brand-cool: #EDE6DD;
|
||||
--color-brand-success: #5B9A4D;
|
||||
--color-brand-soft: #DABE7E;
|
||||
--color-surface-page: #F5F0EB;
|
||||
--color-surface-nav-rest: #FDFAF7;
|
||||
--color-surface-card: #FFFFFF;
|
||||
--color-text-main: #333333;
|
||||
--color-muted-text: #666666;
|
||||
--color-text-main: #3A2F28;
|
||||
--color-muted-text: #7A6E63;
|
||||
--color-line-primary: #00B900;
|
||||
--color-line-primary-hover: #009900;
|
||||
|
||||
/* New tokens — extended Quiet Luxury system */
|
||||
--color-caption-text: #B0A396;
|
||||
--color-border-color: #E8E0D8;
|
||||
--color-surface-muted: #EDE6DD;
|
||||
--radius-base: 8px;
|
||||
--radius-card: 16px;
|
||||
|
||||
/* pop-* aliases — used by apps/spa ThemeInjector */
|
||||
--color-pop-pink: var(--color-brand-primary);
|
||||
--color-pop-yellow: var(--color-brand-accent);
|
||||
--color-pop-orange: var(--color-brand-warm);
|
||||
@@ -25,12 +34,16 @@
|
||||
--color-pop-purple: var(--color-brand-soft);
|
||||
--color-pop-bg: var(--color-surface-page);
|
||||
--color-pop-black: var(--color-text-main);
|
||||
--color-pop-caption: var(--color-caption-text);
|
||||
--color-pop-border: var(--color-border-color);
|
||||
--color-pop-muted-bg: var(--color-surface-muted);
|
||||
|
||||
/* Serif font — mirrors apps/spa font-serif token */
|
||||
/* Serif font — Playfair Display for Quiet Luxury headings */
|
||||
--font-playfair: var(--font-playfair, "Playfair Display"), ui-serif, Georgia, serif;
|
||||
--font-serif: var(--font-serif-locale, ui-serif), Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
|
||||
--shadow-card: 0 10px 25px rgba(0,0,0,0.05);
|
||||
--shadow-card-hover: 0 20px 40px rgba(0,0,0,0.1);
|
||||
--shadow-card: 0 1px 4px rgba(0,0,0,0.04);
|
||||
--shadow-card-hover: 0 4px 12px rgba(0,0,0,0.06);
|
||||
--shadow-brutal: var(--shadow-card);
|
||||
--shadow-brutal-hover: var(--shadow-card-hover);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from '../LocalizedLink'
|
||||
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart } from 'lucide-react'
|
||||
import { useState, useRef } from 'react'
|
||||
import type { Category, PageSection, Product } from '@homaroma/types'
|
||||
import { getCategoryImage } from '@homaroma/types'
|
||||
@@ -152,6 +152,140 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
|
||||
)
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<string, React.ElementType> = {
|
||||
leaf: Leaf,
|
||||
droplet: Droplet,
|
||||
sparkles: Sparkles,
|
||||
gift: Gift,
|
||||
check: CheckCircle,
|
||||
shield: ShieldCheck,
|
||||
heart: Heart,
|
||||
}
|
||||
|
||||
function UspBarSection({ 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 backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : 'var(--surface-page)'
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="w-full py-8 border-t border-b border-line-primary/10" style={{ backgroundColor }}>
|
||||
<div className="ui-section-shell">
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 md:grid-cols-${Math.min(items.length, 4)} gap-6 divide-y sm:divide-y-0 sm:divide-x divide-line-primary/20`}>
|
||||
{items.slice(0, 4).map((item: any, idx: number) => {
|
||||
const Icon = ICON_MAP[item.icon] || CheckCircle
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-4 px-4 pt-4 sm:pt-0 first:pt-0">
|
||||
<div className="flex-shrink-0 text-brand-primary">
|
||||
<Icon className="w-8 h-8" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm md:text-base font-semibold text-text-main">
|
||||
{localizedFromRecord(item, 'title', resolvedLang)}
|
||||
</h3>
|
||||
{item.subtitle && (
|
||||
<p className="text-xs md:text-sm text-muted-text mt-1">
|
||||
{localizedFromRecord(item, 'subtitle', resolvedLang)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroV2Section({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
|
||||
const title = localizedFromRecord(payload, 'title', resolvedLang)
|
||||
const description = localizedFromRecord(payload, 'description', resolvedLang)
|
||||
|
||||
const primaryBtnText = localizedFromRecord(payload, 'primaryButtonText', resolvedLang)
|
||||
const primaryBtnLink = typeof payload.primaryButtonLink === 'string' ? payload.primaryButtonLink : undefined
|
||||
const secondaryBtnText = localizedFromRecord(payload, 'secondaryButtonText', resolvedLang)
|
||||
const secondaryBtnLink = typeof payload.secondaryButtonLink === 'string' ? payload.secondaryButtonLink : undefined
|
||||
|
||||
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
|
||||
const align = typeof payload.contentAlignment === 'string' ? payload.contentAlignment : 'left'
|
||||
const showOrnament = typeof payload.showOrnament === 'boolean' ? payload.showOrnament : true
|
||||
|
||||
// Alignment classes
|
||||
const alignClass = align === 'center' ? 'text-center items-center mx-auto' : align === 'right' ? 'text-right items-end ml-auto' : 'text-left items-start'
|
||||
|
||||
return (
|
||||
<section className="relative w-full h-[80vh] min-h-[600px] bg-surface-page flex items-center overflow-hidden">
|
||||
<div className="absolute inset-0 w-full h-full">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title || 'Hero'}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-surface-page/90 via-surface-page/50 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 md:px-12">
|
||||
<div className={`max-w-xl flex flex-col gap-6 ${alignClass}`}>
|
||||
{tagline && (
|
||||
<div className="flex items-center gap-4">
|
||||
{showOrnament && align !== 'right' && <div className="w-8 h-[1px] bg-brand-primary" />}
|
||||
<span className="text-sm font-semibold tracking-[0.2em] uppercase text-brand-primary">
|
||||
{tagline}
|
||||
</span>
|
||||
{showOrnament && align !== 'left' && <div className="w-8 h-[1px] bg-brand-primary" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-serif text-text-main leading-tight">
|
||||
{renderAccentTitle(title || '', 'text-brand-primary')}
|
||||
</h1>
|
||||
|
||||
{showOrnament && (
|
||||
<div className="flex justify-center items-center text-brand-primary my-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p className="text-base md:text-lg text-text-main/80 leading-relaxed max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={`flex flex-col sm:flex-row items-center gap-4 mt-4 w-full sm:w-auto ${align === 'center' ? 'justify-center' : align === 'right' ? 'justify-end' : 'justify-start'}`}>
|
||||
{primaryBtnText && primaryBtnLink && (
|
||||
<Link
|
||||
href={primaryBtnLink}
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-[#A18868] text-white font-medium rounded-md hover:bg-[#8B7355] transition-colors w-full sm:w-auto"
|
||||
>
|
||||
{primaryBtnText}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
{secondaryBtnText && secondaryBtnLink && (
|
||||
<Link
|
||||
href={secondaryBtnLink}
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-transparent border border-[#A18868] text-[#A18868] font-medium rounded-md hover:bg-[#A18868]/5 transition-colors w-full sm:w-auto"
|
||||
>
|
||||
{secondaryBtnText}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoriesSection({
|
||||
payload,
|
||||
categories,
|
||||
@@ -888,6 +1022,18 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
<HeroSection payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'HERO_V2':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
<HeroV2Section payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'USP_BAR':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
<UspBarSection payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'CATEGORIES':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
|
||||
Reference in new issue
Block a user