This commit is contained in:
Your Name committed 2026-07-10 20:27:26 -04:00
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>
);
}
+25
View File
@@ -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 -1
View File
@@ -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>
))}
+10 -3
View File
@@ -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>
+8 -1
View File
@@ -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,
+9 -2
View File
@@ -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) {
+110
View File
@@ -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':
+55
View File
@@ -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() !== '') {
+14 -2
View File
@@ -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} />
+27 -14
View File
@@ -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}>