This commit is contained in:
Your Name committed 2026-07-01 19:32:20 -04:00
1 parent be0ee265d5
commit 10131e08bf
11 files changed
+415 -88

No files matched your search

@@ -3,6 +3,8 @@ import Image from 'next/image'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { getCategoryImage, type Product } from '@homaroma/types'
import AddToCartSection from '@/components/AddToCartSection'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
@@ -10,6 +12,7 @@ import ProductCard from '@/components/ProductCard'
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
import { extractIdFromSlug, generateProductSlug } from '@/lib/slugify'
import type { ProductAttribute } from '@homaroma/types'
function formatAttributeValue(attribute: ProductAttribute): string {
@@ -30,8 +33,9 @@ async function getRelated(categoryId: number, excludeId: number, lang: string):
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
}
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
const { id, lang } = await params
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
const { slug, lang } = await params
const id = extractIdFromSlug(slug)
const product = await getProduct(id, lang)
if (!product) return { title: 'Product Not Found' }
@@ -40,12 +44,22 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
const categoryName = resolveCategoryName(product.category, lang)
const description = resolveLocalized(product.description, lang)
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com'
const customLanguages: Record<string, string> = {}
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko']
for (const l of languages) {
const locName = resolveLocalized(product.name, l) || product.brand.name
customLanguages[l] = `${baseUrl}/${l}/products/${generateProductSlug(locName, product.id)}`
}
return generateSeoMetadata({
title: `${productName} ${categoryName ? `| ${categoryName}` : ''}`,
description: description || `Premium ${categoryName} from .`,
path: `/products/${id}`,
path: `/products/${slug}`,
lang,
customLanguages,
openGraph: {
type: 'website',
title: productName,
description,
images: product.images?.[0]?.url ? [product.images[0].url] : [],
@@ -53,8 +67,9 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
})
}
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
const { id, lang } = await params
export default async function ProductDetailPage({ params }: { params: Promise<{ slug: string, lang: string }> }) {
const { slug, lang } = await params
const id = extractIdFromSlug(slug)
const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getProductResult(id, lang),
getCategoriesResult(lang),
@@ -91,12 +106,50 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const related = await getRelated(product.category.id, product.id, lang)
const productName = resolveLocalized(product.name, lang) || product.brand.name
const categoryName = resolveCategoryName(product.category, lang)
const description = resolveLocalized(product.description, lang)
const details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const visibleAttributes = (product.attributes || []).filter((attribute) => formatAttributeValue(attribute))
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com'
const canonicalUrl = `${baseUrl}/${lang}/products/${slug}`
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: productName,
description: details || description || undefined,
image: product.images?.map(img => img.url) || [imageUrl],
sku: product.variants?.[0]?.sku || undefined,
brand: {
'@type': 'Brand',
name: product.brand.name,
},
offers: {
'@type': 'AggregateOffer',
offerCount: product.variants?.length || 1,
lowPrice: Math.min(...(product.variants?.map(v => v.price || 0) || [0])),
highPrice: Math.max(...(product.variants?.map(v => v.price || 0) || [0])),
priceCurrency: 'THB',
availability: (product.variants?.some(v => (v.stock ?? 0) > 0)) ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: `${baseUrl}/${lang}` },
{ '@type': 'ListItem', position: 2, name: 'Shop', item: `${baseUrl}/${lang}/shop` },
{ '@type': 'ListItem', position: 3, name: categoryName, item: `${baseUrl}/${lang}/category/${product.category.id}` },
{ '@type': 'ListItem', position: 4, name: productName, item: canonicalUrl }
]
}
return (
<div className="min-h-screen bg-surface-page pb-24 text-text-main md:pb-0">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
@@ -119,7 +172,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
src={imageUrl}
alt={productName}
fill
unoptimized
priority={true}
className="aspect-square h-full w-full object-cover"
/>
{categoryName && (
@@ -135,10 +188,11 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
{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"
dangerouslySetInnerHTML={{ __html: details }}
/>
<div className="product-editorial leading-relaxed text-muted-text">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{details}
</ReactMarkdown>
</div>
</section>
)}
+23 -9
View File
@@ -1,5 +1,7 @@
import { MetadataRoute } from 'next';
import { getCategories, getProducts, getArticlesResult } from '../lib/cms';
import { generateProductSlug } from '../lib/slugify';
import { resolveLocalized } from '../lib/localization';
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
@@ -49,15 +51,27 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
},
}));
const productRoutes: MetadataRoute.Sitemap = products.map((product) => ({
url: `${baseUrl}/th/products/${product.id}`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
alternates: {
languages: createAlternates(`/products/${product.id}`),
},
}));
const productRoutes: MetadataRoute.Sitemap = products.map((product) => {
const alternates: Record<string, string> = {};
let thSlug = '';
for (const lang of languages) {
const localizedName = resolveLocalized(product.name, lang) || (product.brand ? product.brand.name : 'product');
const slug = generateProductSlug(localizedName, product.id);
alternates[lang] = `${baseUrl}/${lang}/products/${slug}`;
if (lang === 'th') thSlug = slug;
}
return {
url: `${baseUrl}/th/products/${thSlug}`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
alternates: {
languages: alternates,
},
};
});
const articleRoutes: MetadataRoute.Sitemap = [];
if (articlesResult.ok && articlesResult.data) {