x
This commit is contained in:
1 parent
feeda6dc60
commit
927f03a8b7
12 files changed
+95
-10
No files matched your search
@@ -7,6 +7,7 @@ export default function SystemSettingsPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
|
const [brandName, setBrandName] = useState('')
|
||||||
const [shippingFee, setShippingFee] = useState<string>('')
|
const [shippingFee, setShippingFee] = useState<string>('')
|
||||||
const [bankName, setBankName] = useState('')
|
const [bankName, setBankName] = useState('')
|
||||||
const [bankAccountName, setBankAccountName] = useState('')
|
const [bankAccountName, setBankAccountName] = useState('')
|
||||||
@@ -29,6 +30,7 @@ export default function SystemSettingsPage() {
|
|||||||
fetch(`${API_BASE}/settings`)
|
fetch(`${API_BASE}/settings`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((data: StoreSettings) => {
|
.then((data: StoreSettings) => {
|
||||||
|
setBrandName(data.brandName || 'HomAroma')
|
||||||
setShippingFee(data.shippingFee.toString())
|
setShippingFee(data.shippingFee.toString())
|
||||||
setBankName(data.bankName || '')
|
setBankName(data.bankName || '')
|
||||||
setBankAccountName(data.bankAccountName || '')
|
setBankAccountName(data.bankAccountName || '')
|
||||||
@@ -58,6 +60,7 @@ export default function SystemSettingsPage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
brandName,
|
||||||
shippingFee: parseFloat(shippingFee) || 0,
|
shippingFee: parseFloat(shippingFee) || 0,
|
||||||
bankName,
|
bankName,
|
||||||
bankAccountName,
|
bankAccountName,
|
||||||
@@ -208,6 +211,19 @@ export default function SystemSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Brand Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={brandName}
|
||||||
|
onChange={e => setBrandName(e.target.value)}
|
||||||
|
className="admin-input w-full max-w-sm"
|
||||||
|
placeholder="HomAroma"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500">This name is used by the website and spa header/footer as the brand display name.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Footer Description</label>
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Footer Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "StoreSettings" ADD COLUMN "brandName" TEXT NOT NULL DEFAULT 'HomAroma';
|
||||||
@@ -207,6 +207,7 @@ model Promotion {
|
|||||||
|
|
||||||
model StoreSettings {
|
model StoreSettings {
|
||||||
id Int @id @default(1)
|
id Int @id @default(1)
|
||||||
|
brandName String @default("HomAroma")
|
||||||
shippingFee Float @default(35)
|
shippingFee Float @default(35)
|
||||||
bankName String @default("KASIKORNBANK (KBANK)")
|
bankName String @default("KASIKORNBANK (KBANK)")
|
||||||
bankAccountName String @default("HomAroma Co., Ltd.")
|
bankAccountName String @default("HomAroma Co., Ltd.")
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
|
brandName: t.String(),
|
||||||
shippingFee: t.Number(),
|
shippingFee: t.Number(),
|
||||||
bankName: t.String(),
|
bankName: t.String(),
|
||||||
bankAccountName: t.String(),
|
bankAccountName: t.String(),
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ bun run dev
|
|||||||
|
|
||||||
ถ้าไม่ตั้งค่า `MCP_BACKEND_URL` server จะเริ่มที่ local backend และสลับเป็น production ได้ด้วย tool `set_target_environment`
|
ถ้าไม่ตั้งค่า `MCP_BACKEND_URL` server จะเริ่มที่ local backend และสลับเป็น production ได้ด้วย tool `set_target_environment`
|
||||||
|
|
||||||
|
## Available tools
|
||||||
|
|
||||||
|
- `update_brand_name` เปลี่ยนชื่อแบรนด์กลางที่ใช้ใน header/footer ของ `apps/spa` และ `apps/website`
|
||||||
|
- `update_store_settings_translation` อัปเดตข้อความแปลของ store settings
|
||||||
|
- `update_footer_content` อัปเดตเนื้อหา footer และลิงก์ที่เกี่ยวข้อง
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `bun run dev`
|
- `bun run dev`
|
||||||
|
|||||||
@@ -477,6 +477,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["lang"]
|
required: ["lang"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "update_brand_name",
|
||||||
|
description: "Update the brand name used across website and SPA headers, footers, and brand displays.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
brandName: { type: "string", description: "Brand display name to store in system settings" }
|
||||||
|
},
|
||||||
|
required: ["brandName"]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "update_article_translation",
|
name: "update_article_translation",
|
||||||
description: "Add or update translations for a specific article.",
|
description: "Add or update translations for a specific article.",
|
||||||
@@ -890,6 +901,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
const res = await api.patch(`/settings/translations`, payload);
|
const res = await api.patch(`/settings/translations`, payload);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
}
|
}
|
||||||
|
case "update_brand_name": {
|
||||||
|
const { brandName } = params as any;
|
||||||
|
const currentSettingsRes = await api.get("/settings");
|
||||||
|
const currentSettings = currentSettingsRes.data || {};
|
||||||
|
const mergedSettings = { ...currentSettings, brandName };
|
||||||
|
|
||||||
|
for (const key in mergedSettings) {
|
||||||
|
if (mergedSettings[key] === null) {
|
||||||
|
delete mergedSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.put("/settings", mergedSettings);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
tool: "update_brand_name",
|
||||||
|
brandName: res.data?.brandName ?? brandName,
|
||||||
|
settings: res.data
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
case "update_article_translation": {
|
case "update_article_translation": {
|
||||||
const { id, lang, title, content } = params as any;
|
const { id, lang, title, content } = params as any;
|
||||||
const payload: any = {};
|
const payload: any = {};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
const [exploreMenu, setExploreMenu] = useState<MenuItem[]>([])
|
const [exploreMenu, setExploreMenu] = useState<MenuItem[]>([])
|
||||||
const [supportMenu, setSupportMenu] = useState<MenuItem[]>([])
|
const [supportMenu, setSupportMenu] = useState<MenuItem[]>([])
|
||||||
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||||
|
const brandName = settings?.brandName || 'HomAroma'
|
||||||
|
|
||||||
const translateMenuLabel = (item: MenuItem) => {
|
const translateMenuLabel = (item: MenuItem) => {
|
||||||
const lang = localStorage.getItem('homaroma_lang') || 'en'
|
const lang = localStorage.getItem('homaroma_lang') || 'en'
|
||||||
@@ -64,7 +65,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-2xl font-bold tracking-widest">
|
<div className="text-2xl font-bold tracking-widest">
|
||||||
HomAroma <span className="text-cyan-glow text-sm font-light italic">Essential</span>
|
{brandName} <span className="text-cyan-glow text-sm font-light italic">Essential</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-500 text-sm leading-relaxed">
|
<p className="text-slate-500 text-sm leading-relaxed">
|
||||||
{settings?.footerDescription || t('footer.catchphrase')}
|
{settings?.footerDescription || t('footer.catchphrase')}
|
||||||
@@ -135,7 +136,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
|
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
|
||||||
{settings?.footerCopyrightText || `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`}
|
{settings?.footerCopyrightText || `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { type Category } from '../types'
|
import { API_BASE, type Category, type StoreSettings } from '../types'
|
||||||
import { ShoppingBag, Menu as MenuIcon, X, Heart, Globe, Check } from 'lucide-react'
|
import { ShoppingBag, Menu as MenuIcon, X, Heart, Globe, Check } from 'lucide-react'
|
||||||
import { useCartStore } from '../store/cartStore'
|
import { useCartStore } from '../store/cartStore'
|
||||||
import { useWishlistStore } from '../store/wishlistStore'
|
import { useWishlistStore } from '../store/wishlistStore'
|
||||||
@@ -26,6 +26,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const [hasAnnouncement, setHasAnnouncement] = useState(false)
|
const [hasAnnouncement, setHasAnnouncement] = useState(false)
|
||||||
|
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { toggleCart, getCartCount } = useCartStore()
|
const { toggleCart, getCartCount } = useCartStore()
|
||||||
@@ -74,6 +75,15 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
}
|
}
|
||||||
}, [fetchMegaMenu])
|
}, [fetchMegaMenu])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/settings`)
|
||||||
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
|
.then((data: StoreSettings | null) => {
|
||||||
|
if (data) setSettings(data)
|
||||||
|
})
|
||||||
|
.catch((error) => console.error('Failed to fetch store settings', error))
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Keep --navbar-bottom in sync so the fixed mega-menu dropdown sits flush below the navbar
|
// Keep --navbar-bottom in sync so the fixed mega-menu dropdown sits flush below the navbar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateNavBottom = () => {
|
const updateNavBottom = () => {
|
||||||
@@ -112,6 +122,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
: (hasAnnouncement ? 'top-[32px] md:top-[40px]' : 'top-0')
|
: (hasAnnouncement ? 'top-[32px] md:top-[40px]' : 'top-0')
|
||||||
|
|
||||||
const finalTopClassName = scrolled ? 'top-0' : computedOffset
|
const finalTopClassName = scrolled ? 'top-0' : computedOffset
|
||||||
|
const brandName = settings?.brandName || 'HomAroma'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -127,7 +138,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
{/* Left: Logo */}
|
{/* Left: Logo */}
|
||||||
<div className="flex items-center shrink-0">
|
<div className="flex items-center shrink-0">
|
||||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0">
|
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0">
|
||||||
HomAroma <span className="hidden md:inline text-spa-stone text-xs md:text-sm not-italic font-light ml-1">Essential</span>
|
{brandName} <span className="hidden md:inline text-spa-stone text-xs md:text-sm not-italic font-light ml-1">Essential</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default async function RootLayout({
|
|||||||
<html lang="th" className="scroll-smooth">
|
<html lang="th" className="scroll-smooth">
|
||||||
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
|
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
|
||||||
<ClientProviders>
|
<ClientProviders>
|
||||||
<Navbar categories={categories} initialMenu={initialMenu} />
|
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
||||||
<main className="pt-[72px]">
|
<main className="pt-[72px]">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default function Footer({
|
|||||||
|
|
||||||
// Social links — use settings if available, fallback to placeholder buttons
|
// Social links — use settings if available, fallback to placeholder buttons
|
||||||
const hasSocialLinks = settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl)
|
const hasSocialLinks = settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl)
|
||||||
|
const brandName = settings?.brandName || 'HomAroma'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-gray-200 bg-white px-6 py-20 text-pop-black">
|
<footer className="border-t border-gray-200 bg-white px-6 py-20 text-pop-black">
|
||||||
@@ -60,7 +61,7 @@ export default function Footer({
|
|||||||
{/* Col 1 — Brand */}
|
{/* Col 1 — Brand */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-2xl font-bold tracking-widest">
|
<div className="text-2xl font-bold tracking-widest">
|
||||||
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
|
{brandName} <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed text-gray-600">
|
<p className="text-sm leading-relaxed text-gray-600">
|
||||||
{resolveLocalized(settings?.footerDescription, effectiveLang) || t('footer.catchphrase')}
|
{resolveLocalized(settings?.footerDescription, effectiveLang) || t('footer.catchphrase')}
|
||||||
@@ -174,8 +175,8 @@ export default function Footer({
|
|||||||
? settings.footerCopyrightText
|
? settings.footerCopyrightText
|
||||||
: settings.footerCopyrightText[effectiveLang]
|
: settings.footerCopyrightText[effectiveLang]
|
||||||
|| settings.footerCopyrightText.en
|
|| settings.footerCopyrightText.en
|
||||||
|| `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`)
|
|| `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`)
|
||||||
: `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`}
|
: `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { useLang } from '../hooks/useLang'
|
|||||||
import { DesktopMegaMenu } from './navigation/DesktopMegaMenu'
|
import { DesktopMegaMenu } from './navigation/DesktopMegaMenu'
|
||||||
import { MobileDrawer } from './navigation/MobileDrawer'
|
import { MobileDrawer } from './navigation/MobileDrawer'
|
||||||
import type { MegaMenuPayload } from './navigation/menuTypes'
|
import type { MegaMenuPayload } from './navigation/menuTypes'
|
||||||
|
import type { StoreSettings } from '@homaroma/types'
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
categories?: Category[]
|
categories?: Category[]
|
||||||
initialMenu?: MegaMenuPayload[]
|
initialMenu?: MegaMenuPayload[]
|
||||||
|
settings?: StoreSettings | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
@@ -60,7 +62,7 @@ function createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ categories = [], initialMenu = [] }: NavbarProps) {
|
export default function Navbar({ categories = [], initialMenu = [], settings = null }: NavbarProps) {
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
||||||
@@ -143,6 +145,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp
|
|||||||
|
|
||||||
const iconButtonClass = 'rounded-full p-1.5 text-pop-black/65 transition-all hover:bg-pop-bg hover:text-pop-pink md:p-2'
|
const iconButtonClass = 'rounded-full p-1.5 text-pop-black/65 transition-all hover:bg-pop-bg hover:text-pop-pink md:p-2'
|
||||||
const activeLanguage = LANGUAGES.find((language) => lang.startsWith(language.code) || language.code.startsWith(lang)) ?? LANGUAGES[0]
|
const activeLanguage = LANGUAGES.find((language) => lang.startsWith(language.code) || language.code.startsWith(lang)) ?? LANGUAGES[0]
|
||||||
|
const brandName = settings?.brandName || 'HomAroma'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -161,7 +164,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp
|
|||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className="shrink-0 text-xl font-bold uppercase italic tracking-widest text-pop-black md:text-2xl"
|
className="shrink-0 text-xl font-bold uppercase italic tracking-widest text-pop-black md:text-2xl"
|
||||||
>
|
>
|
||||||
HomAroma <span className="hidden text-xs font-light not-italic text-pop-black/55 md:inline md:text-sm">Essential</span>
|
{brandName} <span className="hidden text-xs font-light not-italic text-pop-black/55 md:inline md:text-sm">Essential</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export interface Hero {
|
|||||||
|
|
||||||
export interface StoreSettings {
|
export interface StoreSettings {
|
||||||
id: number
|
id: number
|
||||||
|
brandName: string
|
||||||
shippingFee: number
|
shippingFee: number
|
||||||
bankName: string
|
bankName: string
|
||||||
bankAccountName: string
|
bankAccountName: string
|
||||||
|
|||||||
Reference in new issue
Block a user