This commit is contained in:
Your Name committed 2026-07-25 20:05:46 -04:00
1 parent 48aebb2ec9
commit b693c7188c
8 files changed
+267 -4

No files matched your search

+2
View File
@@ -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() {
<Route path="/menu-manager/main-navigation" element={<MainNavigationPage />} />
<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="/subscribers" element={<NewsletterSubscribersPage />} />
<Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticleEditPage />} />
+1
View File
@@ -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 = [
@@ -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<SaveState>(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<Record<string, string>>({ th: '', en: '' })
const [activeLanguage, setActiveLanguage] = useState<'th' | 'en'>('th')
const [version, setVersion] = useState<string | undefined>()
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<FooterBrandResponse>
})
.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<HTMLFormElement>) => {
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 <div className="flex items-center justify-center p-20" role="status" aria-label="Loading footer brand"><Loader2 className="w-8 h-8 text-cyan-glow animate-spin" /></div>
return (
<div className="max-w-4xl space-y-8 animate-fade-in">
<div className="space-y-1">
<h1 className="text-3xl font-bold">Footer Brand</h1>
<p className="text-slate-400 text-sm">Manage the first brand column shown in the website footer.</p>
</div>
{saveState && (
<div role="status" className={`flex items-center 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="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
{saveState.message}
</div>
)}
<form onSubmit={handleSave} 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-purple-500/10 p-2"><ImageIcon className="h-5 w-5 text-purple-200" /></div>
<div><h2 className="text-lg font-bold uppercase tracking-widest text-white">Brand identity</h2><p className="text-xs text-slate-500">Used by the footer and shared site brand.</p></div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2 md:col-span-2"><label htmlFor="footer-brand-name" className="text-xs font-bold uppercase tracking-widest text-slate-400">Brand name</label><input id="footer-brand-name" required value={brandName} onChange={event => setBrandName(event.target.value)} className="admin-input w-full" placeholder="HomAroma" /></div>
<div className="space-y-2 md:col-span-2"><label htmlFor="footer-logo-url" className="text-xs font-bold uppercase tracking-widest text-slate-400">Logo URL</label><input id="footer-logo-url" type="url" value={logoUrl} onChange={event => setLogoUrl(event.target.value)} className="admin-input w-full" placeholder="https://example.com/logo.png" />{logoUrl && <div className="mt-3 inline-flex min-h-16 items-center rounded-lg border border-white/10 bg-black/20 p-3"><img src={logoUrl} alt="Footer logo preview" className="max-h-12 max-w-56 object-contain" /></div>}</div>
<div className="space-y-2 md:col-span-2"><label className="text-xs font-bold uppercase tracking-widest text-slate-400">Footer description</label><div className="flex gap-2">{(['th', 'en'] as const).map(language => <button key={language} type="button" onClick={() => setActiveLanguage(language)} className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase ${activeLanguage === language ? 'bg-cyan-glow text-night-900' : 'bg-white/5 text-slate-400 hover:bg-white/10'}`}>{language}</button>)}</div><textarea id="footer-description" value={description[activeLanguage] || ''} onChange={event => setDescription(current => ({ ...current, [activeLanguage]: event.target.value }))} className="admin-input min-h-24 w-full resize-y" placeholder={activeLanguage === 'th' ? 'กลิ่นหอมที่ออกแบบมาเพื่อบ้านของคุณ' : 'Scents designed for your home.'} /><p className="text-[10px] text-slate-500">Descriptions are saved per language and rendered using the visitor's locale.</p></div>
</div>
</section>
<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"><LinkIcon className="h-5 w-5 text-cyan-glow" /></div><div><h2 className="text-lg font-bold uppercase tracking-widest text-white">Social links</h2><p className="text-xs text-slate-500">Empty links are omitted from the public footer.</p></div></div>
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-2"><label htmlFor="footer-facebook" className="text-xs font-bold uppercase tracking-widest text-slate-400">Facebook URL</label><input id="footer-facebook" type="url" value={facebookUrl} onChange={event => setFacebookUrl(event.target.value)} className="admin-input w-full" placeholder="https://facebook.com/..." /></div>
<div className="space-y-2"><label htmlFor="footer-instagram" className="text-xs font-bold uppercase tracking-widest text-slate-400">Instagram URL</label><input id="footer-instagram" type="url" value={instagramUrl} onChange={event => setInstagramUrl(event.target.value)} className="admin-input w-full" placeholder="https://instagram.com/..." /></div>
<div className="space-y-2"><label htmlFor="footer-tiktok" className="text-xs font-bold uppercase tracking-widest text-slate-400">TikTok URL</label><input id="footer-tiktok" type="url" value={tiktokUrl} onChange={event => setTiktokUrl(event.target.value)} className="admin-input w-full" placeholder="https://tiktok.com/@..." /></div>
</div>
</section>
<button type="submit" disabled={saving} className="btn-primary ml-auto flex items-center gap-2 px-12 py-4 text-sm"><Save className="h-4 w-4" />{saving ? 'Saving…' : 'Save footer brand'}</button>
</form>
</div>
)
}
+94 -4
View File
@@ -3,22 +3,112 @@ import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export const settingsRoute = new Elysia({ prefix: "/settings" })
.get("/", async ({ set }) => {
.get("/", async ({ set, query }) => {
try {
let settings = await prisma.storeSettings.findFirst();
let settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
if (!settings) {
settings = await prisma.storeSettings.create({
data: {}
data: {},
include: { translations: true }
});
}
return settings;
const { translations, ...settingsData } = settings;
const requestedLang = typeof query?.lang === "string" ? query.lang : undefined;
const localizedDescription = requestedLang
? translations.find((translation) => translation.langCode === requestedLang)?.footerDescription
: undefined;
return localizedDescription ? { ...settingsData, footerDescription: localizedDescription } : settingsData;
} catch (err) {
console.error("[Settings] Error:", err);
set.status = 500;
return { error: "Failed to fetch settings" };
}
})
/**
* Dedicated Footer Brand contract. This keeps the editable Brand block
* separate from footer link menus while reusing the existing StoreSettings
* columns and StoreSettingsTranslation rows.
*/
.get("/footer-brand", async ({ set }) => {
try {
const settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
const base = settings ?? await prisma.storeSettings.create({ data: {}, include: { translations: true } });
const descriptions: Record<string, string> = {};
for (const translation of base.translations ?? []) {
if (translation.footerDescription) descriptions[translation.langCode] = translation.footerDescription;
}
if (base.footerDescription && !descriptions.th) descriptions.th = base.footerDescription;
return {
version: base.updatedAt.toISOString(),
brand: {
brandName: base.brandName,
logoUrl: base.logoUrl ?? null,
description: descriptions,
socialLinks: {
facebookUrl: base.facebookUrl ?? null,
instagramUrl: base.instagramUrl ?? null,
tiktokUrl: base.tiktokUrl ?? null,
},
},
};
} catch (err) {
console.error("[Settings] Footer brand fetch error:", err);
set.status = 500;
return { error: "Failed to fetch footer brand" };
}
})
.use(adminOnly)
.patch("/footer-brand", async ({ body, set }) => {
try {
const payload = body as any;
const settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
const current = settings ?? await prisma.storeSettings.create({ data: {}, include: { translations: true } });
const version = current.updatedAt.toISOString();
if (payload.expectedVersion && payload.expectedVersion !== version) {
set.status = 409;
return { error: "Footer brand has changed since it was read", code: "VERSION_CONFLICT", expectedVersion: payload.expectedVersion, currentVersion: version };
}
const updates: any = {};
if (payload.brandName !== undefined) {
if (typeof payload.brandName !== "string" || !payload.brandName.trim()) throw new Error("brandName must be a non-empty string");
updates.brandName = payload.brandName.trim();
}
for (const field of ["logoUrl", "facebookUrl", "instagramUrl", "tiktokUrl"]) {
if (payload[field] !== undefined) {
if (payload[field] !== null && typeof payload[field] !== "string") throw new Error(`${field} must be a string or null`);
updates[field] = payload[field];
}
}
const description = payload.description ?? payload.footerDescription;
if (description !== undefined && (typeof description !== "object" || Array.isArray(description))) throw new Error("description must be an object keyed by locale");
if (payload.dryRun) {
return { dryRun: true, version, wouldUpdate: { ...updates, description: description ?? undefined } };
}
const updated = Object.keys(updates).length
? await prisma.storeSettings.update({ where: { id: current.id }, data: updates })
: current;
if (description) {
for (const [langCode, value] of Object.entries(description)) {
if (typeof langCode !== "string" || typeof value !== "string") throw new Error("description values must be strings");
const existing = await prisma.storeSettingsTranslation.findUnique({ where: { storeSettingsId_langCode: { storeSettingsId: current.id, langCode } } });
if (existing) await prisma.storeSettingsTranslation.update({ where: { id: existing.id }, data: { footerDescription: value } });
else await prisma.storeSettingsTranslation.create({ data: { storeSettingsId: current.id, langCode, footerDescription: value } });
if (langCode === "th") await prisma.storeSettings.update({ where: { id: current.id }, data: { footerDescription: value } });
}
}
const result = await prisma.storeSettings.findUnique({ where: { id: current.id }, include: { translations: true } });
const descriptions: Record<string, string> = {};
for (const translation of result?.translations ?? []) if (translation.footerDescription) descriptions[translation.langCode] = translation.footerDescription;
if (result?.footerDescription && !descriptions.th) descriptions.th = result.footerDescription;
return { success: true, version: result?.updatedAt.toISOString(), brand: { brandName: result?.brandName, logoUrl: result?.logoUrl ?? null, description: descriptions, socialLinks: { facebookUrl: result?.facebookUrl ?? null, instagramUrl: result?.instagramUrl ?? null, tiktokUrl: result?.tiktokUrl ?? null } } };
} catch (err: any) {
console.error("[Settings] Footer brand update error:", err);
set.status = 400;
return { error: err?.message || "Failed to update footer brand" };
}
}, { body: t.Any() })
.put("/", async ({ body, set }) => {
try {
const data = body as any;
+2
View File
@@ -0,0 +1,2 @@
# Backend target used by the MCP server. Override per environment as needed.
MCP_BACKEND_URL=https://api.ddc.kittisak.com/api
+1
View File
@@ -33,6 +33,7 @@ bun run dev
- `update_brand_name` เปลี่ยนชื่อแบรนด์กลางที่ใช้ใน header/footer ของ `apps/spa` และ `apps/website`
- `update_store_settings_translation` อัปเดตข้อความแปลของ store settings
- `update_footer_content` อัปเดตคำอธิบายแบรนด์และ social links เท่านั้น
- `get_footer_brand` / `update_footer_brand` อ่านและแก้ Footer Brand block โดยตรง (brand name, logo, คำอธิบายแยกภาษา, social links) พร้อม `dryRun` และ `expectedVersion`
## Footer Menu LLM Workflow
+32
View File
@@ -439,6 +439,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}
}
},
{
name: "get_footer_brand",
description: "Read the editable Footer Brand block (name, logo, localized description, and social links). Pass the returned version to update_footer_brand to prevent stale writes.",
inputSchema: { type: "object", properties: {} }
},
{
name: "update_footer_brand",
description: "Update Footer Brand fields without replacing footer menus. Supports localized description, dryRun preview, and expectedVersion conflict protection.",
inputSchema: {
type: "object",
properties: {
brandName: { type: "string" },
logoUrl: { type: ["string", "null"] },
description: { type: "object", description: "Locale-to-description map, e.g. { th: '...', en: '...' }" },
socialLinks: { type: "object", properties: { facebookUrl: { type: ["string", "null"] }, instagramUrl: { type: ["string", "null"] }, tiktokUrl: { type: ["string", "null"] } } },
dryRun: { type: "boolean" },
expectedVersion: { type: "string" }
}
}
},
{
name: "update_brand_name",
description: "Update the brand name used across website and SPA headers, footers, and brand displays.",
@@ -1413,6 +1433,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const res = await api.put("/settings", mergedSettings);
return { content: [{ type: "text", text: "Theme tokens updated successfully:\\n" + JSON.stringify(res.data.themeTokens, null, 2) }] };
}
case "get_footer_brand": {
const res = await api.get("/settings/footer-brand");
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_footer_brand": {
const payload: any = { ...params };
const social = payload.socialLinks;
delete payload.socialLinks;
if (social && typeof social === "object") Object.assign(payload, social);
const res = await api.patch("/settings/footer-brand", payload);
return { content: [{ type: "text", text: JSON.stringify({ success: true, tool: "update_footer_brand", ...res.data }, null, 2) }] };
}
case "update_brand_name": {
const { brandName } = params as any;
const currentSettingsRes = await api.get("/settings");
+16
View File
@@ -195,6 +195,22 @@ export interface StoreSettings {
scrollRevealDuration?: number | null
}
export interface FooterBrand {
brandName: string
logoUrl?: string | null
description: Record<string, string>
socialLinks: {
facebookUrl?: string | null
instagramUrl?: string | null
tiktokUrl?: string | null
}
}
export interface FooterBrandResponse {
version: string
brand: FooterBrand
}
export interface MenuItem {
id: number
label: string