Files
homaroma/apps/website/src/app/[lang]/layout.tsx
T
2026-06-21 01:52:57 -04:00

182 lines
6.1 KiB
TypeScript

import type { Metadata } from 'next';
import { Kanit, Sarabun } from 'next/font/google';
import { notFound } from 'next/navigation';
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';
import { isSupportedLocale } from '@/lib/locales';
import type { CSSProperties } from 'react';
const sarabun = Sarabun({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-sarabun',
});
const kanit = Kanit({
subsets: ['latin', 'thai'],
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
variable: '--font-kanit',
});
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
return {
title: {
template: `%s | ${brandName}`,
default: brandName,
},
description: 'Pure Origins, Artisan Craft, Zero Compromise',
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
};
}
// Cache configuration: 0 for dev (no cache), strict numbers for production
const getCacheTime = (prodSeconds: number) => process.env.NODE_ENV === 'development' ? 0 : prodSeconds;
// Fetch categories for Navbar and Footer on the server
async function getCategories(lang: string) {
try {
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: getCacheTime(3600) } });
if (!res.ok) return [];
return await res.json();
} catch (error) {
console.error('Failed to fetch categories in layout', error);
return [];
}
}
// Fetch mega menu on the server — eliminates client-side flash
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
try {
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime(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(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
try {
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: getCacheTime(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(lang: string): Promise<StoreSettings | null> {
try {
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: getCacheTime(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;
}
}
function buildThemeStyle(settings: StoreSettings | null): CSSProperties | undefined {
const tokens = settings?.themeTokens
if (!tokens || typeof tokens !== 'object') return undefined
const style: Record<string, string> = {}
const vars: Array<[string, string | undefined]> = [
['--color-brand-primary', tokens.brandPrimary],
['--color-brand-primary-hover', tokens.brandPrimaryHover],
['--color-brand-accent', tokens.brandAccent],
['--color-brand-warm', tokens.brandWarm],
['--color-brand-cool', tokens.brandCool],
['--color-brand-success', tokens.brandSuccess],
['--color-brand-soft', tokens.brandSoft],
['--color-surface-page', tokens.surfacePage],
['--color-surface-nav-rest', tokens.surfaceNavRest],
['--color-surface-card', tokens.surfaceCard],
['--color-text-main', tokens.textMain],
['--color-muted-text', tokens.mutedText],
['--color-line-primary', tokens.linePrimary],
['--color-line-primary-hover', tokens.linePrimaryHover],
['--font-body-active', tokens.fontBody],
['--font-display-active', tokens.fontDisplay],
['--shadow-card', tokens.shadowCard],
['--shadow-card-hover', tokens.shadowCardHover],
]
for (const [key, value] of vars) {
if (typeof value === 'string' && value.trim()) {
style[key] = value
}
}
return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined
}
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
if (!isSupportedLocale(lang)) {
notFound();
}
const [categories, initialMenu, footerMenus, settings] = await Promise.all([
getCategories(lang),
getMegaMenu(lang),
getFooterMenus(lang),
getStoreSettings(lang),
]);
const themeStyle = buildThemeStyle(settings)
return (
<html lang={lang} className={`${sarabun.variable} ${kanit.variable} scroll-smooth`}>
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased" style={themeStyle}>
<ClientProviders>
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
<main className="global-main">
{children}
</main>
<Footer
categories={categories}
exploreMenu={footerMenus.exploreMenu}
supportMenu={footerMenus.supportMenu}
settings={settings}
/>
</ClientProviders>
</body>
</html>
);
}