This commit is contained in:
Your Name committed 2026-06-20 18:39:03 -04:00
1 parent 3c4bb06003
commit f7aa4d099f
36 files changed
+526 -178

No files matched your search

@@ -1,12 +1,11 @@
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'all'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
// lang is extracted from params
const reservedSlugs = new Set([
'about',
'article',
@@ -32,8 +31,8 @@ function isReservedSlug(slug: string) {
return reservedSlugs.has(normalizeSlug(slug))
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
const { slug, lang } = await params
if (isReservedSlug(slug)) return { title: 'Page Not Found | HomAroma' }
const pageResult = await getCmsPageResult(slug, lang)
@@ -44,8 +43,8 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
}
}
export default async function CmsCustomPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
export default async function CmsCustomPage({ params }: { params: Promise<{ slug: string, lang: string }> }) {
const { slug, lang } = await params
if (isReservedSlug(slug)) notFound()
const [pageResult, categoriesResult] = await Promise.all([
@@ -1,4 +1,4 @@
import Link from 'next/link'
import Link from '@/components/LocalizedLink'
const PILLARS = [
{
@@ -1,10 +1,10 @@
import Link from 'next/link'
import Link from '@/components/LocalizedLink'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { ArrowLeft } from 'lucide-react'
import type { Article } from '@homaroma/types'
import { API_BASE } from '../../../lib/api'
import { API_BASE } from '@/lib/api'
async function getArticle(slug: string): Promise<Article | null> {
try {
@@ -1,30 +1,29 @@
import Link from 'next/link'
import Link from '@/components/LocalizedLink'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import type { Category, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
import ProductCard from '../../../components/ProductCard'
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult } from '../../../lib/cms'
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
import { resolveLocalized } from '../../../lib/localization'
const lang = 'all'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveLocalized } from '@/lib/localization'
// lang is extracted from params
type CategoryProductsResponse = {
category?: Category
data?: Product[]
}
async function getCategoryProducts(id: string): Promise<ApiResult<CategoryProductsResponse>> {
async function getCategoryProducts(id: string, lang: string): Promise<ApiResult<CategoryProductsResponse>> {
return await fetchJsonResult<CategoryProductsResponse>(`/categories/${id}/products?limit=1000&lang=${lang}`, {
next: { revalidate: 60 },
})
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const result = await getCategoryProducts(id)
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
const { id, lang } = await params
const result = await getCategoryProducts(id, lang)
const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : ''
return {
@@ -33,10 +32,10 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
}
}
export default async function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
export default async function CategoryPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
const { id, lang } = await params
const [categoryProductsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoryProducts(id),
getCategoryProducts(id, lang),
getCategoriesResult(lang),
getCmsPageResult('category-layout', lang),
])
@@ -1,18 +1,18 @@
import { CreditCard } from 'lucide-react'
import CheckoutClient from '../../components/CheckoutClient'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'all'
import CheckoutClient from '@/components/CheckoutClient'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
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 default async function CheckoutPage() {
export default async function CheckoutPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params
const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('checkout-layout', lang),
@@ -1,11 +1,10 @@
import Link from 'next/link'
import Link from '@/components/LocalizedLink'
import { Ticket, ArrowRight } from 'lucide-react'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'all'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
// lang is extracted from params
function formatDiscount(type: string, value: number) {
if (type === 'PERCENTAGE') return `${value}% OFF`
if (type === 'FIXED_AMOUNT') return `ลด ${value} ฿`
@@ -18,7 +17,8 @@ export const metadata = {
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก HomAroma สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
}
export default async function CouponsPage() {
export default async function CouponsPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params
const [couponsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getActiveCouponsResult(),
getCategoriesResult(lang),
@@ -1,6 +1,6 @@
"use client";
import { useState } from 'react'
import Link from 'next/link'
import Link from '@/components/LocalizedLink'
const FAQS = [
{
@@ -1,21 +1,22 @@
import type { Metadata } from 'next';
import './globals.css';
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import ClientProviders from '../components/ClientProviders';
import { API_BASE } from '../lib/api';
import type { MegaMenuPayload } from '../components/navigation/menuTypes';
import '../globals.css';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
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';
export const metadata: Metadata = {
title: 'HomAroma',
description: 'Pure Origins, Artisan Craft, Zero Compromise',
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
};
// Fetch categories for Navbar and Footer on the server (lang=all for client-side switching)
async function getCategories() {
// Fetch categories for Navbar and Footer on the server
async function getCategories(lang: string) {
try {
const res = await fetch(`${API_BASE}/categories?lang=all`, { next: { revalidate: 3600 } });
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: 3600 } });
if (!res.ok) return [];
return await res.json();
} catch (error) {
@@ -25,9 +26,9 @@ async function getCategories() {
}
// Fetch mega menu on the server — eliminates client-side flash
async function getMegaMenu(): Promise<MegaMenuPayload[]> {
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
try {
const res = await fetch(`${API_BASE}/menus/main`, { next: { revalidate: 300 } });
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: 300 } });
if (!res.ok) return [];
const data = await res.json() as { payload?: MegaMenuPayload[] };
return Array.isArray(data.payload) ? data.payload : [];
@@ -38,10 +39,10 @@ async function getMegaMenu(): Promise<MegaMenuPayload[]> {
}
// Fetch footer menus (explore + support) on the server
async function getFooterMenus(): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
try {
const res = await fetch(`${API_BASE}/menus/${slug}`, { next: { revalidate: 300 } });
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: 300 } });
if (!res.ok) return [];
const data = await res.json() as { items?: MenuItem[] };
return Array.isArray(data.items)
@@ -61,9 +62,9 @@ async function getFooterMenus(): Promise<{ exploreMenu: MenuItem[]; supportMenu:
}
// Fetch store settings (social links, footer texts) on the server
async function getStoreSettings(): Promise<StoreSettings | null> {
async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
try {
const res = await fetch(`${API_BASE}/settings`, { next: { revalidate: 600 } });
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: 600 } });
if (!res.ok) return null;
return await res.json() as StoreSettings;
} catch (error) {
@@ -74,18 +75,22 @@ async function getStoreSettings(): Promise<StoreSettings | null> {
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const [categories, initialMenu, footerMenus, settings] = await Promise.all([
getCategories(),
getMegaMenu(),
getFooterMenus(),
getStoreSettings(),
getCategories(lang),
getMegaMenu(lang),
getFooterMenus(lang),
getStoreSettings(lang),
]);
return (
<html lang="th" className="scroll-smooth">
<html lang={lang} className="scroll-smooth">
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
<ClientProviders>
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
@@ -1,15 +1,14 @@
import Link from 'next/link'
import Link from '@/components/LocalizedLink'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { CheckCircle2, Clock, PackageCheck, Truck } from 'lucide-react'
import { formatPrice } from '@homaroma/types'
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '../../../lib/cms'
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
const lang = 'all'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
// lang is extracted from params
function orderLabel(id: number) {
return `#${id.toString().padStart(4, '0')}`
}
@@ -47,8 +46,8 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
}
}
export default async function OrderStatusPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
export default async function OrderStatusPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
const { id, lang } = await params
const [orderResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getOrderResult(id),
getCategoriesResult(lang),
@@ -1,11 +1,14 @@
import type { Metadata } from 'next'
import DynamicPageRenderer from '../components/dynamic/DynamicPageRenderer'
import { getCategories, getCmsPage } from '../lib/cms'
import { splitCmsLayoutSections } from '../lib/cms-sections'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getCategories, getCmsPage } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
const lang = 'all'
type Props = {
params: Promise<{ lang: string }>
}
export async function generateMetadata(): Promise<Metadata> {
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { lang } = await params
const page = await getCmsPage('home', lang)
return {
@@ -14,7 +17,9 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
export default async function HomePage() {
export default async function HomePage({ params }: Props) {
const { lang } = await params
const [categories, page] = await Promise.all([
getCategories(lang),
getCmsPage('home', lang),
@@ -1,24 +1,22 @@
import Link from 'next/link'
import Link from '@/components/LocalizedLink'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { getCategoryImage, type Product } from '@homaroma/types'
import AddToCartSection from '../../../components/AddToCartSection'
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
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 AddToCartSection from '@/components/AddToCartSection'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
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'
const lang = 'all'
async function getRelated(categoryId: number, excludeId: number): Promise<Product[]> {
async function getRelated(categoryId: number, excludeId: number, lang: string): Promise<Product[]> {
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
if (!result.ok) return []
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
const { id, lang } = await params
const product = await getProduct(id, lang)
if (!product) return { title: 'Product Not Found | HomAroma' }
@@ -38,8 +36,8 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
}
}
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
const { id, lang } = await params
const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getProductResult(id, lang),
getCategoriesResult(lang),
@@ -73,7 +71,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
}
const product = productResult.data
const related = await getRelated(product.category.id, product.id)
const related = await getRelated(product.category.id, product.id, lang)
const productName = resolveLocalized(product.name, lang) || product.scent.name
const categoryName = resolveCategoryName(product.category, lang)
const details = resolveLocalized(product.details, lang)
@@ -1,18 +1,18 @@
import { User } from 'lucide-react'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import ProfileClient from '../../components/ProfileClient'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'all'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProfileClient from '@/components/ProfileClient'
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 default async function ProfilePage() {
export default async function ProfilePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params
const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('profile-layout', lang),
@@ -1,16 +1,16 @@
import Link from 'next/link'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'all'
import Link from '@/components/LocalizedLink'
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 default async function ScentGuidePage() {
export default async function ScentGuidePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params
const [articlesResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getArticlesResult(),
getCategoriesResult(lang),
@@ -1,20 +1,19 @@
import Link from 'next/link'
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'
const lang = 'all'
import Link from '@/components/LocalizedLink'
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 default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const params = await searchParams
const rawQuery = Array.isArray(params.q) ? params.q[0] : params.q
export default async function SearchPage({ searchParams, params }: { searchParams: Promise<{ q?: string | string[] }>, params: Promise<{ lang: string }> }) {
const { lang } = await params
const sParams = await searchParams
const rawQuery = Array.isArray(sParams.q) ? sParams.q[0] : sParams.q
const query = rawQuery?.trim() ?? ''
const [categoriesResult, cmsLayoutResult, productsResult] = await Promise.all([
getCategoriesResult(lang),
@@ -1,21 +1,21 @@
import Link from 'next/link'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import ProductCard from '../../components/ProductCard'
import ShopCouponBridge from '../../components/ShopCouponBridge'
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
import { resolveLocalized } from '../../lib/localization'
const lang = 'all'
import Link from '@/components/LocalizedLink'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import ShopCouponBridge from '@/components/ShopCouponBridge'
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '@/lib/cms'
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 default async function ShopPage({ searchParams }: { searchParams: Promise<{ coupon?: string | string[] }> }) {
const params = await searchParams
const coupon = Array.isArray(params.coupon) ? params.coupon[0] : params.coupon
export default async function ShopPage({ searchParams, params }: { searchParams: Promise<{ coupon?: string | string[] }>, params: Promise<{ lang: string }> }) {
const { lang } = await params
const sParams = await searchParams
const coupon = Array.isArray(sParams.coupon) ? sParams.coupon[0] : sParams.coupon
const [categoriesResult, productsResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getProductsResult('limit=1000', lang),
@@ -1,18 +1,18 @@
import { MapPin } from 'lucide-react'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import TrackOrderClient from '../../components/TrackOrderClient'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'all'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import TrackOrderClient from '@/components/TrackOrderClient'
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 default async function TrackPage() {
export default async function TrackPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params
const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('track-order-layout', lang),
@@ -1,18 +1,18 @@
import { Heart } from 'lucide-react'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import WishlistClient from '../../components/WishlistClient'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'all'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import WishlistClient from '@/components/WishlistClient'
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 default async function WishlistPage() {
export default async function WishlistPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params
const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('wishlist-layout', lang),
+14
View File
@@ -0,0 +1,14 @@
import { MetadataRoute } from 'next';
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/_next/', '/profile/', '/checkout/', '/order/'],
},
sitemap: `${baseUrl}/sitemap.xml`,
};
}
+78
View File
@@ -0,0 +1,78 @@
import { MetadataRoute } from 'next';
import { getCategories, getProducts, getArticlesResult } from '../lib/cms';
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
function createAlternates(path: string) {
const alternates: Record<string, string> = {};
for (const lang of languages) {
alternates[lang] = `${baseUrl}/${lang}${path}`;
}
return alternates;
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [categories, products, articlesResult] = await Promise.all([
getCategories(),
getProducts('limit=1000'),
getArticlesResult(),
]);
const staticPages = [
'',
'/shop',
'/scent-guide',
'/about',
'/contact',
'/faq',
];
const routes: MetadataRoute.Sitemap = staticPages.map((route) => ({
url: `${baseUrl}/th${route}`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: route === '' ? 1 : 0.8,
alternates: {
languages: createAlternates(route),
},
}));
const categoryRoutes: MetadataRoute.Sitemap = categories.map((category) => ({
url: `${baseUrl}/th/category/${category.id}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.7,
alternates: {
languages: createAlternates(`/category/${category.id}`),
},
}));
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 articleRoutes: MetadataRoute.Sitemap = [];
if (articlesResult.ok && articlesResult.data) {
articlesResult.data.forEach((article) => {
articleRoutes.push({
url: `${baseUrl}/th/article/${article.slug}`,
lastModified: new Date(article.updatedAt || article.createdAt),
changeFrequency: 'monthly',
priority: 0.6,
alternates: {
languages: createAlternates(`/article/${article.slug}`),
},
});
});
}
return [...routes, ...categoryRoutes, ...productRoutes, ...articleRoutes];
}
@@ -7,11 +7,13 @@ import i18n from '../lib/i18n';
export default function ClientProviders({ children }: { children: ReactNode }) {
useEffect(() => {
const savedLang = localStorage.getItem('homaroma_lang')
// With Locale Routing, the URL is the source of truth for language.
// Sync i18n to match the current URL if needed.
const pathLang = window.location.pathname.split('/')[1];
const targetLang = pathLang && ['th', 'en', 'ja', 'zh', 'vi', 'ko'].includes(pathLang) ? pathLang : 'th';
// Hydrate from the user's persisted language only after the first client render.
if (savedLang && savedLang !== i18n.language) {
void i18n.changeLanguage(savedLang)
if (targetLang !== i18n.language) {
void i18n.changeLanguage(targetLang);
}
}, [])
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from 'react'
import Link from 'next/link'
import Link from './LocalizedLink'
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
import { useTranslation } from 'react-i18next'
import { resolveLocalized } from '../lib/localization'
@@ -0,0 +1,63 @@
'use client';
import Link, { LinkProps } from 'next/link';
import { usePathname } from 'next/navigation';
import { useMemo } from 'react';
import { getPathWithLocale, SUPPORTED_LOCALES } from '../lib/locales';
interface LocalizedLinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps>, LinkProps {
children: React.ReactNode;
activeClassName?: string;
exact?: boolean;
}
export default function LocalizedLink({
href,
children,
className = '',
activeClassName = '',
exact = false,
...props
}: LocalizedLinkProps) {
const pathname = usePathname();
// 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)) {
return segments[0];
}
return 'th'; // Fallback
}, [pathname]);
// Resolve the localized href
const localizedHref = useMemo(() => {
if (typeof href === 'string') {
return getPathWithLocale(href, currentLocale);
}
if (typeof href === 'object' && href.pathname) {
return {
...href,
pathname: getPathWithLocale(href.pathname, currentLocale)
};
}
return href;
}, [href, currentLocale]);
// Check if link is active
const isActive = useMemo(() => {
const targetPath = typeof localizedHref === 'string' ? localizedHref : localizedHref.pathname || '';
if (exact) {
return pathname === targetPath;
}
return pathname.startsWith(targetPath) && targetPath !== `/${currentLocale}`;
}, [pathname, localizedHref, exact, currentLocale]);
const combinedClassName = `${className} ${isActive ? activeClassName : ''}`.trim();
return (
<Link href={localizedHref} className={combinedClassName} {...props}>
{children}
</Link>
);
}
+8 -1
View File
@@ -1,6 +1,7 @@
"use client"
import Link from 'next/link'
import Link from './LocalizedLink'
import { getPathWithoutLocale, getPathWithLocale } from '../lib/locales'
import { useRouter, usePathname } from 'next/navigation'
import { useState, useEffect, useMemo, useRef } from 'react'
import type { Category } from '@homaroma/types'
@@ -89,7 +90,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`
const currentPathWithoutLang = getPathWithoutLocale(pathname)
const newPath = getPathWithLocale(currentPathWithoutLang, code)
setLangSheetOpen(false)
router.push(newPath)
}
const loginWithLine = () => {
@@ -1,6 +1,6 @@
"use client"
import Link from 'next/link'
import Link from '../LocalizedLink'
import { useState, useRef } from 'react'
import type { MegaMenuPayload } from './menuTypes'
import { getLocalizedMenuText } from './menuTypes'
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from 'react'
import Link from 'next/link'
import Link from '../LocalizedLink'
import { ChevronLeft, ChevronRight, Globe, X } from 'lucide-react'
import type { MegaMenuPayload, MenuColumn, MenuLink } from './menuTypes'
import { getLocalizedMenuText } from './menuTypes'
+26 -28
View File
@@ -1,34 +1,32 @@
import { useState, useEffect } from 'react'
import i18n from '../lib/i18n'
import { useParams, usePathname } from 'next/navigation';
import { useMemo } from 'react';
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../lib/locales';
/**
* Returns a hydration-safe 2-char language code.
*
* WHY this pattern is necessary:
* - i18n.ts reads localStorage synchronously at module-load time (before React hydrates)
* - So i18n.language may already be 'ja', 'ko', etc. when client components first render
* - useTranslation() subscribes to this and causes components to render the user's language
* DURING hydration, but the server rendered 'th' — causing a mismatch error
*
* SOLUTION:
* - Always return 'th' until the component has mounted (matching the server)
* - After mount, sync to the real language and subscribe to future changes
* - Components using this hook NEVER cause a hydration mismatch
* Returns the current active language based on the URL.
* With Locale Routing, the language is dictated by the first path segment
* (e.g., /en/shop -> 'en').
*/
export function useLang(): string {
// Always 'th' on first render (matches SSR) — updated post-hydration
const [lang, setLang] = useState('th')
const params = useParams();
const pathname = usePathname();
useEffect(() => {
// Sync immediately after hydration
const current = i18n.language ? i18n.language.substring(0, 2) : 'th'
setLang(current)
// Subscribe to future language changes (user clicks language button)
const handler = (lng: string) => setLang(lng.substring(0, 2))
i18n.on('languageChanged', handler)
return () => { i18n.off('languageChanged', handler) }
}, [])
return lang
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)) {
return params.lang;
}
}
// 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)) {
return segments[0];
}
}
return DEFAULT_LOCALE;
}, [params, pathname]);
}
+25
View File
@@ -0,0 +1,25 @@
export const SUPPORTED_LOCALES = ['th', 'en', 'ja', 'zh', 'vi', 'ko'] as const;
export type Locale = typeof SUPPORTED_LOCALES[number];
export const DEFAULT_LOCALE: Locale = 'th';
export function isSupportedLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale as Locale);
}
export function getPathWithoutLocale(path: string): string {
const segments = path.split('/').filter(Boolean);
if (segments.length > 0 && isSupportedLocale(segments[0])) {
segments.shift();
}
return '/' + segments.join('/');
}
export function getPathWithLocale(path: string, locale: string): string {
if (!path) return `/${locale}`;
if (path.startsWith('http')) return path; // Skip absolute external URLs
const pathWithoutLocale = getPathWithoutLocale(path);
if (pathWithoutLocale === '/') return `/${locale}`;
return `/${locale}${pathWithoutLocale}`;
}
+66
View File
@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from './lib/locales';
// Paths that should not be localized
const PUBLIC_FILE = /\.(.*)$/;
const SKIP_PATHS = ['/_next', '/api', '/favicon.ico', '/robots.txt', '/sitemap.xml', '/sitemap'];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip Next.js internals, APIs, and public files
if (
SKIP_PATHS.some((p) => pathname.startsWith(p)) ||
PUBLIC_FILE.test(pathname)
) {
return NextResponse.next();
}
// Check if the pathname already has a locale
const pathnameHasLocale = SUPPORTED_LOCALES.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) {
return NextResponse.next();
}
// Determine the preferred locale
// 1. Check cookie
const cookieLocale = request.cookies.get('homaroma_lang')?.value;
let targetLocale = DEFAULT_LOCALE;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale as any)) {
targetLocale = cookieLocale as any;
} 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;
}
}
}
// Redirect to the localized path
const newUrl = request.nextUrl.clone();
newUrl.pathname = `/${targetLocale}${pathname === '/' ? '' : pathname}`;
return NextResponse.redirect(newUrl);
}
// Next.js 16 Proxy configuration
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};
+38
View File
@@ -0,0 +1,38 @@
const fs = require('fs');
const path = require('path');
function walkDir(dir, callback) {
fs.readdirSync(dir).forEach(f => {
let dirPath = path.join(dir, f);
let isDirectory = fs.statSync(dirPath).isDirectory();
isDirectory ? walkDir(dirPath, callback) : callback(dirPath);
});
}
walkDir('apps/website/src/app/[lang]', (filePath) => {
if (filePath.endsWith('.tsx') || filePath.endsWith('.ts')) {
let content = fs.readFileSync(filePath, 'utf8');
let changed = false;
// Replace import ... from '../../lib/...' to import ... from '../../../lib/...'
// Wait, the files were moved 1 level deeper. So any import starting with '../' needs an extra '../'
// To be safe, let's just find and replace `from '../` with `from '../../` etc?
// Actually, converting to `@/` is safer:
// from '../../components/ => from '@/components/
// from '../../../components/ => from '@/components/
// from '../../lib/ => from '@/lib/
// from '../../../lib/ => from '@/lib/
// from '../components/ => from '@/components/
// from '../lib/ => from '@/lib/
const newContent = content.replace(/from '(\.\.\/)+components\//g, "from '@/components/")
.replace(/from '(\.\.\/)+lib\//g, "from '@/lib/")
.replace(/from '(\.\.\/)+store\//g, "from '@/store/")
.replace(/from '(\.\.\/)+hooks\//g, "from '@/hooks/");
if (newContent !== content) {
fs.writeFileSync(filePath, newContent, 'utf8');
console.log('Fixed', filePath);
}
}
});
+23
View File
@@ -0,0 +1,23 @@
const fs = require('fs');
const path = require('path');
function walkDir(dir, callback) {
fs.readdirSync(dir).forEach(f => {
let dirPath = path.join(dir, f);
let isDirectory = fs.statSync(dirPath).isDirectory();
isDirectory ? walkDir(dirPath, callback) : callback(dirPath);
});
}
walkDir('apps/website/src/app/[lang]', (filePath) => {
if (filePath.endsWith('.tsx') || filePath.endsWith('.ts')) {
let content = fs.readFileSync(filePath, 'utf8');
const newContent = content.replace(/import Link from 'next\/link'/g, "import Link from '@/components/LocalizedLink'");
if (newContent !== content) {
fs.writeFileSync(filePath, newContent, 'utf8');
console.log('Fixed link in', filePath);
}
}
});
+30
View File
@@ -0,0 +1,30 @@
const fs = require('fs');
const path = require('path');
function walkDir(dir, callback) {
fs.readdirSync(dir).forEach(f => {
let dirPath = path.join(dir, f);
let isDirectory = fs.statSync(dirPath).isDirectory();
isDirectory ? walkDir(dirPath, callback) : callback(dirPath);
});
}
walkDir('apps/website/src/app/[lang]', (filePath) => {
if (filePath.endsWith('.tsx') || filePath.endsWith('.ts')) {
let content = fs.readFileSync(filePath, 'utf8');
let newContent = content.replace(/\/\/ lang is extracted from paramsexport/g, "// lang is extracted from params\nexport")
.replace(/\/\/ lang is extracted from paramsfunction/g, "// lang is extracted from params\nfunction")
.replace(/\/\/ lang is extracted from paramstype/g, "// lang is extracted from params\ntype")
.replace(/\/\/ lang is extracted from paramsconst/g, "// lang is extracted from params\nconst");
if (filePath.endsWith('layout.tsx')) {
newContent = newContent.replace("import './globals.css';", "import '../globals.css';");
}
if (newContent !== content) {
fs.writeFileSync(filePath, newContent, 'utf8');
console.log('Fixed syntax in', filePath);
}
}
});