x
This commit is contained in:
1 parent
be0ee265d5
commit
10131e08bf
11 files changed
+415
-88
No files matched your search
@@ -17,6 +17,8 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"swiper": "^12.2.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
|
||||
+64
-10
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user