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

No files matched your search

+2
View File
@@ -4,6 +4,7 @@ import DashboardPage from './pages/DashboardPage'
import ProductsPage from './pages/ProductsPage' import ProductsPage from './pages/ProductsPage'
import ProductEditPage from './pages/ProductEditPage' import ProductEditPage from './pages/ProductEditPage'
import ProductBomPage from './pages/ProductBomPage' import ProductBomPage from './pages/ProductBomPage'
import ProductCmsPage from './pages/ProductCmsPage'
import CategoriesPage from './pages/CategoriesPage' import CategoriesPage from './pages/CategoriesPage'
import CustomersPage from './pages/CustomersPage' import CustomersPage from './pages/CustomersPage'
import PageBuilderPage from './pages/PageBuilderPage' import PageBuilderPage from './pages/PageBuilderPage'
@@ -57,6 +58,7 @@ function App() {
<Route path="/product-attributes" element={<ProductAttributesPage />} /> <Route path="/product-attributes" element={<ProductAttributesPage />} />
<Route path="/products/new" element={<ProductEditPage />} /> <Route path="/products/new" element={<ProductEditPage />} />
<Route path="/products/:id/edit" 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="/products/:id/bom" element={<ProductBomPage />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
<Route path="/customers" element={<CustomersPage />} /> <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 HeroV2Form from '../components/page-builder/HeroV2Form';
import UspBarForm from '../components/page-builder/UspBarForm'; import UspBarForm from '../components/page-builder/UspBarForm';
import CustomBlockForm from '../components/page-builder/CustomBlockForm'; 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 { interface Brand {
id: number; id: number;
@@ -650,6 +659,34 @@ export default function PageBuilderPage() {
showAddToCart: true, showAddToCart: true,
}; };
break; 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': case 'ACCORDION':
defaultPayload = { defaultPayload = {
title: 'Frequently Asked Questions', title: 'Frequently Asked Questions',
@@ -1040,6 +1077,11 @@ export default function PageBuilderPage() {
<option value="SCENT_NOTES_BREAKDOWN">SCENT NOTES (Fragrance layers)</option> <option value="SCENT_NOTES_BREAKDOWN">SCENT NOTES (Fragrance layers)</option>
<option value="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</option> <option value="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</option>
<option value="PRODUCT_CAROUSEL">PRODUCT CAROUSEL (Featured products)</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="ACCORDION">ACCORDION (FAQ Style)</option>
<option value="CONTACT_INFO">CONTACT INFO (Location/Hours)</option> <option value="CONTACT_INFO">CONTACT INFO (Location/Hours)</option>
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</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>
<div className="p-6 bg-night-950/50 border-t border-white/5 flex flex-col sm:flex-row gap-4 shrink-0"> <div className="p-6 bg-night-950/50 border-t border-white/5 flex flex-col sm:flex-row gap-4 shrink-0">
<button <button
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -12,7 +12,8 @@ import {
Image as ImageIcon, Image as ImageIcon,
X, X,
UploadCloud, UploadCloud,
Loader2 Loader2,
LayoutTemplate
} from 'lucide-react' } from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types' 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" /> <Package className="w-4 h-4" />
</button> </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 <button
onClick={() => navigate(`/products/${product.id}/edit`)} 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" className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all"
> >
<Edit3 className="w-4 h-4" /> <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[] images ProductImage[]
attributes ProductAttribute[] attributes ProductAttribute[]
reviews Review[] reviews Review[]
detailSections ProductDetailSection[]
translations ProductTranslation[] translations ProductTranslation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -631,6 +632,22 @@ model PageSection {
updatedAt DateTime @updatedAt 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 { model NewsletterSubscription {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique email String @unique
+37 -2
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({ await prisma.pageSection.create({
data: { data: {
pageId: productDetailPage.id, pageId: productDetailPage.id,
type: "EDITORIAL_TILE", type: "EDITORIAL_TILE",
order: 2, order: 3,
isActive: true, isActive: true,
payload: { payload: {
title: "The [Botanical] Sourcing", 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 // 7. Create FAQ Page Layout
const faqPage = await prisma.page.upsert({ const faqPage = await prisma.page.upsert({
where: { slug: "faq-layout" }, where: { slug: "faq-layout" },
+38 -1
View File
@@ -363,9 +363,20 @@ async function main() {
await prisma.pageSection.create({ await prisma.pageSection.create({
data: { data: {
pageId: productDetailPage.id, pageId: productDetailPage.id,
type: "EDITORIAL_TILE", type: "PRODUCT_MAIN",
order: 2, order: 2,
isActive: true, 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: { payload: {
title: "The [Botanical] Sourcing", title: "The [Botanical] Sourcing",
description: "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.", 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 // 7. Create FAQ Page Layout
const faqPage = await prisma.page.upsert({ const faqPage = await prisma.page.upsert({
where: { slug: "faq-layout" }, where: { slug: "faq-layout" },
+9 -1
View File
@@ -16,6 +16,7 @@ import {
ScentNotesBreakdownSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema, QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema, ProductCarouselSectionPayloadSchema,
ProductLayoutSectionPayloadSchema,
AccordionSectionPayloadSchema, AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema, ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema, ContactFormSectionPayloadSchema,
@@ -536,7 +537,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
} }
}); });
function validateSectionPayload( export function validateSectionPayload(
type: string, type: string,
payload: any, payload: any,
): { success: boolean; issues?: z.ZodIssue[]; data?: any } { ): { success: boolean; issues?: z.ZodIssue[]; data?: any } {
@@ -594,6 +595,13 @@ function validateSectionPayload(
case "PRODUCT_CAROUSEL": case "PRODUCT_CAROUSEL":
schema = ProductCarouselSectionPayloadSchema; schema = ProductCarouselSectionPayloadSchema;
break; break;
case "PRODUCT_MAIN":
case "PRODUCT_DETAILS":
case "PRODUCT_SPECS":
case "PRODUCT_REVIEWS":
case "RELATED_PRODUCTS":
schema = ProductLayoutSectionPayloadSchema;
break;
case "ACCORDION": case "ACCORDION":
schema = AccordionSectionPayloadSchema; schema = AccordionSectionPayloadSchema;
break; break;
+246
View File
@@ -1,6 +1,25 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { localizeProductAttribute } from "../lib/productAttributes"; 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" }) export const productsRoute = new Elysia({ prefix: "/products" })
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => { .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( .put(
"/:id", "/:id",
async ({ params, body, status, validateVariants }) => { 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", slug: "product-detail-layout", title: "Product Detail Layout",
sections: [ sections: [
ANNOUNCEMENT(["Special Offer: Get 10% off your first purchase of single-origin oils", "Hand-formulated with pure botanical extracts"]), 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" }; 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"], required: ["type"],
example: `{ "type": "custom", "productIds": ${productIdListExample}, "title": ${localizedStringExample}, "limit": 4 }` 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: { ACCORDION: {
required: [], required: [],
example: `{ "title": ${localizedStringExample}, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }` 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 ProductReviews from '../components/ProductReviews'
import ProductImageGallery from '../components/ProductImageGallery' import ProductImageGallery from '../components/ProductImageGallery'
import DynamicPageRenderer from '../components/DynamicPageRenderer' 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 { useCartStore } from '../store/cartStore'
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'dompurify' 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] ?? '' 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 (!val) return '';
if (!options || !Array.isArray(options)) return val; if (!options || !Array.isArray(options)) return val;
for (const option of options) { for (const option of options) {
if (!option.values || !Array.isArray(option.values)) continue; if (!option.values || !Array.isArray(option.values)) continue;
for (const v of option.values) { 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 (baseStr === val) {
if (typeof v === 'string') return v; return resolveTranslatable(v, lang) || baseStr;
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? baseStr;
} }
} }
} }
@@ -50,6 +49,63 @@ function formatAttributeValue(attribute: ProductAttribute): string {
return JSON.stringify(value) 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() { export default function ProductDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -59,7 +115,7 @@ export default function ProductDetailPage() {
const [selectedVariant, setSelectedVariant] = useState<number>(0) const [selectedVariant, setSelectedVariant] = useState<number>(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [cmsSections, setCmsSections] = useState<PageSection[]>([]) const [cmsSections, setCmsSections] = useState<ProductDetailSection[]>([])
const { addToCart } = useCartStore() const { addToCart } = useCartStore()
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
@@ -79,27 +135,12 @@ export default function ProductDetailPage() {
.catch(console.error) .catch(console.error)
}, [i18n.language]) }, [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(() => { useEffect(() => {
if (!id) return if (!id) return
setLoading(true) setLoading(true)
setError(null) setError(null)
setSelectedVariant(0) setSelectedVariant(0)
setCmsSections([])
fetch(`${API_BASE}/products/${id}?lang=${i18n.language}`) fetch(`${API_BASE}/products/${id}?lang=${i18n.language}`)
.then(async (r) => { .then(async (r) => {
@@ -113,6 +154,11 @@ export default function ProductDetailPage() {
const relJson = await relRes.json() const relJson = await relRes.json()
const relData: Product[] = relJson.data || [] const relData: Product[] = relJson.data || []
setRelated(relData.filter((p) => p.id !== data.id).slice(0, 6)) 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)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@@ -156,6 +202,10 @@ export default function ProductDetailPage() {
const currentVariant = product.variants[selectedVariant] const currentVariant = product.variants[selectedVariant]
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute)) 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 = () => { const handleAddToCart = () => {
if (!product || !currentVariant) return if (!product || !currentVariant) return
@@ -166,211 +216,273 @@ export default function ProductDetailPage() {
price: currentVariant.price || 0, price: currentVariant.price || 0,
productName: displayName, productName: displayName,
variantName: [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '), 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 activeLayoutSections = cmsSections.filter(s => s.isActive);
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR'); 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 ( const renderProductMainSection = () => (
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg"> <div className="space-y-8">
{announcementSections.length > 0 && ( {/* Breadcrumb */}
<DynamicPageRenderer <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">
sections={announcementSections} <Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link>
categories={categories} <span>/</span>
categoriesLoading={loading} <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} /> {/* Product Detail */}
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-28 pb-24 space-y-8"> {/* Image Gallery */}
{/* Breadcrumb */} <div className="w-full">
<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"> <ProductImageGallery
<Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link> images={product.images || []}
<span>/</span> fallback={fallbackImage}
<Link to="/shop" className="hover:text-spa-forest transition-colors">{t('common.shop')}</Link> alt={displayName}
<span>/</span> badgeText={categoryName}
<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>
</div> </div>
{/* Product Detail */} {/* Info */}
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start"> <div className="space-y-8">
{/* Image Gallery */} <div className="space-y-2">
<div className="w-full"> <p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">
<ProductImageGallery {categoryName}
images={product.images || []} </p>
fallback={getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')} <h1 className="text-5xl font-bold leading-tight text-pop-black">
alt={displayName} {displayName}
badgeText={typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''} </h1>
/> <p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
{t('product.premiumCollection')}
</p>
</div> </div>
{/* Info */} {productDescription && (
<div className="space-y-8"> <p className="text-spa-stone leading-relaxed">{productDescription}</p>
<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>
{resolveTranslatable(product.description, i18n.language) && ( {/* Price */}
<p className="text-spa-stone leading-relaxed">{resolveTranslatable(product.description, i18n.language)}</p> <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 */} {/* Variant Selector */}
<div className="flex items-baseline gap-3"> <div className="space-y-3">
<span className="text-4xl font-bold text-rose-500"> <p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
{formatPrice(currentVariant?.price)} {t('product.selectSize')}
</span> </p>
{currentVariant && ( <div className="flex flex-wrap gap-3">
<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> {product.variants.map((v, i) => (
)} <button
</div> key={v.id}
onClick={() => setSelectedVariant(i)}
{visibleAttributes.length > 0 && ( className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 border ${
<section className="space-y-4 border-t border-spa-stone/10 pt-6"> selectedVariant === i
<div> ? 'bg-spa-forest text-spa-light border-spa-forest shadow-md'
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">Product Specs</p> : 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
<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"
> >
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''} {[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
</Link> <span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
</div> {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> </div>
</div>
</div>
);
{/* Full Details Section */} const renderProductDetailsSection = (section: ProductDetailSection) => {
{resolveTranslatable(product.details, i18n.language) && ( if (!productDetails) return null;
<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> const payload = payloadRecord(section);
<div const title = payloadText(payload, 'title', i18n.language) || t('product.details');
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone" const subtitle = payloadText(payload, 'subtitle', i18n.language);
dangerouslySetInnerHTML={{ const showHeading = payload.showHeading !== false;
__html: DOMPurify.sanitize(
marked.parse(resolveTranslatable(product.details, i18n.language).replace(/\\n/g, '\n'), { async: false }) as string 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>
)} )}
<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 */} const renderProductSpecsSection = (section: ProductDetailSection) => {
<ProductReviews productId={product.id} /> if (visibleAttributes.length === 0) return null;
{/* Related Products */} const payload = payloadRecord(section);
{related.length > 0 && ( const title = payloadText(payload, 'title', i18n.language) || 'Specifications';
<div className="space-y-8 border-t border-spa-stone/10 pt-16"> const subtitle = payloadText(payload, 'subtitle', i18n.language);
<div className="flex justify-between items-end"> const showHeading = payload.showHeading !== false;
<div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2"> return (
{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'] })} <section className="space-y-4 border-t border-spa-stone/10 pt-8">
</p> {showHeading && (
<h2 className="text-3xl font-bold text-pop-black">{t('product.relatedProducts')}</h2> <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>
)
})}
</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 <Link
to={`/category/${product.category.id}`} to={`/category/${product.category.id}`}
className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline" className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline"
> >
{t('common.viewAll')} {t('common.viewAll')}
</Link> </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>
)} )}
<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>
);
};
{bottomSections.length > 0 && ( const renderProductNativeSection = (section: ProductDetailSection, index: number) => {
<DynamicPageRenderer const shellClass = `max-w-7xl mx-auto px-6 ${index === 0 ? 'pt-24 md:pt-28' : 'pt-8'} pb-8`;
sections={bottomSections}
categories={categories} switch (section.type.toUpperCase()) {
categoriesLoading={loading} 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} /> <Footer categories={categories} />
+6
View File
@@ -19,6 +19,12 @@ const nextConfig: NextConfig = {
hostname: "elysia3051.kitizone.com", hostname: "elysia3051.kitizone.com",
pathname: "/**", pathname: "/**",
}, },
{
protocol: "http",
hostname: "localhost",
port: "3051",
pathname: "/uploads/**",
},
], ],
}, },
}; };
+19 -44
View File
@@ -1,7 +1,5 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Kanit, Sarabun, Playfair_Display } from 'next/font/google';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import '../globals.css';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import ClientProviders from '@/components/ClientProviders'; import ClientProviders from '@/components/ClientProviders';
@@ -11,27 +9,6 @@ import type { MenuItem, StoreSettings } from '@homaroma/types';
import { isSupportedLocale } from '@/lib/locales'; import { isSupportedLocale } from '@/lib/locales';
import type { CSSProperties } from 'react'; 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> { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params; const { lang } = await params;
const settings = await getStoreSettings(lang); 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 // 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 // Fetch categories for Navbar and Footer on the server
async function getCategories(lang: string) { async function getCategories(lang: string) {
try { 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 []; if (!res.ok) return [];
return await res.json(); return await res.json();
} catch (error) { } catch (error) {
@@ -65,7 +42,7 @@ async function getCategories(lang: string) {
// Fetch mega menu on the server — eliminates client-side flash // Fetch mega menu on the server — eliminates client-side flash
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> { async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
try { 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 []; if (!res.ok) return [];
const data = await res.json() as { payload?: MegaMenuPayload[] }; const data = await res.json() as { payload?: MegaMenuPayload[] };
return Array.isArray(data.payload) ? data.payload : []; 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[] }> { async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
const fetchMenu = async (slug: string): Promise<MenuItem[]> => { const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
try { 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 []; if (!res.ok) return [];
const data = await res.json() as { items?: MenuItem[] }; const data = await res.json() as { items?: MenuItem[] };
return Array.isArray(data.items) 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 // Fetch store settings (social links, footer texts) on the server
async function getStoreSettings(lang: string): Promise<StoreSettings | null> { async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
try { 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; if (!res.ok) return null;
return await res.json() as StoreSettings; return await res.json() as StoreSettings;
} catch (error) { } catch (error) {
@@ -183,21 +160,19 @@ export default async function RootLayout({
const themeStyle = buildThemeStyle(settings) const themeStyle = buildThemeStyle(settings)
return ( return (
<html lang={lang} className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}> <div className="min-h-screen bg-surface-page text-text-main" lang={lang} style={themeStyle}>
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased" style={themeStyle}> <ClientProviders>
<ClientProviders> <Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} /> <main className="global-main">
<main className="global-main"> {children}
{children} </main>
</main> <Footer
<Footer categories={categories}
categories={categories} exploreMenu={footerMenus.exploreMenu}
exploreMenu={footerMenus.exploreMenu} supportMenu={footerMenus.supportMenu}
supportMenu={footerMenus.supportMenu} settings={settings}
settings={settings} />
/> </ClientProviders>
</ClientProviders> </div>
</body>
</html>
); );
} }
@@ -1,21 +1,70 @@
import Link from '@/components/LocalizedLink' import Link from '@/components/LocalizedLink'
import Image from 'next/image'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Image from 'next/image'
import { generateSeoMetadata } from '@/lib/seo' import { generateSeoMetadata } from '@/lib/seo'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' 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 AddToCartSection from '@/components/AddToCartSection'
import ProductGallery from '@/components/ProductGallery' import ProductGallery from '@/components/ProductGallery'
import ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard' import ProductCard from '@/components/ProductCard'
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms' import { getCategoriesResult, getProduct, getProductDetailSectionsResult, getProductResult, getProductReviewsResult, getProductsResult, type ProductReview } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveCategoryName, resolveLocalized } from '@/lib/localization' import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
import { extractIdFromSlug, generateProductSlug } from '@/lib/slugify' import { extractIdFromSlug, generateProductSlug } from '@/lib/slugify'
import type { ProductAttribute } from '@homaroma/types' 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 { function formatAttributeValue(attribute: ProductAttribute): string {
const value = attribute.value const value = attribute.value
if (value === null || value === undefined || value === '') return '' 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) 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> { export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
const { slug, lang } = await params const { slug, lang } = await params
const id = extractIdFromSlug(slug) const id = extractIdFromSlug(slug)
@@ -72,17 +386,19 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const { slug, lang } = await params const { slug, lang } = await params
const id = extractIdFromSlug(slug) const id = extractIdFromSlug(slug)
console.log(`[DEBUG-PRODUCT] slug="${slug}", id="${id}", lang="${lang}"`) 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), getProductResult(id, lang),
getCategoriesResult(lang), getCategoriesResult(lang),
getCmsPageResult('product-detail-layout', lang), getProductDetailSectionsResult(id, lang),
getProductReviewsResult(id),
]) ])
const categories = categoriesResult.ok ? categoriesResult.data : [] const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : [] const cmsSections = detailSectionsResult.ok ? detailSectionsResult.data : []
const { announcementSections, bottomSections } = splitCmsLayoutSections(cmsSections) const reviews = reviewsResult.ok ? reviewsResult.data : []
const activeLayoutSections = cmsSections.filter((section) => section.isActive)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) { if (!detailSectionsResult.ok && detailSectionsResult.status !== 404) {
console.error(`[website-cms] product-detail-layout unavailable: ${cmsLayoutResult.error}`) console.error(`[website-cms] product detail sections unavailable: ${detailSectionsResult.error}`)
} }
if (!productResult.ok) { if (!productResult.ok) {
@@ -90,9 +406,6 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
return ( return (
<div className="min-h-screen bg-surface-page text-text-main"> <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="px-6 py-20">
<div className="ui-panel mx-auto max-w-3xl border border-rose-100 p-10 text-center"> <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> <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 details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName) const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute)) 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 baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com'
const canonicalUrl = `${baseUrl}/${lang}/products/${slug}` 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 ( return (
<div className="min-h-screen bg-surface-page pb-24 text-text-main md:pb-0"> <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(jsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} /> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
{announcementSections.length > 0 && ( {productLayoutSections.map((section, index) => {
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} /> const renderedSection = isProductNativeSection(section)
)} ? renderProductNativeSection(section, index)
<div className="mx-auto max-w-7xl space-y-8 px-6 pt-28 pb-14 md:pt-32 md:pb-20"> : <DynamicPageRenderer key={section.id} sections={[toPageSection(section)]} categories={categories} lang={lang} />
<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"> if (section.type.toUpperCase() === 'PRODUCT_MAIN') {
<ProductGallery const payload = payloadRecord(section)
images={product.images?.map(img => img.url) || [imageUrl]} return (
productName={productName} <div key={section.id} className="contents">
categoryName={categoryName} {renderedSection}
/> {payload.layoutMode === 'sticky_gallery' && payload.showStickyPurchaseBarAfterMain !== false && (
<>
<AddToCartSection product={product} /> <div id={`product-main-end-${section.id}`} aria-hidden="true" className="h-px" />
</section> <ProductStickyPurchaseBar product={product} triggerId={`product-main-end-${section.id}`} />
</>
{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>
</div> </div>
</section> )
)} }
{visibleAttributes.length > 0 && ( return renderedSection
<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} />
)}
</div> </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 { interface Props {
product: Product 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[]>(() => { const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
if (product.variants && product.variants.length > 0) { if (product.variants && product.variants.length > 0) {
const first = product.variants[0]; const first = product.variants[0];
@@ -195,37 +197,41 @@ export default function AddToCartSection({ product }: Props) {
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text"> {showBrandMeta && (
<div> <div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span> <div>
<span className="font-semibold text-text-main">{product.brand.name}</span> <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>
<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>
<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"> {showMobileStickyBar && (
<div className="flex items-center justify-between gap-4"> <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 flex-col"> <div className="flex items-center justify-between gap-4">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70"> <div className="flex flex-col">
{currentVariantName || 'Total Price'} <span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
</span> {currentVariantName || 'Total Price'}
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span> </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> </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>
</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' import { API_BASE } from './api'
const isDev = process.env.NODE_ENV === 'development' const isDev = process.env.NODE_ENV === 'development'
@@ -19,6 +19,18 @@ export type Coupon = {
expiresAt?: string | null 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> = export type ApiResult<T> =
| { ok: true; data: T } | { ok: true; data: T }
| { ok: false; error: string; status?: number } | { 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 } }) 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[]> { export async function getProducts(query = '', lang = 'all'): Promise<Product[]> {
const separator = query ? '&' : '' const separator = query ? '&' : ''
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, { const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
+46 -1
View File
@@ -300,6 +300,18 @@ export interface PageSection {
updatedAt?: string 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 LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
export const HeroSectionPayloadSchema = z.object({ export const HeroSectionPayloadSchema = z.object({
@@ -463,6 +475,20 @@ export const ProductCarouselSectionPayloadSchema = z.object({
}).strict(); }).strict();
export type ProductCarouselSectionPayload = z.infer<typeof ProductCarouselSectionPayloadSchema>; 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 // Aliases for backwards compatibility in ScentGuideSectionSchema
export const HeroArticlePayloadSchema = HeroArticleSectionPayloadSchema; export const HeroArticlePayloadSchema = HeroArticleSectionPayloadSchema;
export type HeroArticlePayload = HeroArticleSectionPayload; export type HeroArticlePayload = HeroArticleSectionPayload;
@@ -598,6 +624,25 @@ export const PAGE_SECTION_TYPES = [
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER', 'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT', 'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM', '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; ] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number]; 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];