This commit is contained in:
Your Name committed 2026-10-01 17:26:18 +07:00
1 parent 78b722e120
commit 75770ca122
18 files changed
+1119 -318

No files matched your search

+125 -155
View File
@@ -1,9 +1,17 @@
"use client";
import Image from 'next/image'
import { useState, useEffect } from 'react'
import { useState, useEffect, type ReactNode } from 'react'
import Link from './LocalizedLink'
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
import {
DEFAULT_FOOTER_LAYOUT_SETTINGS,
FooterLayoutSettingsSchema,
type Category,
type FooterColumn,
type FooterLayoutSettings,
type MenuItem,
type StoreSettings,
} from '@homaroma/types'
import { useTranslation } from 'react-i18next'
import { resolveLocalized } from '../lib/localization'
import { useLang } from '../hooks/useLang'
@@ -15,6 +23,26 @@ interface FooterProps {
settings?: StoreSettings | null
}
const layoutClassMaps = {
mobileColumns: { 1: 'site-footer--mobile-columns-1', 2: 'site-footer--mobile-columns-2' },
desktopColumns: { 2: 'site-footer--desktop-columns-2', 3: 'site-footer--desktop-columns-3', 4: 'site-footer--desktop-columns-4' },
containerWidth: { narrow: 'site-footer--container-narrow', standard: 'site-footer--container-standard', wide: 'site-footer--container-wide', full: 'site-footer--container-full' },
verticalPadding: { sm: 'site-footer--padding-sm', md: 'site-footer--padding-md', lg: 'site-footer--padding-lg', xl: 'site-footer--padding-xl' },
columnGap: { sm: 'site-footer--gap-sm', md: 'site-footer--gap-md', lg: 'site-footer--gap-lg', xl: 'site-footer--gap-xl' },
headingSize: { sm: 'site-footer--heading-sm', md: 'site-footer--heading-md', lg: 'site-footer--heading-lg' },
linkSize: { sm: 'site-footer--link-sm', md: 'site-footer--link-md', lg: 'site-footer--link-lg' },
brandLogoSize: { sm: 'site-footer--brand-logo-sm', md: 'site-footer--brand-logo-md', lg: 'site-footer--brand-logo-lg', xl: 'site-footer--brand-logo-xl' },
socialSize: { sm: 'site-footer--social-sm', md: 'site-footer--social-md', lg: 'site-footer--social-lg' },
finaleMobileHeight: { compact: 'site-footer--finale-mobile-compact', medium: 'site-footer--finale-mobile-medium', large: 'site-footer--finale-mobile-large' },
finaleDesktopHeight: { compact: 'site-footer--finale-desktop-compact', half: 'site-footer--finale-desktop-half', screen: 'site-footer--finale-desktop-screen' },
finaleLogoSize: { sm: 'site-footer--finale-logo-sm', md: 'site-footer--finale-logo-md', lg: 'site-footer--finale-logo-lg', xl: 'site-footer--finale-logo-xl' },
} as const
function normalizeFooterLayout(value: StoreSettings['footerLayout']): FooterLayoutSettings {
const result = FooterLayoutSettingsSchema.safeParse(value)
return result.success ? result.data : DEFAULT_FOOTER_LAYOUT_SETTINGS
}
export default function Footer({
categories = [],
exploreMenu = [],
@@ -25,15 +53,11 @@ export default function Footer({
const lang = useLang()
const [mounted, setMounted] = useState(false)
useEffect(() => { queueMicrotask(() => setMounted(true)) }, [])
// Before mount: always 'th' to match SSR output → no hydration mismatch
// After mount: real user language → correct content
const effectiveLang = mounted ? lang : 'th'
// 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 (effectiveLang !== 'en' && item.translations && item.translations.length > 0) {
const trans = item.translations.find((tr: { langCode: string; label: string }) => tr.langCode === effectiveLang)
if (effectiveLang !== 'en' && item.translations?.length) {
const trans = item.translations.find((translation: { langCode: string; label: string }) => translation.langCode === effectiveLang)
if (trans?.label) return trans.label
}
const map: Record<string, string> = {
@@ -51,164 +75,110 @@ export default function Footer({
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)
const footerLayout = normalizeFooterLayout(settings?.footerLayout)
const brandName = settings?.brandName || ''
const hasBrandFinale = Boolean(settings?.logoUrl || brandName.trim())
const showFinale = footerLayout.finale.enabled && Boolean(settings?.logoUrl || brandName.trim())
const useCurtain = showFinale && footerLayout.finale.mode === 'curtain'
const footerClasses = [
'site-footer text-text-main',
layoutClassMaps.mobileColumns[footerLayout.mobileColumns],
layoutClassMaps.desktopColumns[footerLayout.desktopColumns],
layoutClassMaps.containerWidth[footerLayout.containerWidth],
layoutClassMaps.verticalPadding[footerLayout.verticalPadding],
layoutClassMaps.columnGap[footerLayout.columnGap],
layoutClassMaps.headingSize[footerLayout.headingSize],
layoutClassMaps.linkSize[footerLayout.linkSize],
layoutClassMaps.brandLogoSize[footerLayout.brandLogoSize],
layoutClassMaps.socialSize[footerLayout.socialSize],
layoutClassMaps.finaleMobileHeight[footerLayout.finale.mobileHeight],
layoutClassMaps.finaleDesktopHeight[footerLayout.finale.desktopHeight],
layoutClassMaps.finaleLogoSize[footerLayout.finale.logoSize],
useCurtain ? 'site-footer--finale-curtain' : 'site-footer--finale-static',
].join(' ')
const localizedSupportHeading = resolveLocalized(settings?.footerSupportHeading, effectiveLang)
const localizedCopyright = resolveLocalized(settings?.footerCopyrightText, effectiveLang)
const columns: Record<FooterColumn, ReactNode> = {
brand: (
<div className="site-footer-column space-y-6">
<div className="text-2xl font-bold tracking-widest">
{settings?.logoUrl
? <Image src={settings.logoUrl} alt={brandName} width={160} height={40} unoptimized className="site-footer-brand-logo object-contain" />
: brandName}
</div>
<p className="site-footer-column-links leading-relaxed ui-muted-text">
{resolveLocalized(settings?.footerDescription, effectiveLang) || t('footer.catchphrase')}
</p>
{(settings?.facebookUrl || settings?.instagramUrl || settings?.tiktokUrl) && (
<div className="flex gap-4">
{settings.facebookUrl && <a href={settings.facebookUrl} target="_blank" rel="noreferrer" className="site-footer-social ui-footer-social" aria-label="Facebook">FB</a>}
{settings.instagramUrl && <a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="site-footer-social ui-footer-social" aria-label="Instagram">IG</a>}
{settings.tiktokUrl && <a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="site-footer-social ui-footer-social" aria-label="TikTok">TT</a>}
</div>
)}
</div>
),
explore: (
<div className="site-footer-column space-y-4">
<h4 className="site-footer-column-heading font-bold uppercase tracking-widest text-text-main">{t('footer.explore')}</h4>
<div className="site-footer-column-links flex flex-col space-y-2 text-muted-text">
{exploreMenu.length > 0 ? exploreMenu.map((item) => (
<Link key={item.id} href={item.url} className="ui-footer-link">{translateMenuLabel(item)}</Link>
)) : (
<>
<Link href="/" className="ui-footer-link">{t('nav.home')}</Link>
<Link href="/shop" className="ui-footer-link">{t('nav.shop')}</Link>
<Link href="/scent-guide" className="ui-footer-link">{t('nav.scentGuide')}</Link>
<Link href="/about" className="ui-footer-link">{t('nav.about')}</Link>
<Link href="/contact" className="ui-footer-link">{t('nav.contact')}</Link>
<Link href="/faq" className="ui-footer-link">{t('nav.faq')}</Link>
</>
)}
</div>
</div>
),
collections: (
<div className="site-footer-column space-y-4">
<h4 className="site-footer-column-heading font-bold uppercase tracking-widest text-text-main">{t('nav.collections')}</h4>
<div className="site-footer-column-links flex flex-col space-y-2 text-muted-text">
{categories.map((category) => (
<Link key={category.id} href={`/category/${category.id}`} className="ui-footer-link">{resolveLocalized(category.name, effectiveLang)}</Link>
))}
</div>
</div>
),
support: (
<div className="site-footer-column space-y-4">
<h4 className="site-footer-column-heading font-bold uppercase tracking-widest text-text-main">
{localizedSupportHeading || t('footer.support', 'Support & Legal')}
</h4>
<div className="site-footer-column-links flex flex-col space-y-2 text-muted-text">
{supportMenu.map((item) => <Link key={item.id} href={item.url} className="ui-footer-link">{translateMenuLabel(item)}</Link>)}
</div>
<div className="pt-4">
<p className="text-[10px] uppercase tracking-widest text-muted-text">
{localizedCopyright || `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`}
</p>
</div>
</div>
),
}
return (
<footer className={`site-footer text-text-main${hasBrandFinale ? ' site-footer-with-finale' : ''}`}>
<footer className={footerClasses}>
<div className="site-footer-stage">
{hasBrandFinale && (
<section
className="site-footer-brand-finale"
aria-label={brandName || t('footer.brand', 'Brand')}
>
{showFinale && (
<section className="site-footer-brand-finale" aria-label={brandName || t('footer.brand', 'Brand')}>
{settings?.logoUrl ? (
<Image
src={settings.logoUrl}
alt=""
width={640}
height={240}
unoptimized
className="site-footer-finale-logo"
/>
<Image src={settings.logoUrl} alt="" width={640} height={240} unoptimized className="site-footer-finale-logo" />
) : (
<p className="site-footer-finale-wordmark">{brandName}</p>
)}
</section>
)}
<div className="site-footer-navigation">
<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">
{settings?.logoUrl ? (
<Image src={settings.logoUrl} alt={brandName} width={160} height={40} unoptimized className="h-8 w-auto object-contain md:h-10" />
) : (
brandName
)}
</div>
<p className="text-sm leading-relaxed ui-muted-text">
{resolveLocalized(settings?.footerDescription, effectiveLang) || t('footer.catchphrase')}
</p>
<div className="flex gap-4">
{hasSocialLinks ? (
<>
{settings!.facebookUrl && (
<a
href={settings!.facebookUrl}
target="_blank"
rel="noreferrer"
className="ui-footer-social"
>
FB
</a>
)}
{settings!.instagramUrl && (
<a
href={settings!.instagramUrl}
target="_blank"
rel="noreferrer"
className="ui-footer-social"
>
IG
</a>
)}
{settings!.tiktokUrl && (
<a
href={settings!.tiktokUrl}
target="_blank"
rel="noreferrer"
className="ui-footer-social"
>
TT
</a>
)}
</>
) : (
['IG', 'FB', 'TW', 'YT'].map((s) => (
<a
key={s}
href="#"
className="ui-footer-social"
>
{s}
</a>
))
)}
</div>
</div>
{/* Col 2 — Explore */}
<div className="space-y-4">
<h4 className="ui-menu-title">{t('footer.explore')}</h4>
<div className="flex flex-col space-y-2 text-sm text-muted-text">
{exploreMenu.length > 0 ? (
exploreMenu.map((item) => (
<Link key={item.id} href={item.url} className="ui-footer-link">
{translateMenuLabel(item)}
</Link>
))
) : (
<>
<Link href="/" className="ui-footer-link">{t('nav.home')}</Link>
<Link href="/shop" className="ui-footer-link">{t('nav.shop')}</Link>
<Link href="/scent-guide" className="ui-footer-link">{t('nav.scentGuide')}</Link>
<Link href="/about" className="ui-footer-link">{t('nav.about')}</Link>
<Link href="/contact" className="ui-footer-link">{t('nav.contact')}</Link>
<Link href="/faq" className="ui-footer-link">{t('nav.faq')}</Link>
</>
)}
</div>
</div>
{/* Col 3 — Collections */}
<div className="space-y-4">
<h4 className="ui-menu-title">{t('nav.collections')}</h4>
<div className="flex flex-col space-y-2 text-sm text-muted-text">
{categories.map((c) => (
<Link key={c.id} href={`/category/${c.id}`} className="ui-footer-link">
{resolveLocalized(c.name, effectiveLang)}
</Link>
))}
</div>
</div>
{/* Col 4 — Support & Legal */}
<div className="space-y-4">
<h4 className="ui-menu-title">
{settings?.footerSupportHeading
? (typeof settings.footerSupportHeading === 'string'
? settings.footerSupportHeading
: settings.footerSupportHeading[effectiveLang]
|| settings.footerSupportHeading.en
|| 'Support & Legal')
: t('footer.support', 'Support & Legal')}
</h4>
<div className="flex flex-col space-y-2 text-sm text-muted-text">
{supportMenu.map((item) => (
<Link key={item.id} href={item.url} className="ui-footer-link">
{translateMenuLabel(item)}
</Link>
))}
</div>
<div className="pt-4">
<p className="text-[10px] uppercase tracking-widest text-muted-text">
{settings?.footerCopyrightText
? (typeof settings.footerCopyrightText === 'string'
? settings.footerCopyrightText
: settings.footerCopyrightText[effectiveLang]
|| settings.footerCopyrightText.en
|| `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`)
: `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`}
</p>
</div>
</div>
<div className="site-footer-navigation-inner">
{footerLayout.columns.map((column) => <div key={column}>{columns[column]}</div>)}
</div>
</div>
</div>