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 [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}
+7
View File
@@ -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' },
+53 -81
View File
@@ -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";
}
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 '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 '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':
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";
}
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':
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";
}
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':
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'";
}
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':
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 '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';