Apply Lang

This commit is contained in:
Your Name committed 2026-06-14 23:43:21 -04:00
1 parent f0f714e8e2
commit 7cb65bf5e7
7 files changed
+434 -159

No files matched your search

+288 -5
View File
@@ -134,6 +134,50 @@ export default function PageBuilderPage() {
}); });
}; };
// Accordion Localization Helpers
const getLocalizedAccordionValue = (item: any, field: string) => {
if (!item || item[field] === undefined) return '';
const val = item[field];
if (typeof val === 'string') return editingLang === 'th' ? val : '';
return val[editingLang] || '';
};
const updateAccordionCategory = (catIdx: number, field: string, value: string) => {
setEditingSection((prev: any) => {
const categories = [...(prev.payload?.categories || [])];
const cat = categories[catIdx] || {};
if (field === 'icon') {
categories[catIdx] = { ...cat, [field]: value };
} else {
let newVal;
if (typeof cat[field] === 'string') {
newVal = { th: cat[field], [editingLang]: value };
} else {
newVal = { ...(cat[field] || {}), [editingLang]: value };
}
categories[catIdx] = { ...cat, [field]: newVal };
}
return { ...prev, payload: { ...(prev.payload || {}), categories } };
});
};
const updateAccordionQuestion = (catIdx: number, qIdx: number, field: string, value: string) => {
setEditingSection((prev: any) => {
const categories = [...(prev.payload?.categories || [])];
const cat = categories[catIdx] || { questions: [] };
const questions = [...(cat.questions || [])];
const q = questions[qIdx] || {};
let newVal;
if (typeof q[field] === 'string') {
newVal = { th: q[field], [editingLang]: value };
} else {
newVal = { ...(q[field] || {}), [editingLang]: value };
}
questions[qIdx] = { ...q, [field]: newVal };
categories[catIdx] = { ...cat, questions };
return { ...prev, payload: { ...(prev.payload || {}), categories } };
});
};
const [pages, setPages] = useState<Page[]>([]); const [pages, setPages] = useState<Page[]>([]);
const [selectedPageId, setSelectedPageId] = useState<number | null>(null); const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
@@ -507,6 +551,35 @@ export default function PageBuilderPage() {
textAlign: 'center' textAlign: 'center'
}; };
break; break;
case 'ACCORDION':
defaultPayload = {
title: 'Frequently Asked Questions',
categories: [
{
name: 'General',
icon: 'HelpCircle',
questions: [
{ q: 'What is this?', a: 'This is an example.' }
]
}
]
};
break;
case 'CONTACT_INFO':
defaultPayload = {
title: 'Contact Us',
address: '123 Street Name',
email: 'hello@example.com',
line: '@example',
hours: 'Mon-Fri 9AM-5PM'
};
break;
case 'CONTACT_FORM':
defaultPayload = {
title: 'Get In Touch',
subtitle: 'We would love to hear from you.'
};
break;
} }
setEditingSection(prev => { setEditingSection(prev => {
@@ -738,7 +811,7 @@ export default function PageBuilderPage() {
)} )}
</div> </div>
<h3 className="text-base font-bold mt-1"> <h3 className="text-base font-bold mt-1">
{getAdminDisplayString(section.payload?.title || section.payload?.tagline || (section.type === 'ANNOUNCEMENT_BAR' && section.payload?.slides?.[0])) || `Block #${section.id}`} {getAdminDisplayString(section.payload?.title || section.payload?.tagline || (section.type === 'ANNOUNCEMENT_BAR' ? section.payload?.slides?.[0] : undefined)) || `Block #${section.id}`}
</h3> </h3>
{section.type === 'PRODUCTS' && ( {section.type === 'PRODUCTS' && (
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
@@ -790,8 +863,8 @@ export default function PageBuilderPage() {
{/* Editor Modal */} {/* Editor Modal */}
{isModalOpen && editingSection && ( {isModalOpen && editingSection && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 md:p-8">
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-5xl md:max-w-6xl max-h-[90vh] flex flex-col overflow-hidden animate-pop-in text-slate-200"> <div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-5xl flex flex-col max-h-full overflow-hidden animate-pop-in text-slate-200">
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.02]"> <div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
<h3 className="text-xl font-bold flex items-center gap-2"> <h3 className="text-xl font-bold flex items-center gap-2">
<Settings2 className="w-5 h-5 text-cyan-glow" /> <Settings2 className="w-5 h-5 text-cyan-glow" />
@@ -824,7 +897,8 @@ export default function PageBuilderPage() {
))} ))}
</div> </div>
<form onSubmit={handleFormSubmit} className="flex-1 overflow-y-auto p-8 space-y-6"> <form onSubmit={handleFormSubmit} className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto p-8 space-y-6">
{!editingSection.id && ( {!editingSection.id && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Block Type</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Block Type</label>
@@ -842,6 +916,9 @@ export default function PageBuilderPage() {
<option value="PRODUCTS">PRODUCTS (Product list Showcase)</option> <option value="PRODUCTS">PRODUCTS (Product list Showcase)</option>
<option value="BANNER">BANNER (Promo Image call-to-action)</option> <option value="BANNER">BANNER (Promo Image call-to-action)</option>
<option value="TEXT">TEXT (About / Brand story text)</option> <option value="TEXT">TEXT (About / Brand story text)</option>
<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>
</select> </select>
</div> </div>
)} )}
@@ -1744,7 +1821,213 @@ export default function PageBuilderPage() {
</div> </div>
)} )}
<div className="pt-6 border-t border-white/5 flex flex-col sm:flex-row gap-4"> {editingSection.type === 'ACCORDION' && (
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
<input
type="text"
value={getLocalizedValue(editingSection.payload, 'title')}
onChange={(e) => updateLocalizedValue('title', e.target.value)}
className="admin-input w-full"
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Categories & Questions</label>
<button
type="button"
onClick={() => {
const categories = [...(editingSection.payload?.categories || []), { icon: '✨', name: { th: 'หมวดหมู่ใหม่' }, questions: [] }];
setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), categories } });
}}
className="text-xs text-cyan-glow hover:underline flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> Add Category
</button>
</div>
{(editingSection.payload?.categories || []).map((cat: any, cIdx: number) => (
<div key={cIdx} className="p-5 border border-white/10 bg-white/[0.02] rounded-2xl space-y-4">
<div className="flex items-center gap-3">
<input
type="text"
value={cat.icon || ''}
onChange={(e) => updateAccordionCategory(cIdx, 'icon', e.target.value)}
className="admin-input w-16 text-center text-xl"
placeholder="Emoji"
/>
<input
type="text"
value={getLocalizedAccordionValue(cat, 'name')}
onChange={(e) => updateAccordionCategory(cIdx, 'name', e.target.value)}
className="admin-input flex-1"
placeholder="Category Name"
/>
<button
type="button"
onClick={() => {
const categories = [...editingSection.payload.categories];
categories.splice(cIdx, 1);
setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), categories } });
}}
className="p-2.5 bg-rose-500/10 text-rose-500 rounded-xl hover:bg-rose-500 hover:text-white transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="pl-4 border-l-2 border-white/5 space-y-3">
{(cat.questions || []).map((q: any, qIdx: number) => (
<div key={qIdx} className="p-4 bg-night-950/40 rounded-xl border border-white/5 space-y-3">
<div className="flex items-start gap-3">
<div className="flex-1 space-y-3">
<div>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 mb-1 block">Question (Q)</label>
<input
type="text"
value={getLocalizedAccordionValue(q, 'q')}
onChange={(e) => updateAccordionQuestion(cIdx, qIdx, 'q', e.target.value)}
className="admin-input w-full text-xs"
placeholder="What is..."
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 mb-1 block">Answer (A)</label>
<textarea
rows={2}
value={getLocalizedAccordionValue(q, 'a')}
onChange={(e) => updateAccordionQuestion(cIdx, qIdx, 'a', e.target.value)}
className="admin-input w-full text-xs resize-none"
placeholder="Provide the answer here..."
/>
</div>
</div>
<button
type="button"
onClick={() => {
const categories = [...editingSection.payload.categories];
categories[cIdx].questions.splice(qIdx, 1);
setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), categories } });
}}
className="mt-5 p-2 text-rose-500/50 hover:text-rose-500 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
<button
type="button"
onClick={() => {
const categories = [...editingSection.payload.categories];
categories[cIdx].questions = [...(categories[cIdx].questions || []), { q: { th: 'คำถามใหม่' }, a: { th: 'คำตอบใหม่' } }];
setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), categories } });
}}
className="text-[10px] text-cyan-glow/80 hover:text-cyan-glow uppercase tracking-widest font-bold flex items-center gap-1"
>
<Plus className="w-3 h-3" /> Add Question
</button>
</div>
</div>
))}
</div>
</div>
)}
{editingSection.type === 'CONTACT_INFO' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Email</label>
<input
type="text"
value={editingSection.payload?.email || ''}
onChange={(e) =>
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), email: 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">Line ID</label>
<input
type="text"
value={editingSection.payload?.line || ''}
onChange={(e) =>
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), line: 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">Business Hours</label>
<input
type="text"
value={getLocalizedValue(editingSection.payload, 'hours')}
onChange={(e) => updateLocalizedValue('hours', e.target.value)}
className="admin-input w-full"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Address</label>
<textarea
rows={3}
value={getLocalizedValue(editingSection.payload, 'address')}
onChange={(e) => updateLocalizedValue('address', e.target.value)}
className="admin-input w-full resize-none"
/>
</div>
</div>
)}
{editingSection.type === 'CONTACT_FORM' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtitle</label>
<input
type="text"
value={getLocalizedValue(editingSection.payload, 'subtitle')}
onChange={(e) => updateLocalizedValue('subtitle', e.target.value)}
className="admin-input w-full"
/>
</div>
</div>
</div>
)}
</div>
<div className="p-6 bg-night-950/50 border-t border-white/5 flex flex-col sm:flex-row gap-4 shrink-0">
<button <button
type="submit" type="submit"
disabled={isUploading} disabled={isUploading}
+7
View File
@@ -1,6 +1,13 @@
import prisma from '../src/lib/prisma'; import prisma from '../src/lib/prisma';
async function main() { async function main() {
console.log('Migrating pages to CMS...');
// Delete existing to force fresh payload insert
await prisma.page.deleteMany({
where: { slug: { in: ['about', 'faq', 'contact'] } }
});
// 1. Create About Page // 1. Create About Page
await prisma.page.upsert({ await prisma.page.upsert({
where: { slug: 'about' }, where: { slug: 'about' },
+53 -81
View File
@@ -1,6 +1,15 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { import {
HeroSectionPayloadSchema,
CategoriesSectionPayloadSchema,
ProductsSectionPayloadSchema,
BannerSectionPayloadSchema,
TextSectionPayloadSchema,
AnnouncementBarSectionPayloadSchema,
NewsletterSectionPayloadSchema,
EditorialTileSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroArticlePayloadSchema, HeroArticlePayloadSchema,
RichTextPayloadSchema, RichTextPayloadSchema,
ScentNotesBreakdownPayloadSchema, ScentNotesBreakdownPayloadSchema,
@@ -391,132 +400,95 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
} }
}); });
function isValidText(val: any, allowEmpty: boolean = false): boolean {
if (typeof val === 'string') return allowEmpty ? true : val.trim().length > 0;
return val !== null && typeof val === 'object' && !Array.isArray(val);
}
function validateSectionPayload(type: string, payload: any): string | null { function validateSectionPayload(type: string, payload: any): string | null {
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return "Payload must be a JSON object"; return "Payload must be a JSON object";
} }
switch (type.toUpperCase()) { switch (type.toUpperCase()) {
case 'HERO': case 'HERO': {
if (!isValidText(payload.title)) return "Hero payload must contain a non-empty title (string or object)"; const res = HeroSectionPayloadSchema.safeParse(payload);
if (payload.description === undefined || !isValidText(payload.description, true)) return "Hero payload must contain a description (string or object)"; if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Hero payload must contain a non-empty string imageUrl";
if (payload.tagline === undefined || !isValidText(payload.tagline, true)) return "Hero payload must contain a tagline (string or object)";
if (payload.buttonText === undefined || !isValidText(payload.buttonText, true)) return "Hero payload must contain a buttonText (string or object)";
if (typeof payload.buttonLink !== 'string') return "Hero payload must contain a string buttonLink";
break; break;
case 'CATEGORIES':
if (!isValidText(payload.title)) return "Categories payload must contain a non-empty title (string or object)";
if (payload.subtitle !== undefined && !isValidText(payload.subtitle, true)) return "Subtitle must be a string or object";
if (payload.categoryIds !== undefined && !Array.isArray(payload.categoryIds)) {
return "categoryIds must be an array of numbers";
} }
case 'CATEGORIES': {
const res = CategoriesSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
case 'PRODUCTS':
if (!isValidText(payload.title)) return "Products payload must contain a non-empty title (string or object)";
if (payload.subtitle !== undefined && !isValidText(payload.subtitle, true)) return "Subtitle must be a string or object";
if (!['featured', 'scent', 'custom'].includes(payload.type)) {
return "Products type must be 'featured', 'scent', or 'custom'";
}
if (payload.type === 'scent' && typeof payload.scentId !== 'number') {
return "Scent products section requires a numeric scentId";
}
if (payload.type === 'custom' && !Array.isArray(payload.productIds)) {
return "Custom products section requires an array of productIds";
}
if (payload.limit !== undefined && typeof payload.limit !== 'number') {
return "Limit must be a number";
}
if (payload.layout !== undefined && !['grid', 'carousel'].includes(payload.layout)) {
return "layout must be 'grid' or 'carousel'";
}
if (payload.enableQuickShop !== undefined && typeof payload.enableQuickShop !== 'boolean') {
return "enableQuickShop must be a boolean";
} }
case 'PRODUCTS': {
const res = ProductsSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
case 'BANNER':
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Banner payload must contain a non-empty string imageUrl";
if (payload.linkUrl !== undefined && typeof payload.linkUrl !== 'string') return "linkUrl must be a string";
if (payload.title !== undefined && !isValidText(payload.title, true)) return "title must be a string or object";
if (payload.description !== undefined && !isValidText(payload.description, true)) return "description must be a string or object";
if (payload.buttonText !== undefined && !isValidText(payload.buttonText, true)) return "buttonText must be a string or object";
break;
case 'TEXT':
if (!isValidText(payload.content)) return "Text payload must contain non-empty content (string or object)";
if (payload.title !== undefined && !isValidText(payload.title, true)) return "title must be a string or object";
if (payload.textAlign !== undefined && !['left', 'center', 'right'].includes(payload.textAlign)) {
return "textAlign must be 'left', 'center', or 'right'";
} }
case 'BANNER': {
const res = BannerSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
case 'ANNOUNCEMENT_BAR':
if (!Array.isArray(payload.slides) || payload.slides.length === 0) return "Announcement Bar requires an array of slides";
if (payload.slides.some((s: any) => !isValidText(s, true))) return "All slides must be strings or objects";
break;
case 'NEWSLETTER':
if (!isValidText(payload.title)) return "Newsletter payload must contain a non-empty title (string or object)";
if (payload.description === undefined || !isValidText(payload.description, true)) return "Newsletter description must be a string or object";
if (!isValidText(payload.buttonText)) return "Newsletter buttonText must be a non-empty string or object";
if (typeof payload.showPrivacyCheckbox !== 'boolean') return "showPrivacyCheckbox must be a boolean";
break;
case 'EDITORIAL_TILE':
if (!isValidText(payload.title)) return "Editorial Tile payload must contain a non-empty title (string or object)";
if (payload.description === undefined || !isValidText(payload.description, true)) return "Editorial Tile description must be a string or object";
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Editorial Tile imageUrl must be a non-empty string";
if (!['left', 'right'].includes(payload.imagePosition)) return "imagePosition must be 'left' or 'right'";
break;
case 'EDITORIAL_CAROUSEL':
if (!isValidText(payload.title)) return "Editorial Carousel payload must contain a non-empty title (string or object)";
if (!Array.isArray(payload.slides) || payload.slides.length === 0) return "Editorial Carousel requires an array of slides";
for (const [idx, slide] of payload.slides.entries()) {
if (!isValidText(slide.title)) return `Slide #${idx+1} must contain a non-empty title (string or object)`;
if (slide.description === undefined || !isValidText(slide.description, true)) return `Slide #${idx+1} must contain a description (string or object)`;
if (typeof slide.imageUrl !== 'string' || !slide.imageUrl.trim()) return `Slide #${idx+1} must contain a non-empty imageUrl`;
if (slide.category === undefined || !isValidText(slide.category, true)) return `Slide #${idx+1} must contain a category (string or object)`;
} }
case 'TEXT': {
const res = TextSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
}
case 'ANNOUNCEMENT_BAR': {
const res = AnnouncementBarSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'NEWSLETTER': {
const res = NewsletterSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'EDITORIAL_TILE': {
const res = EditorialTileSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'EDITORIAL_CAROUSEL': {
const res = EditorialCarouselSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'HERO_ARTICLE': { case 'HERO_ARTICLE': {
const res = HeroArticlePayloadSchema.safeParse(payload); const res = HeroArticlePayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
} }
case 'RICH_TEXT': { case 'RICH_TEXT': {
const res = RichTextPayloadSchema.safeParse(payload); const res = RichTextPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
} }
case 'SCENT_NOTES_BREAKDOWN': { case 'SCENT_NOTES_BREAKDOWN': {
const res = ScentNotesBreakdownPayloadSchema.safeParse(payload); const res = ScentNotesBreakdownPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
} }
case 'QUOTE_CALLOUT': { case 'QUOTE_CALLOUT': {
const res = QuoteCalloutPayloadSchema.safeParse(payload); const res = QuoteCalloutPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
} }
case 'PRODUCT_CAROUSEL': { case 'PRODUCT_CAROUSEL': {
const res = ProductCarouselPayloadSchema.safeParse(payload); const res = ProductCarouselPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
} }
case 'ACCORDION': { case 'ACCORDION': {
const res = AccordionSectionPayloadSchema.safeParse(payload); const res = AccordionSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
} }
case 'CONTACT_INFO': { case 'CONTACT_INFO': {
const res = ContactInfoSectionPayloadSchema.safeParse(payload); const res = ContactInfoSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
} }
case 'CONTACT_FORM': { case 'CONTACT_FORM': {
const res = ContactFormSectionPayloadSchema.safeParse(payload); const res = ContactFormSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break; break;
} }
default: default:
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import type { AccordionSectionPayload } from '@homaroma/types'; import type { AccordionSectionPayload } from '@homaroma/types';
import { ChevronDown, ChevronUp } from 'lucide-react'; import { ChevronDown, ChevronUp } from 'lucide-react';
@@ -1,4 +1,4 @@
import React from 'react';
import type { ContactInfoSectionPayload } from '@homaroma/types'; import type { ContactInfoSectionPayload } from '@homaroma/types';
import { MapPin, Mail, MessageCircle, Clock } from 'lucide-react'; import { MapPin, Mail, MessageCircle, Clock } from 'lucide-react';
+83 -71
View File
@@ -1,3 +1,5 @@
import { z } from "zod";
export interface Scent { export interface Scent {
id: number id: number
name: string name: string
@@ -211,84 +213,95 @@ export interface PageSection {
updatedAt?: string updatedAt?: string
} }
export interface HeroSectionPayload { export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
tagline: string
title: string
description: string
imageUrl: string
buttonText: string
buttonLink: string
}
export interface CategoriesSectionPayload { export const HeroSectionPayloadSchema = z.object({
title: string tagline: LocalizedStringSchema,
subtitle?: string title: LocalizedStringSchema,
categoryIds?: number[] description: LocalizedStringSchema,
} imageUrl: z.string().min(1),
buttonText: LocalizedStringSchema,
buttonLink: z.string(),
});
export type HeroSectionPayload = z.infer<typeof HeroSectionPayloadSchema>;
export interface ProductsSectionPayload { export const CategoriesSectionPayloadSchema = z.object({
title: string title: LocalizedStringSchema,
subtitle?: string subtitle: LocalizedStringSchema.optional(),
type: 'featured' | 'scent' | 'custom' categoryIds: z.array(z.number()).optional(),
scentId?: number });
productIds?: number[] export type CategoriesSectionPayload = z.infer<typeof CategoriesSectionPayloadSchema>;
limit?: number
layout?: 'grid' | 'carousel'
enableQuickShop?: boolean
}
export interface BannerSectionPayload { export const ProductsSectionPayloadSchema = z.object({
imageUrl: string title: LocalizedStringSchema,
linkUrl?: string subtitle: LocalizedStringSchema.optional(),
title?: string type: z.enum(['featured', 'scent', 'custom']),
description?: string scentId: z.number().optional(),
buttonText?: string productIds: z.array(z.number()).optional(),
} limit: z.number().optional(),
layout: z.enum(['grid', 'carousel']).optional(),
enableQuickShop: z.boolean().optional(),
});
export type ProductsSectionPayload = z.infer<typeof ProductsSectionPayloadSchema>;
export interface TextSectionPayload { export const BannerSectionPayloadSchema = z.object({
title?: string imageUrl: z.string().min(1),
content: string linkUrl: z.string().optional(),
textAlign?: 'left' | 'center' | 'right' title: LocalizedStringSchema.optional(),
} description: LocalizedStringSchema.optional(),
buttonText: LocalizedStringSchema.optional(),
});
export type BannerSectionPayload = z.infer<typeof BannerSectionPayloadSchema>;
export interface AnnouncementBarSectionPayload { export const TextSectionPayloadSchema = z.object({
slides: string[] title: LocalizedStringSchema.optional(),
backgroundColor?: string content: LocalizedStringSchema,
textColor?: string textAlign: z.enum(['left', 'center', 'right']).optional(),
speed?: number });
} export type TextSectionPayload = z.infer<typeof TextSectionPayloadSchema>;
export interface NewsletterSectionPayload { export const AnnouncementBarSectionPayloadSchema = z.object({
title: string slides: z.array(LocalizedStringSchema).min(1),
description: string backgroundColor: z.string().optional(),
buttonText: string textColor: z.string().optional(),
placeholderText?: string speed: z.number().optional(),
showPrivacyCheckbox: boolean });
privacyText?: string; export type AnnouncementBarSectionPayload = z.infer<typeof AnnouncementBarSectionPayloadSchema>;
}
export interface EditorialTileSectionPayload { export const NewsletterSectionPayloadSchema = z.object({
title: string title: LocalizedStringSchema,
description: string description: LocalizedStringSchema,
imageUrl: string buttonText: LocalizedStringSchema,
imagePosition: 'left' | 'right' placeholderText: z.string().optional(),
buttonText?: string showPrivacyCheckbox: z.boolean(),
buttonLink?: string privacyText: z.string().optional(),
backgroundColor?: string });
textColor?: string export type NewsletterSectionPayload = z.infer<typeof NewsletterSectionPayloadSchema>;
}
export interface EditorialCarouselSectionPayload { export const EditorialTileSectionPayloadSchema = z.object({
title: string title: LocalizedStringSchema,
slides: { description: LocalizedStringSchema,
category: string imageUrl: z.string().min(1),
title: string imagePosition: z.enum(['left', 'right']),
description: string buttonText: z.string().optional(),
imageUrl: string buttonLink: z.string().optional(),
buttonText?: string backgroundColor: z.string().optional(),
buttonLink?: string textColor: z.string().optional(),
}[] });
} export type EditorialTileSectionPayload = z.infer<typeof EditorialTileSectionPayloadSchema>;
export const EditorialCarouselSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
slides: z.array(z.object({
category: LocalizedStringSchema,
title: LocalizedStringSchema,
description: LocalizedStringSchema,
imageUrl: z.string().min(1),
buttonText: z.string().optional(),
buttonLink: z.string().optional(),
})).min(1)
});
export type EditorialCarouselSectionPayload = z.infer<typeof EditorialCarouselSectionPayloadSchema>;
export interface CustomerAddress { export interface CustomerAddress {
id: number id: number
@@ -305,7 +318,6 @@ export interface CustomerAddress {
updatedAt?: string updatedAt?: string
} }
import { z } from "zod";
export const HeroArticlePayloadSchema = z.object({ export const HeroArticlePayloadSchema = z.object({
title: z.string().min(1, "Title is required"), title: z.string().min(1, "Title is required"),
+1
View File
@@ -2,6 +2,7 @@
"name": "@homaroma/types", "name": "@homaroma/types",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"main": "index.ts", "main": "index.ts",
"types": "index.ts", "types": "index.ts",
"dependencies": { "dependencies": {