This commit is contained in:
Your Name committed 2026-07-19 06:37:42 -04:00
1 parent 913cbef765
commit 173c583787
22 files changed
+2847 -364

No files matched your search

+9 -1
View File
@@ -16,6 +16,7 @@ import {
ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema,
ProductLayoutSectionPayloadSchema,
AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema,
@@ -536,7 +537,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
}
});
function validateSectionPayload(
export function validateSectionPayload(
type: string,
payload: any,
): { success: boolean; issues?: z.ZodIssue[]; data?: any } {
@@ -594,6 +595,13 @@ function validateSectionPayload(
case "PRODUCT_CAROUSEL":
schema = ProductCarouselSectionPayloadSchema;
break;
case "PRODUCT_MAIN":
case "PRODUCT_DETAILS":
case "PRODUCT_SPECS":
case "PRODUCT_REVIEWS":
case "RELATED_PRODUCTS":
schema = ProductLayoutSectionPayloadSchema;
break;
case "ACCORDION":
schema = AccordionSectionPayloadSchema;
break;
+246
View File
@@ -1,6 +1,25 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { localizeProductAttribute } from "../lib/productAttributes";
import { hydrateSectionPayload, validateSectionPayload } from "./pages";
function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({
path: issue.path?.join(".") || "(root)",
message: issue.message,
code: issue.code,
}));
}
function defaultProductDetailSections(productId: number) {
return [
{ productId, type: "PRODUCT_MAIN", order: 1, payload: { layoutMode: "standard", galleryBehavior: "carousel", purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
{ productId, type: "PRODUCT_DETAILS", order: 2, payload: { title: { th: "รายละเอียดสินค้า", en: "Product Details" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_SPECS", order: 3, payload: { title: { th: "ข้อมูลสินค้า", en: "Specifications" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_REVIEWS", order: 4, payload: { title: { th: "รีวิว", en: "Reviews" }, showHeading: true }, isActive: true },
{ productId, type: "RELATED_PRODUCTS", order: 5, payload: { title: { th: "สินค้าที่เกี่ยวข้อง", en: "Related Products" }, showHeading: true, showViewAllLink: true }, isActive: true },
];
}
export const productsRoute = new Elysia({ prefix: "/products" })
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
@@ -255,6 +274,233 @@ export const productsRoute = new Elysia({ prefix: "/products" })
};
}
})
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const includeInactive = query.includeInactive === "true";
const lang = query.lang || "th";
const sections = await prisma.productDetailSection.findMany({
where: {
productId: id,
...(includeInactive ? {} : { isActive: true }),
},
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
})
.post("/:id/detail-sections/seed-default", async ({ params, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const existingCount = await prisma.productDetailSection.count({ where: { productId: id } });
if (existingCount > 0) {
return status(409, { success: false, message: "Product detail sections already exist" });
}
await prisma.productDetailSection.createMany({
data: defaultProductDetailSections(id),
});
const sections = await prisma.productDetailSection.findMany({
where: { productId: id },
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return { success: true, sections };
})
.post("/:id/detail-sections", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const payload = body as any;
const type = String(payload.type || "").toUpperCase();
const sectionPayload = payload.payload || {};
const validationResult = validateSectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
message: "Invalid product detail section payload",
issues: summarizeValidationIssues(validationResult.issues || []),
});
}
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const isProductMain = type === "PRODUCT_MAIN";
if (isProductMain) {
const existingMain = await prisma.productDetailSection.count({
where: { productId: id, type: "PRODUCT_MAIN" },
});
if (existingMain > 0) {
return status(409, { success: false, message: "Product main section already exists" });
}
}
const lastSection = await prisma.productDetailSection.findFirst({
where: { productId: id },
orderBy: { order: "desc" },
});
const section = isProductMain
? (await prisma.$transaction([
prisma.productDetailSection.updateMany({
where: { productId: id },
data: { order: { increment: 1 } },
}),
prisma.productDetailSection.create({
data: {
productId: id,
type,
order: 1,
payload: validationResult.data,
isActive: true,
isLocked: true,
},
}),
]))[1]
: await prisma.productDetailSection.create({
data: {
productId: id,
type,
order: Number.isFinite(Number(payload.order)) ? Number(payload.order) : (lastSection?.order || 0) + 1,
payload: validationResult.data,
isActive: payload.isActive !== false,
isLocked: Boolean(payload.isLocked),
},
});
return { success: true, section };
})
.put("/detail-sections/:sectionId", async ({ params, body, status }) => {
const sectionId = parseInt(params.sectionId);
if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
const existing = await prisma.productDetailSection.findUnique({ where: { id: sectionId } });
if (!existing) return status(404, { message: "Product detail section not found" });
const payload = body as any;
const type = String(payload.type || existing.type).toUpperCase();
const sectionPayload = payload.payload === undefined ? existing.payload : payload.payload;
const validationResult = validateSectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
message: "Invalid product detail section payload",
issues: summarizeValidationIssues(validationResult.issues || []),
});
}
if (existing.isLocked) {
if (payload.type && type !== existing.type) {
return status(409, { success: false, message: "Locked product detail sections cannot change type" });
}
if (payload.isActive === false) {
return status(409, { success: false, message: "Locked product detail sections cannot be hidden" });
}
if (payload.isLocked === false) {
return status(409, { success: false, message: "Locked product detail sections cannot be unlocked" });
}
if (payload.order !== undefined) {
const nextOrder = Number(payload.order);
const canPinProductMain = existing.type === "PRODUCT_MAIN" && nextOrder === 1;
if (!canPinProductMain && nextOrder !== existing.order) {
return status(409, { success: false, message: "Locked product detail sections cannot change order" });
}
}
}
const section = await prisma.productDetailSection.update({
where: { id: sectionId },
data: {
type,
payload: validationResult.data,
...(payload.order !== undefined ? { order: Number(payload.order) } : {}),
...(payload.isActive !== undefined ? { isActive: Boolean(payload.isActive) } : {}),
...(payload.isLocked !== undefined ? { isLocked: Boolean(payload.isLocked) } : {}),
},
});
return { success: true, section };
})
.put("/:id/detail-sections/reorder", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const items = (body as any)?.sections;
if (!Array.isArray(items)) return status(400, { message: "sections must be an array" });
const existingSections = await prisma.productDetailSection.findMany({
where: { productId: id },
select: { id: true, type: true, order: true, isLocked: true },
});
const requestedOrderById = new Map(
items.map((item: any, index: number) => [
Number(item.id),
Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1,
])
);
const lockedProductMain = existingSections.find((section) => section.isLocked && section.type === "PRODUCT_MAIN");
if (lockedProductMain) {
const targetOrder = requestedOrderById.get(lockedProductMain.id) ?? lockedProductMain.order;
if (targetOrder !== 1) {
return status(409, { success: false, message: "Product main section must stay first" });
}
for (const [sectionId, targetOrder] of requestedOrderById) {
if (sectionId !== lockedProductMain.id && targetOrder === 1) {
return status(409, { success: false, message: "Sections cannot be moved before product main" });
}
}
}
for (const section of existingSections) {
if (!section.isLocked) continue;
const targetOrder = requestedOrderById.get(section.id);
if (targetOrder === undefined) continue;
const canPinProductMain = section.type === "PRODUCT_MAIN" && targetOrder === 1;
if (!canPinProductMain && targetOrder !== section.order) {
return status(409, { success: false, message: "Locked product detail sections cannot change order" });
}
}
await prisma.$transaction(
items.map((item: any, index: number) =>
prisma.productDetailSection.updateMany({
where: { id: Number(item.id), productId: id },
data: { order: Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1 },
})
)
);
const sections = await prisma.productDetailSection.findMany({
where: { productId: id },
orderBy: [{ order: "asc" }, { id: "asc" }],
});
return { success: true, sections };
})
.delete("/detail-sections/:sectionId", async ({ params, status }) => {
const sectionId = parseInt(params.sectionId);
if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
const existing = await prisma.productDetailSection.findUnique({ where: { id: sectionId } });
if (!existing) return status(404, { message: "Product detail section not found" });
if (existing.isLocked) {
return status(409, { success: false, message: "Locked product detail sections cannot be deleted" });
}
await prisma.productDetailSection.delete({ where: { id: sectionId } });
return { success: true };
})
.put(
"/:id",
async ({ params, body, status, validateVariants }) => {
+5 -2
View File
@@ -175,7 +175,11 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
slug: "product-detail-layout", title: "Product Detail Layout",
sections: [
ANNOUNCEMENT(["Special Offer: Get 10% off your first purchase of single-origin oils", "Hand-formulated with pure botanical extracts"]),
EDITORIAL("The [Botanical] Sourcing", "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.", "left", "Sourcing Map", "/about"),
{ type: "PRODUCT_MAIN", order: 2, isActive: true, payload: {} },
EDITORIAL("The [Botanical] Sourcing", "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.", "left", "Sourcing Map", "/about", 3),
{ type: "PRODUCT_DETAILS", order: 4, isActive: true, payload: { title: "Product Details", showHeading: true } },
{ type: "PRODUCT_SPECS", order: 5, isActive: true, payload: { title: "Specifications", showHeading: true } },
{ type: "RELATED_PRODUCTS", order: 6, isActive: true, payload: { title: "Related Products", showHeading: true, showViewAllLink: true } },
]
},
{
@@ -288,4 +292,3 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
return { error: "Failed to seed pages" };
}
});