x
This commit is contained in:
1 parent
3c4bb06003
commit
f7aa4d099f
36 files changed
+528
-180
No files matched your search
@@ -1,12 +1,11 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
const reservedSlugs = new Set([
|
const reservedSlugs = new Set([
|
||||||
'about',
|
'about',
|
||||||
'article',
|
'article',
|
||||||
@@ -32,8 +31,8 @@ function isReservedSlug(slug: string) {
|
|||||||
return reservedSlugs.has(normalizeSlug(slug))
|
return reservedSlugs.has(normalizeSlug(slug))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { slug, lang } = await params
|
||||||
if (isReservedSlug(slug)) return { title: 'Page Not Found | HomAroma' }
|
if (isReservedSlug(slug)) return { title: 'Page Not Found | HomAroma' }
|
||||||
|
|
||||||
const pageResult = await getCmsPageResult(slug, lang)
|
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 }> }) {
|
export default async function CmsCustomPage({ params }: { params: Promise<{ slug: string, lang: string }> }) {
|
||||||
const { slug } = await params
|
const { slug, lang } = await params
|
||||||
if (isReservedSlug(slug)) notFound()
|
if (isReservedSlug(slug)) notFound()
|
||||||
|
|
||||||
const [pageResult, categoriesResult] = await Promise.all([
|
const [pageResult, categoriesResult] = await Promise.all([
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
|
|
||||||
const PILLARS = [
|
const PILLARS = [
|
||||||
{
|
{
|
||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
import type { Article } from '@homaroma/types'
|
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> {
|
async function getArticle(slug: string): Promise<Article | null> {
|
||||||
try {
|
try {
|
||||||
+14
-15
@@ -1,30 +1,29 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import type { Category, Product } from '@homaroma/types'
|
import type { Category, Product } from '@homaroma/types'
|
||||||
import { getCategoryImage } from '@homaroma/types'
|
import { getCategoryImage } from '@homaroma/types'
|
||||||
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProductCard from '../../../components/ProductCard'
|
import ProductCard from '@/components/ProductCard'
|
||||||
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult } from '../../../lib/cms'
|
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { resolveLocalized } from '../../../lib/localization'
|
import { resolveLocalized } from '@/lib/localization'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
type CategoryProductsResponse = {
|
type CategoryProductsResponse = {
|
||||||
category?: Category
|
category?: Category
|
||||||
data?: Product[]
|
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}`, {
|
return await fetchJsonResult<CategoryProductsResponse>(`/categories/${id}/products?limit=1000&lang=${lang}`, {
|
||||||
next: { revalidate: 60 },
|
next: { revalidate: 60 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
|
||||||
const { id } = await params
|
const { id, lang } = await params
|
||||||
const result = await getCategoryProducts(id)
|
const result = await getCategoryProducts(id, lang)
|
||||||
const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : ''
|
const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -33,10 +32,10 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function CategoryPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
|
||||||
const { id } = await params
|
const { id, lang } = await params
|
||||||
const [categoryProductsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
const [categoryProductsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getCategoryProducts(id),
|
getCategoryProducts(id, lang),
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
getCmsPageResult('category-layout', lang),
|
getCmsPageResult('category-layout', lang),
|
||||||
])
|
])
|
||||||
+7
-7
@@ -1,18 +1,18 @@
|
|||||||
import { CreditCard } from 'lucide-react'
|
import { CreditCard } from 'lucide-react'
|
||||||
|
|
||||||
import CheckoutClient from '../../components/CheckoutClient'
|
import CheckoutClient from '@/components/CheckoutClient'
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Checkout | HomAroma',
|
title: 'Checkout | HomAroma',
|
||||||
description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ 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([
|
const [categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
getCmsPageResult('checkout-layout', lang),
|
getCmsPageResult('checkout-layout', lang),
|
||||||
File renamed without changes.
File renamed without changes.
+7
-7
@@ -1,11 +1,10 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
import { Ticket, ArrowRight } from 'lucide-react'
|
import { Ticket, ArrowRight } from 'lucide-react'
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
function formatDiscount(type: string, value: number) {
|
function formatDiscount(type: string, value: number) {
|
||||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||||
if (type === 'FIXED_AMOUNT') return `ลด ${value} ฿`
|
if (type === 'FIXED_AMOUNT') return `ลด ${value} ฿`
|
||||||
@@ -18,7 +17,8 @@ export const metadata = {
|
|||||||
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก HomAroma สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
|
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([
|
const [couponsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getActiveCouponsResult(),
|
getActiveCouponsResult(),
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
|
|
||||||
const FAQS = [
|
const FAQS = [
|
||||||
{
|
{
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import '../globals.css';
|
||||||
import Navbar from '../components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import Footer from '../components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
import ClientProviders from '../components/ClientProviders';
|
import ClientProviders from '@/components/ClientProviders';
|
||||||
import { API_BASE } from '../lib/api';
|
import { API_BASE } from '@/lib/api';
|
||||||
import type { MegaMenuPayload } from '../components/navigation/menuTypes';
|
import type { MegaMenuPayload } from '@/components/navigation/menuTypes';
|
||||||
import type { MenuItem, StoreSettings } from '@homaroma/types';
|
import type { MenuItem, StoreSettings } from '@homaroma/types';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'HomAroma',
|
title: 'HomAroma',
|
||||||
description: 'Pure Origins, Artisan Craft, Zero Compromise',
|
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)
|
// Fetch categories for Navbar and Footer on the server
|
||||||
async function getCategories() {
|
async function getCategories(lang: string) {
|
||||||
try {
|
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 [];
|
if (!res.ok) return [];
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -25,9 +26,9 @@ async function getCategories() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch mega menu on the server — eliminates client-side flash
|
// Fetch mega menu on the server — eliminates client-side flash
|
||||||
async function getMegaMenu(): Promise<MegaMenuPayload[]> {
|
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
|
||||||
try {
|
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 [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json() as { payload?: MegaMenuPayload[] };
|
const data = await res.json() as { payload?: MegaMenuPayload[] };
|
||||||
return Array.isArray(data.payload) ? data.payload : [];
|
return Array.isArray(data.payload) ? data.payload : [];
|
||||||
@@ -38,10 +39,10 @@ async function getMegaMenu(): Promise<MegaMenuPayload[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch footer menus (explore + support) on the server
|
// 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[]> => {
|
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
|
||||||
try {
|
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 [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json() as { items?: MenuItem[] };
|
const data = await res.json() as { items?: MenuItem[] };
|
||||||
return Array.isArray(data.items)
|
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
|
// 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 {
|
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;
|
if (!res.ok) return null;
|
||||||
return await res.json() as StoreSettings;
|
return await res.json() as StoreSettings;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -74,18 +75,22 @@ async function getStoreSettings(): Promise<StoreSettings | null> {
|
|||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ lang: string }>;
|
||||||
}) {
|
}) {
|
||||||
|
const { lang } = await params;
|
||||||
|
|
||||||
const [categories, initialMenu, footerMenus, settings] = await Promise.all([
|
const [categories, initialMenu, footerMenus, settings] = await Promise.all([
|
||||||
getCategories(),
|
getCategories(lang),
|
||||||
getMegaMenu(),
|
getMegaMenu(lang),
|
||||||
getFooterMenus(),
|
getFooterMenus(lang),
|
||||||
getStoreSettings(),
|
getStoreSettings(lang),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
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">
|
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
|
||||||
<ClientProviders>
|
<ClientProviders>
|
||||||
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
||||||
+7
-8
@@ -1,15 +1,14 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { CheckCircle2, Clock, PackageCheck, Truck } from 'lucide-react'
|
import { CheckCircle2, Clock, PackageCheck, Truck } from 'lucide-react'
|
||||||
import { formatPrice } from '@homaroma/types'
|
import { formatPrice } from '@homaroma/types'
|
||||||
|
|
||||||
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '../../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
function orderLabel(id: number) {
|
function orderLabel(id: number) {
|
||||||
return `#${id.toString().padStart(4, '0')}`
|
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 }> }) {
|
export default async function OrderStatusPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
|
||||||
const { id } = await params
|
const { id, lang } = await params
|
||||||
const [orderResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
const [orderResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getOrderResult(id),
|
getOrderResult(id),
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import DynamicPageRenderer from '../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import { getCategories, getCmsPage } from '../lib/cms'
|
import { getCategories, getCmsPage } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../lib/cms-sections'
|
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)
|
const page = await getCmsPage('home', lang)
|
||||||
|
|
||||||
return {
|
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([
|
const [categories, page] = await Promise.all([
|
||||||
getCategories(lang),
|
getCategories(lang),
|
||||||
getCmsPage('home', lang),
|
getCmsPage('home', lang),
|
||||||
+13
-15
@@ -1,24 +1,22 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { getCategoryImage, type Product } from '@homaroma/types'
|
import { getCategoryImage, type Product } from '@homaroma/types'
|
||||||
import AddToCartSection from '../../../components/AddToCartSection'
|
import AddToCartSection from '@/components/AddToCartSection'
|
||||||
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProductCard from '../../../components/ProductCard'
|
import ProductCard from '@/components/ProductCard'
|
||||||
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '../../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { resolveCategoryName, resolveLocalized } from '../../../lib/localization'
|
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
|
||||||
|
|
||||||
const lang = 'all'
|
async function getRelated(categoryId: number, excludeId: number, lang: string): Promise<Product[]> {
|
||||||
|
|
||||||
async function getRelated(categoryId: number, excludeId: number): Promise<Product[]> {
|
|
||||||
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
||||||
if (!result.ok) return []
|
if (!result.ok) return []
|
||||||
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
|
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
|
||||||
const { id } = await params
|
const { id, lang } = await params
|
||||||
const product = await getProduct(id, lang)
|
const product = await getProduct(id, lang)
|
||||||
|
|
||||||
if (!product) return { title: 'Product Not Found | HomAroma' }
|
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 }> }) {
|
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
|
||||||
const { id } = await params
|
const { id, lang } = await params
|
||||||
const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getProductResult(id, lang),
|
getProductResult(id, lang),
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
@@ -73,7 +71,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const product = productResult.data
|
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 productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||||
const categoryName = resolveCategoryName(product.category, lang)
|
const categoryName = resolveCategoryName(product.category, lang)
|
||||||
const details = resolveLocalized(product.details, lang)
|
const details = resolveLocalized(product.details, lang)
|
||||||
+7
-7
@@ -1,18 +1,18 @@
|
|||||||
import { User } from 'lucide-react'
|
import { User } from 'lucide-react'
|
||||||
|
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProfileClient from '../../components/ProfileClient'
|
import ProfileClient from '@/components/ProfileClient'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'โปรไฟล์สมาชิก | HomAroma',
|
title: 'โปรไฟล์สมาชิก | HomAroma',
|
||||||
description: 'จัดการโปรไฟล์สมาชิก 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([
|
const [categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
getCmsPageResult('profile-layout', lang),
|
getCmsPageResult('profile-layout', lang),
|
||||||
+7
-7
@@ -1,16 +1,16 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Scent Guide | HomAroma',
|
title: 'Scent Guide | HomAroma',
|
||||||
description: 'Explore our collection of aroma articles, guides, and sensory knowledge to find your perfect scent.',
|
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([
|
const [articlesResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getArticlesResult(),
|
getArticlesResult(),
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
File renamed without changes.
+10
-11
@@ -1,20 +1,19 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProductCard from '../../components/ProductCard'
|
import ProductCard from '@/components/ProductCard'
|
||||||
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { resolveLocalized } from '../../lib/localization'
|
import { resolveLocalized } from '@/lib/localization'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Search Products | HomAroma',
|
title: 'Search Products | HomAroma',
|
||||||
description: 'Search HomAroma products by scent, category, or keyword.',
|
description: 'Search HomAroma products by scent, category, or keyword.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
|
export default async function SearchPage({ searchParams, params }: { searchParams: Promise<{ q?: string | string[] }>, params: Promise<{ lang: string }> }) {
|
||||||
const params = await searchParams
|
const { lang } = await params
|
||||||
const rawQuery = Array.isArray(params.q) ? params.q[0] : params.q
|
const sParams = await searchParams
|
||||||
|
const rawQuery = Array.isArray(sParams.q) ? sParams.q[0] : sParams.q
|
||||||
const query = rawQuery?.trim() ?? ''
|
const query = rawQuery?.trim() ?? ''
|
||||||
const [categoriesResult, cmsLayoutResult, productsResult] = await Promise.all([
|
const [categoriesResult, cmsLayoutResult, productsResult] = await Promise.all([
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
File renamed without changes.
@@ -1,21 +1,21 @@
|
|||||||
import Link from 'next/link'
|
import Link from '@/components/LocalizedLink'
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProductCard from '../../components/ProductCard'
|
import ProductCard from '@/components/ProductCard'
|
||||||
import ShopCouponBridge from '../../components/ShopCouponBridge'
|
import ShopCouponBridge from '@/components/ShopCouponBridge'
|
||||||
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { resolveLocalized } from '../../lib/localization'
|
import { resolveLocalized } from '@/lib/localization'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Shop Aroma Products | HomAroma',
|
title: 'Shop Aroma Products | HomAroma',
|
||||||
description: 'Browse HomAroma aroma oils, reed diffusers, room sprays, and premium scent products.',
|
description: 'Browse HomAroma aroma oils, reed diffusers, room sprays, and premium scent products.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ShopPage({ searchParams }: { searchParams: Promise<{ coupon?: string | string[] }> }) {
|
export default async function ShopPage({ searchParams, params }: { searchParams: Promise<{ coupon?: string | string[] }>, params: Promise<{ lang: string }> }) {
|
||||||
const params = await searchParams
|
const { lang } = await params
|
||||||
const coupon = Array.isArray(params.coupon) ? params.coupon[0] : params.coupon
|
const sParams = await searchParams
|
||||||
|
const coupon = Array.isArray(sParams.coupon) ? sParams.coupon[0] : sParams.coupon
|
||||||
const [categoriesResult, productsResult, cmsLayoutResult] = await Promise.all([
|
const [categoriesResult, productsResult, cmsLayoutResult] = await Promise.all([
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
getProductsResult('limit=1000', lang),
|
getProductsResult('limit=1000', lang),
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { MapPin } from 'lucide-react'
|
import { MapPin } from 'lucide-react'
|
||||||
|
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import TrackOrderClient from '../../components/TrackOrderClient'
|
import TrackOrderClient from '@/components/TrackOrderClient'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'ติดตามคำสั่งซื้อ | HomAroma',
|
title: 'ติดตามคำสั่งซื้อ | HomAroma',
|
||||||
description: 'ตรวจสอบสถานะคำสั่งซื้อ 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([
|
const [categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
getCmsPageResult('track-order-layout', lang),
|
getCmsPageResult('track-order-layout', lang),
|
||||||
+7
-7
@@ -1,18 +1,18 @@
|
|||||||
import { Heart } from 'lucide-react'
|
import { Heart } from 'lucide-react'
|
||||||
|
|
||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import WishlistClient from '../../components/WishlistClient'
|
import WishlistClient from '@/components/WishlistClient'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'all'
|
|
||||||
|
|
||||||
|
// lang is extracted from params
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Wishlist | HomAroma',
|
title: 'Wishlist | HomAroma',
|
||||||
description: 'รายการสินค้าที่คุณบันทึกไว้ใน 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([
|
const [categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||||
getCategoriesResult(lang),
|
getCategoriesResult(lang),
|
||||||
getCmsPageResult('wishlist-layout', lang),
|
getCmsPageResult('wishlist-layout', lang),
|
||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 }) {
|
export default function ClientProviders({ children }: { children: ReactNode }) {
|
||||||
useEffect(() => {
|
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.
|
||||||
// Hydrate from the user's persisted language only after the first client render.
|
const pathLang = window.location.pathname.split('/')[1];
|
||||||
if (savedLang && savedLang !== i18n.language) {
|
const targetLang = pathLang && ['th', 'en', 'ja', 'zh', 'vi', 'ko'].includes(pathLang) ? pathLang : 'th';
|
||||||
void i18n.changeLanguage(savedLang)
|
|
||||||
|
if (targetLang !== i18n.language) {
|
||||||
|
void i18n.changeLanguage(targetLang);
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from './LocalizedLink'
|
||||||
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
|
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { resolveLocalized } from '../lib/localization'
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from './LocalizedLink'
|
||||||
|
import { getPathWithoutLocale, getPathWithLocale } from '../lib/locales'
|
||||||
import { useRouter, usePathname } from 'next/navigation'
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import type { Category } from '@homaroma/types'
|
import type { Category } from '@homaroma/types'
|
||||||
@@ -89,7 +90,13 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
|
|||||||
const changeLanguage = (code: string) => {
|
const changeLanguage = (code: string) => {
|
||||||
i18n.changeLanguage(code)
|
i18n.changeLanguage(code)
|
||||||
localStorage.setItem('homaroma_lang', 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)
|
setLangSheetOpen(false)
|
||||||
|
router.push(newPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginWithLine = () => {
|
const loginWithLine = () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from '../LocalizedLink'
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import type { MegaMenuPayload } from './menuTypes'
|
import type { MegaMenuPayload } from './menuTypes'
|
||||||
import { getLocalizedMenuText } from './menuTypes'
|
import { getLocalizedMenuText } from './menuTypes'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from '../LocalizedLink'
|
||||||
import { ChevronLeft, ChevronRight, Globe, X } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Globe, X } from 'lucide-react'
|
||||||
import type { MegaMenuPayload, MenuColumn, MenuLink } from './menuTypes'
|
import type { MegaMenuPayload, MenuColumn, MenuLink } from './menuTypes'
|
||||||
import { getLocalizedMenuText } from './menuTypes'
|
import { getLocalizedMenuText } from './menuTypes'
|
||||||
|
|||||||
@@ -1,34 +1,32 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
import i18n from '../lib/i18n'
|
import { useMemo } from 'react';
|
||||||
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../lib/locales';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a hydration-safe 2-char language code.
|
* Returns the current active language based on the URL.
|
||||||
*
|
* With Locale Routing, the language is dictated by the first path segment
|
||||||
* WHY this pattern is necessary:
|
* (e.g., /en/shop -> 'en').
|
||||||
* - 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
|
|
||||||
*/
|
*/
|
||||||
export function useLang(): string {
|
export function useLang(): string {
|
||||||
// Always 'th' on first render (matches SSR) — updated post-hydration
|
const params = useParams();
|
||||||
const [lang, setLang] = useState('th')
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
return useMemo(() => {
|
||||||
// Sync immediately after hydration
|
// 1. Try to get it from route params
|
||||||
const current = i18n.language ? i18n.language.substring(0, 2) : 'th'
|
if (params?.lang && typeof params.lang === 'string') {
|
||||||
setLang(current)
|
if (SUPPORTED_LOCALES.includes(params.lang as any)) {
|
||||||
|
return params.lang;
|
||||||
// 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) }
|
// 2. Fallback: extract from pathname if params are not available yet
|
||||||
}, [])
|
if (pathname) {
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
return lang
|
if (segments.length > 0 && SUPPORTED_LOCALES.includes(segments[0] as any)) {
|
||||||
|
return segments[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}, [params, pathname]);
|
||||||
}
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user