x
This commit is contained in:
1 parent
913cbef765
commit
173c583787
22 files changed
+2847
-364
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user