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