x
This commit is contained in:
1 parent
81ed9af406
commit
4536db6741
56 files changed
+1127
-198
No files matched your search
@@ -1,13 +1,164 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticlePayloadSchema, RichTextPayloadSchema, ScentNotesBreakdownPayloadSchema, QuoteCalloutPayloadSchema, ProductCarouselPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema } from "@homaroma/types";
|
||||
import { LocalizedStringSchema, HeroSectionPayloadSchema, CategoriesSectionPayloadSchema, ProductsSectionPayloadSchema, BannerSectionPayloadSchema, TextSectionPayloadSchema, AnnouncementBarSectionPayloadSchema, NewsletterSectionPayloadSchema, EditorialTileSectionPayloadSchema, EditorialCarouselSectionPayloadSchema, HeroArticlePayloadSchema, RichTextPayloadSchema, ScentNotesBreakdownPayloadSchema, QuoteCalloutPayloadSchema, ProductCarouselPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, ContactFormSectionPayloadSchema } from "@homaroma/types";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
function isLocalizedStringValue(value) {
|
||||
if (typeof value === "string")
|
||||
return true;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
return false;
|
||||
return Object.values(value).every((entry) => typeof entry === "string");
|
||||
}
|
||||
function isLocalizedStringList(value) {
|
||||
return Array.isArray(value) && value.every((entry) => isLocalizedStringValue(entry));
|
||||
}
|
||||
function hasValidString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
function hasValidNumber(value) {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
function hasValidBoolean(value) {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
function validateHeroArticlePayload(payload) {
|
||||
if (!payload || typeof payload !== "object")
|
||||
return "Payload must be a JSON object";
|
||||
if (!isLocalizedStringValue(payload.title))
|
||||
return "title: Must be a localized string";
|
||||
const localizedFields = ["subtitle", "author", "category"];
|
||||
for (const field of localizedFields) {
|
||||
if (payload[field] !== undefined && !isLocalizedStringValue(payload[field])) {
|
||||
return `${field}: Must be a localized string`;
|
||||
}
|
||||
}
|
||||
const stringFields = ["publishDate", "date", "imageUrl", "backgroundImageUrl", "overlayColor"];
|
||||
for (const field of stringFields) {
|
||||
if (payload[field] !== undefined && !hasValidString(payload[field])) {
|
||||
return `${field}: Must be a string`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function validateRichTextPayload(payload) {
|
||||
if (!payload || typeof payload !== "object")
|
||||
return "Payload must be a JSON object";
|
||||
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
|
||||
return "title: Must be a localized string";
|
||||
if (!isLocalizedStringValue(payload.content))
|
||||
return "content: Must be a localized string";
|
||||
return null;
|
||||
}
|
||||
function validateScentNotesBreakdownPayload(payload) {
|
||||
if (!payload || typeof payload !== "object")
|
||||
return "Payload must be a JSON object";
|
||||
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
|
||||
return "title: Must be a localized string";
|
||||
if (payload.description !== undefined && !isLocalizedStringValue(payload.description))
|
||||
return "description: Must be a localized string";
|
||||
const listFields = ["topNotes", "heartNotes", "middleNotes", "baseNotes"];
|
||||
for (const field of listFields) {
|
||||
const value = payload[field];
|
||||
if (value !== undefined && !(isLocalizedStringValue(value) || isLocalizedStringList(value))) {
|
||||
return `${field}: Must be a localized string or localized string list`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function validateQuoteCalloutPayload(payload) {
|
||||
if (!payload || typeof payload !== "object")
|
||||
return "Payload must be a JSON object";
|
||||
if (!isLocalizedStringValue(payload.quote))
|
||||
return "quote: Must be a localized string";
|
||||
if (payload.author !== undefined && !isLocalizedStringValue(payload.author))
|
||||
return "author: Must be a localized string";
|
||||
if (payload.role !== undefined && !isLocalizedStringValue(payload.role))
|
||||
return "role: Must be a localized string";
|
||||
return null;
|
||||
}
|
||||
function validateProductCarouselPayload(payload) {
|
||||
if (!payload || typeof payload !== "object")
|
||||
return "Payload must be a JSON object";
|
||||
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
|
||||
return "title: Must be a localized string";
|
||||
if (payload.description !== undefined && !isLocalizedStringValue(payload.description))
|
||||
return "description: Must be a localized string";
|
||||
if (payload.collectionId !== undefined && !hasValidString(payload.collectionId))
|
||||
return "collectionId: Must be a string";
|
||||
if (payload.limit !== undefined && (!hasValidNumber(payload.limit) || payload.limit <= 0))
|
||||
return "limit: Must be a positive number";
|
||||
if (payload.showPrice !== undefined && !hasValidBoolean(payload.showPrice))
|
||||
return "showPrice: Must be a boolean";
|
||||
if (payload.showAddToCart !== undefined && !hasValidBoolean(payload.showAddToCart))
|
||||
return "showAddToCart: Must be a boolean";
|
||||
return null;
|
||||
}
|
||||
function validateAccordionPayload(payload) {
|
||||
if (!payload || typeof payload !== "object")
|
||||
return "Payload must be a JSON object";
|
||||
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
|
||||
return "title: Must be a localized string";
|
||||
if (!Array.isArray(payload.categories) || payload.categories.length === 0)
|
||||
return "categories: Must be a non-empty array";
|
||||
for (const [catIndex, category] of payload.categories.entries()) {
|
||||
if (!category || typeof category !== "object")
|
||||
return `categories.${catIndex}: Must be an object`;
|
||||
if (!isLocalizedStringValue(category.name))
|
||||
return `categories.${catIndex}.name: Must be a localized string`;
|
||||
if (category.icon !== undefined && !hasValidString(category.icon))
|
||||
return `categories.${catIndex}.icon: Must be a string`;
|
||||
if (!Array.isArray(category.questions) || category.questions.length === 0) {
|
||||
return `categories.${catIndex}.questions: Must be a non-empty array`;
|
||||
}
|
||||
for (const [qIndex, question] of category.questions.entries()) {
|
||||
if (!question || typeof question !== "object")
|
||||
return `categories.${catIndex}.questions.${qIndex}: Must be an object`;
|
||||
if (!isLocalizedStringValue(question.q))
|
||||
return `categories.${catIndex}.questions.${qIndex}.q: Must be a localized string`;
|
||||
if (!isLocalizedStringValue(question.a))
|
||||
return `categories.${catIndex}.questions.${qIndex}.a: Must be a localized string`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function validateContactInfoPayload(payload) {
|
||||
if (!payload || typeof payload !== "object")
|
||||
return "Payload must be a JSON object";
|
||||
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
|
||||
return "title: Must be a localized string";
|
||||
for (const field of ["address", "email", "line", "hours"]) {
|
||||
if (!isLocalizedStringValue(payload[field]))
|
||||
return `${field}: Must be a localized string`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function validateContactFormPayload(payload) {
|
||||
if (!payload || typeof payload !== "object")
|
||||
return "Payload must be a JSON object";
|
||||
if (payload.title !== undefined && !isLocalizedStringValue(payload.title))
|
||||
return "title: Must be a localized string";
|
||||
if (payload.subtitle !== undefined && !isLocalizedStringValue(payload.subtitle))
|
||||
return "subtitle: Must be a localized string";
|
||||
return null;
|
||||
}
|
||||
const RESERVED_SLUGS = [
|
||||
"shop", "category", "products", "cart", "checkout", "account", "search",
|
||||
"login", "admin", "wishlist", "track", "coupons", "scent-guide",
|
||||
"profile", "article", "pages", "orders", "customers",
|
||||
"settings", "menus", "auth", "api"
|
||||
];
|
||||
/** Resolve category name — mirrors the same helper in categories.ts */
|
||||
function resolveCatName(cat, lang) {
|
||||
if (lang === 'all') {
|
||||
const nameObj = {};
|
||||
cat.translations?.forEach((tr) => { nameObj[tr.langCode] = tr.name; });
|
||||
return nameObj;
|
||||
}
|
||||
const trans = cat.translations?.find((t) => t.langCode === lang)
|
||||
|| cat.translations?.find((t) => t.langCode === 'th')
|
||||
|| cat.translations?.[0];
|
||||
return trans?.name ?? cat.name;
|
||||
}
|
||||
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
.get("/", async () => {
|
||||
return await prisma.page.findMany({
|
||||
@@ -43,7 +194,9 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
let products = [];
|
||||
const limit = payload.limit || 3;
|
||||
const productInclude = {
|
||||
category: true,
|
||||
category: {
|
||||
include: { translations: true },
|
||||
},
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
@@ -96,6 +249,69 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (section.type === 'PRODUCT_CAROUSEL' && !Array.isArray(populatedPayload.products)) {
|
||||
let products = [];
|
||||
const limit = payload.limit || 3;
|
||||
const productInclude = {
|
||||
category: {
|
||||
include: { translations: true },
|
||||
},
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
},
|
||||
orderBy: { price: "asc" }
|
||||
},
|
||||
images: {
|
||||
orderBy: { order: "asc" }
|
||||
}
|
||||
};
|
||||
if (payload.type === 'featured' || payload.collectionId === 'featured') {
|
||||
products = await prisma.product.findMany({
|
||||
where: { categoryId: 1 },
|
||||
include: productInclude,
|
||||
take: limit
|
||||
});
|
||||
}
|
||||
else if (payload.type === 'scent' && payload.scentId) {
|
||||
products = await prisma.product.findMany({
|
||||
where: { scentId: Number(payload.scentId) },
|
||||
include: productInclude,
|
||||
take: limit
|
||||
});
|
||||
}
|
||||
else if (Array.isArray(payload.productIds)) {
|
||||
const ids = payload.productIds.map(Number);
|
||||
products = await prisma.product.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: productInclude
|
||||
});
|
||||
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||
}
|
||||
else {
|
||||
products = await prisma.product.findMany({
|
||||
include: productInclude,
|
||||
take: limit
|
||||
});
|
||||
}
|
||||
populatedPayload.products = products.map((p) => {
|
||||
if (lang === 'all') {
|
||||
const nameObj = {};
|
||||
const descObj = {};
|
||||
const detailsObj = {};
|
||||
p.translations?.forEach((t) => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
return { ...p, name: nameObj, description: descObj, details: detailsObj };
|
||||
}
|
||||
const trans = p.translations?.find((t) => t.langCode === lang) || p.translations?.find((t) => t.langCode === 'th') || p.translations?.[0];
|
||||
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details };
|
||||
});
|
||||
}
|
||||
else if (section.type === 'CATEGORIES') {
|
||||
let categories = [];
|
||||
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
|
||||
@@ -103,6 +319,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
categories = await prisma.category.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: {
|
||||
translations: true,
|
||||
_count: {
|
||||
select: { products: true }
|
||||
}
|
||||
@@ -113,13 +330,18 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
else {
|
||||
categories = await prisma.category.findMany({
|
||||
include: {
|
||||
translations: true,
|
||||
_count: {
|
||||
select: { products: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
populatedPayload.categories = categories;
|
||||
// Resolve name for each category using the lang param (supports 'all')
|
||||
populatedPayload.categories = categories.map((cat) => ({
|
||||
...cat,
|
||||
name: resolveCatName(cat, lang),
|
||||
}));
|
||||
}
|
||||
return {
|
||||
...section,
|
||||
@@ -328,8 +550,17 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
try {
|
||||
const id = Number(sectionId);
|
||||
const { type, order, payload, isActive } = body;
|
||||
if (payload && type) {
|
||||
const validationError = validateSectionPayload(type, payload);
|
||||
if (payload) {
|
||||
let validationType = type;
|
||||
if (!validationType) {
|
||||
const existingSection = await prisma.pageSection.findUnique({ where: { id } });
|
||||
if (!existingSection) {
|
||||
set.status = 404;
|
||||
return { error: "Section not found" };
|
||||
}
|
||||
validationType = existingSection.type;
|
||||
}
|
||||
const validationError = validateSectionPayload(validationType, payload);
|
||||
if (validationError) {
|
||||
set.status = 400;
|
||||
return { error: validationError };
|
||||
@@ -453,51 +684,51 @@ function validateSectionPayload(type, payload) {
|
||||
break;
|
||||
}
|
||||
case 'HERO_ARTICLE': {
|
||||
const res = HeroArticlePayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateHeroArticlePayload(payload);
|
||||
if (error)
|
||||
return error;
|
||||
break;
|
||||
}
|
||||
case 'RICH_TEXT': {
|
||||
const res = RichTextPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateRichTextPayload(payload);
|
||||
if (error)
|
||||
return error;
|
||||
break;
|
||||
}
|
||||
case 'SCENT_NOTES_BREAKDOWN': {
|
||||
const res = ScentNotesBreakdownPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateScentNotesBreakdownPayload(payload);
|
||||
if (error)
|
||||
return error;
|
||||
break;
|
||||
}
|
||||
case 'QUOTE_CALLOUT': {
|
||||
const res = QuoteCalloutPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateQuoteCalloutPayload(payload);
|
||||
if (error)
|
||||
return error;
|
||||
break;
|
||||
}
|
||||
case 'PRODUCT_CAROUSEL': {
|
||||
const res = ProductCarouselPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateProductCarouselPayload(payload);
|
||||
if (error)
|
||||
return error;
|
||||
break;
|
||||
}
|
||||
case 'ACCORDION': {
|
||||
const res = AccordionSectionPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateAccordionPayload(payload);
|
||||
if (error)
|
||||
return error;
|
||||
break;
|
||||
}
|
||||
case 'CONTACT_INFO': {
|
||||
const res = ContactInfoSectionPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateContactInfoPayload(payload);
|
||||
if (error)
|
||||
return error;
|
||||
break;
|
||||
}
|
||||
case 'CONTACT_FORM': {
|
||||
const res = ContactFormSectionPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateContactFormPayload(payload);
|
||||
if (error)
|
||||
return error;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -505,4 +736,4 @@ function validateSectionPayload(type, payload) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//# sourceMappingURL=pages.js.map
|
||||
//# sourceMappingURL=pages.js.map
|
||||
Reference in new issue
Block a user