feat(cms): implement multi-language support across frontend and admin UI

This commit is contained in:
Your Name committed 2026-06-14 09:00:57 -04:00
1 parent 730e2a7b48
commit eac45dd4a7
62 files changed
+1377 -5116

No files matched your search

+68 -23
View File
@@ -232,6 +232,46 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
return { error: "Failed to delete page" };
}
})
.patch("/:id/translations", async ({ params, body, set }) => {
const id = Number(params.id);
if (isNaN(id)) {
set.status = 400;
return { error: "Invalid page ID" };
}
const payload = body as any;
const results: any[] = [];
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
for (const lang of LANGS) {
const title = typeof payload.title === 'string' ? (lang === 'th' ? payload.title : null) : payload.title?.[lang];
if (!title) continue;
const existing = await prisma.pageTranslation.findUnique({
where: { pageId_langCode: { pageId: id, langCode: lang } }
});
if (existing) {
const updated = await prisma.pageTranslation.update({
where: { id: existing.id },
data: { title }
});
results.push(updated);
} else {
const created = await prisma.pageTranslation.create({
data: { pageId: id, langCode: lang, title }
});
results.push(created);
}
}
const updated = await prisma.page.findUnique({
where: { id },
include: { translations: true }
});
return { success: true, translations: results, page: updated };
})
.post("/:slug/sections", async ({ params: { slug }, body, set }) => {
try {
const pageId = Number(slug);
@@ -348,6 +388,11 @@ 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";
@@ -355,23 +400,23 @@ function validateSectionPayload(type: string, payload: any): string | null {
switch (type.toUpperCase()) {
case 'HERO':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Hero payload must contain a non-empty string title";
if (typeof payload.description !== 'string') return "Hero payload must contain a string description";
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 (typeof payload.tagline !== 'string') return "Hero payload must contain a string tagline";
if (typeof payload.buttonText !== 'string') return "Hero payload must contain a string buttonText";
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;
case 'CATEGORIES':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Categories payload must contain a non-empty string title";
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string') return "Subtitle must be a string";
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";
}
break;
case 'PRODUCTS':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Products payload must contain a non-empty string title";
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string') return "Subtitle must be a string";
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'";
}
@@ -394,41 +439,41 @@ function validateSectionPayload(type: string, payload: any): string | null {
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 && typeof payload.title !== 'string') return "title must be a string";
if (payload.description !== undefined && typeof payload.description !== 'string') return "description must be a string";
if (payload.buttonText !== undefined && typeof payload.buttonText !== 'string') return "buttonText 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 (typeof payload.content !== 'string' || !payload.content.trim()) return "Text payload must contain a non-empty string content";
if (payload.title !== undefined && typeof payload.title !== 'string') return "title must be a string";
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'";
}
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) => typeof s !== 'string')) return "All slides must be strings";
if (payload.slides.some((s: any) => !isValidText(s, true))) return "All slides must be strings or objects";
break;
case 'NEWSLETTER':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Newsletter payload must contain a non-empty string title";
if (typeof payload.description !== 'string') return "Newsletter description must be a string";
if (typeof payload.buttonText !== 'string' || !payload.buttonText.trim()) return "Newsletter buttonText must be a non-empty string";
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 (typeof payload.title !== 'string' || !payload.title.trim()) return "Editorial Tile payload must contain a non-empty string title";
if (typeof payload.description !== 'string') return "Editorial Tile description must be a string";
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 (typeof payload.title !== 'string' || !payload.title.trim()) return "Editorial Carousel payload must contain a non-empty string title";
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 (typeof slide.title !== 'string' || !slide.title.trim()) return `Slide #${idx+1} must contain a non-empty title`;
if (typeof slide.description !== 'string') return `Slide #${idx+1} must contain a description string`;
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 (typeof slide.category !== 'string') return `Slide #${idx+1} must contain a category string`;
if (slide.category === undefined || !isValidText(slide.category, true)) return `Slide #${idx+1} must contain a category (string or object)`;
}
break;
case 'HERO_ARTICLE': {