x
This commit is contained in:
1 parent
78b722e120
commit
75770ca122
18 files changed
+1119
-318
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user