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) {
+5 -3
View File
@@ -7,6 +7,7 @@ import { ShoppingBag, Layers } from 'lucide-react'
import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '@homaroma/types'
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
import { generateProductSlug } from '../lib/slugify'
import { useLang } from '../hooks/useLang'
import { useCartStore } from '../store/cartStore'
import WishlistButton from './WishlistButton'
@@ -22,6 +23,7 @@ export default function ProductCard({ product }: ProductCardProps) {
const categoryName = resolveCategoryName(product.category, lang)
const productName = resolveLocalized(product.name, lang) || product.brand.name
const productSlug = generateProductSlug(productName, product.id)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const displayPrice = selectedVariant?.price ?? product.variants[0]?.price ?? 0
@@ -58,7 +60,7 @@ export default function ProductCard({ product }: ProductCardProps) {
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-surface-page md:h-64 md:aspect-auto">
<WishlistButton product={product} />
<Link href={`/products/${product.id}`} className="block h-full w-full">
<Link href={`/products/${productSlug}`} className="block h-full w-full">
<Image
src={imageUrl}
alt={productName}
@@ -77,7 +79,7 @@ export default function ProductCard({ product }: ProductCardProps) {
<div className="flex flex-1 flex-col p-4">
<div className="mb-2">
<h3 className="line-clamp-1 text-base font-bold leading-tight text-text-main transition-colors group-hover:text-brand-primary md:text-lg">
<Link href={`/products/${product.id}`}>
<Link href={`/products/${productSlug}`}>
{productName}
</Link>
</h3>
@@ -105,7 +107,7 @@ export default function ProductCard({ product }: ProductCardProps) {
</div>
{product.variants && product.variants.length > 1 ? (
<Link href={`/products/${product.id}`} className="block w-full">
<Link href={`/products/${productSlug}`} className="block w-full">
<button
type="button"
className="ui-button-primary w-full gap-1.5 shadow-sm"
+9 -3
View File
@@ -9,6 +9,7 @@ type SeoMetadataProps = {
path: string; // e.g. '/shop', '/category/1', or ''
lang: string;
openGraph?: Metadata['openGraph'];
customLanguages?: Record<string, string>;
};
export function generateSeoMetadata({
@@ -17,14 +18,19 @@ export function generateSeoMetadata({
path,
lang,
openGraph,
customLanguages,
}: SeoMetadataProps): Metadata {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
const pathPart = cleanPath === '/' ? '' : cleanPath;
const languages: Record<string, string> = {};
SUPPORTED_LOCALES.forEach((locale) => {
languages[locale] = `${baseUrl}/${locale}${pathPart}`;
});
if (customLanguages) {
Object.assign(languages, customLanguages);
} else {
SUPPORTED_LOCALES.forEach((locale) => {
languages[locale] = `${baseUrl}/${locale}${pathPart}`;
});
}
return {
title,
+22
View File
@@ -0,0 +1,22 @@
export function generateProductSlug(name: string, id: string | number): string {
if (!name) return `p${id}`;
const cleanName = name
.toLowerCase()
.trim()
.replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens
.replace(/[^\w\u0E00-\u0E7F-]+/g, '') // Keep alphanumeric, Thai chars, and hyphens.
.replace(/-+/g, '-') // Remove consecutive hyphens
.replace(/^-+|-+$/g, ''); // Trim hyphens from start and end
return cleanName ? `${cleanName}-p${id}` : `p${id}`;
}
export function extractIdFromSlug(slug: string): string {
if (!slug) return '';
const match = slug.match(/-p(\d+)$|^p(\d+)$/);
if (match) {
return match[1] || match[2];
}
return slug; // Fallback if no match
}