x
This commit is contained in:
1 parent
78b722e120
commit
75770ca122
18 files changed
+1119
-318
No files matched your search
@@ -27,6 +27,7 @@ import MainNavigationPage from './pages/menu-manager/MainNavigationPage'
|
||||
import FooterExplorePage from './pages/menu-manager/FooterExplorePage'
|
||||
import FooterSupportPage from './pages/menu-manager/FooterSupportPage'
|
||||
import FooterBrandPage from './pages/menu-manager/FooterBrandPage'
|
||||
import FooterLayoutPage from './pages/menu-manager/FooterLayoutPage'
|
||||
import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
|
||||
import ArticlesPage from './pages/ArticlesPage'
|
||||
import ArticleEditPage from './pages/ArticleEditPage'
|
||||
@@ -125,6 +126,7 @@ function App() {
|
||||
<Route path="/menu-manager/footer-explore" element={<FooterExplorePage />} />
|
||||
<Route path="/menu-manager/footer-support" element={<FooterSupportPage />} />
|
||||
<Route path="/menu-manager/footer-brand" element={<FooterBrandPage />} />
|
||||
<Route path="/menu-manager/footer-layout" element={<FooterLayoutPage />} />
|
||||
<Route path="/subscribers" element={<NewsletterSubscribersPage />} />
|
||||
<Route path="/articles" element={<ArticlesPage />} />
|
||||
<Route path="/articles/:id" element={<ArticleEditPage />} />
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Map,
|
||||
Target,
|
||||
BrainCircuit,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
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: 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: LayoutTemplate, label: t('nav.footerLayout', 'Footer Layout & Presentation'), to: '/menu-manager/footer-layout' },
|
||||
]
|
||||
|
||||
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 & 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 & 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 { 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 FocalPointPicker from '../../components/page-builder/FocalPointPicker'
|
||||
|
||||
@@ -447,10 +447,25 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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 dataStr = JSON.stringify(currentMenu, null, 2)
|
||||
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) => (
|
||||
<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="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "StoreSettings" ADD COLUMN "footerLayout" JSONB;
|
||||
@@ -301,6 +301,7 @@ model StoreSettings {
|
||||
newsletterButtonText String?
|
||||
footerSupportHeading Json?
|
||||
footerCopyrightText Json?
|
||||
footerLayout Json?
|
||||
scrollRevealEnabled Boolean @default(true)
|
||||
scrollRevealStyle String @default("fade-up")
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,27 @@
|
||||
import { Elysia } from "elysia";
|
||||
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" })
|
||||
.use(
|
||||
jwt({
|
||||
@@ -8,23 +29,4 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
|
||||
secret: process.env.JWT_SECRET || "fallback-secret"
|
||||
})
|
||||
)
|
||||
.onBeforeHandle(async ({ jwt, headers, set }) => {
|
||||
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" };
|
||||
}
|
||||
});
|
||||
.onBeforeHandle(requireAdmin);
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,25 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { FooterLayoutSettingsPatchSchema } from "@homaroma/types";
|
||||
import { z } from "zod";
|
||||
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) {
|
||||
const results: any[] = [];
|
||||
@@ -171,6 +190,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
||||
return {
|
||||
...settingsData,
|
||||
footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout),
|
||||
translations,
|
||||
headerBehavior,
|
||||
mobileHeaderBehavior,
|
||||
@@ -220,6 +240,21 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
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)
|
||||
.patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => {
|
||||
try {
|
||||
@@ -271,10 +306,74 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
set.status = 400;
|
||||
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 }) => {
|
||||
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)
|
||||
? data.themeTokens
|
||||
: {};
|
||||
@@ -354,12 +453,17 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
||||
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
||||
brandPrompt: t.Optional(t.Nullable(t.String())),
|
||||
footerLayout: t.Optional(t.Any()),
|
||||
translations: t.Optional(t.Any()),
|
||||
})
|
||||
})
|
||||
.post("/", async ({ body, set }: { body: any; set: any }) => {
|
||||
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)
|
||||
? data.themeTokens
|
||||
: {};
|
||||
@@ -439,6 +543,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
||||
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
||||
brandPrompt: t.Optional(t.Nullable(t.String())),
|
||||
footerLayout: t.Optional(t.Any()),
|
||||
translations: t.Optional(t.Any()),
|
||||
})
|
||||
})
|
||||
|
||||
+131
-112
@@ -1,19 +1,43 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE, type Category, type StoreSettings, type MenuItem } from '../types'
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
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 { getLocalizedField } from '../utils/localization'
|
||||
|
||||
interface FooterProps {
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
type MenuTranslation = {
|
||||
langCode?: string
|
||||
label?: string
|
||||
}
|
||||
type MenuTranslation = { langCode?: string; label?: string }
|
||||
type MenuItemWithTranslations = MenuItem & { translations?: MenuTranslation[] }
|
||||
|
||||
type MenuItemWithTranslations = MenuItem & {
|
||||
translations?: MenuTranslation[]
|
||||
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 = [] }: FooterProps) {
|
||||
@@ -22,14 +46,12 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
const [supportMenu, setSupportMenu] = useState<MenuItem[]>([])
|
||||
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||
const brandName = settings?.brandName || ''
|
||||
const lang = localStorage.getItem('app_lang') || 'en'
|
||||
|
||||
const translateMenuLabel = (item: MenuItemWithTranslations) => {
|
||||
const lang = localStorage.getItem('app_lang') || 'en'
|
||||
if (lang !== 'en' && item.translations && item.translations.length > 0) {
|
||||
if (lang !== 'en' && item.translations?.length) {
|
||||
const trans = item.translations.find((translation) => translation.langCode === lang)
|
||||
if (trans && trans.label) {
|
||||
return trans.label
|
||||
}
|
||||
if (trans?.label) return trans.label
|
||||
}
|
||||
|
||||
const map: Record<string, string> = {
|
||||
@@ -42,7 +64,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
'Contact': 'nav.contact',
|
||||
'Contact Us': 'nav.contact',
|
||||
'Track Order': 'nav.trackOrder',
|
||||
'Delivery & Returns': 'nav.delivery'
|
||||
'Delivery & Returns': 'nav.delivery',
|
||||
}
|
||||
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`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.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)
|
||||
|
||||
fetch(`${API_BASE}/menus/footer-support`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.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)
|
||||
|
||||
@@ -70,109 +92,106 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
.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 (
|
||||
<footer className={`site-footer text-pop-black${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')}
|
||||
>
|
||||
{settings?.logoUrl ? (
|
||||
<img
|
||||
src={settings.logoUrl}
|
||||
alt=""
|
||||
className="site-footer-finale-logo"
|
||||
/>
|
||||
) : (
|
||||
<p className="site-footer-finale-wordmark">{brandName}</p>
|
||||
)}
|
||||
{showFinale && (
|
||||
<section className="site-footer-brand-finale" aria-label={brandName || t('footer.brand', 'Brand')}>
|
||||
{settings?.logoUrl
|
||||
? <img src={settings.logoUrl} alt="" className="site-footer-finale-logo" />
|
||||
: <p className="site-footer-finale-wordmark">{brandName}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="site-footer-navigation bg-night-900 text-pop-black px-6 py-20 border-t border-pop-black/20">
|
||||
<div className="ui-section-inner grid grid-cols-1 gap-12 md:grid-cols-4">
|
||||
<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 className="site-footer-navigation bg-night-900 text-pop-black">
|
||||
<div className="site-footer-navigation-inner">
|
||||
{footerLayout.columns.map((column) => <div key={column}>{columns[column]}</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>
|
||||
</footer>
|
||||
)
|
||||
|
||||
+61
-12
@@ -306,10 +306,55 @@ html {
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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) */
|
||||
@media (min-width: 1024px) {
|
||||
.global-main {
|
||||
@@ -318,11 +363,11 @@ html {
|
||||
background: var(--color-pop-bg);
|
||||
}
|
||||
|
||||
.site-footer-with-finale {
|
||||
.site-footer--finale-curtain {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
.site-footer-stage {
|
||||
.site-footer--finale-curtain .site-footer-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
@@ -332,32 +377,36 @@ html {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.site-footer-brand-finale {
|
||||
.site-footer--finale-curtain .site-footer-brand-finale {
|
||||
min-height: 0;
|
||||
padding-top: 4rem;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.site-footer-finale-logo {
|
||||
@apply max-h-48 max-w-[min(68vw,48rem)];
|
||||
}
|
||||
|
||||
.site-footer-finale-wordmark {
|
||||
.site-footer--finale-curtain .site-footer-finale-wordmark {
|
||||
@apply text-8xl;
|
||||
}
|
||||
|
||||
.site-footer-navigation {
|
||||
.site-footer--finale-curtain .site-footer-navigation {
|
||||
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) {
|
||||
.site-footer-stage {
|
||||
.site-footer--finale-curtain .site-footer-stage {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-footer-with-finale {
|
||||
.site-footer--finale-curtain {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,53 @@
|
||||
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) */
|
||||
@media (min-width: 1024px) {
|
||||
.global-main {
|
||||
@@ -282,11 +329,11 @@
|
||||
background: var(--color-surface-page);
|
||||
}
|
||||
|
||||
.site-footer-with-finale {
|
||||
.site-footer--finale-curtain {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
.site-footer-stage {
|
||||
.site-footer--finale-curtain .site-footer-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
@@ -296,33 +343,36 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.site-footer-brand-finale {
|
||||
.site-footer--finale-curtain .site-footer-brand-finale {
|
||||
min-height: 0;
|
||||
padding-block: calc(var(--spacing) * 16);
|
||||
}
|
||||
|
||||
.site-footer-finale-logo {
|
||||
max-height: calc(var(--spacing) * 48);
|
||||
max-width: min(68vw, 48rem);
|
||||
}
|
||||
|
||||
.site-footer-finale-wordmark {
|
||||
.site-footer--finale-curtain .site-footer-finale-wordmark {
|
||||
font-size: var(--text-8xl);
|
||||
line-height: var(--tw-leading, var(--text-8xl--line-height));
|
||||
}
|
||||
|
||||
.site-footer-navigation {
|
||||
.site-footer--finale-curtain .site-footer-navigation {
|
||||
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) {
|
||||
.site-footer-stage {
|
||||
.site-footer--finale-curtain .site-footer-stage {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-footer-with-finale {
|
||||
.site-footer--finale-curtain {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -239,6 +239,72 @@ export interface StoreSettingsTranslation {
|
||||
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 {
|
||||
id: number
|
||||
brandName: string
|
||||
@@ -260,6 +326,7 @@ export interface StoreSettings {
|
||||
newsletterButtonText?: string | null
|
||||
footerSupportHeading?: any | null
|
||||
footerCopyrightText?: any | null
|
||||
footerLayout?: FooterLayoutSettings | null
|
||||
scrollRevealEnabled?: boolean | null
|
||||
scrollRevealStyle?: string | null
|
||||
scrollRevealDuration?: number | null
|
||||
|
||||
Reference in new issue
Block a user