x
This commit is contained in:
1 parent
73590e06c4
commit
f9c4046d35
6 files changed
+148
-1
No files matched your search
@@ -2,6 +2,27 @@ import { useState, useEffect } from 'react'
|
|||||||
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
||||||
import { Save, Loader2, Landmark, Truck } from 'lucide-react'
|
import { Save, Loader2, Landmark, Truck } from 'lucide-react'
|
||||||
|
|
||||||
|
const defaultThemeTokens = {
|
||||||
|
brandPrimary: '#FF6B6B',
|
||||||
|
brandPrimaryHover: '#FF4500',
|
||||||
|
brandAccent: '#FFF200',
|
||||||
|
brandWarm: '#FFA07A',
|
||||||
|
brandCool: '#E0FFFF',
|
||||||
|
brandSuccess: '#98FB98',
|
||||||
|
brandSoft: '#D8BFD8',
|
||||||
|
surfacePage: '#FFF0F5',
|
||||||
|
surfaceNavRest: '#F9F8F5',
|
||||||
|
surfaceCard: '#FFFFFF',
|
||||||
|
textMain: '#333333',
|
||||||
|
mutedText: '#666666',
|
||||||
|
linePrimary: '#00B900',
|
||||||
|
linePrimaryHover: '#009900',
|
||||||
|
fontBody: 'system-ui, sans-serif',
|
||||||
|
fontDisplay: 'system-ui, sans-serif',
|
||||||
|
shadowCard: '0 10px 25px rgba(0,0,0,0.05)',
|
||||||
|
shadowCardHover: '0 20px 40px rgba(0,0,0,0.1)',
|
||||||
|
}
|
||||||
|
|
||||||
export default function SystemSettingsPage() {
|
export default function SystemSettingsPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -9,6 +30,7 @@ export default function SystemSettingsPage() {
|
|||||||
// Form State
|
// Form State
|
||||||
const [brandName, setBrandName] = useState('')
|
const [brandName, setBrandName] = useState('')
|
||||||
const [logoUrl, setLogoUrl] = useState('')
|
const [logoUrl, setLogoUrl] = useState('')
|
||||||
|
const [themePreset, setThemePreset] = useState('default')
|
||||||
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('')
|
||||||
@@ -26,6 +48,7 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
const [footerSupportHeading, setFooterSupportHeading] = useState('')
|
const [footerSupportHeading, setFooterSupportHeading] = useState('')
|
||||||
const [footerCopyrightText, setFooterCopyrightText] = useState('')
|
const [footerCopyrightText, setFooterCopyrightText] = useState('')
|
||||||
|
const [themeTokens, setThemeTokens] = useState<Record<string, string>>(defaultThemeTokens)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/settings`)
|
fetch(`${API_BASE}/settings`)
|
||||||
@@ -33,6 +56,7 @@ export default function SystemSettingsPage() {
|
|||||||
.then((data: StoreSettings) => {
|
.then((data: StoreSettings) => {
|
||||||
setBrandName(data.brandName || 'HomAroma')
|
setBrandName(data.brandName || 'HomAroma')
|
||||||
setLogoUrl(data.logoUrl || '')
|
setLogoUrl(data.logoUrl || '')
|
||||||
|
setThemePreset(data.themePreset || 'default')
|
||||||
setShippingFee(data.shippingFee.toString())
|
setShippingFee(data.shippingFee.toString())
|
||||||
setBankName(data.bankName || '')
|
setBankName(data.bankName || '')
|
||||||
setBankAccountName(data.bankAccountName || '')
|
setBankAccountName(data.bankAccountName || '')
|
||||||
@@ -48,6 +72,10 @@ export default function SystemSettingsPage() {
|
|||||||
setNewsletterButtonText(data.newsletterButtonText || '')
|
setNewsletterButtonText(data.newsletterButtonText || '')
|
||||||
setFooterSupportHeading(data.footerSupportHeading || '')
|
setFooterSupportHeading(data.footerSupportHeading || '')
|
||||||
setFooterCopyrightText(data.footerCopyrightText || '')
|
setFooterCopyrightText(data.footerCopyrightText || '')
|
||||||
|
setThemeTokens({
|
||||||
|
...defaultThemeTokens,
|
||||||
|
...(data.themeTokens && typeof data.themeTokens === 'object' ? data.themeTokens : {}),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@@ -64,6 +92,8 @@ export default function SystemSettingsPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
brandName,
|
brandName,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
|
themePreset,
|
||||||
|
themeTokens,
|
||||||
shippingFee: parseFloat(shippingFee) || 0,
|
shippingFee: parseFloat(shippingFee) || 0,
|
||||||
bankName,
|
bankName,
|
||||||
bankAccountName,
|
bankAccountName,
|
||||||
@@ -244,6 +274,20 @@ export default function SystemSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Theme Preset</label>
|
||||||
|
<select
|
||||||
|
value={themePreset}
|
||||||
|
onChange={e => setThemePreset(e.target.value)}
|
||||||
|
className="admin-input w-full max-w-sm bg-slate-900 border-white/10"
|
||||||
|
>
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="warm">Warm</option>
|
||||||
|
<option value="minimal">Minimal</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-[10px] text-slate-400">Theme preset helps websites choose a starting palette before custom tokens are applied.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Footer Description</label>
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Footer Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -312,6 +356,59 @@ export default function SystemSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Settings */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
|
<div className="p-2 bg-cyan-glow/10 rounded-lg">
|
||||||
|
<span className="text-xl">🎨</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Theme Tokens</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{[
|
||||||
|
['brandPrimary', 'Brand Primary'],
|
||||||
|
['brandPrimaryHover', 'Brand Primary Hover'],
|
||||||
|
['brandAccent', 'Brand Accent'],
|
||||||
|
['surfacePage', 'Surface Page'],
|
||||||
|
['surfaceCard', 'Surface Card'],
|
||||||
|
['textMain', 'Text Main'],
|
||||||
|
['mutedText', 'Muted Text'],
|
||||||
|
['linePrimary', 'Line Primary'],
|
||||||
|
['linePrimaryHover', 'Line Primary Hover'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{label}</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={themeTokens[key] || ''}
|
||||||
|
onChange={e => setThemeTokens((current) => ({ ...current, [key]: e.target.value }))}
|
||||||
|
className="h-12 w-full max-w-sm rounded-xl border border-white/10 bg-slate-900 p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{[
|
||||||
|
['fontBody', 'Font Body'],
|
||||||
|
['fontDisplay', 'Font Display'],
|
||||||
|
['shadowCard', 'Shadow Card'],
|
||||||
|
['shadowCardHover', 'Shadow Card Hover'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{label}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={themeTokens[key] || ''}
|
||||||
|
onChange={e => setThemeTokens((current) => ({ ...current, [key]: e.target.value }))}
|
||||||
|
className="admin-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Newsletter Settings */}
|
{/* Newsletter Settings */}
|
||||||
<div className="admin-card p-8 space-y-6">
|
<div className="admin-card p-8 space-y-6">
|
||||||
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add theme support to singleton store settings.
|
||||||
|
ALTER TABLE "StoreSettings"
|
||||||
|
ADD COLUMN "themePreset" TEXT NOT NULL DEFAULT 'default';
|
||||||
|
|
||||||
|
ALTER TABLE "StoreSettings"
|
||||||
|
ADD COLUMN "themeTokens" JSONB;
|
||||||
@@ -209,6 +209,8 @@ model StoreSettings {
|
|||||||
id Int @id @default(1)
|
id Int @id @default(1)
|
||||||
brandName String @default("HomAroma")
|
brandName String @default("HomAroma")
|
||||||
logoUrl String?
|
logoUrl String?
|
||||||
|
themePreset String @default("default")
|
||||||
|
themeTokens Json?
|
||||||
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.")
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
bankAccountNumber: t.String(),
|
bankAccountNumber: t.String(),
|
||||||
pointsEarnRate: t.Number(),
|
pointsEarnRate: t.Number(),
|
||||||
logoUrl: t.Optional(t.String()),
|
logoUrl: t.Optional(t.String()),
|
||||||
|
themePreset: t.Optional(t.String()),
|
||||||
|
themeTokens: t.Optional(t.Any()),
|
||||||
facebookUrl: t.Optional(t.String()),
|
facebookUrl: t.Optional(t.String()),
|
||||||
instagramUrl: t.Optional(t.String()),
|
instagramUrl: t.Optional(t.String()),
|
||||||
tiktokUrl: t.Optional(t.String()),
|
tiktokUrl: t.Optional(t.String()),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { API_BASE } from '@/lib/api';
|
|||||||
import type { MegaMenuPayload } from '@/components/navigation/menuTypes';
|
import type { MegaMenuPayload } from '@/components/navigation/menuTypes';
|
||||||
import type { MenuItem, StoreSettings } from '@homaroma/types';
|
import type { MenuItem, StoreSettings } from '@homaroma/types';
|
||||||
import { isSupportedLocale } from '@/lib/locales';
|
import { isSupportedLocale } from '@/lib/locales';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'HomAroma',
|
title: 'HomAroma',
|
||||||
@@ -75,6 +76,41 @@ async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildThemeStyle(settings: StoreSettings | null): CSSProperties | undefined {
|
||||||
|
const tokens = settings?.themeTokens
|
||||||
|
if (!tokens || typeof tokens !== 'object') return undefined
|
||||||
|
|
||||||
|
const style: Record<string, string> = {}
|
||||||
|
const vars: Array<[string, string | undefined]> = [
|
||||||
|
['--color-brand-primary', tokens.brandPrimary],
|
||||||
|
['--color-brand-primary-hover', tokens.brandPrimaryHover],
|
||||||
|
['--color-brand-accent', tokens.brandAccent],
|
||||||
|
['--color-brand-warm', tokens.brandWarm],
|
||||||
|
['--color-brand-cool', tokens.brandCool],
|
||||||
|
['--color-brand-success', tokens.brandSuccess],
|
||||||
|
['--color-brand-soft', tokens.brandSoft],
|
||||||
|
['--color-surface-page', tokens.surfacePage],
|
||||||
|
['--color-surface-nav-rest', tokens.surfaceNavRest],
|
||||||
|
['--color-surface-card', tokens.surfaceCard],
|
||||||
|
['--color-text-main', tokens.textMain],
|
||||||
|
['--color-muted-text', tokens.mutedText],
|
||||||
|
['--color-line-primary', tokens.linePrimary],
|
||||||
|
['--color-line-primary-hover', tokens.linePrimaryHover],
|
||||||
|
['--font-body', tokens.fontBody],
|
||||||
|
['--font-display', tokens.fontDisplay],
|
||||||
|
['--shadow-card', tokens.shadowCard],
|
||||||
|
['--shadow-card-hover', tokens.shadowCardHover],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [key, value] of vars) {
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
style[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
@@ -95,9 +131,11 @@ export default async function RootLayout({
|
|||||||
getStoreSettings(lang),
|
getStoreSettings(lang),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const themeStyle = buildThemeStyle(settings)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={lang} className="scroll-smooth">
|
<html lang={lang} className="scroll-smooth">
|
||||||
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased">
|
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased" style={themeStyle}>
|
||||||
<ClientProviders>
|
<ClientProviders>
|
||||||
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
||||||
<main className="global-main">
|
<main className="global-main">
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export interface StoreSettings {
|
|||||||
id: number
|
id: number
|
||||||
brandName: string
|
brandName: string
|
||||||
logoUrl?: string | null
|
logoUrl?: string | null
|
||||||
|
themePreset?: string | null
|
||||||
|
themeTokens?: Record<string, string> | null
|
||||||
shippingFee: number
|
shippingFee: number
|
||||||
bankName: string
|
bankName: string
|
||||||
bankAccountName: string
|
bankAccountName: string
|
||||||
|
|||||||
Reference in new issue
Block a user