diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index a9ba6e6..1f357a0 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -21,6 +21,7 @@ import SystemMaintenancePage from './pages/SystemMaintenancePage' 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 NewsletterSubscribersPage from './pages/NewsletterSubscribersPage' import ArticlesPage from './pages/ArticlesPage' import ArticleEditPage from './pages/ArticleEditPage' @@ -78,6 +79,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index a326c9b..1783b1e 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -61,6 +61,7 @@ const MENU_MANAGER_MENU = [ { icon: Layers, label: 'Main Navigation', to: '/menu-manager/main-navigation' }, { icon: FileText, label: 'Footer Explore', to: '/menu-manager/footer-explore' }, { icon: MessageSquare, label: 'Footer Support', to: '/menu-manager/footer-support' }, + { icon: Store, label: 'Footer Brand', to: '/menu-manager/footer-brand' }, ] const SETTINGS_MENU = [ diff --git a/apps/admin/src/pages/menu-manager/FooterBrandPage.tsx b/apps/admin/src/pages/menu-manager/FooterBrandPage.tsx new file mode 100644 index 0000000..e7430fd --- /dev/null +++ b/apps/admin/src/pages/menu-manager/FooterBrandPage.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react' +import type { FormEvent } from 'react' +import { API_BASE, type FooterBrandResponse } from '@homaroma/types' +import { AlertCircle, CheckCircle2, Image as ImageIcon, Link as LinkIcon, Loader2, Save } from 'lucide-react' + +type SaveState = { type: 'success' | 'error'; message: string } | null + +export default function FooterBrandPage() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saveState, setSaveState] = useState(null) + const [brandName, setBrandName] = useState('') + const [logoUrl, setLogoUrl] = useState('') + const [facebookUrl, setFacebookUrl] = useState('') + const [instagramUrl, setInstagramUrl] = useState('') + const [tiktokUrl, setTiktokUrl] = useState('') + const [description, setDescription] = useState>({ th: '', en: '' }) + const [activeLanguage, setActiveLanguage] = useState<'th' | 'en'>('th') + const [version, setVersion] = useState() + + useEffect(() => { + let cancelled = false + fetch(`${API_BASE}/settings/footer-brand`) + .then(async response => { + if (!response.ok) throw new Error(`Unable to load settings (${response.status})`) + return response.json() as Promise + }) + .then(data => { + if (cancelled) return + setVersion(data.version) + setBrandName(data.brand.brandName || '') + setLogoUrl(data.brand.logoUrl || '') + setDescription({ th: '', en: '', ...(data.brand.description || {}) }) + setFacebookUrl(data.brand.socialLinks?.facebookUrl || '') + setInstagramUrl(data.brand.socialLinks?.instagramUrl || '') + setTiktokUrl(data.brand.socialLinks?.tiktokUrl || '') + }) + .catch(error => { + if (!cancelled) setSaveState({ type: 'error', message: error instanceof Error ? error.message : 'Unable to load footer brand settings.' }) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { cancelled = true } + }, []) + + const handleSave = async (event: FormEvent) => { + event.preventDefault() + setSaving(true) + setSaveState(null) + try { + const response = await fetch(`${API_BASE}/settings/footer-brand`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token') || ''}`, + }, + body: JSON.stringify({ brandName, logoUrl: logoUrl || null, description, facebookUrl: facebookUrl || null, instagramUrl: instagramUrl || null, tiktokUrl: tiktokUrl || null, expectedVersion: version }), + }) + const result = await response.json() as FooterBrandResponse & { error?: string; code?: string } + if (!response.ok) { + if (response.status === 409 && result.code === 'VERSION_CONFLICT') { + throw new Error('Footer Brand changed in another session. Reload the page before saving again.') + } + throw new Error(result.error || `Unable to save settings (${response.status})`) + } + setVersion(result.version) + setDescription({ th: '', en: '', ...(result.brand.description || {}) }) + setSaveState({ type: 'success', message: 'Footer brand saved successfully.' }) + } catch (error) { + setSaveState({ type: 'error', message: error instanceof Error ? error.message : 'Unable to save footer brand.' }) + } finally { + setSaving(false) + } + } + + if (loading) return
+ + return ( +
+
+

Footer Brand

+

Manage the first brand column shown in the website footer.

+
+ + {saveState && ( +
+ {saveState.type === 'success' ? : } + {saveState.message} +
+ )} + +
+
+
+
+

Brand identity

Used by the footer and shared site brand.

+
+
+
setBrandName(event.target.value)} className="admin-input w-full" placeholder="HomAroma" />
+
setLogoUrl(event.target.value)} className="admin-input w-full" placeholder="https://example.com/logo.png" />{logoUrl &&
Footer logo preview
}
+
{(['th', 'en'] as const).map(language => )}