x
This commit is contained in:
1 parent
913cbef765
commit
173c583787
22 files changed
+2847
-364
No files matched your search
@@ -4,6 +4,7 @@ import DashboardPage from './pages/DashboardPage'
|
||||
import ProductsPage from './pages/ProductsPage'
|
||||
import ProductEditPage from './pages/ProductEditPage'
|
||||
import ProductBomPage from './pages/ProductBomPage'
|
||||
import ProductCmsPage from './pages/ProductCmsPage'
|
||||
import CategoriesPage from './pages/CategoriesPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
import PageBuilderPage from './pages/PageBuilderPage'
|
||||
@@ -57,6 +58,7 @@ function App() {
|
||||
<Route path="/product-attributes" element={<ProductAttributesPage />} />
|
||||
<Route path="/products/new" element={<ProductEditPage />} />
|
||||
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
||||
<Route path="/products/:id/cms" element={<ProductCmsPage />} />
|
||||
<Route path="/products/:id/bom" element={<ProductBomPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/customers" element={<CustomersPage />} />
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type PageSection } from '@homaroma/types';
|
||||
|
||||
interface ProductLayoutBlockFormProps {
|
||||
editingSection: Partial<PageSection>;
|
||||
getLocalizedValue: (payload: unknown, field: string) => string;
|
||||
updateLocalizedValue: (field: string, value: string) => void;
|
||||
updatePayloadValue: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
const BLOCK_HELP: Record<string, string> = {
|
||||
PRODUCT_MAIN: 'Renders the product gallery and purchase panel from live product data.',
|
||||
PRODUCT_DETAILS: 'Renders the product details markdown from the selected product.',
|
||||
PRODUCT_SPECS: 'Renders product attributes and specifications when available.',
|
||||
PRODUCT_REVIEWS: 'Renders customer reviews for the selected product.',
|
||||
RELATED_PRODUCTS: 'Renders products from the same category as the current product.',
|
||||
};
|
||||
|
||||
export default function ProductLayoutBlockForm({
|
||||
editingSection,
|
||||
getLocalizedValue,
|
||||
updateLocalizedValue,
|
||||
updatePayloadValue,
|
||||
}: ProductLayoutBlockFormProps) {
|
||||
const type = editingSection.type?.toUpperCase() || '';
|
||||
const canShowViewAll = type === 'RELATED_PRODUCTS';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-cyan-glow/20 bg-cyan-glow/5 p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-cyan-glow">
|
||||
Product Detail Native Block
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-slate-400">
|
||||
{BLOCK_HELP[type] || 'This block renders live product detail content on the website.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(event) => updateLocalizedValue('title', event.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="Leave blank to use the default label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'subtitle')}
|
||||
onChange={(event) => updateLocalizedValue('subtitle', event.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="Optional supporting text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingSection.payload?.showHeading !== false}
|
||||
onChange={(event) => updatePayloadValue('showHeading', event.target.checked)}
|
||||
className="accent-cyan-glow"
|
||||
/>
|
||||
<span className="text-xs font-bold text-slate-300">Show heading</span>
|
||||
</label>
|
||||
|
||||
{canShowViewAll && (
|
||||
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingSection.payload?.showViewAllLink !== false}
|
||||
onChange={(event) => updatePayloadValue('showViewAllLink', event.target.checked)}
|
||||
className="accent-cyan-glow"
|
||||
/>
|
||||
<span className="text-xs font-bold text-slate-300">Show view all link</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,15 @@ import ContactFormForm from '../components/page-builder/ContactFormForm';
|
||||
import HeroV2Form from '../components/page-builder/HeroV2Form';
|
||||
import UspBarForm from '../components/page-builder/UspBarForm';
|
||||
import CustomBlockForm from '../components/page-builder/CustomBlockForm';
|
||||
import ProductLayoutBlockForm from '../components/page-builder/ProductLayoutBlockForm';
|
||||
|
||||
const PRODUCT_LAYOUT_BLOCK_TYPES = new Set([
|
||||
'PRODUCT_MAIN',
|
||||
'PRODUCT_DETAILS',
|
||||
'PRODUCT_SPECS',
|
||||
'PRODUCT_REVIEWS',
|
||||
'RELATED_PRODUCTS',
|
||||
]);
|
||||
|
||||
interface Brand {
|
||||
id: number;
|
||||
@@ -650,6 +659,34 @@ export default function PageBuilderPage() {
|
||||
showAddToCart: true,
|
||||
};
|
||||
break;
|
||||
case 'PRODUCT_MAIN':
|
||||
defaultPayload = {};
|
||||
break;
|
||||
case 'PRODUCT_DETAILS':
|
||||
defaultPayload = {
|
||||
title: 'Product Details',
|
||||
showHeading: true,
|
||||
};
|
||||
break;
|
||||
case 'PRODUCT_SPECS':
|
||||
defaultPayload = {
|
||||
title: 'Specifications',
|
||||
showHeading: true,
|
||||
};
|
||||
break;
|
||||
case 'PRODUCT_REVIEWS':
|
||||
defaultPayload = {
|
||||
title: 'Reviews',
|
||||
showHeading: true,
|
||||
};
|
||||
break;
|
||||
case 'RELATED_PRODUCTS':
|
||||
defaultPayload = {
|
||||
title: 'Related Products',
|
||||
showHeading: true,
|
||||
showViewAllLink: true,
|
||||
};
|
||||
break;
|
||||
case 'ACCORDION':
|
||||
defaultPayload = {
|
||||
title: 'Frequently Asked Questions',
|
||||
@@ -1040,6 +1077,11 @@ export default function PageBuilderPage() {
|
||||
<option value="SCENT_NOTES_BREAKDOWN">SCENT NOTES (Fragrance layers)</option>
|
||||
<option value="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</option>
|
||||
<option value="PRODUCT_CAROUSEL">PRODUCT CAROUSEL (Featured products)</option>
|
||||
<option value="PRODUCT_MAIN">PRODUCT MAIN (Gallery + purchase panel)</option>
|
||||
<option value="PRODUCT_DETAILS">PRODUCT DETAILS (Product markdown)</option>
|
||||
<option value="PRODUCT_SPECS">PRODUCT SPECS (Attributes)</option>
|
||||
<option value="PRODUCT_REVIEWS">PRODUCT REVIEWS (Customer reviews)</option>
|
||||
<option value="RELATED_PRODUCTS">RELATED PRODUCTS (Same category)</option>
|
||||
<option value="ACCORDION">ACCORDION (FAQ Style)</option>
|
||||
<option value="CONTACT_INFO">CONTACT INFO (Location/Hours)</option>
|
||||
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option>
|
||||
@@ -1268,6 +1310,15 @@ export default function PageBuilderPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{PRODUCT_LAYOUT_BLOCK_TYPES.has(editingSection.type?.toUpperCase() || '') && (
|
||||
<ProductLayoutBlockForm
|
||||
editingSection={editingSection}
|
||||
getLocalizedValue={getLocalizedValue}
|
||||
updateLocalizedValue={updateLocalizedValue}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="p-6 bg-night-950/50 border-t border-white/5 flex flex-col sm:flex-row gap-4 shrink-0">
|
||||
<button
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,8 @@ import {
|
||||
Image as ImageIcon,
|
||||
X,
|
||||
UploadCloud,
|
||||
Loader2
|
||||
Loader2,
|
||||
LayoutTemplate
|
||||
} from 'lucide-react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types'
|
||||
@@ -366,8 +367,16 @@ export default function ProductsPage() {
|
||||
>
|
||||
<Package className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/products/${product.id}/cms`)}
|
||||
title="Edit Product CMS"
|
||||
className="p-2 rounded-lg hover:bg-cyan-glow/10 text-slate-500 hover:text-cyan-glow transition-all"
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/products/${product.id}/edit`)}
|
||||
title="Edit Product"
|
||||
className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
|
||||
+23
@@ -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;
|
||||
@@ -67,6 +67,7 @@ model Product {
|
||||
images ProductImage[]
|
||||
attributes ProductAttribute[]
|
||||
reviews Review[]
|
||||
detailSections ProductDetailSection[]
|
||||
translations ProductTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -631,6 +632,22 @@ model PageSection {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ProductDetailSection {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
type String
|
||||
order Int
|
||||
payload Json
|
||||
isActive Boolean @default(true)
|
||||
isLocked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([productId, order])
|
||||
@@index([productId, isActive])
|
||||
}
|
||||
|
||||
model NewsletterSubscription {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
|
||||
@@ -322,12 +322,22 @@ async function main() {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Editorial banner at the bottom of Product Details
|
||||
// Native product detail hero block
|
||||
await prisma.pageSection.create({
|
||||
data: {
|
||||
pageId: productDetailPage.id,
|
||||
type: "PRODUCT_MAIN",
|
||||
order: 2,
|
||||
isActive: true,
|
||||
payload: {}
|
||||
}
|
||||
});
|
||||
// Editorial banner can now be reordered between native product blocks
|
||||
await prisma.pageSection.create({
|
||||
data: {
|
||||
pageId: productDetailPage.id,
|
||||
type: "EDITORIAL_TILE",
|
||||
order: 2,
|
||||
order: 3,
|
||||
isActive: true,
|
||||
payload: {
|
||||
title: "The [Botanical] Sourcing",
|
||||
@@ -341,6 +351,31 @@ async function main() {
|
||||
}
|
||||
}
|
||||
});
|
||||
await prisma.pageSection.createMany({
|
||||
data: [
|
||||
{
|
||||
pageId: productDetailPage.id,
|
||||
type: "PRODUCT_DETAILS",
|
||||
order: 4,
|
||||
isActive: true,
|
||||
payload: { title: "Product Details", showHeading: true },
|
||||
},
|
||||
{
|
||||
pageId: productDetailPage.id,
|
||||
type: "PRODUCT_SPECS",
|
||||
order: 5,
|
||||
isActive: true,
|
||||
payload: { title: "Specifications", showHeading: true },
|
||||
},
|
||||
{
|
||||
pageId: productDetailPage.id,
|
||||
type: "RELATED_PRODUCTS",
|
||||
order: 6,
|
||||
isActive: true,
|
||||
payload: { title: "Related Products", showHeading: true, showViewAllLink: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
// 7. Create FAQ Page Layout
|
||||
const faqPage = await prisma.page.upsert({
|
||||
where: { slug: "faq-layout" },
|
||||
@@ -659,4 +694,4 @@ main()
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
});
|
||||
//# sourceMappingURL=seed-pages.js.map
|
||||
//# sourceMappingURL=seed-pages.js.map
|
||||
@@ -363,9 +363,20 @@ async function main() {
|
||||
await prisma.pageSection.create({
|
||||
data: {
|
||||
pageId: productDetailPage.id,
|
||||
type: "EDITORIAL_TILE",
|
||||
type: "PRODUCT_MAIN",
|
||||
order: 2,
|
||||
isActive: true,
|
||||
payload: {}
|
||||
}
|
||||
});
|
||||
|
||||
// Editorial banner can now be reordered between native product blocks
|
||||
await prisma.pageSection.create({
|
||||
data: {
|
||||
pageId: productDetailPage.id,
|
||||
type: "EDITORIAL_TILE",
|
||||
order: 3,
|
||||
isActive: true,
|
||||
payload: {
|
||||
title: "The [Botanical] Sourcing",
|
||||
description: "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.",
|
||||
@@ -379,6 +390,32 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.pageSection.createMany({
|
||||
data: [
|
||||
{
|
||||
pageId: productDetailPage.id,
|
||||
type: "PRODUCT_DETAILS",
|
||||
order: 4,
|
||||
isActive: true,
|
||||
payload: { title: "Product Details", showHeading: true },
|
||||
},
|
||||
{
|
||||
pageId: productDetailPage.id,
|
||||
type: "PRODUCT_SPECS",
|
||||
order: 5,
|
||||
isActive: true,
|
||||
payload: { title: "Specifications", showHeading: true },
|
||||
},
|
||||
{
|
||||
pageId: productDetailPage.id,
|
||||
type: "RELATED_PRODUCTS",
|
||||
order: 6,
|
||||
isActive: true,
|
||||
payload: { title: "Related Products", showHeading: true, showViewAllLink: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 7. Create FAQ Page Layout
|
||||
const faqPage = await prisma.page.upsert({
|
||||
where: { slug: "faq-layout" },
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ScentNotesBreakdownSectionPayloadSchema,
|
||||
QuoteCalloutSectionPayloadSchema,
|
||||
ProductCarouselSectionPayloadSchema,
|
||||
ProductLayoutSectionPayloadSchema,
|
||||
AccordionSectionPayloadSchema,
|
||||
ContactInfoSectionPayloadSchema,
|
||||
ContactFormSectionPayloadSchema,
|
||||
@@ -536,7 +537,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
}
|
||||
});
|
||||
|
||||
function validateSectionPayload(
|
||||
export function validateSectionPayload(
|
||||
type: string,
|
||||
payload: any,
|
||||
): { success: boolean; issues?: z.ZodIssue[]; data?: any } {
|
||||
@@ -594,6 +595,13 @@ function validateSectionPayload(
|
||||
case "PRODUCT_CAROUSEL":
|
||||
schema = ProductCarouselSectionPayloadSchema;
|
||||
break;
|
||||
case "PRODUCT_MAIN":
|
||||
case "PRODUCT_DETAILS":
|
||||
case "PRODUCT_SPECS":
|
||||
case "PRODUCT_REVIEWS":
|
||||
case "RELATED_PRODUCTS":
|
||||
schema = ProductLayoutSectionPayloadSchema;
|
||||
break;
|
||||
case "ACCORDION":
|
||||
schema = AccordionSectionPayloadSchema;
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { localizeProductAttribute } from "../lib/productAttributes";
|
||||
import { hydrateSectionPayload, validateSectionPayload } from "./pages";
|
||||
|
||||
function summarizeValidationIssues(issues: any[]) {
|
||||
return issues.map((issue) => ({
|
||||
path: issue.path?.join(".") || "(root)",
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}));
|
||||
}
|
||||
|
||||
function defaultProductDetailSections(productId: number) {
|
||||
return [
|
||||
{ productId, type: "PRODUCT_MAIN", order: 1, payload: { layoutMode: "standard", galleryBehavior: "carousel", purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
|
||||
{ productId, type: "PRODUCT_DETAILS", order: 2, payload: { title: { th: "รายละเอียดสินค้า", en: "Product Details" }, showHeading: true }, isActive: true },
|
||||
{ productId, type: "PRODUCT_SPECS", order: 3, payload: { title: { th: "ข้อมูลสินค้า", en: "Specifications" }, showHeading: true }, isActive: true },
|
||||
{ productId, type: "PRODUCT_REVIEWS", order: 4, payload: { title: { th: "รีวิว", en: "Reviews" }, showHeading: true }, isActive: true },
|
||||
{ productId, type: "RELATED_PRODUCTS", order: 5, payload: { title: { th: "สินค้าที่เกี่ยวข้อง", en: "Related Products" }, showHeading: true, showViewAllLink: true }, isActive: true },
|
||||
];
|
||||
}
|
||||
|
||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
|
||||
@@ -255,6 +274,233 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
};
|
||||
}
|
||||
})
|
||||
.get("/:id/detail-sections", async ({ params, query, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!product) return status(404, { message: "Product not found" });
|
||||
|
||||
const includeInactive = query.includeInactive === "true";
|
||||
const lang = query.lang || "th";
|
||||
const sections = await prisma.productDetailSection.findMany({
|
||||
where: {
|
||||
productId: id,
|
||||
...(includeInactive ? {} : { isActive: true }),
|
||||
},
|
||||
orderBy: [{ order: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
return await Promise.all(sections.map((section: any) => hydrateSectionPayload(section, lang)));
|
||||
})
|
||||
.post("/:id/detail-sections/seed-default", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!product) return status(404, { message: "Product not found" });
|
||||
|
||||
const existingCount = await prisma.productDetailSection.count({ where: { productId: id } });
|
||||
if (existingCount > 0) {
|
||||
return status(409, { success: false, message: "Product detail sections already exist" });
|
||||
}
|
||||
|
||||
await prisma.productDetailSection.createMany({
|
||||
data: defaultProductDetailSections(id),
|
||||
});
|
||||
|
||||
const sections = await prisma.productDetailSection.findMany({
|
||||
where: { productId: id },
|
||||
orderBy: [{ order: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
return { success: true, sections };
|
||||
})
|
||||
.post("/:id/detail-sections", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
const payload = body as any;
|
||||
const type = String(payload.type || "").toUpperCase();
|
||||
const sectionPayload = payload.payload || {};
|
||||
const validationResult = validateSectionPayload(type, sectionPayload);
|
||||
if (!validationResult.success) {
|
||||
return status(400, {
|
||||
success: false,
|
||||
message: "Invalid product detail section payload",
|
||||
issues: summarizeValidationIssues(validationResult.issues || []),
|
||||
});
|
||||
}
|
||||
|
||||
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!product) return status(404, { message: "Product not found" });
|
||||
|
||||
const isProductMain = type === "PRODUCT_MAIN";
|
||||
if (isProductMain) {
|
||||
const existingMain = await prisma.productDetailSection.count({
|
||||
where: { productId: id, type: "PRODUCT_MAIN" },
|
||||
});
|
||||
if (existingMain > 0) {
|
||||
return status(409, { success: false, message: "Product main section already exists" });
|
||||
}
|
||||
}
|
||||
|
||||
const lastSection = await prisma.productDetailSection.findFirst({
|
||||
where: { productId: id },
|
||||
orderBy: { order: "desc" },
|
||||
});
|
||||
|
||||
const section = isProductMain
|
||||
? (await prisma.$transaction([
|
||||
prisma.productDetailSection.updateMany({
|
||||
where: { productId: id },
|
||||
data: { order: { increment: 1 } },
|
||||
}),
|
||||
prisma.productDetailSection.create({
|
||||
data: {
|
||||
productId: id,
|
||||
type,
|
||||
order: 1,
|
||||
payload: validationResult.data,
|
||||
isActive: true,
|
||||
isLocked: true,
|
||||
},
|
||||
}),
|
||||
]))[1]
|
||||
: await prisma.productDetailSection.create({
|
||||
data: {
|
||||
productId: id,
|
||||
type,
|
||||
order: Number.isFinite(Number(payload.order)) ? Number(payload.order) : (lastSection?.order || 0) + 1,
|
||||
payload: validationResult.data,
|
||||
isActive: payload.isActive !== false,
|
||||
isLocked: Boolean(payload.isLocked),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, section };
|
||||
})
|
||||
.put("/detail-sections/:sectionId", async ({ params, body, status }) => {
|
||||
const sectionId = parseInt(params.sectionId);
|
||||
if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
|
||||
|
||||
const existing = await prisma.productDetailSection.findUnique({ where: { id: sectionId } });
|
||||
if (!existing) return status(404, { message: "Product detail section not found" });
|
||||
|
||||
const payload = body as any;
|
||||
const type = String(payload.type || existing.type).toUpperCase();
|
||||
const sectionPayload = payload.payload === undefined ? existing.payload : payload.payload;
|
||||
const validationResult = validateSectionPayload(type, sectionPayload);
|
||||
if (!validationResult.success) {
|
||||
return status(400, {
|
||||
success: false,
|
||||
message: "Invalid product detail section payload",
|
||||
issues: summarizeValidationIssues(validationResult.issues || []),
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.isLocked) {
|
||||
if (payload.type && type !== existing.type) {
|
||||
return status(409, { success: false, message: "Locked product detail sections cannot change type" });
|
||||
}
|
||||
if (payload.isActive === false) {
|
||||
return status(409, { success: false, message: "Locked product detail sections cannot be hidden" });
|
||||
}
|
||||
if (payload.isLocked === false) {
|
||||
return status(409, { success: false, message: "Locked product detail sections cannot be unlocked" });
|
||||
}
|
||||
if (payload.order !== undefined) {
|
||||
const nextOrder = Number(payload.order);
|
||||
const canPinProductMain = existing.type === "PRODUCT_MAIN" && nextOrder === 1;
|
||||
if (!canPinProductMain && nextOrder !== existing.order) {
|
||||
return status(409, { success: false, message: "Locked product detail sections cannot change order" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const section = await prisma.productDetailSection.update({
|
||||
where: { id: sectionId },
|
||||
data: {
|
||||
type,
|
||||
payload: validationResult.data,
|
||||
...(payload.order !== undefined ? { order: Number(payload.order) } : {}),
|
||||
...(payload.isActive !== undefined ? { isActive: Boolean(payload.isActive) } : {}),
|
||||
...(payload.isLocked !== undefined ? { isLocked: Boolean(payload.isLocked) } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, section };
|
||||
})
|
||||
.put("/:id/detail-sections/reorder", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
const items = (body as any)?.sections;
|
||||
if (!Array.isArray(items)) return status(400, { message: "sections must be an array" });
|
||||
|
||||
const existingSections = await prisma.productDetailSection.findMany({
|
||||
where: { productId: id },
|
||||
select: { id: true, type: true, order: true, isLocked: true },
|
||||
});
|
||||
const requestedOrderById = new Map(
|
||||
items.map((item: any, index: number) => [
|
||||
Number(item.id),
|
||||
Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1,
|
||||
])
|
||||
);
|
||||
const lockedProductMain = existingSections.find((section) => section.isLocked && section.type === "PRODUCT_MAIN");
|
||||
if (lockedProductMain) {
|
||||
const targetOrder = requestedOrderById.get(lockedProductMain.id) ?? lockedProductMain.order;
|
||||
if (targetOrder !== 1) {
|
||||
return status(409, { success: false, message: "Product main section must stay first" });
|
||||
}
|
||||
|
||||
for (const [sectionId, targetOrder] of requestedOrderById) {
|
||||
if (sectionId !== lockedProductMain.id && targetOrder === 1) {
|
||||
return status(409, { success: false, message: "Sections cannot be moved before product main" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of existingSections) {
|
||||
if (!section.isLocked) continue;
|
||||
const targetOrder = requestedOrderById.get(section.id);
|
||||
if (targetOrder === undefined) continue;
|
||||
const canPinProductMain = section.type === "PRODUCT_MAIN" && targetOrder === 1;
|
||||
if (!canPinProductMain && targetOrder !== section.order) {
|
||||
return status(409, { success: false, message: "Locked product detail sections cannot change order" });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
items.map((item: any, index: number) =>
|
||||
prisma.productDetailSection.updateMany({
|
||||
where: { id: Number(item.id), productId: id },
|
||||
data: { order: Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1 },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const sections = await prisma.productDetailSection.findMany({
|
||||
where: { productId: id },
|
||||
orderBy: [{ order: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
return { success: true, sections };
|
||||
})
|
||||
.delete("/detail-sections/:sectionId", async ({ params, status }) => {
|
||||
const sectionId = parseInt(params.sectionId);
|
||||
if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
|
||||
|
||||
const existing = await prisma.productDetailSection.findUnique({ where: { id: sectionId } });
|
||||
if (!existing) return status(404, { message: "Product detail section not found" });
|
||||
if (existing.isLocked) {
|
||||
return status(409, { success: false, message: "Locked product detail sections cannot be deleted" });
|
||||
}
|
||||
|
||||
await prisma.productDetailSection.delete({ where: { id: sectionId } });
|
||||
return { success: true };
|
||||
})
|
||||
.put(
|
||||
"/:id",
|
||||
async ({ params, body, status, validateVariants }) => {
|
||||
|
||||
@@ -175,7 +175,11 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
slug: "product-detail-layout", title: "Product Detail Layout",
|
||||
sections: [
|
||||
ANNOUNCEMENT(["Special Offer: Get 10% off your first purchase of single-origin oils", "Hand-formulated with pure botanical extracts"]),
|
||||
EDITORIAL("The [Botanical] Sourcing", "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.", "left", "Sourcing Map", "/about"),
|
||||
{ type: "PRODUCT_MAIN", order: 2, isActive: true, payload: {} },
|
||||
EDITORIAL("The [Botanical] Sourcing", "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.", "left", "Sourcing Map", "/about", 3),
|
||||
{ type: "PRODUCT_DETAILS", order: 4, isActive: true, payload: { title: "Product Details", showHeading: true } },
|
||||
{ type: "PRODUCT_SPECS", order: 5, isActive: true, payload: { title: "Specifications", showHeading: true } },
|
||||
{ type: "RELATED_PRODUCTS", order: 6, isActive: true, payload: { title: "Related Products", showHeading: true, showViewAllLink: true } },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -288,4 +292,3 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
return { error: "Failed to seed pages" };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,6 +79,22 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
|
||||
required: ["type"],
|
||||
example: `{ "type": "custom", "productIds": ${productIdListExample}, "title": ${localizedStringExample}, "limit": 4 }`
|
||||
},
|
||||
PRODUCT_MAIN: {
|
||||
required: [],
|
||||
example: `{ }`
|
||||
},
|
||||
PRODUCT_DETAILS: {
|
||||
required: [],
|
||||
example: `{ "title": ${localizedStringExample}, "showHeading": true }`
|
||||
},
|
||||
PRODUCT_SPECS: {
|
||||
required: [],
|
||||
example: `{ "title": ${localizedStringExample}, "showHeading": true }`
|
||||
},
|
||||
RELATED_PRODUCTS: {
|
||||
required: [],
|
||||
example: `{ "title": ${localizedStringExample}, "showHeading": true, "showViewAllLink": true }`
|
||||
},
|
||||
ACCORDION: {
|
||||
required: [],
|
||||
example: `{ "title": ${localizedStringExample}, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`
|
||||
|
||||
@@ -7,7 +7,7 @@ import ProductCard from '../components/ProductCard'
|
||||
import ProductReviews from '../components/ProductReviews'
|
||||
import ProductImageGallery from '../components/ProductImageGallery'
|
||||
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type TranslatableString } from '../types'
|
||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type ProductDetailSection, type TranslatableString } from '../types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
@@ -20,17 +20,16 @@ function resolveTranslatable(value: TranslatableString | null | undefined, lang:
|
||||
return value[lang] ?? value[lang.slice(0, 2)] ?? value['th'] ?? value['en'] ?? Object.values(value)[0] ?? ''
|
||||
}
|
||||
|
||||
function resolveOptionValue(val: string, options: any[] | null | undefined, lang: string): string {
|
||||
function resolveOptionValue(val: string, options: Product['options'], lang: string): string {
|
||||
if (!val) return '';
|
||||
if (!options || !Array.isArray(options)) return val;
|
||||
|
||||
for (const option of options) {
|
||||
if (!option.values || !Array.isArray(option.values)) continue;
|
||||
for (const v of option.values) {
|
||||
const baseStr = typeof v === 'string' ? v : (v['th'] || v['en'] || Object.values(v)[0] || '');
|
||||
const baseStr = resolveTranslatable(v, 'th') || resolveTranslatable(v, 'en');
|
||||
if (baseStr === val) {
|
||||
if (typeof v === 'string') return v;
|
||||
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? baseStr;
|
||||
return resolveTranslatable(v, lang) || baseStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +49,63 @@ function formatAttributeValue(attribute: ProductAttribute): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
const productNativeSectionTypes = new Set([
|
||||
'PRODUCT_MAIN',
|
||||
'PRODUCT_DETAILS',
|
||||
'PRODUCT_SPECS',
|
||||
'PRODUCT_REVIEWS',
|
||||
'RELATED_PRODUCTS',
|
||||
])
|
||||
|
||||
function isProductNativeSection(section: ProductDetailSection) {
|
||||
return productNativeSectionTypes.has(section.type.toUpperCase())
|
||||
}
|
||||
|
||||
function payloadRecord(section: ProductDetailSection) {
|
||||
return section.payload && typeof section.payload === 'object' && !Array.isArray(section.payload)
|
||||
? section.payload as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
function payloadText(payload: Record<string, unknown>, field: string, lang: string) {
|
||||
return resolveTranslatable(payload[field] as TranslatableString | null | undefined, lang)
|
||||
}
|
||||
|
||||
function resolveCategoryDisplayName(category: Category, lang: string) {
|
||||
return resolveTranslatable(category.name, lang)
|
||||
}
|
||||
|
||||
function resolveCategoryImageName(category: Category) {
|
||||
return resolveTranslatable(category.name, 'en') || resolveTranslatable(category.name, 'th')
|
||||
}
|
||||
|
||||
function toPageSection(section: ProductDetailSection): PageSection {
|
||||
return {
|
||||
id: section.id,
|
||||
pageId: 0,
|
||||
type: section.type,
|
||||
order: section.order,
|
||||
payload: section.payload,
|
||||
isActive: section.isActive,
|
||||
createdAt: section.createdAt,
|
||||
updatedAt: section.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProductLayoutSections(
|
||||
sections: ProductDetailSection[],
|
||||
defaultSections: ProductDetailSection[],
|
||||
) {
|
||||
if (sections.length === 0) return defaultSections
|
||||
|
||||
const productMainSection = sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN' && section.isLocked)
|
||||
|| sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN')
|
||||
|| defaultSections.find((section) => section.type === 'PRODUCT_MAIN')
|
||||
const remainingSections = sections.filter((section) => section.type.toUpperCase() !== 'PRODUCT_MAIN')
|
||||
|
||||
return productMainSection ? [productMainSection, ...remainingSections] : remainingSections
|
||||
}
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
@@ -59,7 +115,7 @@ export default function ProductDetailPage() {
|
||||
const [selectedVariant, setSelectedVariant] = useState<number>(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||
const [cmsSections, setCmsSections] = useState<ProductDetailSection[]>([])
|
||||
|
||||
const { addToCart } = useCartStore()
|
||||
const { i18n, t } = useTranslation()
|
||||
@@ -79,27 +135,12 @@ export default function ProductDetailPage() {
|
||||
.catch(console.error)
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/pages/product-detail-layout?lang=${i18n.language}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('Page not found');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data && data.sections) {
|
||||
setCmsSections(data.sections);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('Product detail dynamic layout sections not loaded:', err.message);
|
||||
});
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSelectedVariant(0)
|
||||
setCmsSections([])
|
||||
|
||||
fetch(`${API_BASE}/products/${id}?lang=${i18n.language}`)
|
||||
.then(async (r) => {
|
||||
@@ -113,6 +154,11 @@ export default function ProductDetailPage() {
|
||||
const relJson = await relRes.json()
|
||||
const relData: Product[] = relJson.data || []
|
||||
setRelated(relData.filter((p) => p.id !== data.id).slice(0, 6))
|
||||
const sectionsRes = await fetch(`${API_BASE}/products/${data.id}/detail-sections?lang=${i18n.language}`)
|
||||
if (sectionsRes.ok) {
|
||||
const sections: ProductDetailSection[] = await sectionsRes.json()
|
||||
setCmsSections(sections)
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -156,6 +202,10 @@ export default function ProductDetailPage() {
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
|
||||
const categoryName = resolveCategoryDisplayName(product.category, i18n.language)
|
||||
const productDescription = resolveTranslatable(product.description, i18n.language)
|
||||
const productDetails = resolveTranslatable(product.details, i18n.language)
|
||||
const fallbackImage = getCategoryImage(resolveCategoryImageName(product.category))
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product || !currentVariant) return
|
||||
@@ -166,211 +216,273 @@ export default function ProductDetailPage() {
|
||||
price: currentVariant.price || 0,
|
||||
productName: displayName,
|
||||
variantName: [currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '),
|
||||
productImage: product.images?.[0]?.url || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')
|
||||
productImage: product.images?.[0]?.url || fallbackImage
|
||||
})
|
||||
}
|
||||
|
||||
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
|
||||
const activeLayoutSections = cmsSections.filter(s => s.isActive);
|
||||
const defaultProductSections: ProductDetailSection[] = [
|
||||
{ id: -1, productId: product.id, type: 'PRODUCT_MAIN', order: 1, payload: {}, isActive: true, isLocked: true },
|
||||
{ id: -2, productId: product.id, type: 'PRODUCT_DETAILS', order: 2, payload: { title: t('product.details'), showHeading: true }, isActive: true, isLocked: false },
|
||||
{ id: -3, productId: product.id, type: 'PRODUCT_REVIEWS', order: 3, payload: {}, isActive: true, isLocked: false },
|
||||
{ id: -4, productId: product.id, type: 'PRODUCT_SPECS', order: 4, payload: { title: 'Specifications', showHeading: true }, isActive: true, isLocked: false },
|
||||
{ id: -5, productId: product.id, type: 'RELATED_PRODUCTS', order: 5, payload: { title: t('product.relatedProducts'), showHeading: true, showViewAllLink: true }, isActive: true, isLocked: false },
|
||||
];
|
||||
const productLayoutSections = normalizeProductLayoutSections(activeLayoutSections, defaultProductSections);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer
|
||||
sections={announcementSections}
|
||||
categories={categories}
|
||||
categoriesLoading={loading}
|
||||
/>
|
||||
)}
|
||||
const renderProductMainSection = () => (
|
||||
<div className="space-y-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center flex-wrap gap-2 text-[10px] md:text-xs text-spa-stone/60 uppercase tracking-widest font-semibold mb-6 md:mb-0">
|
||||
<Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link>
|
||||
<span>/</span>
|
||||
<Link to="/shop" className="hover:text-spa-forest transition-colors">{t('common.shop')}</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
|
||||
{categoryName}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-spa-forest">{displayName}</span>
|
||||
</div>
|
||||
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-28 pb-24 space-y-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center flex-wrap gap-2 text-[10px] md:text-xs text-spa-stone/60 uppercase tracking-widest font-semibold mb-6 md:mb-0">
|
||||
<Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link>
|
||||
<span>/</span>
|
||||
<Link to="/shop" className="hover:text-spa-forest transition-colors">{t('common.shop')}</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
|
||||
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-spa-forest">{displayName}</span>
|
||||
{/* Product Detail */}
|
||||
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
|
||||
{/* Image Gallery */}
|
||||
<div className="w-full">
|
||||
<ProductImageGallery
|
||||
images={product.images || []}
|
||||
fallback={fallbackImage}
|
||||
alt={displayName}
|
||||
badgeText={categoryName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Detail */}
|
||||
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
|
||||
{/* Image Gallery */}
|
||||
<div className="w-full">
|
||||
<ProductImageGallery
|
||||
images={product.images || []}
|
||||
fallback={getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')}
|
||||
alt={displayName}
|
||||
badgeText={typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
|
||||
/>
|
||||
{/* Info */}
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">
|
||||
{categoryName}
|
||||
</p>
|
||||
<h1 className="text-5xl font-bold leading-tight text-pop-black">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
|
||||
{t('product.premiumCollection')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">
|
||||
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
|
||||
</p>
|
||||
<h1 className="text-5xl font-bold leading-tight text-pop-black">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
|
||||
{t('product.premiumCollection')}
|
||||
</p>
|
||||
</div>
|
||||
{productDescription && (
|
||||
<p className="text-spa-stone leading-relaxed">{productDescription}</p>
|
||||
)}
|
||||
|
||||
{resolveTranslatable(product.description, i18n.language) && (
|
||||
<p className="text-spa-stone leading-relaxed">{resolveTranslatable(product.description, i18n.language)}</p>
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-4xl font-bold text-rose-500">
|
||||
{formatPrice(currentVariant?.price)}
|
||||
</span>
|
||||
{currentVariant && (
|
||||
<span className="text-spa-stone/60 text-sm font-semibold">/ {[currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-4xl font-bold text-rose-500">
|
||||
{formatPrice(currentVariant?.price)}
|
||||
</span>
|
||||
{currentVariant && (
|
||||
<span className="text-spa-stone/60 text-sm font-semibold">/ {[currentVariant.option1, currentVariant.option2, currentVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleAttributes.length > 0 && (
|
||||
<section className="space-y-4 border-t border-spa-stone/10 pt-6">
|
||||
<div>
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">Product Specs</p>
|
||||
<h2 className="text-xl font-bold text-pop-black">Specifications</h2>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{visibleAttributes.map((attribute) => {
|
||||
const label = typeof attribute.attributeDefinition.label === 'string'
|
||||
? attribute.attributeDefinition.label
|
||||
: resolveTranslatable(attribute.attributeDefinition.label, i18n.language)
|
||||
return (
|
||||
<div key={attribute.id} className="flex items-start justify-between gap-6 rounded-2xl border border-spa-stone/10 bg-white/70 px-4 py-3">
|
||||
<span className="text-sm font-semibold text-pop-black">{label}</span>
|
||||
<span className="text-sm text-spa-stone text-right">{formatAttributeValue(attribute)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Variant Selector */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
|
||||
{t('product.selectSize')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.variants.map((v, i) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVariant(i)}
|
||||
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 border ${
|
||||
selectedVariant === i
|
||||
? 'bg-spa-forest text-spa-light border-spa-forest shadow-md'
|
||||
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
|
||||
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
|
||||
{formatPrice(v.price)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="hidden md:flex gap-4">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-4 bg-spa-forest text-spa-light font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
{t('cart.addToCart')}
|
||||
</button>
|
||||
<button className="bg-spa-light border border-spa-stone/20 text-spa-stone p-4 rounded-lg hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.brand', 'Brand')}</span>
|
||||
<span className="text-pop-black font-bold">{product.brand?.name || ''}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.category')}</span>
|
||||
<Link
|
||||
to={`/category/${product.category.id}`}
|
||||
className="text-pop-black font-bold hover:text-spa-forest transition-colors"
|
||||
{/* Variant Selector */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
|
||||
{t('product.selectSize')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.variants.map((v, i) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVariant(i)}
|
||||
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 border ${
|
||||
selectedVariant === i
|
||||
? 'bg-spa-forest text-spa-light border-spa-forest shadow-md'
|
||||
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
|
||||
</Link>
|
||||
</div>
|
||||
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
|
||||
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
|
||||
{formatPrice(v.price)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="hidden md:flex gap-4">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-4 bg-spa-forest text-spa-light font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
{t('cart.addToCart')}
|
||||
</button>
|
||||
<button className="bg-spa-light border border-spa-stone/20 text-spa-stone p-4 rounded-lg hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.brand', 'Brand')}</span>
|
||||
<span className="text-pop-black font-bold">{product.brand?.name || ''}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.category')}</span>
|
||||
<Link
|
||||
to={`/category/${product.category.id}`}
|
||||
className="text-pop-black font-bold hover:text-spa-forest transition-colors"
|
||||
>
|
||||
{categoryName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Full Details Section */}
|
||||
{resolveTranslatable(product.details, i18n.language) && (
|
||||
<div className="pt-16 border-t border-spa-stone/10 space-y-6">
|
||||
<h2 className="text-3xl font-bold text-pop-black">{t('product.details')}</h2>
|
||||
<div
|
||||
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(
|
||||
marked.parse(resolveTranslatable(product.details, i18n.language).replace(/\\n/g, '\n'), { async: false }) as string
|
||||
)
|
||||
}}
|
||||
/>
|
||||
const renderProductDetailsSection = (section: ProductDetailSection) => {
|
||||
if (!productDetails) return null;
|
||||
|
||||
const payload = payloadRecord(section);
|
||||
const title = payloadText(payload, 'title', i18n.language) || t('product.details');
|
||||
const subtitle = payloadText(payload, 'subtitle', i18n.language);
|
||||
const showHeading = payload.showHeading !== false;
|
||||
|
||||
return (
|
||||
<div className="pt-8 border-t border-spa-stone/10 space-y-6">
|
||||
{showHeading && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold text-pop-black">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-spa-stone">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(
|
||||
marked.parse(productDetails.replace(/\\n/g, '\n'), { async: false }) as string
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{/* Reviews Section */}
|
||||
<ProductReviews productId={product.id} />
|
||||
const renderProductSpecsSection = (section: ProductDetailSection) => {
|
||||
if (visibleAttributes.length === 0) return null;
|
||||
|
||||
{/* Related Products */}
|
||||
{related.length > 0 && (
|
||||
<div className="space-y-8 border-t border-spa-stone/10 pt-16">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2">
|
||||
{t('product.moreRelated', { category: typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.['en'] })}
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold text-pop-black">{t('product.relatedProducts')}</h2>
|
||||
const payload = payloadRecord(section);
|
||||
const title = payloadText(payload, 'title', i18n.language) || 'Specifications';
|
||||
const subtitle = payloadText(payload, 'subtitle', i18n.language);
|
||||
const showHeading = payload.showHeading !== false;
|
||||
|
||||
return (
|
||||
<section className="space-y-4 border-t border-spa-stone/10 pt-8">
|
||||
{showHeading && (
|
||||
<div>
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">Product Specs</p>
|
||||
<h2 className="text-xl font-bold text-pop-black">{title}</h2>
|
||||
{subtitle && <p className="mt-2 text-sm text-spa-stone">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3">
|
||||
{visibleAttributes.map((attribute) => {
|
||||
const label = typeof attribute.attributeDefinition.label === 'string'
|
||||
? attribute.attributeDefinition.label
|
||||
: resolveTranslatable(attribute.attributeDefinition.label, i18n.language)
|
||||
return (
|
||||
<div key={attribute.id} className="flex items-start justify-between gap-6 rounded-2xl border border-spa-stone/10 bg-white/70 px-4 py-3">
|
||||
<span className="text-sm font-semibold text-pop-black">{label}</span>
|
||||
<span className="text-sm text-spa-stone text-right">{formatAttributeValue(attribute)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRelatedProductsSection = (section: ProductDetailSection) => {
|
||||
if (related.length === 0) return null;
|
||||
|
||||
const payload = payloadRecord(section);
|
||||
const title = payloadText(payload, 'title', i18n.language) || t('product.relatedProducts');
|
||||
const subtitle = payloadText(payload, 'subtitle', i18n.language);
|
||||
const showHeading = payload.showHeading !== false;
|
||||
const showViewAllLink = payload.showViewAllLink !== false;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 border-t border-spa-stone/10 pt-8">
|
||||
{showHeading && (
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2">
|
||||
{subtitle || t('product.moreRelated', { category: categoryName })}
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold text-pop-black">{title}</h2>
|
||||
</div>
|
||||
{showViewAllLink && (
|
||||
<Link
|
||||
to={`/category/${product.category.id}`}
|
||||
className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline"
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{related.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{related.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer
|
||||
sections={bottomSections}
|
||||
categories={categories}
|
||||
categoriesLoading={loading}
|
||||
/>
|
||||
)}
|
||||
const renderProductNativeSection = (section: ProductDetailSection, index: number) => {
|
||||
const shellClass = `max-w-7xl mx-auto px-6 ${index === 0 ? 'pt-24 md:pt-28' : 'pt-8'} pb-8`;
|
||||
|
||||
switch (section.type.toUpperCase()) {
|
||||
case 'PRODUCT_MAIN':
|
||||
return <div key={section.id} className={shellClass}>{renderProductMainSection()}</div>;
|
||||
case 'PRODUCT_DETAILS':
|
||||
return <div key={section.id} className={shellClass}>{renderProductDetailsSection(section)}</div>;
|
||||
case 'PRODUCT_SPECS':
|
||||
return <div key={section.id} className={shellClass}>{renderProductSpecsSection(section)}</div>;
|
||||
case 'PRODUCT_REVIEWS':
|
||||
return <div key={section.id} className={shellClass}><ProductReviews productId={product.id} /></div>;
|
||||
case 'RELATED_PRODUCTS':
|
||||
return <div key={section.id} className={shellClass}>{renderRelatedProductsSection(section)}</div>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{productLayoutSections.map((section, index) => {
|
||||
if (isProductNativeSection(section) || section.type.toUpperCase() === 'PRODUCT_REVIEWS') {
|
||||
return renderProductNativeSection(section, index);
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicPageRenderer
|
||||
key={section.id}
|
||||
sections={[toPageSection(section)]}
|
||||
categories={categories}
|
||||
categoriesLoading={loading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Footer categories={categories} />
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ const nextConfig: NextConfig = {
|
||||
hostname: "elysia3051.kitizone.com",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
port: "3051",
|
||||
pathname: "/uploads/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Kanit, Sarabun, Playfair_Display } from 'next/font/google';
|
||||
import { notFound } from 'next/navigation';
|
||||
import '../globals.css';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import Footer from '@/components/Footer';
|
||||
import ClientProviders from '@/components/ClientProviders';
|
||||
@@ -11,27 +9,6 @@ import type { MenuItem, StoreSettings } from '@homaroma/types';
|
||||
import { isSupportedLocale } from '@/lib/locales';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const sarabun = Sarabun({
|
||||
subsets: ['latin', 'thai'],
|
||||
display: 'swap',
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
variable: '--font-sarabun',
|
||||
});
|
||||
|
||||
const kanit = Kanit({
|
||||
subsets: ['latin', 'thai'],
|
||||
display: 'swap',
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
variable: '--font-kanit',
|
||||
});
|
||||
|
||||
const playfairDisplay = Playfair_Display({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-playfair',
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const settings = await getStoreSettings(lang);
|
||||
@@ -48,12 +25,12 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
|
||||
}
|
||||
|
||||
// Cache configuration: 0 for dev (no cache), strict numbers for production
|
||||
const getCacheTime = (prodSeconds: number) => 0; // Temporary bypass for 5-day testing
|
||||
const getCacheTime = () => 0; // Temporary bypass for 5-day testing
|
||||
|
||||
// Fetch categories for Navbar and Footer on the server
|
||||
async function getCategories(lang: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: getCacheTime(3600) } });
|
||||
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: getCacheTime() } });
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
@@ -65,7 +42,7 @@ async function getCategories(lang: string) {
|
||||
// Fetch mega menu on the server — eliminates client-side flash
|
||||
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
|
||||
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime() } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { payload?: MegaMenuPayload[] };
|
||||
return Array.isArray(data.payload) ? data.payload : [];
|
||||
@@ -79,7 +56,7 @@ async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
|
||||
async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
|
||||
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
|
||||
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: getCacheTime() } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { items?: MenuItem[] };
|
||||
return Array.isArray(data.items)
|
||||
@@ -101,7 +78,7 @@ async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[];
|
||||
// Fetch store settings (social links, footer texts) on the server
|
||||
async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: getCacheTime(600) } });
|
||||
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: getCacheTime() } });
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as StoreSettings;
|
||||
} catch (error) {
|
||||
@@ -183,21 +160,19 @@ export default async function RootLayout({
|
||||
const themeStyle = buildThemeStyle(settings)
|
||||
|
||||
return (
|
||||
<html lang={lang} className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
|
||||
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased" style={themeStyle}>
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
||||
<main className="global-main">
|
||||
{children}
|
||||
</main>
|
||||
<Footer
|
||||
categories={categories}
|
||||
exploreMenu={footerMenus.exploreMenu}
|
||||
supportMenu={footerMenus.supportMenu}
|
||||
settings={settings}
|
||||
/>
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
<div className="min-h-screen bg-surface-page text-text-main" lang={lang} style={themeStyle}>
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
||||
<main className="global-main">
|
||||
{children}
|
||||
</main>
|
||||
<Footer
|
||||
categories={categories}
|
||||
exploreMenu={footerMenus.exploreMenu}
|
||||
supportMenu={footerMenus.supportMenu}
|
||||
settings={settings}
|
||||
/>
|
||||
</ClientProviders>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,70 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
import Image from 'next/image'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import Image from 'next/image'
|
||||
import { generateSeoMetadata } from '@/lib/seo'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { getCategoryImage, type Product } from '@homaroma/types'
|
||||
import { getCategoryImage, type PageSection, type Product, type ProductDetailSection } from '@homaroma/types'
|
||||
import AddToCartSection from '@/components/AddToCartSection'
|
||||
import ProductGallery from '@/components/ProductGallery'
|
||||
import ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import ProductCard from '@/components/ProductCard'
|
||||
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
import { getCategoriesResult, getProduct, getProductDetailSectionsResult, getProductResult, getProductReviewsResult, getProductsResult, type ProductReview } from '@/lib/cms'
|
||||
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
|
||||
import { extractIdFromSlug, generateProductSlug } from '@/lib/slugify'
|
||||
import type { ProductAttribute } from '@homaroma/types'
|
||||
|
||||
const productNativeSectionTypes = new Set([
|
||||
'PRODUCT_MAIN',
|
||||
'PRODUCT_DETAILS',
|
||||
'PRODUCT_SPECS',
|
||||
'PRODUCT_REVIEWS',
|
||||
'RELATED_PRODUCTS',
|
||||
])
|
||||
|
||||
function isProductNativeSection(section: ProductDetailSection) {
|
||||
return productNativeSectionTypes.has(section.type.toUpperCase())
|
||||
}
|
||||
|
||||
function payloadRecord(section: ProductDetailSection) {
|
||||
return section.payload && typeof section.payload === 'object' && !Array.isArray(section.payload)
|
||||
? section.payload as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
function payloadText(payload: Record<string, unknown>, field: string, lang: string) {
|
||||
return resolveLocalized(payload[field] as never, lang)
|
||||
}
|
||||
|
||||
function toPageSection(section: ProductDetailSection): PageSection {
|
||||
return {
|
||||
id: section.id,
|
||||
pageId: 0,
|
||||
type: section.type,
|
||||
order: section.order,
|
||||
payload: section.payload,
|
||||
isActive: section.isActive,
|
||||
createdAt: section.createdAt,
|
||||
updatedAt: section.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProductLayoutSections(
|
||||
sections: ProductDetailSection[],
|
||||
defaultSections: ProductDetailSection[],
|
||||
) {
|
||||
if (sections.length === 0) return defaultSections
|
||||
|
||||
const productMainSection = sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN' && section.isLocked)
|
||||
|| sections.find((section) => section.type.toUpperCase() === 'PRODUCT_MAIN')
|
||||
|| defaultSections.find((section) => section.type === 'PRODUCT_MAIN')
|
||||
const remainingSections = sections.filter((section) => section.type.toUpperCase() !== 'PRODUCT_MAIN')
|
||||
|
||||
return productMainSection ? [productMainSection, ...remainingSections] : remainingSections
|
||||
}
|
||||
|
||||
function formatAttributeValue(attribute: ProductAttribute): string {
|
||||
const value = attribute.value
|
||||
if (value === null || value === undefined || value === '') return ''
|
||||
@@ -34,6 +83,271 @@ async function getRelated(categoryId: number, excludeId: number, lang: string):
|
||||
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
|
||||
}
|
||||
|
||||
function ProductMainSection({
|
||||
product,
|
||||
productName,
|
||||
categoryName,
|
||||
imageUrl,
|
||||
payload,
|
||||
}: {
|
||||
product: Product
|
||||
productName: string
|
||||
categoryName: string
|
||||
imageUrl: string
|
||||
payload: Record<string, unknown>
|
||||
}) {
|
||||
const layoutMode = payload.layoutMode === 'sticky_gallery' ? 'sticky_gallery' : 'standard'
|
||||
const galleryBehavior = payload.galleryBehavior === 'stacked' ? 'stacked' : 'carousel'
|
||||
const purchaseCardSticky = payload.purchaseCardSticky !== false
|
||||
const showBreadcrumbs = payload.showBreadcrumbs !== false
|
||||
const showBrandMeta = payload.showBrandMeta !== false
|
||||
const images = product.images?.map(img => img.url).filter(Boolean) || []
|
||||
const galleryImages = images.length > 0 ? images : [imageUrl]
|
||||
|
||||
if (layoutMode === 'sticky_gallery') {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{showBreadcrumbs && (
|
||||
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
|
||||
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
|
||||
<span>/</span>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-text-main">{productName}</span>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<section className="grid gap-10 lg:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] lg:items-start lg:gap-16">
|
||||
<div className="lg:hidden">
|
||||
<ProductGallery images={galleryImages} productName={productName} categoryName={categoryName} />
|
||||
</div>
|
||||
|
||||
<div className="hidden space-y-6 lg:block">
|
||||
{galleryBehavior === 'stacked' ? (
|
||||
galleryImages.map((src, index) => (
|
||||
<div key={`${src}-${index}`} className="relative min-h-[78vh] overflow-hidden rounded-3xl bg-surface-card shadow-card">
|
||||
<Image
|
||||
src={src}
|
||||
alt={`${productName} ${index + 1}`}
|
||||
fill
|
||||
priority={index === 0}
|
||||
sizes="(min-width: 1024px) 54vw, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
{index === 0 && categoryName && (
|
||||
<div className="absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm">
|
||||
{categoryName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<ProductGallery images={galleryImages} productName={productName} categoryName={categoryName} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={purchaseCardSticky ? 'lg:sticky lg:top-24' : ''}>
|
||||
<div className="rounded-3xl border border-line-primary/15 bg-surface-card/95 p-6 shadow-card md:p-8">
|
||||
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{showBreadcrumbs && (
|
||||
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
|
||||
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
|
||||
<span>/</span>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-text-main">{productName}</span>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<section className="grid gap-10 md:grid-cols-2 md:gap-16">
|
||||
<ProductGallery
|
||||
images={galleryImages}
|
||||
productName={productName}
|
||||
categoryName={categoryName}
|
||||
/>
|
||||
|
||||
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductDetailsSection({
|
||||
details,
|
||||
title,
|
||||
subtitle,
|
||||
showHeading,
|
||||
}: {
|
||||
details: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
showHeading: boolean
|
||||
}) {
|
||||
if (!details) return null
|
||||
|
||||
return (
|
||||
<section className="ui-panel p-8 md:p-12">
|
||||
{showHeading && (
|
||||
<div className="mb-6 space-y-2">
|
||||
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-muted-text">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="product-editorial leading-relaxed text-muted-text">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{details}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductSpecsSection({
|
||||
attributes,
|
||||
title,
|
||||
subtitle,
|
||||
showHeading,
|
||||
lang,
|
||||
}: {
|
||||
attributes: ProductAttribute[]
|
||||
title: string
|
||||
subtitle?: string
|
||||
showHeading: boolean
|
||||
lang: string
|
||||
}) {
|
||||
if (attributes.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="ui-panel p-8 md:p-12">
|
||||
{showHeading && (
|
||||
<div className="mb-6 space-y-2">
|
||||
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-muted-text">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3">
|
||||
{attributes.map((attribute) => {
|
||||
const label = typeof attribute.attributeDefinition.label === 'string'
|
||||
? attribute.attributeDefinition.label
|
||||
: resolveLocalized(attribute.attributeDefinition.label, lang)
|
||||
return (
|
||||
<div key={attribute.id} className="flex flex-col gap-2 rounded-2xl border border-line-primary/10 bg-surface-card px-5 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<span className="text-sm font-semibold text-text-main">{label}</span>
|
||||
<span className="text-sm text-muted-text">{formatAttributeValue(attribute)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RelatedProductsSection({
|
||||
related,
|
||||
categoryId,
|
||||
categoryName,
|
||||
title,
|
||||
subtitle,
|
||||
showHeading,
|
||||
showViewAllLink,
|
||||
}: {
|
||||
related: Product[]
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
showHeading: boolean
|
||||
showViewAllLink: boolean
|
||||
}) {
|
||||
if (related.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
{showHeading && (
|
||||
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="mb-2 ui-eyebrow">
|
||||
{subtitle || `More ${categoryName}`}
|
||||
</p>
|
||||
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
|
||||
</div>
|
||||
{showViewAllLink && (
|
||||
<Link href={`/category/${categoryId}`} className="font-semibold text-brand-primary transition hover:text-brand-primary-hover">
|
||||
View All →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{related.map((item) => <ProductCard key={item.id} product={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductReviewsSection({
|
||||
reviews,
|
||||
title,
|
||||
subtitle,
|
||||
showHeading,
|
||||
}: {
|
||||
reviews: ProductReview[]
|
||||
title: string
|
||||
subtitle?: string
|
||||
showHeading: boolean
|
||||
}) {
|
||||
if (reviews.length === 0) return null
|
||||
|
||||
const average = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
|
||||
|
||||
return (
|
||||
<section className="ui-panel p-8 md:p-12">
|
||||
{showHeading && (
|
||||
<div className="mb-8 space-y-2">
|
||||
<p className="ui-eyebrow">{subtitle || `${reviews.length} reviews`}</p>
|
||||
<h2 className="text-3xl font-semibold text-text-main">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
|
||||
<div className="rounded-2xl border border-line-primary/10 bg-surface-card p-6 text-center">
|
||||
<p className="text-5xl font-semibold text-brand-primary">{average.toFixed(1)}</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-widest text-muted-text">Average rating</p>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{reviews.slice(0, 4).map((review) => (
|
||||
<article key={review.id} className="rounded-2xl border border-line-primary/10 bg-surface-card p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="font-semibold text-text-main">
|
||||
{review.customer?.displayName || review.customerName || 'Customer'}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-brand-primary">{review.rating}/5</p>
|
||||
</div>
|
||||
{review.comment && <p className="mt-3 text-sm leading-relaxed text-muted-text">{review.comment}</p>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
|
||||
const { slug, lang } = await params
|
||||
const id = extractIdFromSlug(slug)
|
||||
@@ -72,17 +386,19 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
const { slug, lang } = await params
|
||||
const id = extractIdFromSlug(slug)
|
||||
console.log(`[DEBUG-PRODUCT] slug="${slug}", id="${id}", lang="${lang}"`)
|
||||
const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||
const [productResult, categoriesResult, detailSectionsResult, reviewsResult] = await Promise.all([
|
||||
getProductResult(id, lang),
|
||||
getCategoriesResult(lang),
|
||||
getCmsPageResult('product-detail-layout', lang),
|
||||
getProductDetailSectionsResult(id, lang),
|
||||
getProductReviewsResult(id),
|
||||
])
|
||||
const categories = categoriesResult.ok ? categoriesResult.data : []
|
||||
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
|
||||
const { announcementSections, bottomSections } = splitCmsLayoutSections(cmsSections)
|
||||
const cmsSections = detailSectionsResult.ok ? detailSectionsResult.data : []
|
||||
const reviews = reviewsResult.ok ? reviewsResult.data : []
|
||||
const activeLayoutSections = cmsSections.filter((section) => section.isActive)
|
||||
|
||||
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
|
||||
console.error(`[website-cms] product-detail-layout unavailable: ${cmsLayoutResult.error}`)
|
||||
if (!detailSectionsResult.ok && detailSectionsResult.status !== 404) {
|
||||
console.error(`[website-cms] product detail sections unavailable: ${detailSectionsResult.error}`)
|
||||
}
|
||||
|
||||
if (!productResult.ok) {
|
||||
@@ -90,9 +406,6 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-page text-text-main">
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
<div className="px-6 py-20">
|
||||
<div className="ui-panel mx-auto max-w-3xl border border-rose-100 p-10 text-center">
|
||||
<p className="text-lg font-semibold text-text-main">Unable to load this product</p>
|
||||
@@ -112,6 +425,14 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
const details = resolveLocalized(product.details, lang)
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
|
||||
const defaultProductSections: ProductDetailSection[] = [
|
||||
{ id: -1, productId: product.id, type: 'PRODUCT_MAIN', order: 1, payload: { layoutMode: 'standard', galleryBehavior: 'carousel', purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
|
||||
{ id: -2, productId: product.id, type: 'PRODUCT_DETAILS', order: 2, payload: { title: 'Product Details', showHeading: true }, isActive: true, isLocked: false },
|
||||
{ id: -3, productId: product.id, type: 'PRODUCT_SPECS', order: 3, payload: { title: 'Specifications', showHeading: true }, isActive: true, isLocked: false },
|
||||
{ id: -4, productId: product.id, type: 'PRODUCT_REVIEWS', order: 4, payload: { title: 'Reviews', showHeading: true }, isActive: true, isLocked: false },
|
||||
{ id: -5, productId: product.id, type: 'RELATED_PRODUCTS', order: 5, payload: { title: 'Related Products', showHeading: true, showViewAllLink: true }, isActive: true, isLocked: false },
|
||||
]
|
||||
const productLayoutSections = normalizeProductLayoutSections(activeLayoutSections, defaultProductSections)
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com'
|
||||
const canonicalUrl = `${baseUrl}/${lang}/products/${slug}`
|
||||
@@ -148,88 +469,99 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
]
|
||||
}
|
||||
|
||||
const renderProductNativeSection = (section: ProductDetailSection, index: number) => {
|
||||
const payload = payloadRecord(section)
|
||||
const title = payloadText(payload, 'title', lang)
|
||||
const subtitle = payloadText(payload, 'subtitle', lang)
|
||||
const showHeading = payload.showHeading !== false
|
||||
const sectionShellClass = `mx-auto max-w-7xl px-6 ${index === 0 ? 'pt-28 md:pt-32' : 'pt-8'} pb-8`
|
||||
|
||||
switch (section.type.toUpperCase()) {
|
||||
case 'PRODUCT_MAIN':
|
||||
return (
|
||||
<div key={section.id} className={sectionShellClass}>
|
||||
<ProductMainSection product={product} productName={productName} categoryName={categoryName} imageUrl={imageUrl} payload={payload} />
|
||||
</div>
|
||||
)
|
||||
case 'PRODUCT_DETAILS':
|
||||
return (
|
||||
<div key={section.id} className={sectionShellClass}>
|
||||
<ProductDetailsSection
|
||||
details={details}
|
||||
title={title || 'Product Details'}
|
||||
subtitle={subtitle}
|
||||
showHeading={showHeading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'PRODUCT_SPECS':
|
||||
return (
|
||||
<div key={section.id} className={sectionShellClass}>
|
||||
<ProductSpecsSection
|
||||
attributes={visibleAttributes}
|
||||
title={title || 'Specifications'}
|
||||
subtitle={subtitle}
|
||||
showHeading={showHeading}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'PRODUCT_REVIEWS':
|
||||
return (
|
||||
<div key={section.id} className={sectionShellClass}>
|
||||
<ProductReviewsSection
|
||||
reviews={reviews}
|
||||
title={title || 'Reviews'}
|
||||
subtitle={subtitle}
|
||||
showHeading={showHeading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'RELATED_PRODUCTS':
|
||||
return (
|
||||
<div key={section.id} className={sectionShellClass}>
|
||||
<RelatedProductsSection
|
||||
related={related}
|
||||
categoryId={product.category.id}
|
||||
categoryName={categoryName}
|
||||
title={title || 'Related Products'}
|
||||
subtitle={subtitle}
|
||||
showHeading={showHeading}
|
||||
showViewAllLink={payload.showViewAllLink !== false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-page pb-24 text-text-main md:pb-0">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
<div className="mx-auto max-w-7xl space-y-8 px-6 pt-28 pb-14 md:pt-32 md:pb-20">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text/70">
|
||||
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
|
||||
<span>/</span>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/category/${product.category.id}`} className="transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-text-main">{productName}</span>
|
||||
</nav>
|
||||
{productLayoutSections.map((section, index) => {
|
||||
const renderedSection = isProductNativeSection(section)
|
||||
? renderProductNativeSection(section, index)
|
||||
: <DynamicPageRenderer key={section.id} sections={[toPageSection(section)]} categories={categories} lang={lang} />
|
||||
|
||||
<section className="grid gap-10 md:grid-cols-2 md:gap-16">
|
||||
<ProductGallery
|
||||
images={product.images?.map(img => img.url) || [imageUrl]}
|
||||
productName={productName}
|
||||
categoryName={categoryName}
|
||||
/>
|
||||
|
||||
<AddToCartSection product={product} />
|
||||
</section>
|
||||
|
||||
{details && (
|
||||
<section className="ui-panel p-8 md:p-12">
|
||||
<h2 className="mb-6 text-3xl font-semibold text-text-main">Product Details</h2>
|
||||
<div className="product-editorial leading-relaxed text-muted-text">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{details}
|
||||
</ReactMarkdown>
|
||||
if (section.type.toUpperCase() === 'PRODUCT_MAIN') {
|
||||
const payload = payloadRecord(section)
|
||||
return (
|
||||
<div key={section.id} className="contents">
|
||||
{renderedSection}
|
||||
{payload.layoutMode === 'sticky_gallery' && payload.showStickyPurchaseBarAfterMain !== false && (
|
||||
<>
|
||||
<div id={`product-main-end-${section.id}`} aria-hidden="true" className="h-px" />
|
||||
<ProductStickyPurchaseBar product={product} triggerId={`product-main-end-${section.id}`} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
{visibleAttributes.length > 0 && (
|
||||
<section className="ui-panel p-8 md:p-12">
|
||||
<h2 className="mb-6 text-3xl font-semibold text-text-main">Specifications</h2>
|
||||
<div className="grid gap-3">
|
||||
{visibleAttributes.map((attribute) => {
|
||||
const label = typeof attribute.attributeDefinition.label === 'string'
|
||||
? attribute.attributeDefinition.label
|
||||
: resolveLocalized(attribute.attributeDefinition.label, lang)
|
||||
return (
|
||||
<div key={attribute.id} className="flex flex-col gap-2 rounded-2xl border border-line-primary/10 bg-surface-card px-5 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<span className="text-sm font-semibold text-text-main">{label}</span>
|
||||
<span className="text-sm text-muted-text">{formatAttributeValue(attribute)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="space-y-8">
|
||||
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="mb-2 ui-eyebrow">
|
||||
More {categoryName}
|
||||
</p>
|
||||
<h2 className="text-3xl font-semibold text-text-main">Related Products</h2>
|
||||
</div>
|
||||
<Link href={`/category/${product.category.id}`} className="font-semibold text-brand-primary transition hover:text-brand-primary-hover">
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{related.map((item) => <ProductCard key={item.id} product={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
return renderedSection
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Kanit, Sarabun, Playfair_Display } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const sarabun = Sarabun({
|
||||
subsets: ['latin', 'thai'],
|
||||
display: 'swap',
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
variable: '--font-sarabun',
|
||||
});
|
||||
|
||||
const kanit = Kanit({
|
||||
subsets: ['latin', 'thai'],
|
||||
display: 'swap',
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
variable: '--font-kanit',
|
||||
});
|
||||
|
||||
const playfairDisplay = Playfair_Display({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-playfair',
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="th" className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
|
||||
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -33,9 +33,11 @@ function resolveOptionValue(val: string, options: any[] | null | undefined, lang
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
showBrandMeta?: boolean
|
||||
showMobileStickyBar?: boolean
|
||||
}
|
||||
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
export default function AddToCartSection({ product, showBrandMeta = true, showMobileStickyBar = true }: Props) {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>(() => {
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
const first = product.variants[0];
|
||||
@@ -195,37 +197,41 @@ export default function AddToCartSection({ product }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
|
||||
<span className="font-semibold text-text-main">{product.brand.name}</span>
|
||||
{showBrandMeta && (
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-line-primary/20 pt-6 text-sm text-muted-text">
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Brand</span>
|
||||
<span className="font-semibold text-text-main">{product.brand.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
|
||||
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-muted-text/70">Category</span>
|
||||
<Link href={`/category/${product.category.id}`} className="font-semibold text-text-main transition hover:text-brand-primary">
|
||||
{categoryName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
|
||||
{currentVariantName || 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span>
|
||||
{showMobileStickyBar && (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-line-primary/20 bg-surface-card/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-text/70">
|
||||
{currentVariantName || 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-brand-primary">{formatPrice(currentVariant?.price ?? 0)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!currentVariant}
|
||||
className={`ui-form-inline-button flex-1 px-5 py-3.5 ${!currentVariant ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{currentVariant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from './LocalizedLink'
|
||||
import { Heart, Search, ShoppingBag, Star, User } from 'lucide-react'
|
||||
import { formatPrice, getCategoryImage, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
function resolveOptionValue(val: string, options: Product['options'], lang: string): string {
|
||||
if (!val || !options || !Array.isArray(options)) return val
|
||||
|
||||
for (const option of options) {
|
||||
if (!Array.isArray(option.values)) continue
|
||||
for (const item of option.values) {
|
||||
const base = typeof item === 'string' ? item : item.th || item.en || Object.values(item)[0] || ''
|
||||
if (base === val) {
|
||||
if (typeof item === 'string') return item
|
||||
return item[lang] ?? item[lang.slice(0, 2)] ?? item.th ?? item.en ?? Object.values(item)[0] ?? base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
export default function ProductStickyPurchaseBar({
|
||||
product,
|
||||
triggerId,
|
||||
}: {
|
||||
product: Product
|
||||
triggerId: string
|
||||
}) {
|
||||
const lang = useLang()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const categoryName = resolveCategoryName(product.category, lang)
|
||||
const variant = product.variants?.[0]
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
|
||||
const variantName = useMemo(() => {
|
||||
if (!variant) return ''
|
||||
return [variant.option1, variant.option2, variant.option3]
|
||||
.filter(Boolean)
|
||||
.map((option) => resolveOptionValue(option, product.options, lang))
|
||||
.join(' / ')
|
||||
}, [lang, product.options, variant])
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!variant) return
|
||||
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: variant.id,
|
||||
quantity: 1,
|
||||
price: variant.price || 0,
|
||||
productName,
|
||||
variantName: variantName || undefined,
|
||||
productImage: imageUrl,
|
||||
})
|
||||
toggleCart(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const updateVisibility = () => {
|
||||
const trigger = document.getElementById(triggerId)
|
||||
if (!trigger) {
|
||||
setIsVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsVisible(trigger.getBoundingClientRect().top <= 0)
|
||||
}
|
||||
|
||||
updateVisibility()
|
||||
window.addEventListener('scroll', updateVisibility, { passive: true })
|
||||
window.addEventListener('resize', updateVisibility)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateVisibility)
|
||||
window.removeEventListener('resize', updateVisibility)
|
||||
}
|
||||
}, [triggerId])
|
||||
|
||||
return (
|
||||
<div className={`fixed left-0 right-0 top-0 z-50 hidden border-b border-line-primary/15 bg-surface-page/98 px-6 py-3 backdrop-blur transition duration-300 md:block ${
|
||||
isVisible ? 'translate-y-0 opacity-100' : 'pointer-events-none -translate-y-full opacity-0'
|
||||
}`}>
|
||||
<div className="relative mx-auto flex max-w-7xl items-start justify-between gap-6">
|
||||
<div className="absolute left-1/2 top-0 hidden -translate-x-1/2 md:block">
|
||||
<Link href="/" className="text-3xl font-bold uppercase tracking-[0.22em] text-text-main">
|
||||
HomAroma
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-5 pt-9 md:max-w-[48%]">
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden bg-surface-card">
|
||||
<Image src={imageUrl} alt={productName} fill className="object-cover" sizes="56px" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold uppercase tracking-[0.18em] text-text-main">{productName}</p>
|
||||
<p className="mt-1 truncate text-sm text-muted-text">
|
||||
{variantName || categoryName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-6">
|
||||
<div className="hidden items-center gap-5 text-text-main md:flex">
|
||||
<button type="button" aria-label="Search" className="transition hover:text-brand-primary">
|
||||
<Search className="h-5 w-5" strokeWidth={1.6} />
|
||||
</button>
|
||||
<Link href="/profile" aria-label="Account" className="transition hover:text-brand-primary">
|
||||
<User className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<Link href="/wishlist" aria-label="Wishlist" className="transition hover:text-brand-primary">
|
||||
<Star className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<Link href="/wishlist" aria-label="Favorites" className="transition hover:text-brand-primary">
|
||||
<Heart className="h-5 w-5" strokeWidth={1.6} />
|
||||
</Link>
|
||||
<button type="button" aria-label="Cart" onClick={() => toggleCart(true)} className="transition hover:text-brand-primary">
|
||||
<ShoppingBag className="h-5 w-5" strokeWidth={1.6} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<p className="hidden text-base font-semibold uppercase tracking-[0.16em] text-text-main sm:block">{formatPrice(variant?.price ?? 0)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!variant}
|
||||
className="bg-text-main px-8 py-4 text-xs font-bold uppercase tracking-widest text-surface-page transition hover:bg-brand-primary disabled:cursor-not-allowed disabled:opacity-50 md:min-w-32"
|
||||
>
|
||||
{variant ? 'Add to Cart' : 'Unavailable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Article, Category, Order, Page, Product } from '@homaroma/types'
|
||||
import type { Article, Category, Order, Page, Product, ProductDetailSection } from '@homaroma/types'
|
||||
import { API_BASE } from './api'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
@@ -19,6 +19,18 @@ export type Coupon = {
|
||||
expiresAt?: string | null
|
||||
}
|
||||
|
||||
export type ProductReview = {
|
||||
id: number
|
||||
rating: number
|
||||
comment?: string | null
|
||||
customerName?: string | null
|
||||
createdAt: string
|
||||
customer?: {
|
||||
displayName?: string | null
|
||||
pictureUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type ApiResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: string; status?: number }
|
||||
@@ -104,6 +116,14 @@ export async function getProductResult(id: string | number, lang = 'all'): Promi
|
||||
return await fetchJsonResult<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProductDetailSectionsResult(id: string | number, lang = 'all'): Promise<ApiResult<ProductDetailSection[]>> {
|
||||
return await fetchJsonResult<ProductDetailSection[]>(`/products/${id}/detail-sections?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProductReviewsResult(id: string | number): Promise<ApiResult<ProductReview[]>> {
|
||||
return await fetchJsonResult<ProductReview[]>(`/reviews/product/${id}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProducts(query = '', lang = 'all'): Promise<Product[]> {
|
||||
const separator = query ? '&' : ''
|
||||
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
||||
|
||||
Reference in new issue
Block a user