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

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "ProductDetailSection" (
"id" SERIAL NOT NULL,
"productId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isLocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductDetailSection_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProductDetailSection_productId_order_idx" ON "ProductDetailSection"("productId", "order");
-- CreateIndex
CREATE INDEX "ProductDetailSection_productId_isActive_idx" ON "ProductDetailSection"("productId", "isActive");
-- AddForeignKey
ALTER TABLE "ProductDetailSection" ADD CONSTRAINT "ProductDetailSection_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+17
View File
@@ -67,6 +67,7 @@ model Product {
images ProductImage[]
attributes ProductAttribute[]
reviews Review[]
detailSections ProductDetailSection[]
translations ProductTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -631,6 +632,22 @@ model PageSection {
updatedAt DateTime @updatedAt
}
model ProductDetailSection {
id Int @id @default(autoincrement())
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
type String
order Int
payload Json
isActive Boolean @default(true)
isLocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([productId, order])
@@index([productId, isActive])
}
model NewsletterSubscription {
id Int @id @default(autoincrement())
email String @unique
+38 -3
View File
@@ -322,12 +322,22 @@ async function main() {
}
}
});
// Editorial banner at the bottom of Product Details
// Native product detail hero block
await prisma.pageSection.create({
data: {
pageId: productDetailPage.id,
type: "PRODUCT_MAIN",
order: 2,
isActive: true,
payload: {}
}
});
// Editorial banner can now be reordered between native product blocks
await prisma.pageSection.create({
data: {
pageId: productDetailPage.id,
type: "EDITORIAL_TILE",
order: 2,
order: 3,
isActive: true,
payload: {
title: "The [Botanical] Sourcing",
@@ -341,6 +351,31 @@ async function main() {
}
}
});
await prisma.pageSection.createMany({
data: [
{
pageId: productDetailPage.id,
type: "PRODUCT_DETAILS",
order: 4,
isActive: true,
payload: { title: "Product Details", showHeading: true },
},
{
pageId: productDetailPage.id,
type: "PRODUCT_SPECS",
order: 5,
isActive: true,
payload: { title: "Specifications", showHeading: true },
},
{
pageId: productDetailPage.id,
type: "RELATED_PRODUCTS",
order: 6,
isActive: true,
payload: { title: "Related Products", showHeading: true, showViewAllLink: true },
},
],
});
// 7. Create FAQ Page Layout
const faqPage = await prisma.page.upsert({
where: { slug: "faq-layout" },
@@ -659,4 +694,4 @@ main()
await prisma.$disconnect();
await pool.end();
});
//# sourceMappingURL=seed-pages.js.map
//# sourceMappingURL=seed-pages.js.map
+38 -1
View File
@@ -363,9 +363,20 @@ async function main() {
await prisma.pageSection.create({
data: {
pageId: productDetailPage.id,
type: "EDITORIAL_TILE",
type: "PRODUCT_MAIN",
order: 2,
isActive: true,
payload: {}
}
});
// Editorial banner can now be reordered between native product blocks
await prisma.pageSection.create({
data: {
pageId: productDetailPage.id,
type: "EDITORIAL_TILE",
order: 3,
isActive: true,
payload: {
title: "The [Botanical] Sourcing",
description: "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.",
@@ -379,6 +390,32 @@ async function main() {
}
});
await prisma.pageSection.createMany({
data: [
{
pageId: productDetailPage.id,
type: "PRODUCT_DETAILS",
order: 4,
isActive: true,
payload: { title: "Product Details", showHeading: true },
},
{
pageId: productDetailPage.id,
type: "PRODUCT_SPECS",
order: 5,
isActive: true,
payload: { title: "Specifications", showHeading: true },
},
{
pageId: productDetailPage.id,
type: "RELATED_PRODUCTS",
order: 6,
isActive: true,
payload: { title: "Related Products", showHeading: true, showViewAllLink: true },
},
],
});
// 7. Create FAQ Page Layout
const faqPage = await prisma.page.upsert({
where: { slug: "faq-layout" },
+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" };
}
});