From ab3e53c4da4a88533db567e864788dfa427723ae Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 18:57:24 -0400 Subject: [PATCH] x --- apps/website/src/app/[lang]/[slug]/page.tsx | 8 +- apps/website/src/app/[lang]/about/page.tsx | 12 ++- .../src/app/[lang]/article/[slug]/page.tsx | 11 ++- .../src/app/[lang]/category/[id]/page.tsx | 7 +- apps/website/src/app/[lang]/checkout/page.tsx | 12 ++- apps/website/src/app/[lang]/coupons/page.tsx | 12 ++- apps/website/src/app/[lang]/layout.tsx | 6 ++ .../src/app/[lang]/order/[id]/page.tsx | 11 ++- apps/website/src/app/[lang]/page.tsx | 7 +- .../src/app/[lang]/products/[id]/page.tsx | 7 +- apps/website/src/app/[lang]/profile/page.tsx | 12 ++- .../src/app/[lang]/scent-guide/page.tsx | 12 ++- apps/website/src/app/[lang]/search/page.tsx | 12 ++- apps/website/src/app/[lang]/shop/page.tsx | 12 ++- apps/website/src/app/[lang]/track/page.tsx | 12 ++- apps/website/src/app/[lang]/wishlist/page.tsx | 12 ++- apps/website/src/app/robots.ts | 11 ++- .../src/components/AddToCartSection.tsx | 2 +- apps/website/src/components/CartDrawer.tsx | 2 +- .../website/src/components/CheckoutClient.tsx | 7 +- apps/website/src/components/Footer.tsx | 4 +- apps/website/src/components/LocalizedLink.tsx | 2 +- apps/website/src/components/Navbar.tsx | 14 +++- apps/website/src/components/ProductCard.tsx | 2 +- apps/website/src/components/ProfileClient.tsx | 2 +- .../src/components/ShopCouponBridge.tsx | 7 +- .../src/components/TrackOrderClient.tsx | 7 +- .../website/src/components/WishlistClient.tsx | 2 +- .../dynamic/DynamicPageRenderer.tsx | 2 +- apps/website/src/hooks/useLang.ts | 4 +- apps/website/src/lib/seo.ts | 38 +++++++++ apps/website/src/proxy.ts | 8 +- fix_metadata.js | 80 +++++++++++++++++++ 33 files changed, 290 insertions(+), 69 deletions(-) create mode 100644 apps/website/src/lib/seo.ts create mode 100644 fix_metadata.js diff --git a/apps/website/src/app/[lang]/[slug]/page.tsx b/apps/website/src/app/[lang]/[slug]/page.tsx index f65f8d7..ddd5b64 100644 --- a/apps/website/src/app/[lang]/[slug]/page.tsx +++ b/apps/website/src/app/[lang]/[slug]/page.tsx @@ -1,5 +1,6 @@ import { notFound } from 'next/navigation' import type { Metadata } from 'next' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import { getCategoriesResult, getCmsPageResult } from '@/lib/cms' @@ -38,9 +39,12 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str const pageResult = await getCmsPageResult(slug, lang) if (!pageResult.ok || !pageResult.data.isPublished) return { title: 'Page Not Found | HomAroma' } - return { + return generateSeoMetadata({ title: `${pageResult.data.title} | HomAroma`, - } + description: `HomAroma - ${pageResult.data.title}`, + path: `/${slug}`, + lang, + }) } export default async function CmsCustomPage({ params }: { params: Promise<{ slug: string, lang: string }> }) { diff --git a/apps/website/src/app/[lang]/about/page.tsx b/apps/website/src/app/[lang]/about/page.tsx index 84e74c0..ba61031 100644 --- a/apps/website/src/app/[lang]/about/page.tsx +++ b/apps/website/src/app/[lang]/about/page.tsx @@ -1,4 +1,5 @@ import Link from '@/components/LocalizedLink' +import { generateSeoMetadata } from '@/lib/seo' const PILLARS = [ { @@ -25,9 +26,14 @@ const STATS = [ { value: '100%', label: 'Natural Extracts' }, ] -export const metadata = { - title: 'About Us | HomAroma', - description: 'Born from a passion for stillness. HomAroma began in 2019 as a Bangkok studio crafting botanical aroma blends.', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'About Us | HomAroma', + description: 'Born from a passion for stillness. HomAroma began in 2019 as a Bangkok studio crafting botanical aroma blends.', + path: '/about', + lang, + }); } export default function AboutPage() { diff --git a/apps/website/src/app/[lang]/article/[slug]/page.tsx b/apps/website/src/app/[lang]/article/[slug]/page.tsx index 97c3015..d416d92 100644 --- a/apps/website/src/app/[lang]/article/[slug]/page.tsx +++ b/apps/website/src/app/[lang]/article/[slug]/page.tsx @@ -1,6 +1,7 @@ import Link from '@/components/LocalizedLink' import { notFound } from 'next/navigation' import type { Metadata } from 'next' +import { generateSeoMetadata } from '@/lib/seo' import { ArrowLeft } from 'lucide-react' import type { Article } from '@homaroma/types' @@ -13,20 +14,22 @@ async function getArticle(slug: string): Promise
{ } catch { return null } } -export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { - const { slug } = await params +export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise { + const { slug, lang } = await params const article = await getArticle(slug) if (!article) return { title: 'Article Not Found | HomAroma' } - return { + return generateSeoMetadata({ title: `${article.title} | HomAroma Scent Guide`, description: article.content.replace(/<[^>]+>/g, '').slice(0, 160), + path: `/article/${slug}`, + lang, openGraph: { title: article.title, images: article.coverImage ? [article.coverImage] : [], type: 'article', publishedTime: article.createdAt, }, - } + }) } export default async function ArticleReaderPage({ params }: { params: Promise<{ slug: string }> }) { diff --git a/apps/website/src/app/[lang]/category/[id]/page.tsx b/apps/website/src/app/[lang]/category/[id]/page.tsx index d0dd5c1..214bb66 100644 --- a/apps/website/src/app/[lang]/category/[id]/page.tsx +++ b/apps/website/src/app/[lang]/category/[id]/page.tsx @@ -1,6 +1,7 @@ import Link from '@/components/LocalizedLink' import { notFound } from 'next/navigation' import type { Metadata } from 'next' +import { generateSeoMetadata } from '@/lib/seo' import type { Category, Product } from '@homaroma/types' import { getCategoryImage } from '@homaroma/types' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' @@ -26,10 +27,12 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin const result = await getCategoryProducts(id, lang) const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : '' - return { + return generateSeoMetadata({ title: categoryName ? `${categoryName} | HomAroma` : 'Category | HomAroma', description: categoryName ? `Shop ${categoryName} products from HomAroma.` : 'Shop HomAroma category products.', - } + path: `/category/${id}`, + lang, + }) } export default async function CategoryPage({ params }: { params: Promise<{ id: string, lang: string }> }) { diff --git a/apps/website/src/app/[lang]/checkout/page.tsx b/apps/website/src/app/[lang]/checkout/page.tsx index 4e14d5e..79cfc48 100644 --- a/apps/website/src/app/[lang]/checkout/page.tsx +++ b/apps/website/src/app/[lang]/checkout/page.tsx @@ -1,4 +1,5 @@ import { CreditCard } from 'lucide-react' +import { generateSeoMetadata } from '@/lib/seo' import CheckoutClient from '@/components/CheckoutClient' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' @@ -6,9 +7,14 @@ import { getCategoriesResult, getCmsPageResult } from '@/lib/cms' import { splitCmsLayoutSections } from '@/lib/cms-sections' // lang is extracted from params -export const metadata = { - title: 'Checkout | HomAroma', - description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ HomAroma', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'Checkout | HomAroma', + description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ HomAroma', + path: '/checkout', + lang, + }); } export default async function CheckoutPage({ params }: { params: Promise<{ lang: string }> }) { diff --git a/apps/website/src/app/[lang]/coupons/page.tsx b/apps/website/src/app/[lang]/coupons/page.tsx index 1714c05..260ec0f 100644 --- a/apps/website/src/app/[lang]/coupons/page.tsx +++ b/apps/website/src/app/[lang]/coupons/page.tsx @@ -1,5 +1,6 @@ import Link from '@/components/LocalizedLink' import { Ticket, ArrowRight } from 'lucide-react' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms' import { splitCmsLayoutSections } from '@/lib/cms-sections' @@ -12,9 +13,14 @@ function formatDiscount(type: string, value: number) { return `${value}` } -export const metadata = { - title: 'โปรโมชั่นและคูปองส่วนลด | HomAroma', - description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก HomAroma สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'โปรโมชั่นและคูปองส่วนลด | HomAroma', + description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก HomAroma สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ', + path: '/coupons', + lang, + }); } export default async function CouponsPage({ params }: { params: Promise<{ lang: string }> }) { diff --git a/apps/website/src/app/[lang]/layout.tsx b/apps/website/src/app/[lang]/layout.tsx index 066cffb..445d26b 100644 --- a/apps/website/src/app/[lang]/layout.tsx +++ b/apps/website/src/app/[lang]/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; import '../globals.css'; import Navbar from '@/components/Navbar'; import Footer from '@/components/Footer'; @@ -6,6 +7,7 @@ import ClientProviders from '@/components/ClientProviders'; import { API_BASE } from '@/lib/api'; import type { MegaMenuPayload } from '@/components/navigation/menuTypes'; import type { MenuItem, StoreSettings } from '@homaroma/types'; +import { isSupportedLocale } from '@/lib/locales'; export const metadata: Metadata = { title: 'HomAroma', @@ -82,6 +84,10 @@ export default async function RootLayout({ }) { const { lang } = await params; + if (!isSupportedLocale(lang)) { + notFound(); + } + const [categories, initialMenu, footerMenus, settings] = await Promise.all([ getCategories(lang), getMegaMenu(lang), diff --git a/apps/website/src/app/[lang]/order/[id]/page.tsx b/apps/website/src/app/[lang]/order/[id]/page.tsx index c9a0ae2..4d1f9e5 100644 --- a/apps/website/src/app/[lang]/order/[id]/page.tsx +++ b/apps/website/src/app/[lang]/order/[id]/page.tsx @@ -1,6 +1,7 @@ import Link from '@/components/LocalizedLink' import { notFound } from 'next/navigation' import type { Metadata } from 'next' +import { generateSeoMetadata } from '@/lib/seo' import { CheckCircle2, Clock, PackageCheck, Truck } from 'lucide-react' import { formatPrice } from '@homaroma/types' @@ -34,16 +35,18 @@ function statusTone(status: string) { return 'bg-gray-50 text-gray-700 border-gray-100' } -export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { - const { id } = await params +export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise { + const { id, lang } = await params const result = await getOrderResult(id) if (!result.ok) return { title: 'Order Not Found | HomAroma' } - return { + return generateSeoMetadata({ title: `Order ${orderLabel(result.data.id)} | HomAroma`, description: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก HomAroma`, - } + path: `/order/${id}`, + lang, + }) } export default async function OrderStatusPage({ params }: { params: Promise<{ id: string, lang: string }> }) { diff --git a/apps/website/src/app/[lang]/page.tsx b/apps/website/src/app/[lang]/page.tsx index 9bd3096..0bf6e8f 100644 --- a/apps/website/src/app/[lang]/page.tsx +++ b/apps/website/src/app/[lang]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import { getCategories, getCmsPage } from '@/lib/cms' import { splitCmsLayoutSections } from '@/lib/cms-sections' @@ -11,10 +12,12 @@ export async function generateMetadata({ params }: Props): Promise { const { lang } = await params const page = await getCmsPage('home', lang) - return { + return generateSeoMetadata({ title: page?.title ? `${page.title} | HomAroma` : 'HomAroma', description: 'Premium aromatherapy, home fragrance, and botanical scent products from HomAroma.', - } + path: '', + lang, + }) } export default async function HomePage({ params }: Props) { diff --git a/apps/website/src/app/[lang]/products/[id]/page.tsx b/apps/website/src/app/[lang]/products/[id]/page.tsx index 8120e1d..c365662 100644 --- a/apps/website/src/app/[lang]/products/[id]/page.tsx +++ b/apps/website/src/app/[lang]/products/[id]/page.tsx @@ -1,6 +1,7 @@ import Link from '@/components/LocalizedLink' import { notFound } from 'next/navigation' import type { Metadata } from 'next' +import { generateSeoMetadata } from '@/lib/seo' import { getCategoryImage, type Product } from '@homaroma/types' import AddToCartSection from '@/components/AddToCartSection' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' @@ -25,15 +26,17 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin const categoryName = resolveCategoryName(product.category, lang) const description = resolveLocalized(product.description, lang) - return { + return generateSeoMetadata({ title: `${productName} ${categoryName ? `| ${categoryName}` : ''} | HomAroma`, description: description || `Premium ${categoryName} from HomAroma.`, + path: `/products/${id}`, + lang, openGraph: { title: productName, description, images: product.images?.[0]?.url ? [product.images[0].url] : [], }, - } + }) } export default async function ProductDetailPage({ params }: { params: Promise<{ id: string, lang: string }> }) { diff --git a/apps/website/src/app/[lang]/profile/page.tsx b/apps/website/src/app/[lang]/profile/page.tsx index 71838ef..629735e 100644 --- a/apps/website/src/app/[lang]/profile/page.tsx +++ b/apps/website/src/app/[lang]/profile/page.tsx @@ -1,4 +1,5 @@ import { User } from 'lucide-react' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import ProfileClient from '@/components/ProfileClient' @@ -6,9 +7,14 @@ import { getCategoriesResult, getCmsPageResult } from '@/lib/cms' import { splitCmsLayoutSections } from '@/lib/cms-sections' // lang is extracted from params -export const metadata = { - title: 'โปรไฟล์สมาชิก | HomAroma', - description: 'จัดการโปรไฟล์สมาชิก HomAroma ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'โปรไฟล์สมาชิก | HomAroma', + description: 'จัดการโปรไฟล์สมาชิก HomAroma ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง', + path: '/profile', + lang, + }); } export default async function ProfilePage({ params }: { params: Promise<{ lang: string }> }) { diff --git a/apps/website/src/app/[lang]/scent-guide/page.tsx b/apps/website/src/app/[lang]/scent-guide/page.tsx index 266dd98..0bddb5c 100644 --- a/apps/website/src/app/[lang]/scent-guide/page.tsx +++ b/apps/website/src/app/[lang]/scent-guide/page.tsx @@ -1,12 +1,18 @@ import Link from '@/components/LocalizedLink' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms' import { splitCmsLayoutSections } from '@/lib/cms-sections' // lang is extracted from params -export const metadata = { - title: 'Scent Guide | HomAroma', - description: 'Explore our collection of aroma articles, guides, and sensory knowledge to find your perfect scent.', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'Scent Guide | HomAroma', + description: 'Explore our collection of aroma articles, guides, and sensory knowledge to find your perfect scent.', + path: '/scent-guide', + lang, + }); } export default async function ScentGuidePage({ params }: { params: Promise<{ lang: string }> }) { diff --git a/apps/website/src/app/[lang]/search/page.tsx b/apps/website/src/app/[lang]/search/page.tsx index 78dadff..c32cecc 100644 --- a/apps/website/src/app/[lang]/search/page.tsx +++ b/apps/website/src/app/[lang]/search/page.tsx @@ -1,13 +1,19 @@ import Link from '@/components/LocalizedLink' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import ProductCard from '@/components/ProductCard' import { getCategoriesResult, getCmsPageResult, getProductsResult } from '@/lib/cms' import { splitCmsLayoutSections } from '@/lib/cms-sections' import { resolveLocalized } from '@/lib/localization' -export const metadata = { - title: 'Search Products | HomAroma', - description: 'Search HomAroma products by scent, category, or keyword.', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'Search Products | HomAroma', + description: 'Search HomAroma products by scent, category, or keyword.', + path: '/search', + lang, + }); } export default async function SearchPage({ searchParams, params }: { searchParams: Promise<{ q?: string | string[] }>, params: Promise<{ lang: string }> }) { diff --git a/apps/website/src/app/[lang]/shop/page.tsx b/apps/website/src/app/[lang]/shop/page.tsx index f85a243..71d94be 100644 --- a/apps/website/src/app/[lang]/shop/page.tsx +++ b/apps/website/src/app/[lang]/shop/page.tsx @@ -1,4 +1,5 @@ import Link from '@/components/LocalizedLink' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import ProductCard from '@/components/ProductCard' import ShopCouponBridge from '@/components/ShopCouponBridge' @@ -7,9 +8,14 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections' import { resolveLocalized } from '@/lib/localization' // lang is extracted from params -export const metadata = { - title: 'Shop Aroma Products | HomAroma', - description: 'Browse HomAroma aroma oils, reed diffusers, room sprays, and premium scent products.', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'Shop Aroma Products | HomAroma', + description: 'Browse HomAroma aroma oils, reed diffusers, room sprays, and premium scent products.', + path: '/shop', + lang, + }); } export default async function ShopPage({ searchParams, params }: { searchParams: Promise<{ coupon?: string | string[] }>, params: Promise<{ lang: string }> }) { diff --git a/apps/website/src/app/[lang]/track/page.tsx b/apps/website/src/app/[lang]/track/page.tsx index 42b5e54..1457229 100644 --- a/apps/website/src/app/[lang]/track/page.tsx +++ b/apps/website/src/app/[lang]/track/page.tsx @@ -1,4 +1,5 @@ import { MapPin } from 'lucide-react' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import TrackOrderClient from '@/components/TrackOrderClient' @@ -6,9 +7,14 @@ import { getCategoriesResult, getCmsPageResult } from '@/lib/cms' import { splitCmsLayoutSections } from '@/lib/cms-sections' // lang is extracted from params -export const metadata = { - title: 'ติดตามคำสั่งซื้อ | HomAroma', - description: 'ตรวจสอบสถานะคำสั่งซื้อ HomAroma ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'ติดตามคำสั่งซื้อ | HomAroma', + description: 'ตรวจสอบสถานะคำสั่งซื้อ HomAroma ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ', + path: '/track', + lang, + }); } export default async function TrackPage({ params }: { params: Promise<{ lang: string }> }) { diff --git a/apps/website/src/app/[lang]/wishlist/page.tsx b/apps/website/src/app/[lang]/wishlist/page.tsx index a768355..5d8fc74 100644 --- a/apps/website/src/app/[lang]/wishlist/page.tsx +++ b/apps/website/src/app/[lang]/wishlist/page.tsx @@ -1,4 +1,5 @@ import { Heart } from 'lucide-react' +import { generateSeoMetadata } from '@/lib/seo' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import WishlistClient from '@/components/WishlistClient' @@ -6,9 +7,14 @@ import { getCategoriesResult, getCmsPageResult } from '@/lib/cms' import { splitCmsLayoutSections } from '@/lib/cms-sections' // lang is extracted from params -export const metadata = { - title: 'Wishlist | HomAroma', - description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ HomAroma', +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: 'Wishlist | HomAroma', + description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ HomAroma', + path: '/wishlist', + lang, + }); } export default async function WishlistPage({ params }: { params: Promise<{ lang: string }> }) { diff --git a/apps/website/src/app/robots.ts b/apps/website/src/app/robots.ts index b55ec65..f02ef5c 100644 --- a/apps/website/src/app/robots.ts +++ b/apps/website/src/app/robots.ts @@ -7,7 +7,16 @@ export default function robots(): MetadataRoute.Robots { rules: { userAgent: '*', allow: '/', - disallow: ['/api/', '/_next/', '/profile/', '/checkout/', '/order/'], + disallow: [ + '/api/', + '/_next/', + '/*/profile/', + '/*/checkout/', + '/*/order/', + '/profile/', + '/checkout/', + '/order/' + ], }, sitemap: `${baseUrl}/sitemap.xml`, }; diff --git a/apps/website/src/components/AddToCartSection.tsx b/apps/website/src/components/AddToCartSection.tsx index cbe9745..6eb59ca 100644 --- a/apps/website/src/components/AddToCartSection.tsx +++ b/apps/website/src/components/AddToCartSection.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from 'react' -import Link from 'next/link' +import Link from './LocalizedLink' import { getCategoryImage, formatPrice, type Product } from '@homaroma/types' import { useCartStore } from '../store/cartStore' import { resolveCategoryName, resolveLocalized } from '../lib/localization' diff --git a/apps/website/src/components/CartDrawer.tsx b/apps/website/src/components/CartDrawer.tsx index 2046ddd..9fdb75f 100644 --- a/apps/website/src/components/CartDrawer.tsx +++ b/apps/website/src/components/CartDrawer.tsx @@ -1,6 +1,6 @@ "use client"; import { X, ShoppingBag, Plus, Minus, Trash2 } from 'lucide-react' -import Link from 'next/link' +import Link from './LocalizedLink' import { useCartStore } from '../store/cartStore' import { formatPrice } from '@homaroma/types' import { useTranslation } from 'react-i18next' diff --git a/apps/website/src/components/CheckoutClient.tsx b/apps/website/src/components/CheckoutClient.tsx index becb8e0..e232171 100644 --- a/apps/website/src/components/CheckoutClient.tsx +++ b/apps/website/src/components/CheckoutClient.tsx @@ -1,6 +1,6 @@ "use client"; -import Link from 'next/link' +import Link from './LocalizedLink' import { useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' import { AlertCircle, CheckCircle2, Loader2, PackageCheck, ShoppingBag, Ticket, UserRound } from 'lucide-react' @@ -9,6 +9,8 @@ import { formatPrice, type CartItem, type CustomerAddress } from '@homaroma/type import { API_BASE } from '../lib/api' import { useAuthStore, type Customer } from '../store/authStore' import { useCartStore } from '../store/cartStore' +import { useLang } from '../hooks/useLang' +import { getPathWithLocale } from '../lib/locales' type StoreSettings = { shippingFee?: number @@ -115,6 +117,7 @@ function saveLocalOrder(orderId: number) { export default function CheckoutClient() { const router = useRouter() + const lang = useLang() const { customer } = useAuthStore() const { items, clearCart, getCartTotal } = useCartStore() const [hydrated, setHydrated] = useState(false) @@ -373,7 +376,7 @@ export default function CheckoutClient() { clearCart() saveLocalOrder(order.id) - router.push(`/order/${order.id}`) + router.push(getPathWithLocale(`/order/${order.id}`, lang)) } catch (error) { setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถสร้างคำสั่งซื้อได้') } finally { diff --git a/apps/website/src/components/Footer.tsx b/apps/website/src/components/Footer.tsx index c92323a..0e5b18c 100644 --- a/apps/website/src/components/Footer.tsx +++ b/apps/website/src/components/Footer.tsx @@ -23,7 +23,7 @@ export default function Footer({ const { t } = useTranslation() const lang = useLang() const [mounted, setMounted] = useState(false) - useEffect(() => { setMounted(true) }, []) + useEffect(() => { queueMicrotask(() => setMounted(true)) }, []) // Before mount: always 'th' to match SSR output → no hydration mismatch // After mount: real user language → correct content const effectiveLang = mounted ? lang : 'th' @@ -32,7 +32,7 @@ export default function Footer({ const translateMenuLabel = (item: MenuItem): string => { // Use outer `lang` from closure — hooks cannot be called inside nested functions if (effectiveLang !== 'en' && item.translations && item.translations.length > 0) { - const trans = item.translations.find((tr: any) => tr.langCode === effectiveLang) + const trans = item.translations.find((tr: { langCode: string; label: string }) => tr.langCode === effectiveLang) if (trans?.label) return trans.label } const map: Record = { diff --git a/apps/website/src/components/LocalizedLink.tsx b/apps/website/src/components/LocalizedLink.tsx index 84cd9d7..2470539 100644 --- a/apps/website/src/components/LocalizedLink.tsx +++ b/apps/website/src/components/LocalizedLink.tsx @@ -24,7 +24,7 @@ export default function LocalizedLink({ // Extract current locale from pathname const currentLocale = useMemo(() => { const segments = pathname.split('/').filter(Boolean); - if (segments.length > 0 && SUPPORTED_LOCALES.includes(segments[0] as any)) { + if (segments.length > 0 && (SUPPORTED_LOCALES as readonly string[]).includes(segments[0])) { return segments[0]; } return 'th'; // Fallback diff --git a/apps/website/src/components/Navbar.tsx b/apps/website/src/components/Navbar.tsx index 42c3775..2220698 100644 --- a/apps/website/src/components/Navbar.tsx +++ b/apps/website/src/components/Navbar.tsx @@ -32,6 +32,12 @@ const LANGUAGES = [ { code: 'ko', label: '한국어', short: 'KO' }, ] +function setLanguageCookie(code: string) { + if (typeof document !== 'undefined') { + document.cookie = `homaroma_lang=${code}; path=/; max-age=31536000` + } +} + function createFallbackMenu(categories: Category[]): MegaMenuPayload[] { const categoryLinks = categories.map((category) => { // category.name is a multilingual Record when fetched with lang=all @@ -90,13 +96,13 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n const changeLanguage = (code: string) => { i18n.changeLanguage(code) localStorage.setItem('homaroma_lang', code) - document.cookie = `homaroma_lang=${code}; path=/; max-age=31536000` + setLanguageCookie(code) const currentPathWithoutLang = getPathWithoutLocale(pathname) const newPath = getPathWithLocale(currentPathWithoutLang, code) setLangSheetOpen(false) - router.push(newPath) + router.push(newPath + window.location.search) } const loginWithLine = () => { @@ -105,7 +111,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n maybeWindow.liff.login() return } - router.push('/profile') + router.push(getPathWithLocale('/profile', lang)) } @@ -163,7 +169,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n const handleSearch = (e: React.FormEvent) => { e.preventDefault() if (searchQuery.trim()) { - router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`) + router.push(getPathWithLocale(`/search?q=${encodeURIComponent(searchQuery.trim())}`, lang)) setSearchOpen(false) setMobileOpen(false) setSearchQuery('') diff --git a/apps/website/src/components/ProductCard.tsx b/apps/website/src/components/ProductCard.tsx index ad6d621..d681779 100644 --- a/apps/website/src/components/ProductCard.tsx +++ b/apps/website/src/components/ProductCard.tsx @@ -1,6 +1,6 @@ "use client" -import Link from 'next/link' +import Link from './LocalizedLink' import type { Product } from '@homaroma/types' import { getCategoryImage, formatPrice } from '@homaroma/types' import { resolveCategoryName, resolveLocalized } from '../lib/localization' diff --git a/apps/website/src/components/ProfileClient.tsx b/apps/website/src/components/ProfileClient.tsx index c1e7430..ffddf84 100644 --- a/apps/website/src/components/ProfileClient.tsx +++ b/apps/website/src/components/ProfileClient.tsx @@ -1,6 +1,6 @@ "use client"; -import Link from 'next/link' +import Link from './LocalizedLink' import { useEffect, useState } from 'react' import { Award, diff --git a/apps/website/src/components/ShopCouponBridge.tsx b/apps/website/src/components/ShopCouponBridge.tsx index 63de76d..a438215 100644 --- a/apps/website/src/components/ShopCouponBridge.tsx +++ b/apps/website/src/components/ShopCouponBridge.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react' import { useRouter } from 'next/navigation' +import { useLang } from '../hooks/useLang' +import { getPathWithLocale } from '../lib/locales' type ShopCouponBridgeProps = { coupon?: string @@ -9,13 +11,14 @@ type ShopCouponBridgeProps = { export default function ShopCouponBridge({ coupon }: ShopCouponBridgeProps) { const router = useRouter() + const lang = useLang() useEffect(() => { if (!coupon) return localStorage.setItem('homaroma_active_coupon', coupon) - router.replace('/shop', { scroll: false }) - }, [coupon, router]) + router.replace(getPathWithLocale('/shop', lang), { scroll: false }) + }, [coupon, router, lang]) return null } diff --git a/apps/website/src/components/TrackOrderClient.tsx b/apps/website/src/components/TrackOrderClient.tsx index 66f57df..8732243 100644 --- a/apps/website/src/components/TrackOrderClient.tsx +++ b/apps/website/src/components/TrackOrderClient.tsx @@ -1,11 +1,13 @@ "use client"; -import Link from 'next/link' +import Link from './LocalizedLink' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { ChevronRight, Loader2, Package, Search } from 'lucide-react' import { API_BASE } from '../lib/api' +import { useLang } from '../hooks/useLang' +import { getPathWithLocale } from '../lib/locales' type TrackResponse = | { success: true; orderId: number } @@ -30,6 +32,7 @@ function orderLabel(id: number) { export default function TrackOrderClient() { const router = useRouter() + const lang = useLang() const [orderId, setOrderId] = useState('') const [phone, setPhone] = useState('') const [localOrders, setLocalOrders] = useState([]) @@ -75,7 +78,7 @@ export default function TrackOrderClient() { if (response.ok && 'success' in data && data.success) { saveLocalOrder(data.orderId) - router.push(`/order/${data.orderId}`) + router.push(getPathWithLocale(`/order/${data.orderId}`, lang)) return } diff --git a/apps/website/src/components/WishlistClient.tsx b/apps/website/src/components/WishlistClient.tsx index d02bee1..6cd0e40 100644 --- a/apps/website/src/components/WishlistClient.tsx +++ b/apps/website/src/components/WishlistClient.tsx @@ -1,6 +1,6 @@ "use client"; -import Link from 'next/link' +import Link from './LocalizedLink' import { Heart } from 'lucide-react' import ProductCard from './ProductCard' diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx index e51d358..b57134b 100644 --- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx +++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx @@ -1,6 +1,6 @@ "use client" -import Link from 'next/link' +import Link from '../LocalizedLink' import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react' import type { Category, PageSection, Product } from '@homaroma/types' import { getCategoryImage } from '@homaroma/types' diff --git a/apps/website/src/hooks/useLang.ts b/apps/website/src/hooks/useLang.ts index b3e18e5..77d0829 100644 --- a/apps/website/src/hooks/useLang.ts +++ b/apps/website/src/hooks/useLang.ts @@ -14,7 +14,7 @@ export function useLang(): string { return useMemo(() => { // 1. Try to get it from route params if (params?.lang && typeof params.lang === 'string') { - if (SUPPORTED_LOCALES.includes(params.lang as any)) { + if ((SUPPORTED_LOCALES as readonly string[]).includes(params.lang)) { return params.lang; } } @@ -22,7 +22,7 @@ export function useLang(): string { // 2. Fallback: extract from pathname if params are not available yet if (pathname) { const segments = pathname.split('/').filter(Boolean); - if (segments.length > 0 && SUPPORTED_LOCALES.includes(segments[0] as any)) { + if (segments.length > 0 && (SUPPORTED_LOCALES as readonly string[]).includes(segments[0])) { return segments[0]; } } diff --git a/apps/website/src/lib/seo.ts b/apps/website/src/lib/seo.ts new file mode 100644 index 0000000..cd55351 --- /dev/null +++ b/apps/website/src/lib/seo.ts @@ -0,0 +1,38 @@ +import { Metadata } from 'next'; +import { SUPPORTED_LOCALES } from './locales'; + +const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; + +type SeoMetadataProps = { + title: string; + description: string; + path: string; // e.g. '/shop', '/category/1', or '' + lang: string; + openGraph?: Metadata['openGraph']; +}; + +export function generateSeoMetadata({ + title, + description, + path, + lang, + openGraph, +}: SeoMetadataProps): Metadata { + const cleanPath = path.startsWith('/') ? path : `/${path}`; + const pathPart = cleanPath === '/' ? '' : cleanPath; + + const languages: Record = {}; + SUPPORTED_LOCALES.forEach((locale) => { + languages[locale] = `${baseUrl}/${locale}${pathPart}`; + }); + + return { + title, + description, + alternates: { + canonical: `${baseUrl}/${lang}${pathPart}`, + languages, + }, + openGraph, + }; +} diff --git a/apps/website/src/proxy.ts b/apps/website/src/proxy.ts index 9d73204..d283023 100644 --- a/apps/website/src/proxy.ts +++ b/apps/website/src/proxy.ts @@ -31,15 +31,15 @@ export function proxy(request: NextRequest) { const cookieLocale = request.cookies.get('homaroma_lang')?.value; let targetLocale = DEFAULT_LOCALE; - if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale as any)) { - targetLocale = cookieLocale as any; + if (cookieLocale && (SUPPORTED_LOCALES as readonly string[]).includes(cookieLocale)) { + targetLocale = cookieLocale as typeof SUPPORTED_LOCALES[number]; } else { // 2. Check Accept-Language header const acceptLang = request.headers.get('accept-language'); if (acceptLang) { const preferred = acceptLang.split(',')[0].split('-')[0].toLowerCase(); - if (SUPPORTED_LOCALES.includes(preferred as any)) { - targetLocale = preferred as any; + if ((SUPPORTED_LOCALES as readonly string[]).includes(preferred)) { + targetLocale = preferred as typeof SUPPORTED_LOCALES[number]; } } } diff --git a/fix_metadata.js b/fix_metadata.js new file mode 100644 index 0000000..078b807 --- /dev/null +++ b/fix_metadata.js @@ -0,0 +1,80 @@ +const fs = require('fs'); +const path = require('path'); + +function walkDir(dir, callback) { + fs.readdirSync(dir).forEach(f => { + let dirPath = path.join(dir, f); + if (fs.statSync(dirPath).isDirectory()) { + walkDir(dirPath, callback); + } else { + callback(dirPath); + } + }); +} + +walkDir('apps/website/src/app/[lang]', (filePath) => { + if (!filePath.endsWith('.tsx') && !filePath.endsWith('.ts')) return; + + let content = fs.readFileSync(filePath, 'utf8'); + let changed = false; + + // 1. Convert `export const metadata = { title: '...', description: '...' }` to generateMetadata + // We need to parse: + // export const metadata = { + // title: '...', + // description: '...', + // } + + const metadataRegex = /export const metadata\s*(?::\s*Metadata\s*)?=\s*\{([\s\S]*?)\};?/g; + + // Exclude layout.tsx because we'll keep its global metadata, and handle it separately. + if (filePath.endsWith('layout.tsx')) return; + + content = content.replace(metadataRegex, (match, body) => { + changed = true; + + // Extract title and description + const titleMatch = body.match(/title:\s*['"`](.*?)['"`]/); + const descMatch = body.match(/description:\s*['"`](.*?)['"`]/); + + const title = titleMatch ? titleMatch[1] : ''; + const desc = descMatch ? descMatch[1] : ''; + + // Determine the route path (e.g. /shop, /about, /checkout) + // from the filePath. e.g. apps/website/src/app/[lang]/shop/page.tsx -> /shop + let routePath = ''; + const relPath = filePath.split('app/[lang]/')[1].replace('/page.tsx', '').replace('page.tsx', ''); + if (relPath && !relPath.includes('[') && !relPath.includes(']')) { + routePath = '/' + relPath; + } else if (relPath.includes('[') && relPath.includes(']')) { + // Dynamic route, can't just use string replacement easily. We will leave it to be manually fixed or use params. + // But wait! If it's a dynamic route, it probably already has `generateMetadata`! + // Let's check if there are static metadata in dynamic routes. + // Wait, `/order/[id]/page.tsx` has `generateMetadata`. + } + + if (relPath.includes('[')) { + // Just fallback to empty for dynamic segments if any static metadata exists + routePath = ''; + } + + return `export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + return generateSeoMetadata({ + title: '${title}', + description: '${desc}', + path: '${routePath}', + lang, + }); +}`; + }); + + if (changed) { + if (!content.includes('generateSeoMetadata')) { + // Insert import at the top + content = `import { generateSeoMetadata } from '@/lib/seo';\n` + content; + } + fs.writeFileSync(filePath, content, 'utf8'); + console.log('Converted static metadata in', filePath); + } +});