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 [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}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in new issue
Block a user