189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
import type { Metadata } from 'next';
|
|
import { notFound } from 'next/navigation';
|
|
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';
|
|
|
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
|
const { lang } = await params;
|
|
const settings = await getStoreSettings(lang);
|
|
const brandName = settings?.brandName || 'HomAroma';
|
|
const defaultTitle = settings?.defaultSeoTitle || brandName;
|
|
const description = settings?.defaultSeoDescription || settings?.footerDescription || 'Pure Origins, Artisan Craft, Zero Compromise';
|
|
const ogImage = settings?.defaultOgImage;
|
|
|
|
return {
|
|
title: {
|
|
template: `%s | ${brandName}`,
|
|
default: defaultTitle,
|
|
},
|
|
description,
|
|
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
|
|
openGraph: ogImage
|
|
? {
|
|
title: defaultTitle,
|
|
description,
|
|
images: [ogImage],
|
|
}
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
// Cache configuration: 0 for dev (no cache), strict numbers for production
|
|
const getCacheTime = () => 0; // Temporary bypass for 5-day testing
|
|
|
|
// 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() } });
|
|
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() } });
|
|
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() } });
|
|
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() } });
|
|
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],
|
|
['--color-caption-text', tokens.captionText],
|
|
['--color-border-color', tokens.borderColor],
|
|
['--color-surface-muted', tokens.surfaceMuted],
|
|
['--radius-base', tokens.radiusBase],
|
|
['--radius-card', tokens.radiusCard],
|
|
]
|
|
|
|
// Add Scroll Reveal Settings
|
|
const scrollEnabled = settings?.scrollRevealEnabled !== false ? '1' : '0'
|
|
const scrollStyle = settings?.scrollRevealStyle || 'fade-up'
|
|
const scrollDuration = settings?.scrollRevealDuration ? `${settings.scrollRevealDuration}ms` : '700ms'
|
|
|
|
style['--scroll-reveal-enabled'] = scrollEnabled
|
|
style['--scroll-reveal-style'] = scrollStyle
|
|
style['--scroll-reveal-duration'] = scrollDuration
|
|
|
|
|
|
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 (
|
|
<div className="site-theme-root min-h-screen bg-surface-page text-text-main" lang={lang} 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>
|
|
</div>
|
|
);
|
|
}
|