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

+2
View File
@@ -4,6 +4,7 @@ import DashboardPage from './pages/DashboardPage'
import ProductsPage from './pages/ProductsPage'
import ProductEditPage from './pages/ProductEditPage'
import ProductBomPage from './pages/ProductBomPage'
import ProductCmsPage from './pages/ProductCmsPage'
import CategoriesPage from './pages/CategoriesPage'
import CustomersPage from './pages/CustomersPage'
import PageBuilderPage from './pages/PageBuilderPage'
@@ -57,6 +58,7 @@ function App() {
<Route path="/product-attributes" element={<ProductAttributesPage />} />
<Route path="/products/new" element={<ProductEditPage />} />
<Route path="/products/:id/edit" element={<ProductEditPage />} />
<Route path="/products/:id/cms" element={<ProductCmsPage />} />
<Route path="/products/:id/bom" element={<ProductBomPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/customers" element={<CustomersPage />} />
@@ -0,0 +1,87 @@
import { type PageSection } from '@homaroma/types';
interface ProductLayoutBlockFormProps {
editingSection: Partial<PageSection>;
getLocalizedValue: (payload: unknown, field: string) => string;
updateLocalizedValue: (field: string, value: string) => void;
updatePayloadValue: (field: string, value: unknown) => void;
}
const BLOCK_HELP: Record<string, string> = {
PRODUCT_MAIN: 'Renders the product gallery and purchase panel from live product data.',
PRODUCT_DETAILS: 'Renders the product details markdown from the selected product.',
PRODUCT_SPECS: 'Renders product attributes and specifications when available.',
PRODUCT_REVIEWS: 'Renders customer reviews for the selected product.',
RELATED_PRODUCTS: 'Renders products from the same category as the current product.',
};
export default function ProductLayoutBlockForm({
editingSection,
getLocalizedValue,
updateLocalizedValue,
updatePayloadValue,
}: ProductLayoutBlockFormProps) {
const type = editingSection.type?.toUpperCase() || '';
const canShowViewAll = type === 'RELATED_PRODUCTS';
return (
<div className="space-y-6">
<div className="rounded-2xl border border-cyan-glow/20 bg-cyan-glow/5 p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-cyan-glow">
Product Detail Native Block
</p>
<p className="mt-2 text-xs leading-relaxed text-slate-400">
{BLOCK_HELP[type] || 'This block renders live product detail content on the website.'}
</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
<input
type="text"
value={getLocalizedValue(editingSection.payload, 'title')}
onChange={(event) => updateLocalizedValue('title', event.target.value)}
className="admin-input w-full"
placeholder="Leave blank to use the default label"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtitle</label>
<input
type="text"
value={getLocalizedValue(editingSection.payload, 'subtitle')}
onChange={(event) => updateLocalizedValue('subtitle', event.target.value)}
className="admin-input w-full"
placeholder="Optional supporting text"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-4">
<input
type="checkbox"
checked={editingSection.payload?.showHeading !== false}
onChange={(event) => updatePayloadValue('showHeading', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show heading</span>
</label>
{canShowViewAll && (
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-4">
<input
type="checkbox"
checked={editingSection.payload?.showViewAllLink !== false}
onChange={(event) => updatePayloadValue('showViewAllLink', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show view all link</span>
</label>
)}
</div>
</div>
);
}
+51
View File
@@ -39,6 +39,15 @@ import ContactFormForm from '../components/page-builder/ContactFormForm';
import HeroV2Form from '../components/page-builder/HeroV2Form';
import UspBarForm from '../components/page-builder/UspBarForm';
import CustomBlockForm from '../components/page-builder/CustomBlockForm';
import ProductLayoutBlockForm from '../components/page-builder/ProductLayoutBlockForm';
const PRODUCT_LAYOUT_BLOCK_TYPES = new Set([
'PRODUCT_MAIN',
'PRODUCT_DETAILS',
'PRODUCT_SPECS',
'PRODUCT_REVIEWS',
'RELATED_PRODUCTS',
]);
interface Brand {
id: number;
@@ -650,6 +659,34 @@ export default function PageBuilderPage() {
showAddToCart: true,
};
break;
case 'PRODUCT_MAIN':
defaultPayload = {};
break;
case 'PRODUCT_DETAILS':
defaultPayload = {
title: 'Product Details',
showHeading: true,
};
break;
case 'PRODUCT_SPECS':
defaultPayload = {
title: 'Specifications',
showHeading: true,
};
break;
case 'PRODUCT_REVIEWS':
defaultPayload = {
title: 'Reviews',
showHeading: true,
};
break;
case 'RELATED_PRODUCTS':
defaultPayload = {
title: 'Related Products',
showHeading: true,
showViewAllLink: true,
};
break;
case 'ACCORDION':
defaultPayload = {
title: 'Frequently Asked Questions',
@@ -1040,6 +1077,11 @@ export default function PageBuilderPage() {
<option value="SCENT_NOTES_BREAKDOWN">SCENT NOTES (Fragrance layers)</option>
<option value="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</option>
<option value="PRODUCT_CAROUSEL">PRODUCT CAROUSEL (Featured products)</option>
<option value="PRODUCT_MAIN">PRODUCT MAIN (Gallery + purchase panel)</option>
<option value="PRODUCT_DETAILS">PRODUCT DETAILS (Product markdown)</option>
<option value="PRODUCT_SPECS">PRODUCT SPECS (Attributes)</option>
<option value="PRODUCT_REVIEWS">PRODUCT REVIEWS (Customer reviews)</option>
<option value="RELATED_PRODUCTS">RELATED PRODUCTS (Same category)</option>
<option value="ACCORDION">ACCORDION (FAQ Style)</option>
<option value="CONTACT_INFO">CONTACT INFO (Location/Hours)</option>
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option>
@@ -1268,6 +1310,15 @@ export default function PageBuilderPage() {
/>
)}
{PRODUCT_LAYOUT_BLOCK_TYPES.has(editingSection.type?.toUpperCase() || '') && (
<ProductLayoutBlockForm
editingSection={editingSection}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
/>
)}
</div>
<div className="p-6 bg-night-950/50 border-t border-white/5 flex flex-col sm:flex-row gap-4 shrink-0">
<button
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -12,7 +12,8 @@ import {
Image as ImageIcon,
X,
UploadCloud,
Loader2
Loader2,
LayoutTemplate
} from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types'
@@ -366,8 +367,16 @@ export default function ProductsPage() {
>
<Package className="w-4 h-4" />
</button>
<button
onClick={() => navigate(`/products/${product.id}/cms`)}
title="Edit Product CMS"
className="p-2 rounded-lg hover:bg-cyan-glow/10 text-slate-500 hover:text-cyan-glow transition-all"
>
<LayoutTemplate className="w-4 h-4" />
</button>
<button
onClick={() => navigate(`/products/${product.id}/edit`)}
title="Edit Product"
className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all"
>
<Edit3 className="w-4 h-4" />
@@ -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" };
}
});
+16
View File
@@ -79,6 +79,22 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
required: ["type"],
example: `{ "type": "custom", "productIds": ${productIdListExample}, "title": ${localizedStringExample}, "limit": 4 }`
},
PRODUCT_MAIN: {
required: [],
example: `{ }`
},
PRODUCT_DETAILS: {
required: [],
example: `{ "title": ${localizedStringExample}, "showHeading": true }`
},
PRODUCT_SPECS: {
required: [],
example: `{ "title": ${localizedStringExample}, "showHeading": true }`
},
RELATED_PRODUCTS: {
required: [],
example: `{ "title": ${localizedStringExample}, "showHeading": true, "showViewAllLink": true }`
},
ACCORDION: {
required: [],
example: `{ "title": ${localizedStringExample}, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`
+307 -195
View File
@@ -7,7 +7,7 @@ import ProductCard from '../components/ProductCard'
import ProductReviews from '../components/ProductReviews'
import ProductImageGallery from '../components/ProductImageGallery'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type TranslatableString } from '../types'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type ProductDetailSection, type TranslatableString } from '../types'
import { useCartStore } from '../store/cartStore'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
@@ -20,17 +20,16 @@ function resolveTranslatable(value: TranslatableString | null | undefined, lang:
return value[lang] ?? value[lang.slice(0, 2)] ?? value['th'] ?? value['en'] ?? Object.values(value)[0] ?? ''
}
function resolveOptionValue(val: string, options: any[] | null | undefined, lang: string): string {
function resolveOptionValue(val: string, options: Product['options'], lang: string): string {
if (!val) return '';
if (!options || !Array.isArray(options)) return val;
for (const option of options) {
if (!option.values || !Array.isArray(option.values)) continue;
for (const v of option.values) {
const baseStr = typeof v === 'string' ? v : (v['th'] || v['en'] || Object.values(v)[0] || '');
const baseStr = resolveTranslatable(v, 'th') || resolveTranslatable(v, 'en');
if (baseStr === val) {
if (typeof v === 'string') return v;
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? baseStr;
return resolveTranslatable(v, lang) || baseStr;
}
}
}
@@ -50,6 +49,63 @@ function formatAttributeValue(attribute: ProductAttribute): string {
return JSON.stringify(value)
}
const productNativeSectionTypes = new Set([
'PRODUCT_MAIN',
'PRODUCT_DETAILS',
'PRODUCT_SPECS',
'PRODUCT_REVIEWS',
'RELATED_PRODUCTS',
])
function isProductNativeSection(section: ProductDetailSection) {
return productNativeSectionTypes.has(section.type.toUpperCase())
}
function payloadRecord(section: ProductDetailSection) {
return section.payload && typeof section.payload === 'object' && !Array.isArray(section.payload)
? section.payload as Record<string, unknown>
: {}
}
function payloadText(payload: Record<string, unknown>, field: string, lang: string) {
return resolveTranslatable(payload[field] as TranslatableString | null | undefined, lang)
}
function resolveCategoryDisplayName(category: Category, lang: string) {
return resolveTranslatable(category.name, lang)
}
function resolveCategoryImageName(category: Category) {
return resolveTranslatable(category.name, 'en') || resolveTranslatable(category.name, 'th')
}
function toPageSection(section: ProductDetailSection): PageSection {
return {
id: section.id,
pageId: 0,
type: section.type,
order: section.order,
payload: section.payload,
isActive: section.isActive,
createdAt: section.createdAt,
updatedAt: section.updatedAt,
}
}
function normalizeProductLayoutSections(
sections: ProductDetailSection[],
defaultSections: ProductDetailSection[],
) {
if (sections.length === 0) return defaultSections
const productMainSection = sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN' && section.isLocked)
|| sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN')
|| defaultSections.find((section) => section.type === 'PRODUCT_MAIN')
const remainingSections = sections.filter((section) => section.type.toUpperCase() !== 'PRODUCT_MAIN')
return productMainSection ? [productMainSection, ...remainingSections] : remainingSections
}
export default function ProductDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -59,7 +115,7 @@ export default function ProductDetailPage() {
const [selectedVariant, setSelectedVariant] = useState<number>(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const [cmsSections, setCmsSections] = useState<ProductDetailSection[]>([])
const { addToCart } = useCartStore()
const { i18n, t } = useTranslation()
@@ -79,27 +135,12 @@ export default function ProductDetailPage() {
.catch(console.error)
}, [i18n.language])
useEffect(() => {
fetch(`${API_BASE}/pages/product-detail-layout?lang=${i18n.language}`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Product detail dynamic layout sections not loaded:', err.message);
});
}, [i18n.language])
useEffect(() => {
if (!id) return
setLoading(true)
setError(null)
setSelectedVariant(0)
setCmsSections([])
fetch(`${API_BASE}/products/${id}?lang=${i18n.language}`)
.then(async (r) => {
@@ -113,6 +154,11 @@ export default function ProductDetailPage() {
const relJson = await relRes.json()
const relData: Product[] = relJson.data || []
setRelated(relData.filter((p) => p.id !== data.id).slice(0, 6))
const sectionsRes = await fetch(`${API_BASE}/products/${data.id}/detail-sections?lang=${i18n.language}`)
if (sectionsRes.ok) {
const sections: ProductDetailSection[] = await sectionsRes.json()
setCmsSections(sections)
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
@@ -156,6 +202,10 @@ export default function ProductDetailPage() {
const currentVariant = product.variants[selectedVariant]
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
const categoryName = resolveCategoryDisplayName(product.category, i18n.language)
const productDescription = resolveTranslatable(product.description, i18n.language)
const productDetails = resolveTranslatable(product.details, i18n.language)
const fallbackImage = getCategoryImage(resolveCategoryImageName(product.category))
const handleAddToCart = () => {
if (!product || !currentVariant) return
@@ -166,211 +216,273 @@ export default function ProductDetailPage() {
price: currentVariant.price || 0,
productName: displayName,
variantName: [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '),
productImage: product.images?.[0]?.url || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')
productImage: product.images?.[0]?.url || fallbackImage
})
}
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
const activeLayoutSections = cmsSections.filter(s => s.isActive);
const defaultProductSections: ProductDetailSection[] = [
{ id: -1, productId: product.id, type: 'PRODUCT_MAIN', order: 1, payload: {}, isActive: true, isLocked: true },
{ id: -2, productId: product.id, type: 'PRODUCT_DETAILS', order: 2, payload: { title: t('product.details'), showHeading: true }, isActive: true, isLocked: false },
{ id: -3, productId: product.id, type: 'PRODUCT_REVIEWS', order: 3, payload: {}, isActive: true, isLocked: false },
{ id: -4, productId: product.id, type: 'PRODUCT_SPECS', order: 4, payload: { title: 'Specifications', showHeading: true }, isActive: true, isLocked: false },
{ id: -5, productId: product.id, type: 'RELATED_PRODUCTS', order: 5, payload: { title: t('product.relatedProducts'), showHeading: true, showViewAllLink: true }, isActive: true, isLocked: false },
];
const productLayoutSections = normalizeProductLayoutSections(activeLayoutSections, defaultProductSections);
return (
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={loading}
/>
)}
const renderProductMainSection = () => (
<div className="space-y-8">
{/* Breadcrumb */}
<div className="flex items-center flex-wrap gap-2 text-[10px] md:text-xs text-spa-stone/60 uppercase tracking-widest font-semibold mb-6 md:mb-0">
<Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link>
<span>/</span>
<Link to="/shop" className="hover:text-spa-forest transition-colors">{t('common.shop')}</Link>
<span>/</span>
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
{categoryName}
</Link>
<span>/</span>
<span className="text-spa-forest">{displayName}</span>
</div>
<Navbar categories={categories} />
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-28 pb-24 space-y-8">
{/* Breadcrumb */}
<div className="flex items-center flex-wrap gap-2 text-[10px] md:text-xs text-spa-stone/60 uppercase tracking-widest font-semibold mb-6 md:mb-0">
<Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link>
<span>/</span>
<Link to="/shop" className="hover:text-spa-forest transition-colors">{t('common.shop')}</Link>
<span>/</span>
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
</Link>
<span>/</span>
<span className="text-spa-forest">{displayName}</span>
{/* Product Detail */}
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
{/* Image Gallery */}
<div className="w-full">
<ProductImageGallery
images={product.images || []}
fallback={fallbackImage}
alt={displayName}
badgeText={categoryName}
/>
</div>
{/* Product Detail */}
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
{/* Image Gallery */}
<div className="w-full">
<ProductImageGallery
images={product.images || []}
fallback={getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')}
alt={displayName}
badgeText={typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
/>
{/* Info */}
<div className="space-y-8">
<div className="space-y-2">
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">
{categoryName}
</p>
<h1 className="text-5xl font-bold leading-tight text-pop-black">
{displayName}
</h1>
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
{t('product.premiumCollection')}
</p>
</div>
{/* Info */}
<div className="space-y-8">
<div className="space-y-2">
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
</p>
<h1 className="text-5xl font-bold leading-tight text-pop-black">
{displayName}
</h1>
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
{t('product.premiumCollection')}
</p>
</div>
{productDescription && (
<p className="text-spa-stone leading-relaxed">{productDescription}</p>
)}
{resolveTranslatable(product.description, i18n.language) && (
<p className="text-spa-stone leading-relaxed">{resolveTranslatable(product.description, i18n.language)}</p>
{/* Price */}
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold text-rose-500">
{formatPrice(currentVariant?.price)}
</span>
{currentVariant && (
<span className="text-spa-stone/60 text-sm font-semibold">/ {[currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}</span>
)}
</div>
{/* Price */}
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold text-rose-500">
{formatPrice(currentVariant?.price)}
</span>
{currentVariant && (
<span className="text-spa-stone/60 text-sm font-semibold">/ {[currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}</span>
)}
</div>
{visibleAttributes.length > 0 && (
<section className="space-y-4 border-t border-spa-stone/10 pt-6">
<div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">Product Specs</p>
<h2 className="text-xl font-bold text-pop-black">Specifications</h2>
</div>
<div className="grid gap-3">
{visibleAttributes.map((attribute) => {
const label = typeof attribute.attributeDefinition.label === 'string'
? attribute.attributeDefinition.label
: resolveTranslatable(attribute.attributeDefinition.label, i18n.language)
return (
<div key={attribute.id} className="flex items-start justify-between gap-6 rounded-2xl border border-spa-stone/10 bg-white/70 px-4 py-3">
<span className="text-sm font-semibold text-pop-black">{label}</span>
<span className="text-sm text-spa-stone text-right">{formatAttributeValue(attribute)}</span>
</div>
)
})}
</div>
</section>
)}
{/* Variant Selector */}
<div className="space-y-3">
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
{t('product.selectSize')}
</p>
<div className="flex flex-wrap gap-3">
{product.variants.map((v, i) => (
<button
key={v.id}
onClick={() => setSelectedVariant(i)}
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 border ${
selectedVariant === i
? 'bg-spa-forest text-spa-light border-spa-forest shadow-md'
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
}`}
>
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
{formatPrice(v.price)}
</span>
</button>
))}
</div>
</div>
{/* Actions */}
<div className="hidden md:flex gap-4">
<button
onClick={handleAddToCart}
className="flex-1 py-4 bg-spa-forest text-spa-light font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300"
>
{t('cart.addToCart')}
</button>
<button className="bg-spa-light border border-spa-stone/20 text-spa-stone p-4 rounded-lg hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
</div>
{/* Meta */}
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
<div>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.brand', 'Brand')}</span>
<span className="text-pop-black font-bold">{product.brand?.name || ''}</span>
</div>
<div>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.category')}</span>
<Link
to={`/category/${product.category.id}`}
className="text-pop-black font-bold hover:text-spa-forest transition-colors"
{/* Variant Selector */}
<div className="space-y-3">
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
{t('product.selectSize')}
</p>
<div className="flex flex-wrap gap-3">
{product.variants.map((v, i) => (
<button
key={v.id}
onClick={() => setSelectedVariant(i)}
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 border ${
selectedVariant === i
? 'bg-spa-forest text-spa-light border-spa-forest shadow-md'
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
}`}
>
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
</Link>
</div>
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
{formatPrice(v.price)}
</span>
</button>
))}
</div>
</div>
{/* Actions */}
<div className="hidden md:flex gap-4">
<button
onClick={handleAddToCart}
className="flex-1 py-4 bg-spa-forest text-spa-light font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300"
>
{t('cart.addToCart')}
</button>
<button className="bg-spa-light border border-spa-stone/20 text-spa-stone p-4 rounded-lg hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
</div>
{/* Meta */}
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
<div>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.brand', 'Brand')}</span>
<span className="text-pop-black font-bold">{product.brand?.name || ''}</span>
</div>
<div>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.category')}</span>
<Link
to={`/category/${product.category.id}`}
className="text-pop-black font-bold hover:text-spa-forest transition-colors"
>
{categoryName}
</Link>
</div>
</div>
</div>
</div>
</div>
);
{/* Full Details Section */}
{resolveTranslatable(product.details, i18n.language) && (
<div className="pt-16 border-t border-spa-stone/10 space-y-6">
<h2 className="text-3xl font-bold text-pop-black">{t('product.details')}</h2>
<div
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
marked.parse(resolveTranslatable(product.details, i18n.language).replace(/\\n/g, '\n'), { async: false }) as string
)
}}
/>
const renderProductDetailsSection = (section: ProductDetailSection) => {
if (!productDetails) return null;
const payload = payloadRecord(section);
const title = payloadText(payload, 'title', i18n.language) || t('product.details');
const subtitle = payloadText(payload, 'subtitle', i18n.language);
const showHeading = payload.showHeading !== false;
return (
<div className="pt-8 border-t border-spa-stone/10 space-y-6">
{showHeading && (
<div className="space-y-2">
<h2 className="text-3xl font-bold text-pop-black">{title}</h2>
{subtitle && <p className="text-sm text-spa-stone">{subtitle}</p>}
</div>
)}
<div
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
marked.parse(productDetails.replace(/\\n/g, '\n'), { async: false }) as string
)
}}
/>
</div>
);
};
{/* Reviews Section */}
<ProductReviews productId={product.id} />
const renderProductSpecsSection = (section: ProductDetailSection) => {
if (visibleAttributes.length === 0) return null;
{/* Related Products */}
{related.length > 0 && (
<div className="space-y-8 border-t border-spa-stone/10 pt-16">
<div className="flex justify-between items-end">
<div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2">
{t('product.moreRelated', { category: typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.['en'] })}
</p>
<h2 className="text-3xl font-bold text-pop-black">{t('product.relatedProducts')}</h2>
const payload = payloadRecord(section);
const title = payloadText(payload, 'title', i18n.language) || 'Specifications';
const subtitle = payloadText(payload, 'subtitle', i18n.language);
const showHeading = payload.showHeading !== false;
return (
<section className="space-y-4 border-t border-spa-stone/10 pt-8">
{showHeading && (
<div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">Product Specs</p>
<h2 className="text-xl font-bold text-pop-black">{title}</h2>
{subtitle && <p className="mt-2 text-sm text-spa-stone">{subtitle}</p>}
</div>
)}
<div className="grid gap-3">
{visibleAttributes.map((attribute) => {
const label = typeof attribute.attributeDefinition.label === 'string'
? attribute.attributeDefinition.label
: resolveTranslatable(attribute.attributeDefinition.label, i18n.language)
return (
<div key={attribute.id} className="flex items-start justify-between gap-6 rounded-2xl border border-spa-stone/10 bg-white/70 px-4 py-3">
<span className="text-sm font-semibold text-pop-black">{label}</span>
<span className="text-sm text-spa-stone text-right">{formatAttributeValue(attribute)}</span>
</div>
)
})}
</div>
</section>
);
};
const renderRelatedProductsSection = (section: ProductDetailSection) => {
if (related.length === 0) return null;
const payload = payloadRecord(section);
const title = payloadText(payload, 'title', i18n.language) || t('product.relatedProducts');
const subtitle = payloadText(payload, 'subtitle', i18n.language);
const showHeading = payload.showHeading !== false;
const showViewAllLink = payload.showViewAllLink !== false;
return (
<div className="space-y-8 border-t border-spa-stone/10 pt-8">
{showHeading && (
<div className="flex justify-between items-end">
<div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2">
{subtitle || t('product.moreRelated', { category: categoryName })}
</p>
<h2 className="text-3xl font-bold text-pop-black">{title}</h2>
</div>
{showViewAllLink && (
<Link
to={`/category/${product.category.id}`}
className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline"
>
{t('common.viewAll')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
{related.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
)}
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
{related.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</div>
);
};
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={loading}
/>
)}
const renderProductNativeSection = (section: ProductDetailSection, index: number) => {
const shellClass = `max-w-7xl mx-auto px-6 ${index === 0 ? 'pt-24 md:pt-28' : 'pt-8'} pb-8`;
switch (section.type.toUpperCase()) {
case 'PRODUCT_MAIN':
return <div key={section.id} className={shellClass}>{renderProductMainSection()}</div>;
case 'PRODUCT_DETAILS':
return <div key={section.id} className={shellClass}>{renderProductDetailsSection(section)}</div>;
case 'PRODUCT_SPECS':
return <div key={section.id} className={shellClass}>{renderProductSpecsSection(section)}</div>;
case 'PRODUCT_REVIEWS':
return <div key={section.id} className={shellClass}><ProductReviews productId={product.id} /></div>;
case 'RELATED_PRODUCTS':
return <div key={section.id} className={shellClass}>{renderRelatedProductsSection(section)}</div>;
default:
return null;
}
};
return (
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
<Navbar categories={categories} />
{productLayoutSections.map((section, index) => {
if (isProductNativeSection(section) || section.type.toUpperCase() === 'PRODUCT_REVIEWS') {
return renderProductNativeSection(section, index);
}
return (
<DynamicPageRenderer
key={section.id}
sections={[toPageSection(section)]}
categories={categories}
categoriesLoading={loading}
/>
);
})}
<Footer categories={categories} />
+6
View File
@@ -19,6 +19,12 @@ const nextConfig: NextConfig = {
hostname: "elysia3051.kitizone.com",
pathname: "/**",
},
{
protocol: "http",
hostname: "localhost",
port: "3051",
pathname: "/uploads/**",
},
],
},
};
+19 -44
View File
@@ -1,7 +1,5 @@
import type { Metadata } from 'next';
import { Kanit, Sarabun, Playfair_Display } from 'next/font/google';
import { notFound } from 'next/navigation';
import '../globals.css';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import ClientProviders from '@/components/ClientProviders';
@@ -11,27 +9,6 @@ import type { MenuItem, StoreSettings } from '@homaroma/types';
import { isSupportedLocale } from '@/lib/locales';
import type { CSSProperties } from 'react';
const sarabun = Sarabun({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-sarabun',
});
const kanit = Kanit({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-kanit',
});
const playfairDisplay = Playfair_Display({
subsets: ['latin'],
display: 'swap',
weight: ['400', '500', '600', '700'],
variable: '--font-playfair',
});
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params;
const settings = await getStoreSettings(lang);
@@ -48,12 +25,12 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
}
// Cache configuration: 0 for dev (no cache), strict numbers for production
const getCacheTime = (prodSeconds: number) => 0; // Temporary bypass for 5-day testing
const getCacheTime = () => 0; // Temporary bypass for 5-day testing
// Fetch categories for Navbar and Footer on the server
async function getCategories(lang: string) {
try {
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: getCacheTime(3600) } });
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: getCacheTime() } });
if (!res.ok) return [];
return await res.json();
} catch (error) {
@@ -65,7 +42,7 @@ async function getCategories(lang: string) {
// Fetch mega menu on the server — eliminates client-side flash
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
try {
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime() } });
if (!res.ok) return [];
const data = await res.json() as { payload?: MegaMenuPayload[] };
return Array.isArray(data.payload) ? data.payload : [];
@@ -79,7 +56,7 @@ async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
try {
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: getCacheTime() } });
if (!res.ok) return [];
const data = await res.json() as { items?: MenuItem[] };
return Array.isArray(data.items)
@@ -101,7 +78,7 @@ async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[];
// Fetch store settings (social links, footer texts) on the server
async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
try {
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: getCacheTime(600) } });
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: getCacheTime() } });
if (!res.ok) return null;
return await res.json() as StoreSettings;
} catch (error) {
@@ -183,21 +160,19 @@ export default async function RootLayout({
const themeStyle = buildThemeStyle(settings)
return (
<html lang={lang} className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased" style={themeStyle}>
<ClientProviders>
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
<main className="global-main">
{children}
</main>
<Footer
categories={categories}
exploreMenu={footerMenus.exploreMenu}
supportMenu={footerMenus.supportMenu}
settings={settings}
/>
</ClientProviders>
</body>
</html>
<div className="min-h-screen bg-surface-page text-text-main" lang={lang} style={themeStyle}>
<ClientProviders>
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
<main className="global-main">
{children}
</main>
<Footer
categories={categories}
exploreMenu={footerMenus.exploreMenu}
supportMenu={footerMenus.supportMenu}
settings={settings}
/>
</ClientProviders>
</div>
);
}
@@ -1,21 +1,70 @@
import Link from '@/components/LocalizedLink'
import Image from 'next/image'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Image from 'next/image'
import { generateSeoMetadata } from '@/lib/seo'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { getCategoryImage, type Product } from '@homaroma/types'
import { getCategoryImage, type PageSection, type Product, type ProductDetailSection } from '@homaroma/types'
import AddToCartSection from '@/components/AddToCartSection'
import ProductGallery from '@/components/ProductGallery'
import ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { getCategoriesResult, getProduct, getProductDetailSectionsResult, getProductResult, getProductReviewsResult, getProductsResult, type ProductReview } from '@/lib/cms'
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
import { extractIdFromSlug, generateProductSlug } from '@/lib/slugify'
import type { ProductAttribute } from '@homaroma/types'
const productNativeSectionTypes = new Set([
'PRODUCT_MAIN',
'PRODUCT_DETAILS',
'PRODUCT_SPECS',
'PRODUCT_REVIEWS',
'RELATED_PRODUCTS',
])
function isProductNativeSection(section: ProductDetailSection) {
return productNativeSectionTypes.has(section.type.toUpperCase())
}
function payloadRecord(section: ProductDetailSection) {
return section.payload && typeof section.payload === 'object' && !Array.isArray(section.payload)
? section.payload as Record<string, unknown>
: {}
}
function payloadText(payload: Record<string, unknown>, field: string, lang: string) {
return resolveLocalized(payload[field] as never, lang)
}
function toPageSection(section: ProductDetailSection): PageSection {
return {
id: section.id,
pageId: 0,
type: section.type,
order: section.order,
payload: section.payload,
isActive: section.isActive,
createdAt: section.createdAt,
updatedAt: section.updatedAt,
}
}
function normalizeProductLayoutSections(
sections: ProductDetailSection[],
defaultSections: ProductDetailSection[],
) {
if (sections.length === 0) return defaultSections
const productMainSection = sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN' && section.isLocked)
|| sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN')
|| defaultSections.find((section) => section.type === 'PRODUCT_MAIN')
const remainingSections = sections.filter((section) => section.type.toUpperCase() !== 'PRODUCT_MAIN')
return productMainSection ? [productMainSection, ...remainingSections] : remainingSections
}
function formatAttributeValue(attribute: ProductAttribute): string {
const value = attribute.value
if (value === null || value === undefined || value === '') return ''
@@ -34,6 +83,271 @@ async function getRelated(categoryId: number, excludeId: number, lang: string):
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
}
function ProductMainSection({
product,
productName,
categoryName,
imageUrl,
payload,
}: {
product: Product
productName: string
categoryName: string
imageUrl: string
payload: Record<string, unknown>
}) {
const layoutMode = payload.layoutMode === 'sticky_gallery' ? 'sticky_gallery' : 'standard'
const galleryBehavior = payload.galleryBehavior === 'stacked' ? 'stacked' : 'carousel'
const purchaseCardSticky = payload.purchaseCardSticky !== false
const showBreadcrumbs = payload.showBreadcrumbs !== false
const showBrandMeta = payload.showBrandMeta !== false
const images = product.images?.map(img => img.url).filter(Boolean) || []
const galleryImages = images.length > 0 ? images : [imageUrl]
if (layoutMode === 'sticky_gallery') {
return (
<div className="space-y-8">
{showBreadcrumbs && (
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
{categoryName}
</Link>
<span>/</span>
<span className="text-text-main">{productName}</span>
</nav>
)}
<section className="grid gap-10 lg:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] lg:items-start lg:gap-16">
<div className="lg:hidden">
<ProductGallery images={galleryImages} productName={productName} categoryName={categoryName} />
</div>
<div className="hidden space-y-6 lg:block">
{galleryBehavior === 'stacked' ? (
galleryImages.map((src, index) => (
<div key={`${src}-${index}`} className="relative min-h-[78vh] overflow-hidden rounded-3xl bg-surface-card shadow-card">
<Image
src={src}
alt={`${productName} ${index + 1}`}
fill
priority={index === 0}
sizes="(min-width: 1024px) 54vw, 100vw"
className="object-cover"
/>
{index === 0 && categoryName && (
<div className="absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm">
{categoryName}
</div>
)}
</div>
))
) : (
<ProductGallery images={galleryImages} productName={productName} categoryName={categoryName} />
)}
</div>
<div className={purchaseCardSticky ? 'lg:sticky lg:top-24' : ''}>
<div className="rounded-3xl border border-line-primary/15 bg-surface-card/95 p-6 shadow-card md:p-8">
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
</div>
</div>
</section>
</div>
)
}
return (
<div className="space-y-8">
{showBreadcrumbs && (
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
{categoryName}
</Link>
<span>/</span>
<span className="text-text-main">{productName}</span>
</nav>
)}
<section className="grid gap-10 md:grid-cols-2 md:gap-16">
<ProductGallery
images={galleryImages}
productName={productName}
categoryName={categoryName}
/>
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
</section>
</div>
)
}
function ProductDetailsSection({
details,
title,
subtitle,
showHeading,
}: {
details: string
title: string
subtitle?: string
showHeading: boolean
}) {
if (!details) return null
return (
<section className="ui-panel p-8 md:p-12">
{showHeading && (
<div className="mb-6 space-y-2">
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
{subtitle && <p className="text-sm text-muted-text">{subtitle}</p>}
</div>
)}
<div className="product-editorial leading-relaxed text-muted-text">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{details}
</ReactMarkdown>
</div>
</section>
)
}
function ProductSpecsSection({
attributes,
title,
subtitle,
showHeading,
lang,
}: {
attributes: ProductAttribute[]
title: string
subtitle?: string
showHeading: boolean
lang: string
}) {
if (attributes.length === 0) return null
return (
<section className="ui-panel p-8 md:p-12">
{showHeading && (
<div className="mb-6 space-y-2">
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
{subtitle && <p className="text-sm text-muted-text">{subtitle}</p>}
</div>
)}
<div className="grid gap-3">
{attributes.map((attribute) => {
const label = typeof attribute.attributeDefinition.label === 'string'
? attribute.attributeDefinition.label
: resolveLocalized(attribute.attributeDefinition.label, lang)
return (
<div key={attribute.id} className="flex flex-col gap-2 rounded-2xl border border-line-primary/10 bg-surface-card px-5 py-4 md:flex-row md:items-center md:justify-between">
<span className="text-sm font-semibold text-text-main">{label}</span>
<span className="text-sm text-muted-text">{formatAttributeValue(attribute)}</span>
</div>
)
})}
</div>
</section>
)
}
function RelatedProductsSection({
related,
categoryId,
categoryName,
title,
subtitle,
showHeading,
showViewAllLink,
}: {
related: Product[]
categoryId: number
categoryName: string
title: string
subtitle?: string
showHeading: boolean
showViewAllLink: boolean
}) {
if (related.length === 0) return null
return (
<section className="space-y-8">
{showHeading && (
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<p className="mb-2 ui-eyebrow">
{subtitle || `More ${categoryName}`}
</p>
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
</div>
{showViewAllLink && (
<Link href={`/category/${categoryId}`} className="font-semibold text-brand-primary transition hover:text-brand-primary-hover">
View All &rarr;
</Link>
)}
</div>
)}
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{related.map((item) => <ProductCard key={item.id} product={item} />)}
</div>
</section>
)
}
function ProductReviewsSection({
reviews,
title,
subtitle,
showHeading,
}: {
reviews: ProductReview[]
title: string
subtitle?: string
showHeading: boolean
}) {
if (reviews.length === 0) return null
const average = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
return (
<section className="ui-panel p-8 md:p-12">
{showHeading && (
<div className="mb-8 space-y-2">
<p className="ui-eyebrow">{subtitle || `${reviews.length} reviews`}</p>
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
</div>
)}
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
<div className="rounded-2xl border border-line-primary/10 bg-surface-card p-6 text-center">
<p className="text-5xl font-semibold text-brand-primary">{average.toFixed(1)}</p>
<p className="mt-2 text-xs font-semibold uppercase tracking-widest text-muted-text">Average rating</p>
</div>
<div className="grid gap-4">
{reviews.slice(0, 4).map((review) => (
<article key={review.id} className="rounded-2xl border border-line-primary/10 bg-surface-card p-5">
<div className="flex items-center justify-between gap-4">
<p className="font-semibold text-text-main">
{review.customer?.displayName || review.customerName || 'Customer'}
</p>
<p className="text-sm font-semibold text-brand-primary">{review.rating}/5</p>
</div>
{review.comment && <p className="mt-3 text-sm leading-relaxed text-muted-text">{review.comment}</p>}
</article>
))}
</div>
</div>
</section>
)
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
const { slug, lang } = await params
const id = extractIdFromSlug(slug)
@@ -72,17 +386,19 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const { slug, lang } = await params
const id = extractIdFromSlug(slug)
console.log(`[DEBUG-PRODUCT] slug="${slug}", id="${id}", lang="${lang}"`)
const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([
const [productResult, categoriesResult, detailSectionsResult, reviewsResult] = await Promise.all([
getProductResult(id, lang),
getCategoriesResult(lang),
getCmsPageResult('product-detail-layout', lang),
getProductDetailSectionsResult(id, lang),
getProductReviewsResult(id),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, bottomSections } = splitCmsLayoutSections(cmsSections)
const cmsSections = detailSectionsResult.ok ? detailSectionsResult.data : []
const reviews = reviewsResult.ok ? reviewsResult.data : []
const activeLayoutSections = cmsSections.filter((section) => section.isActive)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] product-detail-layout unavailable: ${cmsLayoutResult.error}`)
if (!detailSectionsResult.ok && detailSectionsResult.status !== 404) {
console.error(`[website-cms] product detail sections unavailable: ${detailSectionsResult.error}`)
}
if (!productResult.ok) {
@@ -90,9 +406,6 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
return (
<div className="min-h-screen bg-surface-page text-text-main">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
<div className="px-6 py-20">
<div className="ui-panel mx-auto max-w-3xl border border-rose-100 p-10 text-center">
<p className="text-lg font-semibold text-text-main">Unable to load this product</p>
@@ -112,6 +425,14 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
const defaultProductSections: ProductDetailSection[] = [
{ id: -1, productId: product.id, type: 'PRODUCT_MAIN', order: 1, payload: { layoutMode: 'standard', galleryBehavior: 'carousel', purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
{ id: -2, productId: product.id, type: 'PRODUCT_DETAILS', order: 2, payload: { title: 'Product Details', showHeading: true }, isActive: true, isLocked: false },
{ id: -3, productId: product.id, type: 'PRODUCT_SPECS', order: 3, payload: { title: 'Specifications', showHeading: true }, isActive: true, isLocked: false },
{ id: -4, productId: product.id, type: 'PRODUCT_REVIEWS', order: 4, payload: { title: 'Reviews', showHeading: true }, isActive: true, isLocked: false },
{ id: -5, productId: product.id, type: 'RELATED_PRODUCTS', order: 5, payload: { title: 'Related Products', showHeading: true, showViewAllLink: true }, isActive: true, isLocked: false },
]
const productLayoutSections = normalizeProductLayoutSections(activeLayoutSections, defaultProductSections)
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com'
const canonicalUrl = `${baseUrl}/${lang}/products/${slug}`
@@ -148,88 +469,99 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
]
}
const renderProductNativeSection = (section: ProductDetailSection, index: number) => {
const payload = payloadRecord(section)
const title = payloadText(payload, 'title', lang)
const subtitle = payloadText(payload, 'subtitle', lang)
const showHeading = payload.showHeading !== false
const sectionShellClass = `mx-auto max-w-7xl px-6 ${index === 0 ? 'pt-28 md:pt-32' : 'pt-8'} pb-8`
switch (section.type.toUpperCase()) {
case 'PRODUCT_MAIN':
return (
<div key={section.id} className={sectionShellClass}>
<ProductMainSection product={product} productName={productName} categoryName={categoryName} imageUrl={imageUrl} payload={payload} />
</div>
)
case 'PRODUCT_DETAILS':
return (
<div key={section.id} className={sectionShellClass}>
<ProductDetailsSection
details={details}
title={title || 'Product Details'}
subtitle={subtitle}
showHeading={showHeading}
/>
</div>
)
case 'PRODUCT_SPECS':
return (
<div key={section.id} className={sectionShellClass}>
<ProductSpecsSection
attributes={visibleAttributes}
title={title || 'Specifications'}
subtitle={subtitle}
showHeading={showHeading}
lang={lang}
/>
</div>
)
case 'PRODUCT_REVIEWS':
return (
<div key={section.id} className={sectionShellClass}>
<ProductReviewsSection
reviews={reviews}
title={title || 'Reviews'}
subtitle={subtitle}
showHeading={showHeading}
/>
</div>
)
case 'RELATED_PRODUCTS':
return (
<div key={section.id} className={sectionShellClass}>
<RelatedProductsSection
related={related}
categoryId={product.category.id}
categoryName={categoryName}
title={title || 'Related Products'}
subtitle={subtitle}
showHeading={showHeading}
showViewAllLink={payload.showViewAllLink !== false}
/>
</div>
)
default:
return null
}
}
return (
<div className="min-h-screen bg-surface-page pb-24 text-text-main md:pb-0">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
<div className="mx-auto max-w-7xl space-y-8 px-6 pt-28 pb-14 md:pt-32 md:pb-20">
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
{categoryName}
</Link>
<span>/</span>
<span className="text-text-main">{productName}</span>
</nav>
{productLayoutSections.map((section, index) => {
const renderedSection = isProductNativeSection(section)
? renderProductNativeSection(section, index)
: <DynamicPageRenderer key={section.id} sections={[toPageSection(section)]} categories={categories} lang={lang} />
<section className="grid gap-10 md:grid-cols-2 md:gap-16">
<ProductGallery
images={product.images?.map(img => img.url) || [imageUrl]}
productName={productName}
categoryName={categoryName}
/>
<AddToCartSection product={product} />
</section>
{details && (
<section className="ui-panel p-8 md:p-12">
<h2 className="mb-6 text-3xl font-semibold text-text-main">Product Details</h2>
<div className="product-editorial leading-relaxed text-muted-text">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{details}
</ReactMarkdown>
if (section.type.toUpperCase() === 'PRODUCT_MAIN') {
const payload = payloadRecord(section)
return (
<div key={section.id} className="contents">
{renderedSection}
{payload.layoutMode === 'sticky_gallery' && payload.showStickyPurchaseBarAfterMain !== false && (
<>
<div id={`product-main-end-${section.id}`} aria-hidden="true" className="h-px" />
<ProductStickyPurchaseBar product={product} triggerId={`product-main-end-${section.id}`} />
</>
)}
</div>
</section>
)}
)
}
{visibleAttributes.length > 0 && (
<section className="ui-panel p-8 md:p-12">
<h2 className="mb-6 text-3xl font-semibold text-text-main">Specifications</h2>
<div className="grid gap-3">
{visibleAttributes.map((attribute) => {
const label = typeof attribute.attributeDefinition.label === 'string'
? attribute.attributeDefinition.label
: resolveLocalized(attribute.attributeDefinition.label, lang)
return (
<div key={attribute.id} className="flex flex-col gap-2 rounded-2xl border border-line-primary/10 bg-surface-card px-5 py-4 md:flex-row md:items-center md:justify-between">
<span className="text-sm font-semibold text-text-main">{label}</span>
<span className="text-sm text-muted-text">{formatAttributeValue(attribute)}</span>
</div>
)
})}
</div>
</section>
)}
{related.length > 0 && (
<section className="space-y-8">
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<p className="mb-2 ui-eyebrow">
More {categoryName}
</p>
<h2 className="text-3xl font-semibold text-text-main">Related Products</h2>
</div>
<Link href={`/category/${product.category.id}`} className="font-semibold text-brand-primary transition hover:text-brand-primary-hover">
View All &rarr;
</Link>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{related.map((item) => <ProductCard key={item.id} product={item} />)}
</div>
</section>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
return renderedSection
})}
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { Kanit, Sarabun, Playfair_Display } from 'next/font/google';
import './globals.css';
const sarabun = Sarabun({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-sarabun',
});
const kanit = Kanit({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-kanit',
});
const playfairDisplay = Playfair_Display({
subsets: ['latin'],
display: 'swap',
weight: ['400', '500', '600', '700'],
variable: '--font-playfair',
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="th" className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased">
{children}
</body>
</html>
);
}
@@ -33,9 +33,11 @@ function resolveOptionValue(val: string, options: any[] | null | undefined, lang
interface Props {
product: Product
showBrandMeta?: boolean
showMobileStickyBar?: boolean
}
export default function AddToCartSection({ product }: Props) {
export default function AddToCartSection({ product, showBrandMeta = true, showMobileStickyBar = true }: Props) {
const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
if (product.variants && product.variants.length > 0) {
const first = product.variants[0];
@@ -195,37 +197,41 @@ export default function AddToCartSection({ product }: Props) {
)}
</div>
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
<div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
<span className="font-semibold text-text-main">{product.brand.name}</span>
{showBrandMeta && (
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
<div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
<span className="font-semibold text-text-main">{product.brand.name}</span>
</div>
<div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
{categoryName}
</Link>
</div>
</div>
<div>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
{categoryName}
</Link>
</div>
</div>
)}
</div>
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
{currentVariantName || 'Total Price'}
</span>
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span>
{showMobileStickyBar && (
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
{currentVariantName || 'Total Price'}
</span>
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span>
</div>
<button
onClick={handleAddToCart}
disabled={!currentVariant}
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{currentVariant ? 'Add to Cart' : 'Unavailable'}
</button>
</div>
<button
onClick={handleAddToCart}
disabled={!currentVariant}
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{currentVariant ? 'Add to Cart' : 'Unavailable'}
</button>
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,145 @@
"use client"
import { useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import Link from './LocalizedLink'
import { Heart, Search, ShoppingBag, Star, User } from 'lucide-react'
import { formatPrice, getCategoryImage, type Product } from '@homaroma/types'
import { useCartStore } from '../store/cartStore'
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
import { useLang } from '../hooks/useLang'
function resolveOptionValue(val: string, options: Product['options'], lang: string): string {
if (!val || !options || !Array.isArray(options)) return val
for (const option of options) {
if (!Array.isArray(option.values)) continue
for (const item of option.values) {
const base = typeof item === 'string' ? item : item.th || item.en || Object.values(item)[0] || ''
if (base === val) {
if (typeof item === 'string') return item
return item[lang] ?? item[lang.slice(0, 2)] ?? item.th ?? item.en ?? Object.values(item)[0] ?? base
}
}
}
return val
}
export default function ProductStickyPurchaseBar({
product,
triggerId,
}: {
product: Product
triggerId: string
}) {
const lang = useLang()
const [isVisible, setIsVisible] = useState(false)
const { addToCart, toggleCart } = useCartStore()
const productName = resolveLocalized(product.name, lang) || product.brand.name
const categoryName = resolveCategoryName(product.category, lang)
const variant = product.variants?.[0]
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const variantName = useMemo(() => {
if (!variant) return ''
return [variant.option1, variant.option2, variant.option3]
.filter(Boolean)
.map((option) => resolveOptionValue(option, product.options, lang))
.join(' / ')
}, [lang, product.options, variant])
const handleAddToCart = () => {
if (!variant) return
addToCart({
productId: product.id,
variantId: variant.id,
quantity: 1,
price: variant.price || 0,
productName,
variantName: variantName || undefined,
productImage: imageUrl,
})
toggleCart(true)
}
useEffect(() => {
const updateVisibility = () => {
const trigger = document.getElementById(triggerId)
if (!trigger) {
setIsVisible(false)
return
}
setIsVisible(trigger.getBoundingClientRect().top <= 0)
}
updateVisibility()
window.addEventListener('scroll', updateVisibility, { passive: true })
window.addEventListener('resize', updateVisibility)
return () => {
window.removeEventListener('scroll', updateVisibility)
window.removeEventListener('resize', updateVisibility)
}
}, [triggerId])
return (
<div className={`fixed left-0 right-0 top-0 z-50 hidden border-b border-line-primary/15 bg-surface-page/98 px-6 py-3 backdrop-blur transition duration-300 md:block ${
isVisible ? 'translate-y-0 opacity-100' : 'pointer-events-none -translate-y-full opacity-0'
}`}>
<div className="relative mx-auto flex max-w-7xl items-start justify-between gap-6">
<div className="absolute left-1/2 top-0 hidden -translate-x-1/2 md:block">
<Link href="/" className="text-3xl font-bold uppercase tracking-[0.22em] text-text-main">
HomAroma
</Link>
</div>
<div className="flex min-w-0 items-center gap-5 pt-9 md:max-w-[48%]">
<div className="relative h-12 w-12 shrink-0 overflow-hidden bg-surface-card">
<Image src={imageUrl} alt={productName} fill className="object-cover" sizes="56px" />
</div>
<div className="min-w-0">
<p className="truncate text-base font-semibold uppercase tracking-[0.18em] text-text-main">{productName}</p>
<p className="mt-1 truncate text-sm text-muted-text">
{variantName || categoryName}
</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-6">
<div className="hidden items-center gap-5 text-text-main md:flex">
<button type="button" aria-label="Search" className="transition hover:text-brand-primary">
<Search className="h-5 w-5" strokeWidth={1.6} />
</button>
<Link href="/profile" aria-label="Account" className="transition hover:text-brand-primary">
<User className="h-5 w-5" strokeWidth={1.6} />
</Link>
<Link href="/wishlist" aria-label="Wishlist" className="transition hover:text-brand-primary">
<Star className="h-5 w-5" strokeWidth={1.6} />
</Link>
<Link href="/wishlist" aria-label="Favorites" className="transition hover:text-brand-primary">
<Heart className="h-5 w-5" strokeWidth={1.6} />
</Link>
<button type="button" aria-label="Cart" onClick={() => toggleCart(true)} className="transition hover:text-brand-primary">
<ShoppingBag className="h-5 w-5" strokeWidth={1.6} />
</button>
</div>
<div className="flex items-center gap-8">
<p className="hidden text-base font-semibold uppercase tracking-[0.16em] text-text-main sm:block">{formatPrice(variant?.price ?? 0)}</p>
<button
type="button"
onClick={handleAddToCart}
disabled={!variant}
className="bg-text-main px-8 py-4 text-xs font-bold uppercase tracking-widest text-surface-page transition hover:bg-brand-primary disabled:cursor-not-allowed disabled:opacity-50 md:min-w-32"
>
{variant ? 'Add to Cart' : 'Unavailable'}
</button>
</div>
</div>
</div>
</div>
)
}
+21 -1
View File
@@ -1,4 +1,4 @@
import type { Article, Category, Order, Page, Product } from '@homaroma/types'
import type { Article, Category, Order, Page, Product, ProductDetailSection } from '@homaroma/types'
import { API_BASE } from './api'
const isDev = process.env.NODE_ENV === 'development'
@@ -19,6 +19,18 @@ export type Coupon = {
expiresAt?: string | null
}
export type ProductReview = {
id: number
rating: number
comment?: string | null
customerName?: string | null
createdAt: string
customer?: {
displayName?: string | null
pictureUrl?: string | null
} | null
}
export type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; status?: number }
@@ -104,6 +116,14 @@ export async function getProductResult(id: string | number, lang = 'all'): Promi
return await fetchJsonResult<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getProductDetailSectionsResult(id: string | number, lang = 'all'): Promise<ApiResult<ProductDetailSection[]>> {
return await fetchJsonResult<ProductDetailSection[]>(`/products/${id}/detail-sections?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getProductReviewsResult(id: string | number): Promise<ApiResult<ProductReview[]>> {
return await fetchJsonResult<ProductReview[]>(`/reviews/product/${id}`, { next: { revalidate: defaultRevalidate } })
}
export async function getProducts(query = '', lang = 'all'): Promise<Product[]> {
const separator = query ? '&' : ''
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
+46 -1
View File
@@ -300,6 +300,18 @@ export interface PageSection {
updatedAt?: string
}
export interface ProductDetailSection {
id: number
productId: number
type: string
order: number
payload: any
isActive: boolean
isLocked: boolean
createdAt?: string
updatedAt?: string
}
export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
export const HeroSectionPayloadSchema = z.object({
@@ -463,6 +475,20 @@ export const ProductCarouselSectionPayloadSchema = z.object({
}).strict();
export type ProductCarouselSectionPayload = z.infer<typeof ProductCarouselSectionPayloadSchema>;
export const ProductLayoutSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
subtitle: LocalizedStringSchema.optional(),
showHeading: z.boolean().optional(),
showViewAllLink: z.boolean().optional(),
layoutMode: z.enum(['standard', 'sticky_gallery']).optional(),
galleryBehavior: z.enum(['carousel', 'stacked']).optional(),
purchaseCardSticky: z.boolean().optional(),
showStickyPurchaseBarAfterMain: z.boolean().optional(),
showBreadcrumbs: z.boolean().optional(),
showBrandMeta: z.boolean().optional(),
}).strict();
export type ProductLayoutSectionPayload = z.infer<typeof ProductLayoutSectionPayloadSchema>;
// Aliases for backwards compatibility in ScentGuideSectionSchema
export const HeroArticlePayloadSchema = HeroArticleSectionPayloadSchema;
export type HeroArticlePayload = HeroArticleSectionPayload;
@@ -598,6 +624,25 @@ export const PAGE_SECTION_TYPES = [
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
'HERO_V2', 'USP_BAR', 'CUSTOM'
'HERO_V2', 'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS',
'PRODUCT_REVIEWS', 'RELATED_PRODUCTS', 'CUSTOM'
] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number];
export const PRODUCT_DETAIL_SECTION_TYPES = [
'PRODUCT_MAIN',
'HERO',
'HERO_V2',
'PRODUCT_DETAILS',
'PRODUCT_SPECS',
'PRODUCT_REVIEWS',
'RELATED_PRODUCTS',
'RICH_TEXT',
'TEXT',
'BANNER',
'PRODUCT_CAROUSEL',
'EDITORIAL_TILE',
'QUOTE_CALLOUT',
'CUSTOM',
] as const;
export type ProductDetailSectionType = typeof PRODUCT_DETAIL_SECTION_TYPES[number];