Apply Lang
This commit is contained in:
1 parent
f0f714e8e2
commit
7cb65bf5e7
7 files changed
+434
-159
No files matched your search
@@ -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 [selectedPageId, setSelectedPageId] = useState<number | null>(null);
|
||||
@@ -507,6 +551,35 @@ export default function PageBuilderPage() {
|
||||
textAlign: 'center'
|
||||
};
|
||||
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 => {
|
||||
@@ -738,7 +811,7 @@ export default function PageBuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{section.type === 'PRODUCTS' && (
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
@@ -790,8 +863,8 @@ export default function PageBuilderPage() {
|
||||
|
||||
{/* Editor Modal */}
|
||||
{isModalOpen && editingSection && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<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="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 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]">
|
||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5 text-cyan-glow" />
|
||||
@@ -824,7 +897,8 @@ export default function PageBuilderPage() {
|
||||
))}
|
||||
</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 && (
|
||||
<div className="space-y-2">
|
||||
<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="BANNER">BANNER (Promo Image call-to-action)</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>
|
||||
</div>
|
||||
)}
|
||||
@@ -1744,7 +1821,213 @@ export default function PageBuilderPage() {
|
||||
</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
|
||||
type="submit"
|
||||
disabled={isUploading}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import prisma from '../src/lib/prisma';
|
||||
|
||||
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
|
||||
await prisma.page.upsert({
|
||||
where: { slug: 'about' },
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import {
|
||||
HeroSectionPayloadSchema,
|
||||
CategoriesSectionPayloadSchema,
|
||||
ProductsSectionPayloadSchema,
|
||||
BannerSectionPayloadSchema,
|
||||
TextSectionPayloadSchema,
|
||||
AnnouncementBarSectionPayloadSchema,
|
||||
NewsletterSectionPayloadSchema,
|
||||
EditorialTileSectionPayloadSchema,
|
||||
EditorialCarouselSectionPayloadSchema,
|
||||
HeroArticlePayloadSchema,
|
||||
RichTextPayloadSchema,
|
||||
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 {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return "Payload must be a JSON object";
|
||||
}
|
||||
|
||||
switch (type.toUpperCase()) {
|
||||
case 'HERO':
|
||||
if (!isValidText(payload.title)) return "Hero payload must contain a non-empty title (string or object)";
|
||||
if (payload.description === undefined || !isValidText(payload.description, true)) return "Hero payload must contain a description (string or object)";
|
||||
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";
|
||||
case 'HERO': {
|
||||
const res = HeroSectionPayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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': {
|
||||
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;
|
||||
}
|
||||
case 'RICH_TEXT': {
|
||||
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;
|
||||
}
|
||||
case 'SCENT_NOTES_BREAKDOWN': {
|
||||
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;
|
||||
}
|
||||
case 'QUOTE_CALLOUT': {
|
||||
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;
|
||||
}
|
||||
case 'PRODUCT_CAROUSEL': {
|
||||
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;
|
||||
}
|
||||
case 'ACCORDION': {
|
||||
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;
|
||||
}
|
||||
case 'CONTACT_INFO': {
|
||||
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;
|
||||
}
|
||||
case 'CONTACT_FORM': {
|
||||
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;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { AccordionSectionPayload } from '@homaroma/types';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { ContactInfoSectionPayload } from '@homaroma/types';
|
||||
import { MapPin, Mail, MessageCircle, Clock } from 'lucide-react';
|
||||
|
||||
|
||||
+83
-71
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface Scent {
|
||||
id: number
|
||||
name: string
|
||||
@@ -211,84 +213,95 @@ export interface PageSection {
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface HeroSectionPayload {
|
||||
tagline: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
buttonText: string
|
||||
buttonLink: string
|
||||
}
|
||||
export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
|
||||
|
||||
export interface CategoriesSectionPayload {
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryIds?: number[]
|
||||
}
|
||||
export const HeroSectionPayloadSchema = z.object({
|
||||
tagline: LocalizedStringSchema,
|
||||
title: LocalizedStringSchema,
|
||||
description: LocalizedStringSchema,
|
||||
imageUrl: z.string().min(1),
|
||||
buttonText: LocalizedStringSchema,
|
||||
buttonLink: z.string(),
|
||||
});
|
||||
export type HeroSectionPayload = z.infer<typeof HeroSectionPayloadSchema>;
|
||||
|
||||
export interface ProductsSectionPayload {
|
||||
title: string
|
||||
subtitle?: string
|
||||
type: 'featured' | 'scent' | 'custom'
|
||||
scentId?: number
|
||||
productIds?: number[]
|
||||
limit?: number
|
||||
layout?: 'grid' | 'carousel'
|
||||
enableQuickShop?: boolean
|
||||
}
|
||||
export const CategoriesSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema,
|
||||
subtitle: LocalizedStringSchema.optional(),
|
||||
categoryIds: z.array(z.number()).optional(),
|
||||
});
|
||||
export type CategoriesSectionPayload = z.infer<typeof CategoriesSectionPayloadSchema>;
|
||||
|
||||
export interface BannerSectionPayload {
|
||||
imageUrl: string
|
||||
linkUrl?: string
|
||||
title?: string
|
||||
description?: string
|
||||
buttonText?: string
|
||||
}
|
||||
export const ProductsSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema,
|
||||
subtitle: LocalizedStringSchema.optional(),
|
||||
type: z.enum(['featured', 'scent', 'custom']),
|
||||
scentId: z.number().optional(),
|
||||
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 {
|
||||
title?: string
|
||||
content: string
|
||||
textAlign?: 'left' | 'center' | 'right'
|
||||
}
|
||||
export const BannerSectionPayloadSchema = z.object({
|
||||
imageUrl: z.string().min(1),
|
||||
linkUrl: z.string().optional(),
|
||||
title: LocalizedStringSchema.optional(),
|
||||
description: LocalizedStringSchema.optional(),
|
||||
buttonText: LocalizedStringSchema.optional(),
|
||||
});
|
||||
export type BannerSectionPayload = z.infer<typeof BannerSectionPayloadSchema>;
|
||||
|
||||
export interface AnnouncementBarSectionPayload {
|
||||
slides: string[]
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
speed?: number
|
||||
}
|
||||
export const TextSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
content: LocalizedStringSchema,
|
||||
textAlign: z.enum(['left', 'center', 'right']).optional(),
|
||||
});
|
||||
export type TextSectionPayload = z.infer<typeof TextSectionPayloadSchema>;
|
||||
|
||||
export interface NewsletterSectionPayload {
|
||||
title: string
|
||||
description: string
|
||||
buttonText: string
|
||||
placeholderText?: string
|
||||
showPrivacyCheckbox: boolean
|
||||
privacyText?: string;
|
||||
}
|
||||
export const AnnouncementBarSectionPayloadSchema = z.object({
|
||||
slides: z.array(LocalizedStringSchema).min(1),
|
||||
backgroundColor: z.string().optional(),
|
||||
textColor: z.string().optional(),
|
||||
speed: z.number().optional(),
|
||||
});
|
||||
export type AnnouncementBarSectionPayload = z.infer<typeof AnnouncementBarSectionPayloadSchema>;
|
||||
|
||||
export interface EditorialTileSectionPayload {
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
imagePosition: 'left' | 'right'
|
||||
buttonText?: string
|
||||
buttonLink?: string
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
}
|
||||
export const NewsletterSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema,
|
||||
description: LocalizedStringSchema,
|
||||
buttonText: LocalizedStringSchema,
|
||||
placeholderText: z.string().optional(),
|
||||
showPrivacyCheckbox: z.boolean(),
|
||||
privacyText: z.string().optional(),
|
||||
});
|
||||
export type NewsletterSectionPayload = z.infer<typeof NewsletterSectionPayloadSchema>;
|
||||
|
||||
export interface EditorialCarouselSectionPayload {
|
||||
title: string
|
||||
slides: {
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
buttonText?: string
|
||||
buttonLink?: string
|
||||
}[]
|
||||
}
|
||||
export const EditorialTileSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema,
|
||||
description: LocalizedStringSchema,
|
||||
imageUrl: z.string().min(1),
|
||||
imagePosition: z.enum(['left', 'right']),
|
||||
buttonText: z.string().optional(),
|
||||
buttonLink: z.string().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
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 {
|
||||
id: number
|
||||
@@ -305,7 +318,6 @@ export interface CustomerAddress {
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const HeroArticlePayloadSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@homaroma/types",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"types": "index.ts",
|
||||
"dependencies": {
|
||||
|
||||
Reference in new issue
Block a user