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

+2
View File
@@ -27,6 +27,7 @@ import MainNavigationPage from './pages/menu-manager/MainNavigationPage'
import FooterExplorePage from './pages/menu-manager/FooterExplorePage' import FooterExplorePage from './pages/menu-manager/FooterExplorePage'
import FooterSupportPage from './pages/menu-manager/FooterSupportPage' import FooterSupportPage from './pages/menu-manager/FooterSupportPage'
import FooterBrandPage from './pages/menu-manager/FooterBrandPage' import FooterBrandPage from './pages/menu-manager/FooterBrandPage'
import FooterLayoutPage from './pages/menu-manager/FooterLayoutPage'
import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage' import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
import ArticlesPage from './pages/ArticlesPage' import ArticlesPage from './pages/ArticlesPage'
import ArticleEditPage from './pages/ArticleEditPage' import ArticleEditPage from './pages/ArticleEditPage'
@@ -125,6 +126,7 @@ function App() {
<Route path="/menu-manager/footer-explore" element={<FooterExplorePage />} /> <Route path="/menu-manager/footer-explore" element={<FooterExplorePage />} />
<Route path="/menu-manager/footer-support" element={<FooterSupportPage />} /> <Route path="/menu-manager/footer-support" element={<FooterSupportPage />} />
<Route path="/menu-manager/footer-brand" element={<FooterBrandPage />} /> <Route path="/menu-manager/footer-brand" element={<FooterBrandPage />} />
<Route path="/menu-manager/footer-layout" element={<FooterLayoutPage />} />
<Route path="/subscribers" element={<NewsletterSubscribersPage />} /> <Route path="/subscribers" element={<NewsletterSubscribersPage />} />
<Route path="/articles" element={<ArticlesPage />} /> <Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticleEditPage />} /> <Route path="/articles/:id" element={<ArticleEditPage />} />
+2
View File
@@ -38,6 +38,7 @@ import {
Map, Map,
Target, Target,
BrainCircuit, BrainCircuit,
LayoutTemplate,
} from 'lucide-react' } from 'lucide-react'
import { useStoreSettings } from '../contexts/StoreSettingsContext' import { useStoreSettings } from '../contexts/StoreSettingsContext'
import { clearAdminAuth } from '../utils/auth' import { clearAdminAuth } from '../utils/auth'
@@ -160,6 +161,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
{ icon: FileText, label: t('nav.footerExplore', 'Footer Explore'), to: '/menu-manager/footer-explore' }, { icon: FileText, label: t('nav.footerExplore', 'Footer Explore'), to: '/menu-manager/footer-explore' },
{ icon: MessageSquare, label: t('nav.footerSupport', 'Footer Support'), to: '/menu-manager/footer-support' }, { icon: MessageSquare, label: t('nav.footerSupport', 'Footer Support'), to: '/menu-manager/footer-support' },
{ icon: Store, label: t('nav.footerBrand', 'Footer Brand'), to: '/menu-manager/footer-brand' }, { icon: Store, label: t('nav.footerBrand', 'Footer Brand'), to: '/menu-manager/footer-brand' },
{ icon: LayoutTemplate, label: t('nav.footerLayout', 'Footer Layout & Presentation'), to: '/menu-manager/footer-layout' },
] ]
const BACKUP_MENU = [ const BACKUP_MENU = [
@@ -0,0 +1,311 @@
import { useEffect, useMemo, useState } from 'react'
import type { FormEvent } from 'react'
import {
API_BASE,
DEFAULT_FOOTER_LAYOUT_SETTINGS,
FooterLayoutSettingsSchema,
type FooterLayoutSettings,
} from '@homaroma/types'
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronUp,
Eye,
EyeOff,
LayoutTemplate,
Loader2,
Save,
} from 'lucide-react'
import { getAuthHeaders } from '../../utils/auth'
type FooterColumn = FooterLayoutSettings['columns'][number]
type SaveState = { type: 'success' | 'error'; message: string } | null
const COLUMN_OPTIONS: Array<{ value: FooterColumn; label: string; description: string }> = [
{ value: 'brand', label: 'Brand', description: 'Logo, description and social links' },
{ value: 'explore', label: 'Explore', description: 'Links from Footer Explore' },
{ value: 'collections', label: 'Collections', description: 'Automatic category links' },
{ value: 'support', label: 'Support', description: 'Links from Footer Support' },
]
const SPACING_OPTIONS = ['sm', 'md', 'lg', 'xl'] as const
const TEXT_SIZE_OPTIONS = ['sm', 'md', 'lg'] as const
const CONTAINER_WIDTH_OPTIONS = ['narrow', 'standard', 'wide', 'full'] as const
const FINALE_MODE_OPTIONS = ['static', 'curtain'] as const
const MOBILE_HEIGHT_OPTIONS = ['compact', 'medium', 'large'] as const
const DESKTOP_HEIGHT_OPTIONS = ['compact', 'half', 'screen'] as const
function selectedOption<Options extends readonly string[]>(
value: string,
options: Options,
fallback: Options[number],
): Options[number] {
return options.find(option => option === value) ?? fallback
}
function getErrorMessage(payload: unknown, fallback: string): string {
if (!payload || typeof payload !== 'object') return fallback
const record = payload as Record<string, unknown>
const message = typeof record.error === 'string'
? record.error
: typeof record.message === 'string'
? record.message
: fallback
if (!Array.isArray(record.issues)) return message
const details = record.issues.flatMap(issue => {
if (!issue || typeof issue !== 'object') return []
const issueRecord = issue as Record<string, unknown>
const issueMessage = typeof issueRecord.message === 'string' ? issueRecord.message : null
const path = Array.isArray(issueRecord.path) ? issueRecord.path.join('.') : ''
return issueMessage ? [`${path ? `${path}: ` : ''}${issueMessage}`] : []
})
return details.length > 0 ? `${message} ${details.join('; ')}` : message
}
function selectClassName(): string {
return 'admin-input w-full cursor-pointer'
}
function Preview({ layout, mobile }: { layout: FooterLayoutSettings; mobile: boolean }) {
const visibleColumns = layout.columns
const columnCount = mobile ? layout.mobileColumns : layout.desktopColumns
const gridClass = columnCount === 1
? 'grid-cols-1'
: columnCount === 2
? 'grid-cols-2'
: columnCount === 3
? 'grid-cols-3'
: 'grid-cols-4'
return (
<div className={`rounded-xl border border-white/10 bg-night-900 p-4 ${mobile ? 'max-w-64' : 'w-full'}`}>
<div className="mb-3 flex items-center justify-between text-[10px] font-bold uppercase tracking-widest text-slate-500">
<span>{mobile ? 'Mobile' : 'Desktop'}</span>
<span>{columnCount} column{columnCount === 1 ? '' : 's'}</span>
</div>
<div className={`grid gap-2 ${gridClass}`}>
{visibleColumns.map(column => (
<div key={column} className="min-h-14 rounded-lg border border-cyan-glow/20 bg-cyan-glow/5 p-2">
<div className="h-1.5 w-2/3 rounded-full bg-cyan-glow/40" />
<div className="mt-2 h-1 w-full rounded-full bg-white/15" />
<div className="mt-1 h-1 w-4/5 rounded-full bg-white/10" />
<span className="mt-2 block truncate text-[9px] capitalize text-slate-400">{column}</span>
</div>
))}
</div>
{layout.finale.enabled && (
<div className="mt-3 flex h-8 items-center justify-center rounded-lg border border-purple-400/20 bg-purple-400/5 text-[9px] font-bold uppercase tracking-widest text-purple-200">
{layout.finale.mode} finale
</div>
)}
</div>
)
}
export default function FooterLayoutPage() {
const [layout, setLayout] = useState<FooterLayoutSettings>(() => structuredClone(DEFAULT_FOOTER_LAYOUT_SETTINGS))
const [version, setVersion] = useState<string | undefined>()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saveState, setSaveState] = useState<SaveState>(null)
useEffect(() => {
let cancelled = false
fetch(`${API_BASE}/settings/footer-layout`, { headers: getAuthHeaders() })
.then(async response => {
const payload: unknown = await response.json()
if (!response.ok) throw new Error(getErrorMessage(payload, `Unable to load footer layout (${response.status})`))
if (!payload || typeof payload !== 'object') throw new Error('Footer layout response is invalid.')
const record = payload as Record<string, unknown>
const parsed = FooterLayoutSettingsSchema.safeParse(record.layout)
if (!parsed.success) throw new Error('Footer layout response does not match the shared contract.')
if (typeof record.version !== 'string') throw new Error('Footer layout response is missing its version.')
return {
layout: parsed.data,
version: record.version,
}
})
.then(result => {
if (cancelled) return
setLayout(result.layout)
setVersion(result.version)
})
.catch(error => {
if (!cancelled) setSaveState({ type: 'error', message: error instanceof Error ? error.message : 'Unable to load footer layout.' })
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [])
const orderedColumns = useMemo(() => {
const active = layout.columns
.map(column => COLUMN_OPTIONS.find(option => option.value === column))
.filter((option): option is (typeof COLUMN_OPTIONS)[number] => Boolean(option))
const inactive = COLUMN_OPTIONS.filter(option => !layout.columns.includes(option.value))
return [...active, ...inactive]
}, [layout.columns])
const updateLayout = <Key extends keyof FooterLayoutSettings>(key: Key, value: FooterLayoutSettings[Key]) => {
setLayout(current => ({ ...current, [key]: value }))
}
const updateFinale = <Key extends keyof FooterLayoutSettings['finale']>(
key: Key,
value: FooterLayoutSettings['finale'][Key],
) => {
setLayout(current => ({ ...current, finale: { ...current.finale, [key]: value } }))
}
const toggleColumn = (column: FooterColumn) => {
setLayout(current => {
if (current.columns.includes(column)) {
if (current.columns.length === 1) return current
return { ...current, columns: current.columns.filter(item => item !== column) }
}
return { ...current, columns: [...current.columns, column] }
})
}
const moveColumn = (column: FooterColumn, direction: -1 | 1) => {
setLayout(current => {
const index = current.columns.indexOf(column)
const destination = index + direction
if (index < 0 || destination < 0 || destination >= current.columns.length) return current
const columns = [...current.columns]
columns.splice(index, 1)
columns.splice(destination, 0, column)
return { ...current, columns }
})
}
const handleSave = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSaveState(null)
const parsed = FooterLayoutSettingsSchema.safeParse(layout)
if (!parsed.success) {
setSaveState({ type: 'error', message: parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ') })
return
}
setSaving(true)
try {
const response = await fetch(`${API_BASE}/settings/footer-layout`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify({ ...parsed.data, expectedVersion: version }),
})
const payload: unknown = await response.json()
if (!response.ok) {
if (response.status === 409) throw new Error('Footer layout changed in another session. Reload this page before saving again.')
throw new Error(getErrorMessage(payload, `Unable to save footer layout (${response.status})`))
}
if (!payload || typeof payload !== 'object') throw new Error('Footer layout save response is invalid.')
const record = payload as Record<string, unknown>
const savedLayout = FooterLayoutSettingsSchema.safeParse(record.layout)
if (!savedLayout.success) throw new Error('Saved footer layout does not match the shared contract.')
setLayout(savedLayout.data)
setVersion(typeof record.version === 'string' ? record.version : version)
setSaveState({ type: 'success', message: 'Footer layout saved successfully.' })
} catch (error) {
setSaveState({ type: 'error', message: error instanceof Error ? error.message : 'Unable to save footer layout.' })
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="flex items-center justify-center p-20" role="status" aria-label="Loading footer layout"><Loader2 className="h-8 w-8 animate-spin text-cyan-glow" /></div>
}
return (
<div className="max-w-7xl space-y-8 animate-fade-in">
<div className="space-y-1">
<h1 className="text-3xl font-bold">Footer Layout &amp; Presentation</h1>
<p className="text-sm text-slate-400">Control responsive columns, spacing, typography and the brand finale independently from footer content.</p>
</div>
{saveState && (
<div role="status" className={`flex items-start gap-2 rounded-lg border px-4 py-3 text-sm ${saveState.type === 'success' ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200' : 'border-red-400/30 bg-red-400/10 text-red-200'}`}>
{saveState.type === 'success' ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" /> : <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />}
<span>{saveState.message}</span>
</div>
)}
<form onSubmit={handleSave} className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_24rem]">
<div className="space-y-8">
<section className="admin-card space-y-6 p-8">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="rounded-lg bg-cyan-glow/10 p-2"><LayoutTemplate className="h-5 w-5 text-cyan-glow" /></div>
<div><h2 className="text-lg font-bold uppercase tracking-widest text-white">Column composition</h2><p className="text-xs text-slate-500">Choose which columns appear and their left-to-right order.</p></div>
</div>
<div className="space-y-3">
{orderedColumns.map(option => {
const activeIndex = layout.columns.indexOf(option.value)
const isVisible = activeIndex >= 0
return (
<div key={option.value} className={`flex items-center gap-4 rounded-xl border p-4 ${isVisible ? 'border-cyan-glow/20 bg-cyan-glow/5' : 'border-white/5 bg-white/[0.02]'}`}>
<div className="flex flex-col gap-1">
<button type="button" disabled={!isVisible || activeIndex === 0} onClick={() => moveColumn(option.value, -1)} className="rounded-md p-1 text-slate-400 hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-20" aria-label={`Move ${option.label} up`}><ChevronUp className="h-4 w-4" /></button>
<button type="button" disabled={!isVisible || activeIndex === layout.columns.length - 1} onClick={() => moveColumn(option.value, 1)} className="rounded-md p-1 text-slate-400 hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-20" aria-label={`Move ${option.label} down`}><ChevronDown className="h-4 w-4" /></button>
</div>
<div className="min-w-0 flex-1"><p className="font-bold text-white">{option.label}</p><p className="text-xs text-slate-500">{option.description}</p></div>
<button type="button" onClick={() => toggleColumn(option.value)} disabled={isVisible && layout.columns.length === 1} aria-pressed={isVisible} className="inline-flex min-w-24 items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs font-bold text-slate-300 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40">
{isVisible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}{isVisible ? 'Visible' : 'Hidden'}
</button>
</div>
)
})}
</div>
<div className="grid gap-5 md:grid-cols-2">
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Mobile columns<select className={selectClassName()} value={layout.mobileColumns} onChange={event => updateLayout('mobileColumns', event.target.value === '2' ? 2 : 1)}><option value={1}>1 column</option><option value={2}>2 columns</option></select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Desktop columns<select className={selectClassName()} value={layout.desktopColumns} onChange={event => updateLayout('desktopColumns', event.target.value === '2' ? 2 : event.target.value === '3' ? 3 : 4)}><option value={2}>2 columns</option><option value={3}>3 columns</option><option value={4}>4 columns</option></select></label>
</div>
</section>
<section className="admin-card space-y-6 p-8">
<div className="border-b border-white/10 pb-4"><h2 className="text-lg font-bold uppercase tracking-widest text-white">Sizing &amp; spacing</h2><p className="text-xs text-slate-500">Shared presentation scales keep SPA and Website rendering consistent.</p></div>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Container width<select className={selectClassName()} value={layout.containerWidth} onChange={event => updateLayout('containerWidth', selectedOption(event.target.value, CONTAINER_WIDTH_OPTIONS, 'standard'))}>{CONTAINER_WIDTH_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Vertical padding<select className={selectClassName()} value={layout.verticalPadding} onChange={event => updateLayout('verticalPadding', selectedOption(event.target.value, SPACING_OPTIONS, 'lg'))}>{SPACING_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Column gap<select className={selectClassName()} value={layout.columnGap} onChange={event => updateLayout('columnGap', selectedOption(event.target.value, SPACING_OPTIONS, 'lg'))}>{SPACING_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Heading size<select className={selectClassName()} value={layout.headingSize} onChange={event => updateLayout('headingSize', selectedOption(event.target.value, TEXT_SIZE_OPTIONS, 'sm'))}>{TEXT_SIZE_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Link size<select className={selectClassName()} value={layout.linkSize} onChange={event => updateLayout('linkSize', selectedOption(event.target.value, TEXT_SIZE_OPTIONS, 'md'))}>{TEXT_SIZE_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Brand logo size<select className={selectClassName()} value={layout.brandLogoSize} onChange={event => updateLayout('brandLogoSize', selectedOption(event.target.value, SPACING_OPTIONS, 'md'))}>{SPACING_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Social button size<select className={selectClassName()} value={layout.socialSize} onChange={event => updateLayout('socialSize', selectedOption(event.target.value, TEXT_SIZE_OPTIONS, 'md'))}>{TEXT_SIZE_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
</div>
</section>
<section className="admin-card space-y-6 p-8">
<div className="flex items-center justify-between gap-4 border-b border-white/10 pb-4">
<div><h2 className="text-lg font-bold uppercase tracking-widest text-white">Brand finale</h2><p className="text-xs text-slate-500">Configure the visual brand moment above the footer.</p></div>
<button type="button" onClick={() => updateFinale('enabled', !layout.finale.enabled)} aria-pressed={layout.finale.enabled} className={`rounded-full px-4 py-2 text-xs font-bold ${layout.finale.enabled ? 'bg-cyan-glow text-night-900' : 'bg-white/5 text-slate-400'}`}>{layout.finale.enabled ? 'Enabled' : 'Disabled'}</button>
</div>
<fieldset disabled={!layout.finale.enabled} className="grid gap-5 disabled:opacity-40 md:grid-cols-2">
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Mode<select className={selectClassName()} value={layout.finale.mode} onChange={event => updateFinale('mode', selectedOption(event.target.value, FINALE_MODE_OPTIONS, 'curtain'))}>{FINALE_MODE_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Finale logo size<select className={selectClassName()} value={layout.finale.logoSize} onChange={event => updateFinale('logoSize', selectedOption(event.target.value, SPACING_OPTIONS, 'xl'))}>{SPACING_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Mobile height<select className={selectClassName()} value={layout.finale.mobileHeight} onChange={event => updateFinale('mobileHeight', selectedOption(event.target.value, MOBILE_HEIGHT_OPTIONS, 'medium'))}>{MOBILE_HEIGHT_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Desktop height<select className={selectClassName()} value={layout.finale.desktopHeight} onChange={event => updateFinale('desktopHeight', selectedOption(event.target.value, DESKTOP_HEIGHT_OPTIONS, 'screen'))}>{DESKTOP_HEIGHT_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
</fieldset>
</section>
<button type="submit" disabled={saving || !version} className="btn-primary ml-auto flex items-center gap-2 px-12 py-4 text-sm">{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}{saving ? 'Saving…' : 'Save footer layout'}</button>
</div>
<aside className="space-y-4 xl:sticky xl:top-8 xl:self-start">
<div className="admin-card space-y-5 p-6">
<div><h2 className="text-sm font-bold uppercase tracking-widest text-white">Responsive preview</h2><p className="mt-1 text-xs text-slate-500">Structural preview; storefront theme and content are applied at render time.</p></div>
<Preview layout={layout} mobile={false} />
<Preview layout={layout} mobile />
</div>
</aside>
</form>
</div>
)
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { API_BASE, type Menu } from '@homaroma/types' import { API_BASE, type Menu } from '@homaroma/types'
import { Save, Loader2, Plus, Trash2, GripVertical, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react' import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react'
import { ICON_REGISTRY } from '../../utils/iconRegistry' import { ICON_REGISTRY } from '../../utils/iconRegistry'
import FocalPointPicker from '../../components/page-builder/FocalPointPicker' import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
@@ -447,10 +447,25 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
} }
const handleRemoveItem = (idx: number) => { const handleRemoveItem = (idx: number) => {
const newItems = currentMenu.items.filter((_, i) => i !== idx) const newItems = currentMenu.items
.filter((_, i) => i !== idx)
.map((item, order) => ({ ...item, order }))
setMenus(menus.map(m => m.name === menuName ? { ...m, items: newItems } : m)) setMenus(menus.map(m => m.name === menuName ? { ...m, items: newItems } : m))
} }
const handleMoveItem = (idx: number, direction: -1 | 1) => {
const destination = idx + direction
if (destination < 0 || destination >= currentMenu.items.length) return
const newItems = [...currentMenu.items]
const [movedItem] = newItems.splice(idx, 1)
if (!movedItem) return
newItems.splice(destination, 0, movedItem)
const reorderedItems = newItems.map((item, order) => ({ ...item, order }))
setMenus(menus.map(m => m.name === menuName ? { ...m, items: reorderedItems } : m))
}
const handleExport = () => { const handleExport = () => {
const dataStr = JSON.stringify(currentMenu, null, 2) const dataStr = JSON.stringify(currentMenu, null, 2)
const blob = new Blob([dataStr], { type: 'application/json' }) const blob = new Blob([dataStr], { type: 'application/json' })
@@ -644,7 +659,28 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
) : ( ) : (
currentMenu.items.map((item, idx) => ( currentMenu.items.map((item, idx) => (
<div key={item.id} className="flex gap-4 items-center bg-white/5 p-4 rounded-xl border border-white/5 group"> <div key={item.id} className="flex gap-4 items-center bg-white/5 p-4 rounded-xl border border-white/5 group">
<GripVertical className="w-5 h-5 text-slate-600 cursor-move" /> <div className="flex shrink-0 flex-col gap-1" aria-label={`Reorder ${item.label || `item ${idx + 1}`}`}>
<button
type="button"
onClick={() => handleMoveItem(idx, -1)}
disabled={idx === 0}
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-25"
title="Move item up"
aria-label={`Move ${item.label || `item ${idx + 1}`} up`}
>
<ChevronUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleMoveItem(idx, 1)}
disabled={idx === currentMenu.items.length - 1}
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-25"
title="Move item down"
aria-label={`Move ${item.label || `item ${idx + 1}`} down`}
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "StoreSettings" ADD COLUMN "footerLayout" JSONB;
+1
View File
@@ -301,6 +301,7 @@ model StoreSettings {
newsletterButtonText String? newsletterButtonText String?
footerSupportHeading Json? footerSupportHeading Json?
footerCopyrightText Json? footerCopyrightText Json?
footerLayout Json?
scrollRevealEnabled Boolean @default(true) scrollRevealEnabled Boolean @default(true)
scrollRevealStyle String @default("fade-up") scrollRevealStyle String @default("fade-up")
scrollRevealDuration Int @default(700) scrollRevealDuration Int @default(700)
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'
import { DEFAULT_FOOTER_LAYOUT_SETTINGS } from '@homaroma/types'
import {
mergeFooterLayoutSettings,
normalizeFooterLayoutSettings,
validateFooterLayoutSettingsPatch,
} from './footerLayoutSettings'
describe('footer layout settings', () => {
test('normalizes missing and invalid persisted values to the shared default', () => {
expect(normalizeFooterLayoutSettings(null)).toEqual(DEFAULT_FOOTER_LAYOUT_SETTINGS)
expect(normalizeFooterLayoutSettings({ columns: ['brand'] })).toEqual(
DEFAULT_FOOTER_LAYOUT_SETTINGS,
)
})
test('preserves a valid persisted layout', () => {
const layout = { ...DEFAULT_FOOTER_LAYOUT_SETTINGS, mobileColumns: 2 as const }
expect(normalizeFooterLayoutSettings(layout)).toEqual(layout)
})
test('merges a partial finale patch without losing sibling settings', () => {
expect(mergeFooterLayoutSettings(DEFAULT_FOOTER_LAYOUT_SETTINGS, {
containerWidth: 'wide',
finale: { enabled: false },
})).toEqual({
...DEFAULT_FOOTER_LAYOUT_SETTINGS,
containerWidth: 'wide',
finale: { ...DEFAULT_FOOTER_LAYOUT_SETTINGS.finale, enabled: false },
})
})
test('strictly rejects unknown patch fields', () => {
const result = validateFooterLayoutSettingsPatch({ arbitraryCss: 'display:none' })
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,32 @@
import {
DEFAULT_FOOTER_LAYOUT_SETTINGS,
FooterLayoutSettingsPatchSchema,
FooterLayoutSettingsSchema,
type FooterLayoutSettings,
type FooterLayoutSettingsPatch,
} from '@homaroma/types'
export function normalizeFooterLayoutSettings(value: unknown): FooterLayoutSettings {
const result = FooterLayoutSettingsSchema.safeParse(value)
if (result.success) return result.data
return FooterLayoutSettingsSchema.parse(DEFAULT_FOOTER_LAYOUT_SETTINGS)
}
export function validateFooterLayoutSettingsPatch(value: unknown) {
return FooterLayoutSettingsPatchSchema.safeParse(value)
}
export function mergeFooterLayoutSettings(
current: FooterLayoutSettings,
patch: FooterLayoutSettingsPatch,
): FooterLayoutSettings {
return FooterLayoutSettingsSchema.parse({
...current,
...patch,
finale: {
...current.finale,
...patch.finale,
},
})
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test'
import { requireAdmin } from './auth'
function context(authorization?: string, payload: unknown = null) {
return {
jwt: { verify: async () => payload },
headers: { authorization },
set: { status: 200 },
}
}
describe('requireAdmin', () => {
test('rejects a missing bearer token', async () => {
const request = context()
expect(await requireAdmin(request)).toEqual({
error: 'Unauthorized: Missing or invalid token format',
})
expect(request.set.status).toBe(401)
})
test('rejects a non-admin token', async () => {
const request = context('Bearer customer-token', { role: 'CUSTOMER' })
expect(await requireAdmin(request)).toEqual({
error: 'Unauthorized: Admin access required',
})
expect(request.set.status).toBe(401)
})
test('allows an admin token', async () => {
const request = context('Bearer admin-token', { role: 'ADMIN' })
expect(await requireAdmin(request)).toBeUndefined()
expect(request.set.status).toBe(200)
})
})
+22 -20
View File
@@ -1,6 +1,27 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { jwt } from "@elysiajs/jwt"; import { jwt } from "@elysiajs/jwt";
export async function requireAdmin({ jwt, headers, set }: any) {
const auth = headers.authorization;
if (!auth?.startsWith("Bearer ")) {
set.status = 401;
return { error: "Unauthorized: Missing or invalid token format" };
}
const token = auth.substring(7);
const payload = await jwt.verify(token);
if (!payload) {
set.status = 401;
return { error: "Unauthorized: Invalid token" };
}
if ((payload as any).role !== "ADMIN") {
set.status = 401;
return { error: "Unauthorized: Admin access required" };
}
}
export const adminOnly = new Elysia({ name: "adminOnly" }) export const adminOnly = new Elysia({ name: "adminOnly" })
.use( .use(
jwt({ jwt({
@@ -8,23 +29,4 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
secret: process.env.JWT_SECRET || "fallback-secret" secret: process.env.JWT_SECRET || "fallback-secret"
}) })
) )
.onBeforeHandle(async ({ jwt, headers, set }) => { .onBeforeHandle(requireAdmin);
const auth = headers.authorization;
if (!auth?.startsWith("Bearer ")) {
set.status = 401;
return { error: "Unauthorized: Missing or invalid token format" };
}
const token = auth.substring(7);
const payload = await jwt.verify(token);
if (!payload) {
set.status = 401;
return { error: "Unauthorized: Invalid token" };
}
if ((payload as any).role !== "ADMIN") {
set.status = 401;
return { error: "Unauthorized: Admin access required" };
}
});
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test'
import { settingsRoute } from './settings'
function jsonRequest(path: string, method: string, body: unknown) {
return new Request(`http://localhost${path}`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('footer layout route boundaries', () => {
for (const method of ['PUT', 'POST']) {
test(`${method} /settings rejects footerLayout writes`, async () => {
const response = await settingsRoute.handle(jsonRequest('/settings', method, {
footerLayout: { columns: ['brand'] },
}))
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: 'Use PATCH /settings/footer-layout to update footer layout',
})
})
}
test('PATCH /settings/footer-layout requires admin authentication', async () => {
const response = await settingsRoute.handle(jsonRequest('/settings/footer-layout', 'PATCH', {
dryRun: true,
}))
expect(response.status).toBe(401)
expect(await response.json()).toEqual({
error: 'Unauthorized: Missing or invalid token format',
})
})
})
+109 -4
View File
@@ -1,6 +1,25 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { FooterLayoutSettingsPatchSchema } from "@homaroma/types";
import { z } from "zod";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth"; import { adminOnly, requireAdmin } from "../middleware/auth";
import {
mergeFooterLayoutSettings,
normalizeFooterLayoutSettings,
} from "../lib/footerLayoutSettings";
const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({
expectedVersion: z.string().optional(),
dryRun: z.boolean().optional(),
}).strict().superRefine((value, context) => {
if (!value.dryRun && !value.expectedVersion) {
context.addIssue({
code: "custom",
path: ["expectedVersion"],
message: "expectedVersion is required when updating footer layout",
});
}
});
export async function saveStoreSettingsTranslations(settingsId: number, payload: any) { export async function saveStoreSettingsTranslations(settingsId: number, payload: any) {
const results: any[] = []; const results: any[] = [];
@@ -171,6 +190,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center'; const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
return { return {
...settingsData, ...settingsData,
footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout),
translations, translations,
headerBehavior, headerBehavior,
mobileHeaderBehavior, mobileHeaderBehavior,
@@ -220,6 +240,21 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
return { error: "Failed to fetch footer brand" }; return { error: "Failed to fetch footer brand" };
} }
}) })
.get("/footer-layout", async ({ set }) => {
try {
const settings = await prisma.storeSettings.findFirst();
const current = settings ?? await prisma.storeSettings.create({ data: {} });
return {
version: current.updatedAt.toISOString(),
layout: normalizeFooterLayoutSettings((current as any).footerLayout),
};
} catch (err) {
console.error("[Settings] Footer layout fetch error:", err);
set.status = 500;
return { error: "Failed to fetch footer layout" };
}
})
.use(adminOnly) .use(adminOnly)
.patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => { .patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => {
try { try {
@@ -271,10 +306,74 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
set.status = 400; set.status = 400;
return { error: err?.message || "Failed to update footer brand" }; return { error: err?.message || "Failed to update footer brand" };
} }
}, { body: t.Any() }) }, { beforeHandle: requireAdmin, body: t.Any() })
.patch("/footer-layout", async ({ body, set }: { body: unknown; set: any }) => {
const request = FooterLayoutUpdateRequestSchema.safeParse(body);
if (!request.success) {
set.status = 400;
return { error: "Invalid footer layout", issues: request.error.issues };
}
try {
const { expectedVersion, dryRun, ...patch } = request.data;
const settings = await prisma.storeSettings.findFirst();
const current = settings ?? await prisma.storeSettings.create({ data: {} });
const version = current.updatedAt.toISOString();
if (expectedVersion && expectedVersion !== version) {
set.status = 409;
return {
error: "Footer layout has changed since it was read",
code: "VERSION_CONFLICT",
expectedVersion,
currentVersion: version,
};
}
const currentLayout = normalizeFooterLayoutSettings((current as any).footerLayout);
const layout = mergeFooterLayoutSettings(currentLayout, patch);
if (dryRun) {
return { dryRun: true, version, wouldUpdate: layout };
}
const updated = await prisma.$transaction(async (transaction) => {
const result = await transaction.storeSettings.updateMany({
where: { id: current.id, updatedAt: current.updatedAt },
data: { footerLayout: layout as any },
});
if (result.count === 0) return null;
return transaction.storeSettings.findUniqueOrThrow({ where: { id: current.id } });
});
if (!updated) {
const latest = await prisma.storeSettings.findUnique({ where: { id: current.id } });
set.status = 409;
return {
error: "Footer layout has changed since it was read",
code: "VERSION_CONFLICT",
expectedVersion,
currentVersion: latest?.updatedAt.toISOString() ?? version,
};
}
return {
success: true,
version: updated.updatedAt.toISOString(),
layout,
};
} catch (err) {
console.error("[Settings] Footer layout update error:", err);
set.status = 500;
return { error: "Failed to update footer layout" };
}
}, { beforeHandle: requireAdmin, body: t.Any() })
.put("/", async ({ body, set }: { body: any; set: any }) => { .put("/", async ({ body, set }: { body: any; set: any }) => {
try { try {
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any; const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
if (footerLayout !== undefined) {
set.status = 400;
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
}
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null) const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
? data.themeTokens ? data.themeTokens
: {}; : {};
@@ -354,12 +453,17 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
defaultSeoDescription: t.Optional(t.Nullable(t.String())), defaultSeoDescription: t.Optional(t.Nullable(t.String())),
defaultOgImage: t.Optional(t.Nullable(t.String())), defaultOgImage: t.Optional(t.Nullable(t.String())),
brandPrompt: t.Optional(t.Nullable(t.String())), brandPrompt: t.Optional(t.Nullable(t.String())),
footerLayout: t.Optional(t.Any()),
translations: t.Optional(t.Any()), translations: t.Optional(t.Any()),
}) })
}) })
.post("/", async ({ body, set }: { body: any; set: any }) => { .post("/", async ({ body, set }: { body: any; set: any }) => {
try { try {
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any; const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
if (footerLayout !== undefined) {
set.status = 400;
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
}
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null) const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
? data.themeTokens ? data.themeTokens
: {}; : {};
@@ -439,6 +543,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
defaultSeoDescription: t.Optional(t.Nullable(t.String())), defaultSeoDescription: t.Optional(t.Nullable(t.String())),
defaultOgImage: t.Optional(t.Nullable(t.String())), defaultOgImage: t.Optional(t.Nullable(t.String())),
brandPrompt: t.Optional(t.Nullable(t.String())), brandPrompt: t.Optional(t.Nullable(t.String())),
footerLayout: t.Optional(t.Any()),
translations: t.Optional(t.Any()), translations: t.Optional(t.Any()),
}) })
}) })
+131 -112
View File
@@ -1,19 +1,43 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useState, useEffect } from 'react' import { useState, useEffect, type ReactNode } from 'react'
import { API_BASE, type Category, type StoreSettings, type MenuItem } from '../types' import {
API_BASE,
DEFAULT_FOOTER_LAYOUT_SETTINGS,
FooterLayoutSettingsSchema,
type Category,
type FooterColumn,
type FooterLayoutSettings,
type StoreSettings,
type MenuItem,
} from '../types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLocalizedField } from '../utils/localization' import { getLocalizedField } from '../utils/localization'
interface FooterProps { interface FooterProps {
categories?: Category[] categories?: Category[]
} }
type MenuTranslation = { type MenuTranslation = { langCode?: string; label?: string }
langCode?: string type MenuItemWithTranslations = MenuItem & { translations?: MenuTranslation[] }
label?: string
}
type MenuItemWithTranslations = MenuItem & { const layoutClassMaps = {
translations?: MenuTranslation[] 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 = [] }: FooterProps) { export default function Footer({ categories = [] }: FooterProps) {
@@ -22,14 +46,12 @@ export default function Footer({ categories = [] }: FooterProps) {
const [supportMenu, setSupportMenu] = useState<MenuItem[]>([]) const [supportMenu, setSupportMenu] = useState<MenuItem[]>([])
const [settings, setSettings] = useState<StoreSettings | null>(null) const [settings, setSettings] = useState<StoreSettings | null>(null)
const brandName = settings?.brandName || '' const brandName = settings?.brandName || ''
const lang = localStorage.getItem('app_lang') || 'en'
const translateMenuLabel = (item: MenuItemWithTranslations) => { const translateMenuLabel = (item: MenuItemWithTranslations) => {
const lang = localStorage.getItem('app_lang') || 'en' if (lang !== 'en' && item.translations?.length) {
if (lang !== 'en' && item.translations && item.translations.length > 0) {
const trans = item.translations.find((translation) => translation.langCode === lang) const trans = item.translations.find((translation) => translation.langCode === lang)
if (trans && trans.label) { if (trans?.label) return trans.label
return trans.label
}
} }
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -42,7 +64,7 @@ export default function Footer({ categories = [] }: FooterProps) {
'Contact': 'nav.contact', 'Contact': 'nav.contact',
'Contact Us': 'nav.contact', 'Contact Us': 'nav.contact',
'Track Order': 'nav.trackOrder', 'Track Order': 'nav.trackOrder',
'Delivery & Returns': 'nav.delivery' 'Delivery & Returns': 'nav.delivery',
} }
return map[item.label] ? t(map[item.label]) : item.label return map[item.label] ? t(map[item.label]) : item.label
} }
@@ -51,14 +73,14 @@ export default function Footer({ categories = [] }: FooterProps) {
fetch(`${API_BASE}/menus/footer-explore`) fetch(`${API_BASE}/menus/footer-explore`)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(data => { .then(data => {
if (data && data.items) setExploreMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order)) if (data?.items) setExploreMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order))
}) })
.catch(console.error) .catch(console.error)
fetch(`${API_BASE}/menus/footer-support`) fetch(`${API_BASE}/menus/footer-support`)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(data => { .then(data => {
if (data && data.items) setSupportMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order)) if (data?.items) setSupportMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order))
}) })
.catch(console.error) .catch(console.error)
@@ -70,109 +92,106 @@ export default function Footer({ categories = [] }: FooterProps) {
.catch(console.error) .catch(console.error)
}, []) }, [])
const hasBrandFinale = Boolean(settings?.logoUrl || brandName.trim()) const footerLayout = normalizeFooterLayout(settings?.footerLayout)
const showFinale = footerLayout.finale.enabled && Boolean(settings?.logoUrl || brandName.trim())
const useCurtain = showFinale && footerLayout.finale.mode === 'curtain'
const footerDescription = settings?.translations?.find((translation) => translation.langCode === lang)?.footerDescription
|| settings?.footerDescription
|| t('footer.catchphrase')
const footerClasses = [
'site-footer text-pop-black',
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 columns: Record<FooterColumn, ReactNode> = {
brand: (
<div className="site-footer-column space-y-6">
<div className="text-2xl font-bold tracking-widest text-pop-black">
{settings?.logoUrl ? <img src={settings.logoUrl} alt={brandName} className="site-footer-brand-logo object-contain" /> : brandName}
</div>
<p className="site-footer-column-links leading-relaxed text-pop-black/60">{footerDescription}</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-chip border-pop-black/20 bg-transparent text-pop-black hover:border-pop-pink hover:bg-pop-pink hover:text-pop-bg" aria-label="Facebook">FB</a>}
{settings.instagramUrl && <a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="site-footer-social ui-chip border-pop-black/20 bg-transparent text-pop-black hover:border-pop-pink hover:bg-pop-pink hover:text-pop-bg" aria-label="Instagram">IG</a>}
{settings.tiktokUrl && <a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="site-footer-social ui-chip border-pop-black/20 bg-transparent text-pop-black hover:border-pop-pink hover:bg-pop-pink hover:text-pop-bg" 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-pop-black">{t('footer.explore')}</h4>
<div className="site-footer-column-links flex flex-col space-y-2 text-pop-black/60">
{exploreMenu.length > 0 ? exploreMenu.map(item => (
<Link key={item.id} to={item.url} className="transition-colors hover:text-pop-pink">{translateMenuLabel(item)}</Link>
)) : (
<>
<Link to="/" className="transition-colors hover:text-pop-pink">{t('nav.home')}</Link>
<Link to="/shop" className="transition-colors hover:text-pop-pink">{t('nav.shop')}</Link>
<Link to="/scent-guide" className="transition-colors hover:text-pop-pink">{t('nav.scentGuide')}</Link>
<Link to="/about" className="transition-colors hover:text-pop-pink">{t('nav.about')}</Link>
<Link to="/contact" className="transition-colors hover:text-pop-pink">{t('nav.contact')}</Link>
<Link to="/faq" className="transition-colors hover:text-pop-pink">{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-pop-black">{t('nav.collections')}</h4>
<div className="site-footer-column-links flex flex-col space-y-2 text-pop-black/60">
{categories.map((category) => (
<Link key={category.id} to={`/category/${category.id}`} className="transition-colors hover:text-pop-pink">{getLocalizedField(category.name, lang)}</Link>
))}
</div>
</div>
),
support: (
<div className="site-footer-column space-y-4">
<h4 className="site-footer-column-heading font-bold uppercase tracking-widest text-pop-black">
{getLocalizedField(settings?.footerSupportHeading, lang) || t('footer.support', 'Support & Legal')}
</h4>
<div className="site-footer-column-links flex flex-col space-y-2 text-pop-black/60">
{supportMenu.map(item => <Link key={item.id} to={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-pop-black/40">
{getLocalizedField(settings?.footerCopyrightText, lang) || `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`}
</p>
</div>
</div>
),
}
return ( return (
<footer className={`site-footer text-pop-black${hasBrandFinale ? ' site-footer-with-finale' : ''}`}> <footer className={footerClasses}>
<div className="site-footer-stage"> <div className="site-footer-stage">
{hasBrandFinale && ( {showFinale && (
<section <section className="site-footer-brand-finale" aria-label={brandName || t('footer.brand', 'Brand')}>
className="site-footer-brand-finale" {settings?.logoUrl
aria-label={brandName || t('footer.brand', 'Brand')} ? <img src={settings.logoUrl} alt="" className="site-footer-finale-logo" />
> : <p className="site-footer-finale-wordmark">{brandName}</p>}
{settings?.logoUrl ? (
<img
src={settings.logoUrl}
alt=""
className="site-footer-finale-logo"
/>
) : (
<p className="site-footer-finale-wordmark">{brandName}</p>
)}
</section> </section>
)} )}
<div className="site-footer-navigation bg-night-900 text-pop-black">
<div className="site-footer-navigation bg-night-900 text-pop-black px-6 py-20 border-t border-pop-black/20"> <div className="site-footer-navigation-inner">
<div className="ui-section-inner grid grid-cols-1 gap-12 md:grid-cols-4"> {footerLayout.columns.map((column) => <div key={column}>{columns[column]}</div>)}
<div className="space-y-6">
<div className="text-2xl font-bold tracking-widest text-pop-black">
{settings?.logoUrl ? <img src={settings.logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
</div>
<p className="text-sm leading-relaxed text-pop-black/60">
{settings?.footerDescription || t('footer.catchphrase')}
</p>
<div className="flex gap-4">
{settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl) ? (
<>
{settings.facebookUrl && (
<a href={settings.facebookUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-pop-black/20 bg-transparent text-[10px] text-pop-black hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg">FB</a>
)}
{settings.instagramUrl && (
<a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-pop-black/20 bg-transparent text-[10px] text-pop-black hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg">IG</a>
)}
{settings.tiktokUrl && (
<a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-pop-black/20 bg-transparent text-[10px] text-pop-black hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg">TT</a>
)}
</>
) : (
['IG', 'FB', 'TW', 'YT'].map((s) => (
<a key={s} href="#" className="ui-chip h-9 w-9 border-pop-black/20 bg-transparent text-[10px] text-pop-black hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg">
{s}
</a>
))
)}
</div> </div>
</div> </div>
<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-pop-black/60">
{exploreMenu.length > 0 ? (
exploreMenu.map(item => (
<Link key={item.id} to={item.url} className="hover:text-pop-pink transition-colors">{translateMenuLabel(item)}</Link>
))
) : (
<>
<Link to="/" className="hover:text-pop-pink transition-colors">{t('nav.home')}</Link>
<Link to="/shop" className="hover:text-pop-pink transition-colors">{t('nav.shop')}</Link>
<Link to="/scent-guide" className="hover:text-pop-pink transition-colors">{t('nav.scentGuide')}</Link>
<Link to="/about" className="hover:text-pop-pink transition-colors">{t('nav.about')}</Link>
<Link to="/contact" className="hover:text-pop-pink transition-colors">{t('nav.contact')}</Link>
<Link to="/faq" className="hover:text-pop-pink transition-colors">{t('nav.faq')}</Link>
</>
)}
</div>
</div>
<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-pop-black/60">
{categories.map((c) => (
<Link key={c.id} to={`/category/${c.id}`} className="hover:text-pop-pink transition-colors">
{getLocalizedField(c.name, localStorage.getItem('app_lang') || 'en')}
</Link>
))}
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">
{settings?.footerSupportHeading || t('footer.support', 'Support & Legal')}
</h4>
<div className="flex flex-col space-y-2 text-sm text-pop-black/60">
{supportMenu.map(item => (
<Link key={item.id} to={item.url} className="hover:text-pop-pink transition-colors">{translateMenuLabel(item)}</Link>
))}
</div>
<div className="pt-4">
<p className="text-[10px] text-pop-black/40 uppercase tracking-widest">
{settings?.footerCopyrightText || `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`}
</p>
</div>
</div>
</div>
</div>
</div> </div>
</footer> </footer>
) )
+61 -12
View File
@@ -306,10 +306,55 @@ html {
} }
.site-footer-navigation { .site-footer-navigation {
@apply relative border-t border-pop-black/10 bg-night-900; @apply relative border-t border-pop-black/10 bg-night-900 px-6;
z-index: 3; z-index: 3;
} }
.site-footer-navigation-inner {
@apply mx-auto grid;
}
.site-footer--mobile-columns-1 .site-footer-navigation-inner { @apply grid-cols-1; }
.site-footer--mobile-columns-2 .site-footer-navigation-inner { @apply grid-cols-2; }
.site-footer--container-narrow .site-footer-navigation-inner { max-width: 56rem; }
.site-footer--container-standard .site-footer-navigation-inner { max-width: 80rem; }
.site-footer--container-wide .site-footer-navigation-inner { max-width: 96rem; }
.site-footer--container-full .site-footer-navigation-inner { max-width: none; }
.site-footer--padding-sm .site-footer-navigation { @apply py-8; }
.site-footer--padding-md .site-footer-navigation { @apply py-12; }
.site-footer--padding-lg .site-footer-navigation { @apply py-20; }
.site-footer--padding-xl .site-footer-navigation { @apply py-28; }
.site-footer--gap-sm .site-footer-navigation-inner { @apply gap-4; }
.site-footer--gap-md .site-footer-navigation-inner { @apply gap-8; }
.site-footer--gap-lg .site-footer-navigation-inner { @apply gap-12; }
.site-footer--gap-xl .site-footer-navigation-inner { @apply gap-16; }
.site-footer--heading-sm .site-footer-column-heading { @apply text-xs; }
.site-footer--heading-md .site-footer-column-heading { @apply text-sm; }
.site-footer--heading-lg .site-footer-column-heading { @apply text-base; }
.site-footer--link-sm .site-footer-column-links { @apply text-xs; }
.site-footer--link-md .site-footer-column-links { @apply text-sm; }
.site-footer--link-lg .site-footer-column-links { @apply text-base; }
.site-footer--brand-logo-sm .site-footer-brand-logo { @apply h-6 w-auto; }
.site-footer--brand-logo-md .site-footer-brand-logo { @apply h-8 w-auto md:h-10; }
.site-footer--brand-logo-lg .site-footer-brand-logo { @apply h-12 w-auto; }
.site-footer--brand-logo-xl .site-footer-brand-logo { @apply h-16 w-auto; }
.site-footer--social-sm .site-footer-social { @apply h-8 w-8 text-[9px]; }
.site-footer--social-md .site-footer-social { @apply h-9 w-9 text-[10px]; }
.site-footer--social-lg .site-footer-social { @apply h-11 w-11 text-xs; }
.site-footer--finale-mobile-compact .site-footer-brand-finale { min-height: 35svh; }
.site-footer--finale-mobile-medium .site-footer-brand-finale { min-height: 55svh; }
.site-footer--finale-mobile-large .site-footer-brand-finale { min-height: 75svh; }
.site-footer--finale-logo-sm .site-footer-finale-logo { max-height: 4rem; max-width: min(72vw, 18rem); }
.site-footer--finale-logo-md .site-footer-finale-logo { max-height: 6rem; max-width: min(76vw, 24rem); }
.site-footer--finale-logo-lg .site-footer-finale-logo { max-height: 8rem; max-width: min(82vw, 32rem); }
.site-footer--finale-logo-xl .site-footer-finale-logo { max-height: 8rem; max-width: min(82vw, 32rem); }
@media (min-width: 768px) {
.site-footer--desktop-columns-2 .site-footer-navigation-inner { @apply grid-cols-2; }
.site-footer--desktop-columns-3 .site-footer-navigation-inner { @apply grid-cols-3; }
.site-footer--desktop-columns-4 .site-footer-navigation-inner { @apply grid-cols-4; }
}
/* Desktop Fixed Curtain Reveal Effect (เปิดม่าน Logo แบบ Fixed ตาม pkh.kitizone.com) */ /* Desktop Fixed Curtain Reveal Effect (เปิดม่าน Logo แบบ Fixed ตาม pkh.kitizone.com) */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.global-main { .global-main {
@@ -318,11 +363,11 @@ html {
background: var(--color-pop-bg); background: var(--color-pop-bg);
} }
.site-footer-with-finale { .site-footer--finale-curtain {
min-height: 100svh; min-height: 100svh;
} }
.site-footer-stage { .site-footer--finale-curtain .site-footer-stage {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
@@ -332,32 +377,36 @@ html {
pointer-events: none; pointer-events: none;
} }
.site-footer-brand-finale { .site-footer--finale-curtain .site-footer-brand-finale {
min-height: 0; min-height: 0;
padding-top: 4rem; padding-top: 4rem;
padding-bottom: 4rem; padding-bottom: 4rem;
} }
.site-footer-finale-logo { .site-footer--finale-curtain .site-footer-finale-wordmark {
@apply max-h-48 max-w-[min(68vw,48rem)];
}
.site-footer-finale-wordmark {
@apply text-8xl; @apply text-8xl;
} }
.site-footer-navigation { .site-footer--finale-curtain .site-footer-navigation {
pointer-events: auto; pointer-events: auto;
} }
.site-footer--finale-static.site-footer--finale-desktop-compact .site-footer-brand-finale { min-height: 35svh; }
.site-footer--finale-static.site-footer--finale-desktop-half .site-footer-brand-finale { min-height: 50svh; }
.site-footer--finale-static.site-footer--finale-desktop-screen .site-footer-brand-finale { min-height: 100svh; }
.site-footer--finale-curtain.site-footer--finale-desktop-compact .site-footer-stage { grid-template-rows: 35svh auto; align-content: end; }
.site-footer--finale-curtain.site-footer--finale-desktop-half .site-footer-stage { grid-template-rows: 50svh auto; align-content: end; }
.site-footer--finale-curtain.site-footer--finale-desktop-screen .site-footer-stage { grid-template-rows: minmax(0, 1fr) auto; }
.site-footer--finale-logo-xl .site-footer-finale-logo { max-height: 12rem; max-width: min(68vw, 48rem); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.site-footer-stage { .site-footer--finale-curtain .site-footer-stage {
display: block; display: block;
position: relative; position: relative;
} }
.site-footer-with-finale { .site-footer--finale-curtain {
min-height: 0; min-height: 0;
} }
} }
+62 -12
View File
@@ -274,6 +274,53 @@
z-index: 3; z-index: 3;
} }
.site-footer-navigation-inner {
display: grid;
margin-inline: auto;
}
.site-footer--mobile-columns-1 .site-footer-navigation-inner { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.site-footer--mobile-columns-2 .site-footer-navigation-inner { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.site-footer--container-narrow .site-footer-navigation-inner { max-width: 56rem; }
.site-footer--container-standard .site-footer-navigation-inner { max-width: 80rem; }
.site-footer--container-wide .site-footer-navigation-inner { max-width: 96rem; }
.site-footer--container-full .site-footer-navigation-inner { max-width: none; }
.site-footer--padding-sm .site-footer-navigation { padding-block: calc(var(--spacing) * 8); }
.site-footer--padding-md .site-footer-navigation { padding-block: calc(var(--spacing) * 12); }
.site-footer--padding-lg .site-footer-navigation { padding-block: calc(var(--spacing) * 20); }
.site-footer--padding-xl .site-footer-navigation { padding-block: calc(var(--spacing) * 28); }
.site-footer--gap-sm .site-footer-navigation-inner { gap: calc(var(--spacing) * 4); }
.site-footer--gap-md .site-footer-navigation-inner { gap: calc(var(--spacing) * 8); }
.site-footer--gap-lg .site-footer-navigation-inner { gap: calc(var(--spacing) * 12); }
.site-footer--gap-xl .site-footer-navigation-inner { gap: calc(var(--spacing) * 16); }
.site-footer--heading-sm .site-footer-column-heading { font-size: var(--text-xs); }
.site-footer--heading-md .site-footer-column-heading { font-size: var(--text-sm); }
.site-footer--heading-lg .site-footer-column-heading { font-size: var(--text-base); }
.site-footer--link-sm .site-footer-column-links { font-size: var(--text-xs); }
.site-footer--link-md .site-footer-column-links { font-size: var(--text-sm); }
.site-footer--link-lg .site-footer-column-links { font-size: var(--text-base); }
.site-footer--brand-logo-sm .site-footer-brand-logo { height: 1.5rem; width: auto; }
.site-footer--brand-logo-md .site-footer-brand-logo { height: 2rem; width: auto; }
.site-footer--brand-logo-lg .site-footer-brand-logo { height: 3rem; width: auto; }
.site-footer--brand-logo-xl .site-footer-brand-logo { height: 4rem; width: auto; }
.site-footer--social-sm .site-footer-social { height: 2rem; width: 2rem; font-size: 0.5625rem; }
.site-footer--social-md .site-footer-social { height: 2.25rem; width: 2.25rem; font-size: 0.625rem; }
.site-footer--social-lg .site-footer-social { height: 2.75rem; width: 2.75rem; font-size: var(--text-xs); }
.site-footer--finale-mobile-compact .site-footer-brand-finale { min-height: 35svh; }
.site-footer--finale-mobile-medium .site-footer-brand-finale { min-height: 55svh; }
.site-footer--finale-mobile-large .site-footer-brand-finale { min-height: 75svh; }
.site-footer--finale-logo-sm .site-footer-finale-logo { max-height: 4rem; max-width: min(72vw, 18rem); }
.site-footer--finale-logo-md .site-footer-finale-logo { max-height: 6rem; max-width: min(76vw, 24rem); }
.site-footer--finale-logo-lg .site-footer-finale-logo { max-height: 8rem; max-width: min(82vw, 32rem); }
.site-footer--finale-logo-xl .site-footer-finale-logo { max-height: 8rem; max-width: min(82vw, 32rem); }
@media (min-width: 48rem) {
.site-footer--desktop-columns-2 .site-footer-navigation-inner { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.site-footer--desktop-columns-3 .site-footer-navigation-inner { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.site-footer--desktop-columns-4 .site-footer-navigation-inner { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.site-footer--brand-logo-md .site-footer-brand-logo { height: 2.5rem; }
}
/* Desktop Fixed Curtain Reveal Effect (เปิดม่าน Logo แบบ Fixed ตาม pkh.kitizone.com) */ /* Desktop Fixed Curtain Reveal Effect (เปิดม่าน Logo แบบ Fixed ตาม pkh.kitizone.com) */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.global-main { .global-main {
@@ -282,11 +329,11 @@
background: var(--color-surface-page); background: var(--color-surface-page);
} }
.site-footer-with-finale { .site-footer--finale-curtain {
min-height: 100svh; min-height: 100svh;
} }
.site-footer-stage { .site-footer--finale-curtain .site-footer-stage {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
@@ -296,33 +343,36 @@
pointer-events: none; pointer-events: none;
} }
.site-footer-brand-finale { .site-footer--finale-curtain .site-footer-brand-finale {
min-height: 0; min-height: 0;
padding-block: calc(var(--spacing) * 16); padding-block: calc(var(--spacing) * 16);
} }
.site-footer-finale-logo { .site-footer--finale-curtain .site-footer-finale-wordmark {
max-height: calc(var(--spacing) * 48);
max-width: min(68vw, 48rem);
}
.site-footer-finale-wordmark {
font-size: var(--text-8xl); font-size: var(--text-8xl);
line-height: var(--tw-leading, var(--text-8xl--line-height)); line-height: var(--tw-leading, var(--text-8xl--line-height));
} }
.site-footer-navigation { .site-footer--finale-curtain .site-footer-navigation {
pointer-events: auto; pointer-events: auto;
} }
.site-footer--finale-static.site-footer--finale-desktop-compact .site-footer-brand-finale { min-height: 35svh; }
.site-footer--finale-static.site-footer--finale-desktop-half .site-footer-brand-finale { min-height: 50svh; }
.site-footer--finale-static.site-footer--finale-desktop-screen .site-footer-brand-finale { min-height: 100svh; }
.site-footer--finale-curtain.site-footer--finale-desktop-compact .site-footer-stage { grid-template-rows: 35svh auto; align-content: end; }
.site-footer--finale-curtain.site-footer--finale-desktop-half .site-footer-stage { grid-template-rows: 50svh auto; align-content: end; }
.site-footer--finale-curtain.site-footer--finale-desktop-screen .site-footer-stage { grid-template-rows: minmax(0, 1fr) auto; }
.site-footer--finale-logo-xl .site-footer-finale-logo { max-height: 12rem; max-width: min(68vw, 48rem); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.site-footer-stage { .site-footer--finale-curtain .site-footer-stage {
display: block; display: block;
position: relative; position: relative;
} }
.site-footer-with-finale { .site-footer--finale-curtain {
min-height: 0; min-height: 0;
} }
} }
+125 -155
View File
@@ -1,9 +1,17 @@
"use client"; "use client";
import Image from 'next/image' import Image from 'next/image'
import { useState, useEffect } from 'react' import { useState, useEffect, type ReactNode } from 'react'
import Link from './LocalizedLink' 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 { useTranslation } from 'react-i18next'
import { resolveLocalized } from '../lib/localization' import { resolveLocalized } from '../lib/localization'
import { useLang } from '../hooks/useLang' import { useLang } from '../hooks/useLang'
@@ -15,6 +23,26 @@ interface FooterProps {
settings?: StoreSettings | null 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({ export default function Footer({
categories = [], categories = [],
exploreMenu = [], exploreMenu = [],
@@ -25,15 +53,11 @@ export default function Footer({
const lang = useLang() const lang = useLang()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => { queueMicrotask(() => setMounted(true)) }, []) 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' const effectiveLang = mounted ? lang : 'th'
// Translate a menu item label using its translations array or i18n key map
const translateMenuLabel = (item: MenuItem): string => { const translateMenuLabel = (item: MenuItem): string => {
// Use outer `lang` from closure — hooks cannot be called inside nested functions if (effectiveLang !== 'en' && item.translations?.length) {
if (effectiveLang !== 'en' && item.translations && item.translations.length > 0) { const trans = item.translations.find((translation: { langCode: string; label: string }) => translation.langCode === effectiveLang)
const trans = item.translations.find((tr: { langCode: string; label: string }) => tr.langCode === effectiveLang)
if (trans?.label) return trans.label if (trans?.label) return trans.label
} }
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -51,164 +75,110 @@ export default function Footer({
return map[item.label] ? t(map[item.label]) : item.label return map[item.label] ? t(map[item.label]) : item.label
} }
// Social links — use settings if available, fallback to placeholder buttons const footerLayout = normalizeFooterLayout(settings?.footerLayout)
const hasSocialLinks = settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl)
const brandName = settings?.brandName || '' 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 ( return (
<footer className={`site-footer text-text-main${hasBrandFinale ? ' site-footer-with-finale' : ''}`}> <footer className={footerClasses}>
<div className="site-footer-stage"> <div className="site-footer-stage">
{hasBrandFinale && ( {showFinale && (
<section <section className="site-footer-brand-finale" aria-label={brandName || t('footer.brand', 'Brand')}>
className="site-footer-brand-finale"
aria-label={brandName || t('footer.brand', 'Brand')}
>
{settings?.logoUrl ? ( {settings?.logoUrl ? (
<Image <Image src={settings.logoUrl} alt="" width={640} height={240} unoptimized className="site-footer-finale-logo" />
src={settings.logoUrl}
alt=""
width={640}
height={240}
unoptimized
className="site-footer-finale-logo"
/>
) : ( ) : (
<p className="site-footer-finale-wordmark">{brandName}</p> <p className="site-footer-finale-wordmark">{brandName}</p>
)} )}
</section> </section>
)} )}
<div className="site-footer-navigation"> <div className="site-footer-navigation">
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 md:grid-cols-4"> <div className="site-footer-navigation-inner">
{footerLayout.columns.map((column) => <div key={column}>{columns[column]}</div>)}
{/* 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> </div>
</div> </div>
</div> </div>
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test'
import {
DEFAULT_FOOTER_LAYOUT_SETTINGS,
FooterLayoutSettingsPatchSchema,
FooterLayoutSettingsSchema,
} from './index'
describe('footer layout settings contract', () => {
test('keeps the legacy storefront presentation as the default', () => {
expect(FooterLayoutSettingsSchema.parse(DEFAULT_FOOTER_LAYOUT_SETTINGS)).toEqual(
DEFAULT_FOOTER_LAYOUT_SETTINGS,
)
})
test('rejects duplicate and unknown footer columns', () => {
const duplicate = FooterLayoutSettingsSchema.safeParse({
...DEFAULT_FOOTER_LAYOUT_SETTINGS,
columns: ['brand', 'brand'],
})
const unknown = FooterLayoutSettingsSchema.safeParse({
...DEFAULT_FOOTER_LAYOUT_SETTINGS,
columns: ['brand', 'legal'],
})
expect(duplicate.success).toBe(false)
expect(unknown.success).toBe(false)
})
test('rejects unknown presentation fields', () => {
expect(FooterLayoutSettingsSchema.safeParse({
...DEFAULT_FOOTER_LAYOUT_SETTINGS,
customCss: 'display: none',
}).success).toBe(false)
})
test('allows a partial nested finale patch but remains strict', () => {
expect(FooterLayoutSettingsPatchSchema.parse({
desktopColumns: 3,
finale: { enabled: false },
})).toEqual({ desktopColumns: 3, finale: { enabled: false } })
expect(FooterLayoutSettingsPatchSchema.safeParse({
finale: { height: '1000vh' },
}).success).toBe(false)
})
})
+67
View File
@@ -239,6 +239,72 @@ export interface StoreSettingsTranslation {
updatedAt?: string updatedAt?: string
} }
export const FooterColumnSchema = z.enum([
'brand',
'explore',
'collections',
'support',
])
export const FooterFinaleSettingsSchema = z.object({
enabled: z.boolean(),
mode: z.enum(['static', 'curtain']),
mobileHeight: z.enum(['compact', 'medium', 'large']),
desktopHeight: z.enum(['compact', 'half', 'screen']),
logoSize: z.enum(['sm', 'md', 'lg', 'xl']),
}).strict()
export const FooterLayoutSettingsSchema = z.object({
columns: z.array(FooterColumnSchema).min(1).max(4).superRefine((columns, context) => {
if (new Set(columns).size !== columns.length) {
context.addIssue({
code: 'custom',
message: 'Footer columns must be unique',
})
}
}),
mobileColumns: z.union([z.literal(1), z.literal(2)]),
desktopColumns: z.union([z.literal(2), z.literal(3), z.literal(4)]),
containerWidth: z.enum(['narrow', 'standard', 'wide', 'full']),
verticalPadding: z.enum(['sm', 'md', 'lg', 'xl']),
columnGap: z.enum(['sm', 'md', 'lg', 'xl']),
headingSize: z.enum(['sm', 'md', 'lg']),
linkSize: z.enum(['sm', 'md', 'lg']),
brandLogoSize: z.enum(['sm', 'md', 'lg', 'xl']),
socialSize: z.enum(['sm', 'md', 'lg']),
finale: FooterFinaleSettingsSchema,
}).strict()
export const FooterLayoutSettingsPatchSchema = FooterLayoutSettingsSchema
.partial()
.extend({ finale: FooterFinaleSettingsSchema.partial().strict().optional() })
.strict()
export type FooterColumn = z.infer<typeof FooterColumnSchema>
export type FooterFinaleSettings = z.infer<typeof FooterFinaleSettingsSchema>
export type FooterLayoutSettings = z.infer<typeof FooterLayoutSettingsSchema>
export type FooterLayoutSettingsPatch = z.infer<typeof FooterLayoutSettingsPatchSchema>
export const DEFAULT_FOOTER_LAYOUT_SETTINGS: FooterLayoutSettings = {
columns: ['brand', 'explore', 'collections', 'support'],
mobileColumns: 1,
desktopColumns: 4,
containerWidth: 'standard',
verticalPadding: 'lg',
columnGap: 'lg',
headingSize: 'sm',
linkSize: 'md',
brandLogoSize: 'md',
socialSize: 'md',
finale: {
enabled: true,
mode: 'curtain',
mobileHeight: 'medium',
desktopHeight: 'screen',
logoSize: 'xl',
},
}
export interface StoreSettings { export interface StoreSettings {
id: number id: number
brandName: string brandName: string
@@ -260,6 +326,7 @@ export interface StoreSettings {
newsletterButtonText?: string | null newsletterButtonText?: string | null
footerSupportHeading?: any | null footerSupportHeading?: any | null
footerCopyrightText?: any | null footerCopyrightText?: any | null
footerLayout?: FooterLayoutSettings | null
scrollRevealEnabled?: boolean | null scrollRevealEnabled?: boolean | null
scrollRevealStyle?: string | null scrollRevealStyle?: string | null
scrollRevealDuration?: number | null scrollRevealDuration?: number | null