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 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 />} />
+2
View File
@@ -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 &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 { 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">