fix update Section Block
This commit is contained in:
1 parent
996dfdabd0
commit
6f67ee6077
47 files changed
+1792
-197
No files matched your search
@@ -4,7 +4,7 @@ import type { Metadata } from 'next'
|
||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
const reservedSlugs = new Set([
|
||||
'about',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult
|
||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../../lib/localization'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
type CategoryProductsResponse = {
|
||||
category?: Category
|
||||
|
||||
@@ -5,7 +5,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Checkout | HomAroma',
|
||||
|
||||
@@ -4,7 +4,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
function formatDiscount(type: string, value: number) {
|
||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||
|
||||
@@ -4,16 +4,18 @@ 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',
|
||||
};
|
||||
|
||||
// Fetch categories for Navbar and Footer on the server
|
||||
// Fetch categories for Navbar and Footer on the server (lang=all for client-side switching)
|
||||
async function getCategories() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/categories`, { next: { revalidate: 3600 } });
|
||||
const res = await fetch(`${API_BASE}/categories?lang=all`, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
@@ -22,22 +24,80 @@ async function getCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch mega menu on the server — eliminates client-side flash
|
||||
async function getMegaMenu(): Promise<MegaMenuPayload[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/menus/main`, { next: { revalidate: 300 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { payload?: MegaMenuPayload[] };
|
||||
return Array.isArray(data.payload) ? data.payload : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch mega menu in layout', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch footer menus (explore + support) on the server
|
||||
async function getFooterMenus(): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
|
||||
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/menus/${slug}`, { next: { revalidate: 300 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { items?: MenuItem[] };
|
||||
return Array.isArray(data.items)
|
||||
? data.items.sort((a, b) => a.order - b.order)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const [exploreMenu, supportMenu] = await Promise.all([
|
||||
fetchMenu('footer-explore'),
|
||||
fetchMenu('footer-support'),
|
||||
]);
|
||||
|
||||
return { exploreMenu, supportMenu };
|
||||
}
|
||||
|
||||
// Fetch store settings (social links, footer texts) on the server
|
||||
async function getStoreSettings(): Promise<StoreSettings | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/settings`, { next: { revalidate: 600 } });
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as StoreSettings;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch store settings in layout', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const categories = await getCategories();
|
||||
const [categories, initialMenu, footerMenus, settings] = await Promise.all([
|
||||
getCategories(),
|
||||
getMegaMenu(),
|
||||
getFooterMenus(),
|
||||
getStoreSettings(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<html lang="th" className="scroll-smooth">
|
||||
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} />
|
||||
<Navbar categories={categories} initialMenu={initialMenu} />
|
||||
<main className="pt-[72px]">
|
||||
{children}
|
||||
</main>
|
||||
<Footer categories={categories} />
|
||||
<Footer
|
||||
categories={categories}
|
||||
exploreMenu={footerMenus.exploreMenu}
|
||||
supportMenu={footerMenus.supportMenu}
|
||||
settings={settings}
|
||||
/>
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@ import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer
|
||||
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '../../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
function orderLabel(id: number) {
|
||||
return `#${id.toString().padStart(4, '0')}`
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
|
||||
import DynamicPageRenderer from '../components/dynamic/DynamicPageRenderer'
|
||||
import { getCategories, getCmsPage } from '../lib/cms'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = await getCmsPage('home', lang)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, ge
|
||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../../lib/localization'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
async function getRelated(categoryId: number, excludeId: number): Promise<Product[]> {
|
||||
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
||||
|
||||
@@ -5,7 +5,7 @@ import ProfileClient from '../../components/ProfileClient'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'โปรไฟล์สมาชิก | HomAroma',
|
||||
|
||||
@@ -3,7 +3,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Scent Guide | HomAroma',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../lib/localization'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search Products | HomAroma',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../lib/localization'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Shop Aroma Products | HomAroma',
|
||||
|
||||
@@ -5,7 +5,7 @@ import TrackOrderClient from '../../components/TrackOrderClient'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'ติดตามคำสั่งซื้อ | HomAroma',
|
||||
|
||||
@@ -5,7 +5,7 @@ import WishlistClient from '../../components/WishlistClient'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Wishlist | HomAroma',
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from 'next/link'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
@@ -13,11 +15,13 @@ interface Props {
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
const [selectedVariant, setSelectedVariant] = useState(0)
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
const { t } = useTranslation()
|
||||
const lang = useLang()
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
const categoryName = resolveLocalized(product.category.name)
|
||||
const productName = resolveLocalized(product.name) || product.scent.name
|
||||
const description = resolveLocalized(product.description)
|
||||
const categoryName = resolveLocalized(product.category.name, lang)
|
||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||
const description = resolveLocalized(product.description, lang)
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!currentVariant) return
|
||||
|
||||
@@ -1,46 +1,135 @@
|
||||
"use client";
|
||||
import Link from 'next/link'
|
||||
import type { Category } from '@homaroma/types'
|
||||
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
interface FooterProps {
|
||||
categories?: Category[]
|
||||
exploreMenu?: MenuItem[]
|
||||
supportMenu?: MenuItem[]
|
||||
settings?: StoreSettings | null
|
||||
}
|
||||
|
||||
export default function Footer({ categories = [] }: FooterProps) {
|
||||
export default function Footer({
|
||||
categories = [],
|
||||
exploreMenu = [],
|
||||
supportMenu = [],
|
||||
settings = null,
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const lang = useLang()
|
||||
|
||||
// Translate a menu item label using its translations array or i18n key map
|
||||
const translateMenuLabel = (item: MenuItem): string => {
|
||||
// Use outer `lang` from closure — hooks cannot be called inside nested functions
|
||||
if (lang !== 'en' && item.translations && item.translations.length > 0) {
|
||||
const trans = item.translations.find((tr: any) => tr.langCode === lang)
|
||||
if (trans?.label) return trans.label
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
'Home': 'nav.home',
|
||||
'Shop': 'nav.shop',
|
||||
'Scent Guide': 'nav.scentGuide',
|
||||
'About Us': 'nav.about',
|
||||
'About': 'nav.about',
|
||||
'FAQ': 'nav.faq',
|
||||
'Contact': 'nav.contact',
|
||||
'Contact Us': 'nav.contact',
|
||||
'Track Order': 'nav.trackOrder',
|
||||
'Delivery & Returns': 'nav.delivery',
|
||||
}
|
||||
return map[item.label] ? t(map[item.label]) : item.label
|
||||
}
|
||||
|
||||
// Social links — use settings if available, fallback to placeholder buttons
|
||||
const hasSocialLinks = settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl)
|
||||
|
||||
return (
|
||||
<footer className="border-t border-gray-200 bg-white px-6 py-20 text-pop-black">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 md:grid-cols-4">
|
||||
|
||||
{/* Col 1 — Brand */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-2xl font-bold tracking-widest">
|
||||
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-gray-600">
|
||||
{t('footer.catchphrase')}
|
||||
{settings?.footerDescription || t('footer.catchphrase')}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{['IG', 'FB', 'TW', 'YT'].map((s) => (
|
||||
<a key={s} href="#" className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white">
|
||||
{s}
|
||||
</a>
|
||||
))}
|
||||
{hasSocialLinks ? (
|
||||
<>
|
||||
{settings!.facebookUrl && (
|
||||
<a
|
||||
href={settings!.facebookUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white"
|
||||
>
|
||||
FB
|
||||
</a>
|
||||
)}
|
||||
{settings!.instagramUrl && (
|
||||
<a
|
||||
href={settings!.instagramUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white"
|
||||
>
|
||||
IG
|
||||
</a>
|
||||
)}
|
||||
{settings!.tiktokUrl && (
|
||||
<a
|
||||
href={settings!.tiktokUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white"
|
||||
>
|
||||
TT
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
['IG', 'FB', 'TW', 'YT'].map((s) => (
|
||||
<a
|
||||
key={s}
|
||||
href="#"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white"
|
||||
>
|
||||
{s}
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Col 2 — Explore */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('footer.explore')}</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
||||
<Link href="/" className="transition-colors hover:text-pop-pink">{t('nav.home')}</Link>
|
||||
<Link href="/shop" className="transition-colors hover:text-pop-pink">{t('nav.shop')}</Link>
|
||||
<Link href="/scent-guide" className="transition-colors hover:text-pop-pink">{t('nav.scentGuide')}</Link>
|
||||
<Link href="/about" className="transition-colors hover:text-pop-pink">{t('nav.about')}</Link>
|
||||
<Link href="/contact" className="transition-colors hover:text-pop-pink">{t('nav.contact')}</Link>
|
||||
<Link href="/faq" className="transition-colors hover:text-pop-pink">{t('nav.faq')}</Link>
|
||||
{exploreMenu.length > 0 ? (
|
||||
exploreMenu.map((item) => (
|
||||
<Link key={item.id} href={item.url} className="transition-colors hover:text-pop-pink">
|
||||
{translateMenuLabel(item)}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Link href="/" className="transition-colors hover:text-pop-pink">{t('nav.home')}</Link>
|
||||
<Link href="/shop" className="transition-colors hover:text-pop-pink">{t('nav.shop')}</Link>
|
||||
<Link href="/scent-guide" className="transition-colors hover:text-pop-pink">{t('nav.scentGuide')}</Link>
|
||||
<Link href="/about" className="transition-colors hover:text-pop-pink">{t('nav.about')}</Link>
|
||||
<Link href="/contact" className="transition-colors hover:text-pop-pink">{t('nav.contact')}</Link>
|
||||
<Link href="/faq" className="transition-colors hover:text-pop-pink">{t('nav.faq')}</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Col 3 — Collections */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('nav.collections')}</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
||||
@@ -52,23 +141,38 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">Newsletter</h4>
|
||||
<p className="text-xs text-gray-500">Subscribe for exclusive releases and sensory wisdom.</p>
|
||||
<div className="flex rounded-xl border border-gray-200 bg-pop-bg p-1">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
className="w-full border-none bg-transparent p-2 text-sm text-pop-black outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
<button className="shrink-0 rounded-lg bg-pop-black px-4 py-2 text-xs font-bold uppercase text-white transition-colors hover:bg-pop-pink">
|
||||
Join
|
||||
</button>
|
||||
{/* Col 4 — Support & Legal */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">
|
||||
{settings?.footerSupportHeading
|
||||
? (typeof settings.footerSupportHeading === 'string'
|
||||
? settings.footerSupportHeading
|
||||
: settings.footerSupportHeading[lang]
|
||||
|| settings.footerSupportHeading.en
|
||||
|| 'Support & Legal')
|
||||
: t('footer.support', 'Support & Legal')}
|
||||
</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
||||
{supportMenu.map((item) => (
|
||||
<Link key={item.id} href={item.url} className="transition-colors hover:text-pop-pink">
|
||||
{translateMenuLabel(item)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-gray-500">
|
||||
{settings?.footerCopyrightText
|
||||
? (typeof settings.footerCopyrightText === 'string'
|
||||
? settings.footerCopyrightText
|
||||
: settings.footerCopyrightText[lang]
|
||||
|| settings.footerCopyrightText.en
|
||||
|| `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`)
|
||||
: `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[10px] uppercase tracking-widest text-gray-500">
|
||||
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -10,13 +10,14 @@ import { useWishlistStore } from '../store/wishlistStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { API_BASE } from '../lib/api'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
import { DesktopMegaMenu } from './navigation/DesktopMegaMenu'
|
||||
import { MobileDrawer } from './navigation/MobileDrawer'
|
||||
import type { MegaMenuPayload } from './navigation/menuTypes'
|
||||
|
||||
interface NavbarProps {
|
||||
categories?: Category[]
|
||||
initialMenu?: MegaMenuPayload[]
|
||||
}
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -58,14 +59,14 @@ function createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
|
||||
]
|
||||
}
|
||||
|
||||
export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
export default function Navbar({ categories = [], initialMenu = [] }: NavbarProps) {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [megaMenu, setMegaMenu] = useState<MegaMenuPayload[]>([])
|
||||
const [menuLoaded, setMenuLoaded] = useState(false)
|
||||
// Seed from server-fetched initialMenu — no client flash
|
||||
const [megaMenu] = useState<MegaMenuPayload[]>(initialMenu)
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
@@ -76,10 +77,11 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
const cartCount = getCartCount()
|
||||
const { t, i18n } = useTranslation()
|
||||
const lang = i18n.language ? i18n.language.substring(0, 2) : 'th'
|
||||
const lang = useLang()
|
||||
|
||||
const fallbackMenu = useMemo(() => createFallbackMenu(categories), [categories])
|
||||
const menuItems = menuLoaded && megaMenu.length > 0 ? megaMenu : fallbackMenu
|
||||
// Use server menu if available, fall back to categories-based menu
|
||||
const menuItems = megaMenu.length > 0 ? megaMenu : fallbackMenu
|
||||
|
||||
const changeLanguage = (code: string) => {
|
||||
i18n.changeLanguage(code)
|
||||
@@ -96,30 +98,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
async function fetchMegaMenu() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/menus/main`)
|
||||
if (!response.ok) throw new Error(`menus/main returned ${response.status}`)
|
||||
const data = await response.json() as { payload?: MegaMenuPayload[] }
|
||||
if (isMounted) {
|
||||
setMegaMenu(Array.isArray(data.payload) ? data.payload : [])
|
||||
setMenuLoaded(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[website-menu] Failed to fetch main menu', error)
|
||||
if (isMounted) setMenuLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
fetchMegaMenu()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20)
|
||||
@@ -342,7 +321,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-pop-black/55">{t('nav.search.quickLinks')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
...categories.map((category) => ({ label: resolveLocalized(category.name), to: `/category/${category.id}` })),
|
||||
...categories.map((category) => ({ label: resolveLocalized(category.name, lang), to: `/category/${category.id}` })),
|
||||
{ label: t('nav.scentGuide'), to: '/scent-guide' },
|
||||
{ label: t('nav.search.allProducts'), to: '/shop' },
|
||||
].map((item) => (
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Product } from '@homaroma/types'
|
||||
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
import WishlistButton from './WishlistButton'
|
||||
|
||||
interface ProductCardProps {
|
||||
@@ -9,8 +13,10 @@ interface ProductCardProps {
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
const categoryName = resolveLocalized(product.category.name)
|
||||
const productName = resolveLocalized(product.name) || product.scent.name
|
||||
const { i18n } = useTranslation()
|
||||
const lang = useLang()
|
||||
const categoryName = resolveLocalized(product.category.name, lang)
|
||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
const firstPrice = product.variants[0]?.price ?? product.price
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react'
|
||||
import type { Category, PageSection, Product } from '@homaroma/types'
|
||||
import { getCategoryImage } from '@homaroma/types'
|
||||
import ProductCard from '../ProductCard'
|
||||
import { localizedFromRecord, resolveLocalized, toRecord } from '../../lib/localization'
|
||||
import { useLang } from '../../hooks/useLang'
|
||||
|
||||
type DynamicPageRendererProps = {
|
||||
sections: PageSection[]
|
||||
categories: Category[]
|
||||
lang?: string
|
||||
lang?: string // optional override; defaults to i18n.language
|
||||
}
|
||||
|
||||
function categoryName(category: Category, lang: string) {
|
||||
@@ -540,7 +543,10 @@ function ContactInfoSection({ payload }: { payload: Record<string, unknown> }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function DynamicPageRenderer({ sections, categories, lang = 'th' }: DynamicPageRendererProps) {
|
||||
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
|
||||
const i18nLang = useLang()
|
||||
// Use i18n.language reactively — switches immediately when user changes language
|
||||
const lang = langProp ?? i18nLang
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{sections.map((section) => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Returns the current i18n language code (2-char) in a hydration-safe way.
|
||||
*
|
||||
* On the server and during initial client render the value is always 'th'
|
||||
* (matching the SSR output). After hydration it updates to the user's saved
|
||||
* language so all components re-render with the correct locale without
|
||||
* triggering a hydration mismatch.
|
||||
*/
|
||||
export function useLang(): string {
|
||||
const { i18n } = useTranslation()
|
||||
const [lang, setLang] = useState('th') // must match server default
|
||||
|
||||
useEffect(() => {
|
||||
setLang(i18n.language ? i18n.language.substring(0, 2) : 'th')
|
||||
}, [i18n.language])
|
||||
|
||||
return lang
|
||||
}
|
||||
@@ -61,19 +61,19 @@ export async function fetchJsonResult<T>(path: string, init?: RequestInit & { ne
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategories(lang = 'th'): Promise<Category[]> {
|
||||
export async function getCategories(lang = 'all'): Promise<Category[]> {
|
||||
return await fetchJson<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 3600 } }) ?? []
|
||||
}
|
||||
|
||||
export async function getCategoriesResult(lang = 'th'): Promise<ApiResult<Category[]>> {
|
||||
export async function getCategoriesResult(lang = 'all'): Promise<ApiResult<Category[]>> {
|
||||
return await fetchJsonResult<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 3600 } })
|
||||
}
|
||||
|
||||
export async function getCmsPage(slug: string, lang = 'th'): Promise<Page | null> {
|
||||
export async function getCmsPage(slug: string, lang = 'all'): Promise<Page | null> {
|
||||
return await fetchJson<Page>(`/pages/${slug}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getCmsPageResult(slug: string, lang = 'th'): Promise<ApiResult<Page>> {
|
||||
export async function getCmsPageResult(slug: string, lang = 'all'): Promise<ApiResult<Page>> {
|
||||
return await fetchJsonResult<Page>(`/pages/${slug}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
@@ -95,15 +95,15 @@ export async function getOrderResult(id: string | number): Promise<ApiResult<Ord
|
||||
return await fetchJsonResult<Order>(`/orders/${id}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProduct(id: string | number, lang = 'th'): Promise<Product | null> {
|
||||
export async function getProduct(id: string | number, lang = 'all'): Promise<Product | null> {
|
||||
return await fetchJson<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProductResult(id: string | number, lang = 'th'): Promise<ApiResult<Product>> {
|
||||
export async function getProductResult(id: string | number, lang = 'all'): Promise<ApiResult<Product>> {
|
||||
return await fetchJsonResult<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||
}
|
||||
|
||||
export async function getProducts(query = '', lang = 'th'): Promise<Product[]> {
|
||||
export async function getProducts(query = '', lang = 'all'): Promise<Product[]> {
|
||||
const separator = query ? '&' : ''
|
||||
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
||||
next: { revalidate: defaultRevalidate },
|
||||
@@ -112,7 +112,7 @@ export async function getProducts(query = '', lang = 'th'): Promise<Product[]> {
|
||||
return response?.data ?? []
|
||||
}
|
||||
|
||||
export async function getProductsResult(query = '', lang = 'th'): Promise<ApiResult<Product[]>> {
|
||||
export async function getProductsResult(query = '', lang = 'all'): Promise<ApiResult<Product[]>> {
|
||||
const separator = query ? '&' : ''
|
||||
const response = await fetchJsonResult<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
||||
next: { revalidate: defaultRevalidate },
|
||||
|
||||
Reference in new issue
Block a user