x
This commit is contained in:
1 parent
48aebb2ec9
commit
b693c7188c
8 files changed
+267
-4
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user