i18n: ko/en 다국어 + 한글 맑은 고딕
This commit is contained in:
parent
ac3aa6baf6
commit
e6d0eaecaf
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
489
backend/src/main/resources/static/assets/index-CUN395kM.js
Normal file
489
backend/src/main/resources/static/assets/index-CUN395kM.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -15,8 +15,8 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-BIFg_hhJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CixfEA04.css">
|
||||
<script type="module" crossorigin src="/assets/index-CUN395kM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BzL8NSpt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
98
frontend/package-lock.json
generated
98
frontend/package-lock.json
generated
@ -12,9 +12,12 @@
|
||||
"axios": "^1.7.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"i18next": "^26.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"recharts": "^2.12.0"
|
||||
},
|
||||
@ -2256,6 +2259,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
@ -2269,6 +2281,44 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@ -2866,6 +2916,33 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@ -3323,8 +3400,9 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -3364,6 +3442,15 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@ -3454,6 +3541,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@ -12,9 +12,12 @@
|
||||
"axios": "^1.7.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"i18next": "^26.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"recharts": "^2.12.0"
|
||||
},
|
||||
|
||||
@ -4,32 +4,35 @@ import {
|
||||
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
|
||||
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getMe } from '../api/client'
|
||||
import LanguageSwitcher from '../i18n/LanguageSwitcher'
|
||||
|
||||
const links = [
|
||||
{ to: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/stores', label: 'Stores', icon: Store, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/products', label: 'Products', icon: Flower2, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/inventory', label: 'Store Inventory', icon: Boxes, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/orders', label: 'Orders (Live)', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/transfers', label: 'Inventory Transfers', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/members', label: 'Members (CRM)', icon: Users, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/loyalty', label: 'Tier & Points', icon: Crown, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/events', label: 'Events & Campaigns', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/subscriptions', label: 'Subscriptions', icon: Repeat, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/schedule', label: 'Schedule & Surge', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/analytics', label: 'Revenue Analytics (BI)', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/dashboard', key: 'dashboard', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/stores', key: 'stores', icon: Store, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/products', key: 'products', icon: Flower2, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/inventory', key: 'inventory', icon: Boxes, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/orders', key: 'orders', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/transfers', key: 'transfers', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/members', key: 'members', icon: Users, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/loyalty', key: 'loyalty', icon: Crown, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/events', key: 'events', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/subscriptions', key: 'subscriptions', icon: Repeat, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/schedule', key: 'schedule', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/analytics', key: 'analytics', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] },
|
||||
]
|
||||
const adminLinks = [
|
||||
{ to: '/admin/users', label: 'User Management', icon: UserCog, roles: ['ADMIN'] },
|
||||
{ to: '/admin/audit', label: 'Audit Log', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/settings', label: 'Gateway Settings', icon: Settings, roles: ['ADMIN'] },
|
||||
{ to: '/admin/app', label: 'Admin App Install', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/users', key: 'users', icon: UserCog, roles: ['ADMIN'] },
|
||||
{ to: '/admin/audit', key: 'audit', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/settings', key: 'settings', icon: Settings, roles: ['ADMIN'] },
|
||||
{ to: '/admin/app', key: 'appInstall', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] },
|
||||
]
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60'}`
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { t } = useTranslation()
|
||||
const token = localStorage.getItem('mall_admin_token')
|
||||
const [role, setRole] = useState(() => localStorage.getItem('mall_role') || '')
|
||||
const [who, setWho] = useState(() => localStorage.getItem('mall_admin_user') || '')
|
||||
@ -65,23 +68,24 @@ export default function AdminLayout() {
|
||||
<aside className="w-60 bg-panel border-r border-edge flex flex-col">
|
||||
<div className="h-16 flex items-center gap-2 px-5 border-b border-edge">
|
||||
<Flower2 className="text-brand" size={22} />
|
||||
<div><div className="font-bold text-base leading-tight">GUARDiA Mall</div><div className="text-[11px] text-slate-400">Admin Console</div></div>
|
||||
<div><div className="font-bold text-base leading-tight">GUARDiA Mall</div><div className="text-[11px] text-slate-400">{t('admin.console')}</div></div>
|
||||
</div>
|
||||
<nav className="flex-1 py-2 overflow-auto">
|
||||
{visible.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
|
||||
{visible.map(({ to, key, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{t(`admin.nav.${key}`)}</NavLink>)}
|
||||
{visibleAdmin.length > 0 && (<>
|
||||
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">System</div>
|
||||
{visibleAdmin.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
|
||||
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">{t('admin.system')}</div>
|
||||
{visibleAdmin.map(({ to, key, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{t(`admin.nav.${key}`)}</NavLink>)}
|
||||
</>)}
|
||||
</nav>
|
||||
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">Ollama On-Premise · Omnichannel</div>
|
||||
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">{t('admin.onPremiseTag')}</div>
|
||||
</aside>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<header className="h-16 bg-panel border-b border-edge flex items-center justify-between px-6">
|
||||
<div className="text-sm text-slate-400">US Multi-Store Florist Omnichannel E-Commerce · AI Demand Forecasting · BI</div>
|
||||
<div className="text-sm text-slate-400 truncate">{t('admin.header')}</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<LanguageSwitcher variant="admin" />
|
||||
<span className="flex items-center gap-1.5 text-sm text-slate-300"><UserCircle size={18} /> {who || 'admin'} <span className="text-[10px] text-brand">{role}</span></span>
|
||||
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand"><LogOut size={16} /> Sign Out</button>
|
||||
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand"><LogOut size={16} /> {t('admin.signOut')}</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Flower2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { login, getMe } from '../api/client'
|
||||
import LanguageSwitcher from '../i18n/LanguageSwitcher'
|
||||
|
||||
export default function AdminLogin() {
|
||||
const { t } = useTranslation()
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
@ -25,30 +28,31 @@ export default function AdminLogin() {
|
||||
const me: any = await getMe().catch(() => null)
|
||||
if (me?.role) localStorage.setItem('mall_role', me.role)
|
||||
if (me?.username) localStorage.setItem('mall_admin_user', me.username)
|
||||
if (me?.role === 'USER') { setErr('You do not have admin privileges.'); localStorage.removeItem('mall_admin_token'); return }
|
||||
if (me?.role === 'USER') { setErr(t('admin.login.errNoPriv')); localStorage.removeItem('mall_admin_token'); return }
|
||||
nav('/admin/dashboard')
|
||||
} catch {
|
||||
setErr('Sign-in failed — please check your username and password.')
|
||||
setErr(t('admin.login.errFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-shell min-h-screen flex items-center justify-center bg-ink text-[#e6edf6]">
|
||||
<div className="absolute top-5 right-5"><LanguageSwitcher variant="admin" /></div>
|
||||
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
||||
<div className="flex items-center gap-2 justify-center mb-6">
|
||||
<Flower2 className="text-brand" size={28} />
|
||||
<span className="text-xl font-bold">GUARDiA Mall Console</span>
|
||||
<span className="text-xl font-bold">{t('admin.login.title')}</span>
|
||||
</div>
|
||||
<p className="text-center text-sm text-slate-400 mb-6">Store & Corporate Admin Sign In</p>
|
||||
<label className="block text-xs text-slate-400 mb-1">Username</label>
|
||||
<p className="text-center text-sm text-slate-400 mb-6">{t('admin.login.subtitle')}</p>
|
||||
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.username')}</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)}
|
||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||
<label className="block text-xs text-slate-400 mb-1">Password</label>
|
||||
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.password')}</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
|
||||
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">Sign In</button>
|
||||
<p className="text-center text-[11px] text-slate-500 mt-4">Customer storefront is <a href="/" className="text-brand">here</a></p>
|
||||
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{t('admin.login.signIn')}</button>
|
||||
<p className="text-center text-[11px] text-slate-500 mt-4">{t('admin.login.storefrontHere')} <a href="/" className="text-brand">{t('admin.login.here')}</a></p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DollarSign, ShoppingBag, TrendingUp, Star } from 'lucide-react'
|
||||
import { getAnalyticsDashboard, getStoreSales, getSalesTrend, getTopProducts } from '../api/client'
|
||||
import Chart from '../components/Chart'
|
||||
@ -15,6 +16,7 @@ function Kpi({ icon: Icon, label, value, sub }: { icon: any; label: string; valu
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation()
|
||||
const { data: dash } = useQuery({ queryKey: ['adash'], queryFn: () => getAnalyticsDashboard(1, 500) })
|
||||
const { data: storeSales } = useQuery({ queryKey: ['store-sales'], queryFn: () => getStoreSales(7) })
|
||||
const { data: trend } = useQuery({ queryKey: ['trend'], queryFn: () => getSalesTrend(14) })
|
||||
@ -27,39 +29,39 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold mb-5">Dashboard</h1>
|
||||
<h1 className="text-xl font-bold mb-5">{t('admin.dashboard.title')}</h1>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Kpi icon={DollarSign} label="Today's Revenue" value={money(today.totalSales)} />
|
||||
<Kpi icon={ShoppingBag} label="Today's Orders" value={`${today.orderCount || 0}`} />
|
||||
<Kpi icon={TrendingUp} label="Stores" value={`${(dash?.storeRanking || []).length}`} sub="Active store revenue ranking" />
|
||||
<Kpi icon={Star} label="Large Orders" value={`${(dash?.bigOrders || []).length}`} sub="$500+ high-value orders" />
|
||||
<Kpi icon={DollarSign} label={t('admin.dashboard.todaysRevenue')} value={money(today.totalSales)} />
|
||||
<Kpi icon={ShoppingBag} label={t('admin.dashboard.todaysOrders')} value={`${today.orderCount || 0}`} />
|
||||
<Kpi icon={TrendingUp} label={t('admin.dashboard.stores')} value={`${(dash?.storeRanking || []).length}`} sub={t('admin.dashboard.storesSub')} />
|
||||
<Kpi icon={Star} label={t('admin.dashboard.largeOrders')} value={`${(dash?.bigOrders || []).length}`} sub={t('admin.dashboard.largeOrdersSub')} />
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5 mb-6">
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-3">Revenue by Store (Last 7 Days)</h2>
|
||||
<h2 className="text-sm font-semibold mb-3">{t('admin.dashboard.revenueByStore')}</h2>
|
||||
<Chart type="BAR" rows={storeRows} dataKeys={['Revenue', 'Orders']} />
|
||||
</div>
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-3">Revenue Trend (14 Days)</h2>
|
||||
<h2 className="text-sm font-semibold mb-3">{t('admin.dashboard.revenueTrend')}</h2>
|
||||
<Chart type="AREA" rows={trendRows} dataKeys={['Revenue']} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-3">Best Sellers</h2>
|
||||
<h2 className="text-sm font-semibold mb-3">{t('admin.dashboard.bestSellers')}</h2>
|
||||
<Chart type="BAR" rows={topRows} dataKeys={['Units Sold']} />
|
||||
</div>
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-3">Store Revenue Ranking</h2>
|
||||
<h2 className="text-sm font-semibold mb-3">{t('admin.dashboard.storeRanking')}</h2>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-slate-400 text-xs border-b border-edge"><th className="text-left py-2">Store</th><th className="text-right">Revenue</th><th className="text-right">Orders</th></tr></thead>
|
||||
<thead><tr className="text-slate-400 text-xs border-b border-edge"><th className="text-left py-2">{t('admin.dashboard.store')}</th><th className="text-right">{t('admin.dashboard.revenue')}</th><th className="text-right">{t('admin.dashboard.orders')}</th></tr></thead>
|
||||
<tbody>
|
||||
{(dash?.storeRanking || []).map((s: any, i: number) => (
|
||||
<tr key={i} className="border-b border-edge/50"><td className="py-2">{s.storeName || s.storeCode}</td><td className="text-right">{money(s.sales)}</td><td className="text-right text-slate-400">{s.orderCount}</td></tr>
|
||||
))}
|
||||
{!(dash?.storeRanking || []).length && <tr><td colSpan={3} className="text-center text-slate-500 py-6">No data</td></tr>}
|
||||
{!(dash?.storeRanking || []).length && <tr><td colSpan={3} className="text-center text-slate-500 py-6">{t('common.noData')}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
57
frontend/src/i18n/LanguageSwitcher.tsx
Normal file
57
frontend/src/i18n/LanguageSwitcher.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Globe } from 'lucide-react'
|
||||
import type { Lang } from './index'
|
||||
|
||||
const OPTS: { code: Lang; label: string }[] = [
|
||||
{ code: 'en', label: 'EN' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 언어 스위처 (EN / 한국어 토글).
|
||||
* variant="shop" → 스토어프론트 라이트 무드
|
||||
* variant="admin" → 관리자 NCloud 다크
|
||||
*/
|
||||
export default function LanguageSwitcher({ variant = 'shop' }: { variant?: 'shop' | 'admin' }) {
|
||||
const { i18n } = useTranslation()
|
||||
const current = (i18n.language || 'en').split('-')[0]
|
||||
|
||||
const change = (code: Lang) => {
|
||||
if (code !== current) i18n.changeLanguage(code)
|
||||
}
|
||||
|
||||
const isAdmin = variant === 'admin'
|
||||
const wrap = isAdmin
|
||||
? 'flex items-center gap-0.5 rounded-lg border border-edge bg-card/60 p-0.5'
|
||||
: 'flex items-center gap-0.5 rounded-full border border-blush-100 bg-white/70 p-0.5 shadow-soft'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" aria-label="Language">
|
||||
<Globe size={15} className={isAdmin ? 'text-slate-400' : 'text-sage-600'} />
|
||||
<div className={wrap} role="group">
|
||||
{OPTS.map(o => {
|
||||
const active = o.code === current
|
||||
const base = 'px-2 py-0.5 text-[11px] font-medium rounded-full transition-colors'
|
||||
const cls = isAdmin
|
||||
? active
|
||||
? 'bg-brand text-ink'
|
||||
: 'text-slate-300 hover:text-brand'
|
||||
: active
|
||||
? 'bg-blush-500 text-white'
|
||||
: 'text-sage-700 hover:text-blush-600'
|
||||
return (
|
||||
<button
|
||||
key={o.code}
|
||||
type="button"
|
||||
onClick={() => change(o.code)}
|
||||
aria-pressed={active}
|
||||
className={`${base} ${isAdmin ? 'rounded-md' : ''} ${cls}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
264
frontend/src/i18n/en.json
Normal file
264
frontend/src/i18n/en.json
Normal file
@ -0,0 +1,264 @@
|
||||
{
|
||||
"common": {
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search bouquets, roses, occasions…",
|
||||
"loading": "Loading…",
|
||||
"noData": "No data",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"add": "Add",
|
||||
"create": "Create",
|
||||
"confirm": "Confirm",
|
||||
"submit": "Submit",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"continue": "Continue",
|
||||
"apply": "Apply",
|
||||
"viewAll": "View All",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"actions": "Actions",
|
||||
"status": "Status",
|
||||
"all": "All",
|
||||
"language": "Language"
|
||||
},
|
||||
"shop": {
|
||||
"topbar": {
|
||||
"reviews": "{{n}} reviews"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"shopAll": "Shop All",
|
||||
"loveRomance": "Love & Romance",
|
||||
"birthday": "Birthday",
|
||||
"sympathy": "Sympathy",
|
||||
"todaysBouquet": "Today's Bouquet",
|
||||
"subscriptions": "Subscriptions",
|
||||
"offers": "Offers"
|
||||
},
|
||||
"header": {
|
||||
"zip": "ZIP",
|
||||
"wishlist": "Wishlist",
|
||||
"cart": "Cart",
|
||||
"account": "Account"
|
||||
},
|
||||
"footer": {
|
||||
"since": "Since {{year}}",
|
||||
"visitUs": "Visit Us",
|
||||
"hours": "Hours",
|
||||
"customerCare": "Customer Care",
|
||||
"contactAiHelp": "Contact / AI Help",
|
||||
"orderStatus": "Order Status",
|
||||
"subscriptions": "Subscriptions",
|
||||
"getApp": "Get the App (QR)",
|
||||
"weAccept": "We accept",
|
||||
"rights": "© 2026 Montvale Florist"
|
||||
}
|
||||
},
|
||||
"zip": {
|
||||
"since": "Since {{year}}",
|
||||
"lead": "Tell us where you're sending flowers — we'll show what's available nearby.",
|
||||
"enterZip": "Enter Delivery ZIP Code",
|
||||
"placeholder": "e.g. 07645",
|
||||
"go": "Go",
|
||||
"availableStores": "Available stores",
|
||||
"radiusSameDay": "{{radius}}mi radius · same-day by {{cutoff}} ({{tz}})",
|
||||
"reviews": "{{n}} reviews",
|
||||
"browsePickup": "Browse with store pickup →",
|
||||
"errInvalid": "Please enter a 5-digit ZIP code.",
|
||||
"errNotDeliverable": "Sorry, we don't deliver to that area yet. Try store pickup or another ZIP.",
|
||||
"errFailed": "Lookup failed. Please try again in a moment."
|
||||
},
|
||||
"cart": {
|
||||
"title": "Cart",
|
||||
"signInPrompt": "Please sign in to view your cart.",
|
||||
"signInRegister": "Sign In / Register",
|
||||
"empty": "Your cart is empty.",
|
||||
"startShopping": "Start Shopping →",
|
||||
"card": "Card",
|
||||
"subtotal": "Subtotal",
|
||||
"shippingTax": "Shipping/Tax",
|
||||
"calcAtCheckout": "Calculated at checkout",
|
||||
"total": "Total",
|
||||
"checkout": "Checkout"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Checkout",
|
||||
"recipient": "Recipient",
|
||||
"recipientName": "Recipient Name",
|
||||
"deliveryAddress": "Delivery Address",
|
||||
"deliveryDate": "Delivery Date",
|
||||
"timeSlot": "Time Slot",
|
||||
"cardMessage": "Card Message",
|
||||
"cardMessagePlaceholder": "Write a heartfelt message…",
|
||||
"aiSuggest": "AI Card Message",
|
||||
"payment": "Payment",
|
||||
"placeOrder": "Place Order",
|
||||
"orderSummary": "Order Summary",
|
||||
"contactPhone": "Contact Phone",
|
||||
"sameDay": "Same-day delivery",
|
||||
"processing": "Processing…",
|
||||
"success": "Your order has been placed!"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Flower Subscriptions",
|
||||
"subtitle": "Fresh, florist-designed blooms delivered on your schedule.",
|
||||
"weekly": "Weekly",
|
||||
"biweekly": "Bi-weekly",
|
||||
"monthly": "Monthly",
|
||||
"subscribe": "Subscribe",
|
||||
"manage": "Manage Subscription",
|
||||
"frequency": "Frequency",
|
||||
"nextDelivery": "Next Delivery"
|
||||
},
|
||||
"search": {
|
||||
"resultsFor": "Search Results for \"{{query}}\"",
|
||||
"aiUnderstood": "AI understood:",
|
||||
"searching": "AI is searching…",
|
||||
"noResults": "No results found."
|
||||
},
|
||||
"wishlist": {
|
||||
"title": "Wishlist",
|
||||
"empty": "Your wishlist is empty.",
|
||||
"startShopping": "Start Shopping →"
|
||||
},
|
||||
"mypage": {
|
||||
"title": "My Page",
|
||||
"hello": "Hello",
|
||||
"myOrders": "My Orders",
|
||||
"mySubscriptions": "My Subscriptions",
|
||||
"wishlist": "Wishlist",
|
||||
"points": "Points",
|
||||
"tier": "Tier",
|
||||
"addresses": "Addresses",
|
||||
"signOut": "Sign Out",
|
||||
"memberSince": "Member since"
|
||||
},
|
||||
"product": {
|
||||
"addToCart": "Add to Cart",
|
||||
"buyNow": "Buy Now",
|
||||
"size": "Size",
|
||||
"options": "Options",
|
||||
"reviews": "Reviews",
|
||||
"description": "Description",
|
||||
"writeReview": "Write a Review",
|
||||
"inStock": "In stock",
|
||||
"outOfStock": "Out of stock",
|
||||
"share": "Share",
|
||||
"sizeStandard": "Standard",
|
||||
"sizeDeluxe": "Deluxe",
|
||||
"sizePremium": "Premium"
|
||||
},
|
||||
"review": {
|
||||
"title": "Write a Review",
|
||||
"rating": "Rating",
|
||||
"comment": "Your Review",
|
||||
"commentPlaceholder": "Share your experience…",
|
||||
"submit": "Submit Review",
|
||||
"thanks": "Thank you for your review!"
|
||||
},
|
||||
"events": {
|
||||
"title": "Offers & Events",
|
||||
"subtitle": "Seasonal deals and special campaigns.",
|
||||
"endsIn": "Ends in",
|
||||
"shopNow": "Shop Now"
|
||||
},
|
||||
"account": {
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"name": "Name",
|
||||
"phone": "Phone",
|
||||
"createAccount": "Create Account",
|
||||
"haveAccount": "Already have an account?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"guestCheckout": "Continue as Guest"
|
||||
},
|
||||
"cs": {
|
||||
"title": "Customer Support",
|
||||
"subtitle": "We're here to help — ask our AI assistant or reach out directly.",
|
||||
"askAi": "Ask AI Assistant",
|
||||
"placeholder": "Type your question…",
|
||||
"send": "Send",
|
||||
"contactUs": "Contact Us"
|
||||
},
|
||||
"app": {
|
||||
"title": "Get the Montvale App",
|
||||
"subtitle": "Scan the QR code to install on your phone.",
|
||||
"ios": "iOS",
|
||||
"android": "Android"
|
||||
},
|
||||
"admin": {
|
||||
"console": "Admin Console",
|
||||
"header": "US Multi-Store Florist Omnichannel E-Commerce · AI Demand Forecasting · BI",
|
||||
"onPremiseTag": "Ollama On-Premise · Omnichannel",
|
||||
"signOut": "Sign Out",
|
||||
"system": "System",
|
||||
"login": {
|
||||
"title": "GUARDiA Mall Console",
|
||||
"subtitle": "Store & Corporate Admin Sign In",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"signIn": "Sign In",
|
||||
"storefrontHere": "Customer storefront is",
|
||||
"here": "here",
|
||||
"errNoPriv": "You do not have admin privileges.",
|
||||
"errFailed": "Sign-in failed — please check your username and password."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"stores": "Stores",
|
||||
"products": "Products",
|
||||
"inventory": "Store Inventory",
|
||||
"orders": "Orders (Live)",
|
||||
"transfers": "Inventory Transfers",
|
||||
"members": "Members (CRM)",
|
||||
"loyalty": "Tier & Points",
|
||||
"events": "Events & Campaigns",
|
||||
"subscriptions": "Subscriptions",
|
||||
"schedule": "Schedule & Surge",
|
||||
"analytics": "Revenue Analytics (BI)",
|
||||
"users": "User Management",
|
||||
"audit": "Audit Log",
|
||||
"settings": "Gateway Settings",
|
||||
"appInstall": "Admin App Install"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"todaysRevenue": "Today's Revenue",
|
||||
"todaysOrders": "Today's Orders",
|
||||
"stores": "Stores",
|
||||
"storesSub": "Active store revenue ranking",
|
||||
"largeOrders": "Large Orders",
|
||||
"largeOrdersSub": "$500+ high-value orders",
|
||||
"revenueByStore": "Revenue by Store (Last 7 Days)",
|
||||
"revenueTrend": "Revenue Trend (14 Days)",
|
||||
"bestSellers": "Best Sellers",
|
||||
"storeRanking": "Store Revenue Ranking",
|
||||
"store": "Store",
|
||||
"revenue": "Revenue",
|
||||
"orders": "Orders",
|
||||
"unitsSold": "Units Sold"
|
||||
},
|
||||
"common": {
|
||||
"name": "Name",
|
||||
"code": "Code",
|
||||
"price": "Price",
|
||||
"stock": "Stock",
|
||||
"category": "Category",
|
||||
"store": "Store",
|
||||
"date": "Date",
|
||||
"amount": "Amount",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"search": "Search",
|
||||
"total": "Total",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
}
|
||||
}
|
||||
}
|
||||
45
frontend/src/i18n/index.ts
Normal file
45
frontend/src/i18n/index.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import en from './en.json'
|
||||
import ko from './ko.json'
|
||||
|
||||
export const SUPPORTED_LANGS = ['en', 'ko'] as const
|
||||
export type Lang = (typeof SUPPORTED_LANGS)[number]
|
||||
const STORAGE_KEY = 'mall_lang'
|
||||
|
||||
// 선택 언어에 맞춰 <html lang>과 한글 폰트 토글(.lang-ko)을 동기화
|
||||
export function applyLangSideEffects(lng: string) {
|
||||
const base = (lng || 'en').split('-')[0]
|
||||
const lang = (SUPPORTED_LANGS as readonly string[]).includes(base) ? base : 'en'
|
||||
const html = document.documentElement
|
||||
html.setAttribute('lang', lang)
|
||||
// 한국어 선택 시 본문 폰트를 맑은 고딕 우선으로(.lang-ko CSS 적용)
|
||||
html.classList.toggle('lang-ko', lang === 'ko')
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ko: { translation: ko },
|
||||
},
|
||||
fallbackLng: 'en', // 기본 언어 영어(US 시장)
|
||||
supportedLngs: SUPPORTED_LANGS as unknown as string[],
|
||||
nonExplicitSupportedLngs: true,
|
||||
load: 'languageOnly',
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
// localStorage에 선택 영속 → 없으면 브라우저 언어 감지
|
||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||
lookupLocalStorage: STORAGE_KEY,
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
})
|
||||
|
||||
applyLangSideEffects(i18n.language)
|
||||
i18n.on('languageChanged', applyLangSideEffects)
|
||||
|
||||
export default i18n
|
||||
264
frontend/src/i18n/ko.json
Normal file
264
frontend/src/i18n/ko.json
Normal file
@ -0,0 +1,264 @@
|
||||
{
|
||||
"common": {
|
||||
"search": "검색",
|
||||
"searchPlaceholder": "부케, 장미, 상황별로 검색하세요…",
|
||||
"loading": "불러오는 중…",
|
||||
"noData": "데이터 없음",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"close": "닫기",
|
||||
"edit": "수정",
|
||||
"delete": "삭제",
|
||||
"add": "추가",
|
||||
"create": "생성",
|
||||
"confirm": "확인",
|
||||
"submit": "제출",
|
||||
"back": "뒤로",
|
||||
"next": "다음",
|
||||
"continue": "계속",
|
||||
"apply": "적용",
|
||||
"viewAll": "전체 보기",
|
||||
"yes": "예",
|
||||
"no": "아니오",
|
||||
"actions": "작업",
|
||||
"status": "상태",
|
||||
"all": "전체",
|
||||
"language": "언어"
|
||||
},
|
||||
"shop": {
|
||||
"topbar": {
|
||||
"reviews": "리뷰 {{n}}개"
|
||||
},
|
||||
"nav": {
|
||||
"home": "홈",
|
||||
"shopAll": "전체 상품",
|
||||
"loveRomance": "사랑 & 로맨스",
|
||||
"birthday": "생일",
|
||||
"sympathy": "추모",
|
||||
"todaysBouquet": "오늘의 부케",
|
||||
"subscriptions": "정기 구독",
|
||||
"offers": "이벤트"
|
||||
},
|
||||
"header": {
|
||||
"zip": "우편번호",
|
||||
"wishlist": "찜",
|
||||
"cart": "장바구니",
|
||||
"account": "내 계정"
|
||||
},
|
||||
"footer": {
|
||||
"since": "{{year}}년부터",
|
||||
"visitUs": "매장 안내",
|
||||
"hours": "영업 시간",
|
||||
"customerCare": "고객 지원",
|
||||
"contactAiHelp": "문의 / AI 도움",
|
||||
"orderStatus": "주문 조회",
|
||||
"subscriptions": "정기 구독",
|
||||
"getApp": "앱 다운로드 (QR)",
|
||||
"weAccept": "결제 수단",
|
||||
"rights": "© 2026 Montvale Florist"
|
||||
}
|
||||
},
|
||||
"zip": {
|
||||
"since": "{{year}}년부터",
|
||||
"lead": "어디로 꽃을 보내실지 알려주시면 근처에서 가능한 상품을 보여드립니다.",
|
||||
"enterZip": "배송 우편번호 입력",
|
||||
"placeholder": "예: 07645",
|
||||
"go": "확인",
|
||||
"availableStores": "배송 가능 매장",
|
||||
"radiusSameDay": "반경 {{radius}}마일 · {{cutoff}}까지 당일 배송 ({{tz}})",
|
||||
"reviews": "리뷰 {{n}}개",
|
||||
"browsePickup": "매장 픽업으로 둘러보기 →",
|
||||
"errInvalid": "5자리 우편번호를 입력해 주세요.",
|
||||
"errNotDeliverable": "죄송합니다. 아직 해당 지역은 배송이 어렵습니다. 매장 픽업 또는 다른 우편번호를 시도해 보세요.",
|
||||
"errFailed": "조회에 실패했습니다. 잠시 후 다시 시도해 주세요."
|
||||
},
|
||||
"cart": {
|
||||
"title": "장바구니",
|
||||
"signInPrompt": "장바구니를 보려면 로그인해 주세요.",
|
||||
"signInRegister": "로그인 / 회원가입",
|
||||
"empty": "장바구니가 비어 있습니다.",
|
||||
"startShopping": "쇼핑 시작하기 →",
|
||||
"card": "카드",
|
||||
"subtotal": "상품 합계",
|
||||
"shippingTax": "배송비/세금",
|
||||
"calcAtCheckout": "결제 시 계산",
|
||||
"total": "총 결제금액",
|
||||
"checkout": "결제하기"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "결제",
|
||||
"recipient": "받는 분",
|
||||
"recipientName": "받는 분 성함",
|
||||
"deliveryAddress": "배송 주소",
|
||||
"deliveryDate": "배송 날짜",
|
||||
"timeSlot": "배송 시간대",
|
||||
"cardMessage": "카드 메시지",
|
||||
"cardMessagePlaceholder": "진심 어린 메시지를 적어 주세요…",
|
||||
"aiSuggest": "AI 카드 메시지",
|
||||
"payment": "결제 수단",
|
||||
"placeOrder": "주문하기",
|
||||
"orderSummary": "주문 요약",
|
||||
"contactPhone": "연락처",
|
||||
"sameDay": "당일 배송",
|
||||
"processing": "처리 중…",
|
||||
"success": "주문이 완료되었습니다!"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "꽃 정기 구독",
|
||||
"subtitle": "플로리스트가 디자인한 신선한 꽃을 원하는 주기로 받아보세요.",
|
||||
"weekly": "매주",
|
||||
"biweekly": "격주",
|
||||
"monthly": "매월",
|
||||
"subscribe": "구독하기",
|
||||
"manage": "구독 관리",
|
||||
"frequency": "배송 주기",
|
||||
"nextDelivery": "다음 배송"
|
||||
},
|
||||
"search": {
|
||||
"resultsFor": "\"{{query}}\" 검색 결과",
|
||||
"aiUnderstood": "AI가 이해한 내용:",
|
||||
"searching": "AI가 검색 중입니다…",
|
||||
"noResults": "검색 결과가 없습니다."
|
||||
},
|
||||
"wishlist": {
|
||||
"title": "찜한 상품",
|
||||
"empty": "찜한 상품이 없습니다.",
|
||||
"startShopping": "쇼핑 시작하기 →"
|
||||
},
|
||||
"mypage": {
|
||||
"title": "마이페이지",
|
||||
"hello": "안녕하세요",
|
||||
"myOrders": "내 주문",
|
||||
"mySubscriptions": "내 구독",
|
||||
"wishlist": "찜한 상품",
|
||||
"points": "포인트",
|
||||
"tier": "등급",
|
||||
"addresses": "배송지 관리",
|
||||
"signOut": "로그아웃",
|
||||
"memberSince": "가입일"
|
||||
},
|
||||
"product": {
|
||||
"addToCart": "장바구니 담기",
|
||||
"buyNow": "바로 구매",
|
||||
"size": "사이즈",
|
||||
"options": "옵션",
|
||||
"reviews": "리뷰",
|
||||
"description": "상품 설명",
|
||||
"writeReview": "리뷰 작성",
|
||||
"inStock": "재고 있음",
|
||||
"outOfStock": "품절",
|
||||
"share": "공유",
|
||||
"sizeStandard": "스탠다드",
|
||||
"sizeDeluxe": "디럭스",
|
||||
"sizePremium": "프리미엄"
|
||||
},
|
||||
"review": {
|
||||
"title": "리뷰 작성",
|
||||
"rating": "별점",
|
||||
"comment": "리뷰 내용",
|
||||
"commentPlaceholder": "이용 후기를 남겨 주세요…",
|
||||
"submit": "리뷰 등록",
|
||||
"thanks": "소중한 리뷰 감사합니다!"
|
||||
},
|
||||
"events": {
|
||||
"title": "이벤트 & 혜택",
|
||||
"subtitle": "시즌 특가와 특별 캠페인을 만나보세요.",
|
||||
"endsIn": "종료까지",
|
||||
"shopNow": "쇼핑하기"
|
||||
},
|
||||
"account": {
|
||||
"signIn": "로그인",
|
||||
"signUp": "회원가입",
|
||||
"email": "이메일",
|
||||
"password": "비밀번호",
|
||||
"name": "이름",
|
||||
"phone": "전화번호",
|
||||
"createAccount": "계정 만들기",
|
||||
"haveAccount": "이미 계정이 있으신가요?",
|
||||
"noAccount": "계정이 없으신가요?",
|
||||
"guestCheckout": "비회원으로 계속하기"
|
||||
},
|
||||
"cs": {
|
||||
"title": "고객 지원",
|
||||
"subtitle": "도와드리겠습니다 — AI 어시스턴트에게 묻거나 직접 문의하세요.",
|
||||
"askAi": "AI 어시스턴트에게 문의",
|
||||
"placeholder": "궁금한 점을 입력하세요…",
|
||||
"send": "보내기",
|
||||
"contactUs": "문의하기"
|
||||
},
|
||||
"app": {
|
||||
"title": "Montvale 앱 받기",
|
||||
"subtitle": "QR 코드를 스캔하여 휴대폰에 설치하세요.",
|
||||
"ios": "iOS",
|
||||
"android": "안드로이드"
|
||||
},
|
||||
"admin": {
|
||||
"console": "관리자 콘솔",
|
||||
"header": "미국 다지점 꽃집 옴니채널 e-커머스 · AI 수요 예측 · BI",
|
||||
"onPremiseTag": "Ollama 온프레미스 · 옴니채널",
|
||||
"signOut": "로그아웃",
|
||||
"system": "시스템",
|
||||
"login": {
|
||||
"title": "GUARDiA Mall 콘솔",
|
||||
"subtitle": "매장 및 본사 관리자 로그인",
|
||||
"username": "아이디",
|
||||
"password": "비밀번호",
|
||||
"signIn": "로그인",
|
||||
"storefrontHere": "고객 스토어프론트는",
|
||||
"here": "여기",
|
||||
"errNoPriv": "관리자 권한이 없습니다.",
|
||||
"errFailed": "로그인에 실패했습니다 — 아이디와 비밀번호를 확인해 주세요."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "대시보드",
|
||||
"stores": "매장 관리",
|
||||
"products": "상품 관리",
|
||||
"inventory": "매장 재고",
|
||||
"orders": "주문 (실시간)",
|
||||
"transfers": "재고 이양",
|
||||
"members": "회원 (CRM)",
|
||||
"loyalty": "등급 & 포인트",
|
||||
"events": "이벤트 & 캠페인",
|
||||
"subscriptions": "정기 구독",
|
||||
"schedule": "스케줄 & 서지",
|
||||
"analytics": "매출 분석 (BI)",
|
||||
"users": "사용자 관리",
|
||||
"audit": "감사 로그",
|
||||
"settings": "게이트웨이 설정",
|
||||
"appInstall": "관리자 앱 설치"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "대시보드",
|
||||
"todaysRevenue": "오늘의 매출",
|
||||
"todaysOrders": "오늘의 주문",
|
||||
"stores": "매장 수",
|
||||
"storesSub": "활성 매장 매출 순위",
|
||||
"largeOrders": "대형 주문",
|
||||
"largeOrdersSub": "$500 이상 고액 주문",
|
||||
"revenueByStore": "매장별 매출 (최근 7일)",
|
||||
"revenueTrend": "매출 추이 (14일)",
|
||||
"bestSellers": "베스트셀러",
|
||||
"storeRanking": "매장 매출 순위",
|
||||
"store": "매장",
|
||||
"revenue": "매출",
|
||||
"orders": "주문",
|
||||
"unitsSold": "판매 수량"
|
||||
},
|
||||
"common": {
|
||||
"name": "이름",
|
||||
"code": "코드",
|
||||
"price": "가격",
|
||||
"stock": "재고",
|
||||
"category": "카테고리",
|
||||
"store": "매장",
|
||||
"date": "날짜",
|
||||
"amount": "금액",
|
||||
"status": "상태",
|
||||
"actions": "작업",
|
||||
"search": "검색",
|
||||
"total": "합계",
|
||||
"active": "활성",
|
||||
"inactive": "비활성"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,37 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── 한국어 선택 시: 한글 본문은 맑은 고딕 우선, 영문은 Inter 폴백 ──
|
||||
<html class="lang-ko"> 를 i18n이 토글한다(applyLangSideEffects). */
|
||||
html.lang-ko body,
|
||||
html.lang-ko .admin-shell,
|
||||
html.lang-ko input,
|
||||
html.lang-ko textarea,
|
||||
html.lang-ko select,
|
||||
html.lang-ko button,
|
||||
html.lang-ko p,
|
||||
html.lang-ko span,
|
||||
html.lang-ko a,
|
||||
html.lang-ko li,
|
||||
html.lang-ko td,
|
||||
html.lang-ko th,
|
||||
html.lang-ko label,
|
||||
html.lang-ko h1,
|
||||
html.lang-ko h2,
|
||||
html.lang-ko h3,
|
||||
html.lang-ko h4 {
|
||||
font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', 'Inter',
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
/* 영문 헤드라인(Playfair/Cormorant)은 한국어 모드에서도 유지하되,
|
||||
한글이 섞이면 맑은 고딕으로 자연 폴백되도록 폰트 스택을 합성 */
|
||||
html.lang-ko .font-display {
|
||||
font-family: 'Cormorant Garamond', 'Malgun Gothic', '맑은 고딕', Georgia, serif;
|
||||
}
|
||||
html.lang-ko .font-serif {
|
||||
font-family: 'Playfair Display', 'Malgun Gothic', '맑은 고딕', Georgia, serif;
|
||||
}
|
||||
|
||||
/* 관리자 영역(.admin-shell): NCloud 다크 (변경 금지) */
|
||||
.admin-shell {
|
||||
color-scheme: dark;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './i18n'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Trash2, Flower2, ShoppingCart } from 'lucide-react'
|
||||
import { getCart, updateCartItem, removeCartItem } from '../api/client'
|
||||
import { money, useShop } from '../store/shop'
|
||||
|
||||
export default function Cart() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const nav = useNavigate()
|
||||
const { custToken, setCartCount } = useShop()
|
||||
@ -17,8 +19,8 @@ export default function Cart() {
|
||||
if (!custToken) return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
|
||||
<ShoppingCart className="mx-auto text-bloom/40 mb-3" size={48} />
|
||||
<p className="text-gray-500 mb-4">Please sign in to view your cart.</p>
|
||||
<Link to="/account" className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold">Sign In / Register</Link>
|
||||
<p className="text-gray-500 mb-4">{t('cart.signInPrompt')}</p>
|
||||
<Link to="/account" className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold">{t('cart.signInRegister')}</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -27,9 +29,9 @@ export default function Cart() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="font-serif text-2xl font-bold mb-6">Cart</h1>
|
||||
<h1 className="font-serif text-2xl font-bold mb-6">{t('cart.title')}</h1>
|
||||
{!list.length ? (
|
||||
<div className="text-center text-gray-400 py-16">Your cart is empty. <Link to="/category" className="text-bloom">Start Shopping →</Link></div>
|
||||
<div className="text-center text-gray-400 py-16">{t('cart.empty')} <Link to="/category" className="text-bloom">{t('cart.startShopping')}</Link></div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2 space-y-3">
|
||||
@ -40,7 +42,7 @@ export default function Cart() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{i.productName || `Product #${i.productId}`}</div>
|
||||
<div className="text-xs text-gray-500">{i.sizeCode}{i.cardMessage ? ` · Card: ${i.cardMessage.slice(0, 20)}` : ''}</div>
|
||||
<div className="text-xs text-gray-500">{i.sizeCode}{i.cardMessage ? ` · ${t('cart.card')}: ${i.cardMessage.slice(0, 20)}` : ''}</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="flex items-center border border-blush-100 rounded-full text-sm">
|
||||
<button onClick={() => setQty(i.id, (i.quantity || 1) - 1)} className="px-2.5 py-1 text-bloom2">−</button>
|
||||
@ -55,10 +57,10 @@ export default function Cart() {
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-blush-100/60 p-5 h-fit">
|
||||
<div className="flex justify-between text-sm mb-2"><span className="text-gray-500">Subtotal</span><span className="font-medium">{money(subtotal)}</span></div>
|
||||
<div className="flex justify-between text-sm mb-3"><span className="text-gray-500">Shipping/Tax</span><span className="text-gray-400">Calculated at checkout</span></div>
|
||||
<div className="border-t border-blush-100/60 pt-3 flex justify-between font-bold"><span>Total</span><span className="text-bloom2">{money(subtotal)}</span></div>
|
||||
<button onClick={() => nav('/checkout')} className="w-full mt-4 bg-bloom text-white font-semibold py-3 rounded-full hover:bg-bloom2">Checkout</button>
|
||||
<div className="flex justify-between text-sm mb-2"><span className="text-gray-500">{t('cart.subtotal')}</span><span className="font-medium">{money(subtotal)}</span></div>
|
||||
<div className="flex justify-between text-sm mb-3"><span className="text-gray-500">{t('cart.shippingTax')}</span><span className="text-gray-400">{t('cart.calcAtCheckout')}</span></div>
|
||||
<div className="border-t border-blush-100/60 pt-3 flex justify-between font-bold"><span>{t('cart.total')}</span><span className="text-bloom2">{money(subtotal)}</span></div>
|
||||
<button onClick={() => nav('/checkout')} className="w-full mt-4 bg-bloom text-white font-semibold py-3 rounded-full hover:bg-bloom2">{t('cart.checkout')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { aiNlSearch } from '../api/client'
|
||||
import ProductCard from './components/ProductCard'
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation()
|
||||
const [sp] = useSearchParams()
|
||||
const q = sp.get('q') || ''
|
||||
const { data, isLoading } = useQuery({
|
||||
@ -18,16 +20,16 @@ export default function Search() {
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="text-bloom" size={20} />
|
||||
<h1 className="font-serif text-2xl font-bold">Search Results for "{q}"</h1>
|
||||
<h1 className="font-serif text-2xl font-bold">{t('search.resultsFor', { query: q })}</h1>
|
||||
</div>
|
||||
{data?.parsed && (
|
||||
<div className="text-xs text-bloom2 mb-5">AI understood: <span className="font-medium">{JSON.stringify(data.parsed)}</span> · {data.source}</div>
|
||||
<div className="text-xs text-bloom2 mb-5">{t('search.aiUnderstood')} <span className="font-medium">{JSON.stringify(data.parsed)}</span> · {data.source}</div>
|
||||
)}
|
||||
{isLoading && <div className="text-gray-400 py-10 text-center">AI is searching…</div>}
|
||||
{isLoading && <div className="text-gray-400 py-10 text-center">{t('search.searching')}</div>}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{items.map((p: any) => <ProductCard key={p.id} p={p} />)}
|
||||
</div>
|
||||
{!isLoading && !items.length && <div className="text-center text-gray-400 py-16">No results found.</div>}
|
||||
{!isLoading && !items.length && <div className="text-center text-gray-400 py-16">{t('search.noResults')}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,24 +5,27 @@ import {
|
||||
Flower2, MapPin, ShoppingCart, Heart, User, Search, Star, Phone,
|
||||
Clock, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShop } from '../store/shop'
|
||||
import { getCart } from '../api/client'
|
||||
import { BRAND } from './brand'
|
||||
import { SocialLinks } from './components/social'
|
||||
import { PaymentBadges } from './components/payments'
|
||||
import LanguageSwitcher from '../i18n/LanguageSwitcher'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/home', label: 'Home' },
|
||||
{ to: '/category', label: 'Shop All' },
|
||||
{ to: '/category?occasion=ROMANCE', label: 'Love & Romance' },
|
||||
{ to: '/category?occasion=BIRTHDAY', label: 'Birthday' },
|
||||
{ to: '/category?occasion=SYMPATHY', label: 'Sympathy' },
|
||||
{ to: '/daily-standard', label: "Today's Bouquet", accent: true },
|
||||
{ to: '/subscription', label: 'Subscriptions' },
|
||||
{ to: '/events', label: 'Offers' },
|
||||
{ to: '/home', key: 'home' },
|
||||
{ to: '/category', key: 'shopAll' },
|
||||
{ to: '/category?occasion=ROMANCE', key: 'loveRomance' },
|
||||
{ to: '/category?occasion=BIRTHDAY', key: 'birthday' },
|
||||
{ to: '/category?occasion=SYMPATHY', key: 'sympathy' },
|
||||
{ to: '/daily-standard', key: 'todaysBouquet', accent: true },
|
||||
{ to: '/subscription', key: 'subscriptions' },
|
||||
{ to: '/events', key: 'offers' },
|
||||
]
|
||||
|
||||
export default function ShopLayout() {
|
||||
const { t } = useTranslation()
|
||||
const { zip, storeName, custToken, cartCount, setCartCount } = useShop()
|
||||
const nav = useNavigate()
|
||||
const loc = useLocation()
|
||||
@ -46,7 +49,7 @@ export default function ShopLayout() {
|
||||
</span>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star size={12} className="text-gold" fill="currentColor" /> {BRAND.rating}★ · {BRAND.reviewCount.toLocaleString()} reviews
|
||||
<Star size={12} className="text-gold" fill="currentColor" /> {BRAND.rating}★ · {t('shop.topbar.reviews', { n: BRAND.reviewCount.toLocaleString() })}
|
||||
</span>
|
||||
<a href={`tel:${BRAND.phoneTel}`} className="hidden sm:flex items-center gap-1 hover:text-white">
|
||||
<Phone size={12} /> {BRAND.phone}
|
||||
@ -73,15 +76,16 @@ export default function ShopLayout() {
|
||||
|
||||
<form onSubmit={doSearch} className="flex-1 max-w-md hidden md:flex items-center bg-white border border-blush-100 rounded-full px-4 py-2.5 shadow-soft">
|
||||
<Search size={16} className="text-blush-400" />
|
||||
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search bouquets, roses, occasions…"
|
||||
<input value={q} onChange={e => setQ(e.target.value)} placeholder={t('common.searchPlaceholder')}
|
||||
className="flex-1 bg-transparent ml-2 text-sm outline-none placeholder:text-blush-300" />
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-3 ml-auto">
|
||||
<LanguageSwitcher variant="shop" />
|
||||
<Link to="/" className="hidden sm:flex items-center gap-1 text-sm text-sage-700 hover:text-blush-500 transition-colors">
|
||||
<MapPin size={15} /> {zip ? `${zip}` : 'ZIP'}
|
||||
<MapPin size={15} /> {zip ? `${zip}` : t('shop.header.zip')}
|
||||
</Link>
|
||||
<Link to="/wishlist" className="p-1.5 text-blush-700 hover:text-blush-500 transition-colors"><Heart size={20} /></Link>
|
||||
<Link to="/wishlist" aria-label={t('shop.header.wishlist')} className="p-1.5 text-blush-700 hover:text-blush-500 transition-colors"><Heart size={20} /></Link>
|
||||
<Link to="/cart" className="relative p-1.5 text-blush-700 hover:text-blush-500 transition-colors">
|
||||
<ShoppingCart size={20} />
|
||||
<AnimatePresence>
|
||||
@ -109,7 +113,7 @@ export default function ShopLayout() {
|
||||
className={`relative whitespace-nowrap py-1 transition-colors ${
|
||||
n.accent ? 'text-sage-700 font-medium' : 'text-[#6b5258] hover:text-blush-500'
|
||||
} ${active ? 'text-blush-600' : ''}`}>
|
||||
{n.label}
|
||||
{t(`shop.nav.${n.key}`)}
|
||||
{active && <motion.span layoutId="nav-underline" className="absolute -bottom-[1px] left-0 right-0 h-[2px] bg-blush-500 rounded-full" />}
|
||||
</Link>
|
||||
)
|
||||
@ -128,20 +132,20 @@ export default function ShopLayout() {
|
||||
<div className="max-w-6xl mx-auto px-4 pb-10 grid md:grid-cols-4 gap-8">
|
||||
<div className="md:col-span-1">
|
||||
<div className="font-serif text-xl font-bold text-white mb-1">Montvale Florist</div>
|
||||
<p className="font-display text-[13px] tracking-[0.3em] uppercase text-sage-300 mb-3">Since {BRAND.founded}</p>
|
||||
<p className="font-display text-[13px] tracking-[0.3em] uppercase text-sage-300 mb-3">{t('shop.footer.since', { year: BRAND.founded })}</p>
|
||||
<p className="text-[13px] leading-relaxed text-cream/70">{BRAND.tagline}</p>
|
||||
<SocialLinks size={18} className="mt-4 text-cream/80" iconClass="hover:text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-semibold text-white mb-3 text-sm">Visit Us</div>
|
||||
<div className="font-semibold text-white mb-3 text-sm">{t('shop.footer.visitUs')}</div>
|
||||
<p className="text-[13px] flex items-start gap-1.5 text-cream/75 mb-1.5"><MapPin size={14} className="mt-0.5 shrink-0" /> {BRAND.address}</p>
|
||||
<a href={`tel:${BRAND.phoneTel}`} className="text-[13px] flex items-center gap-1.5 text-cream/75 hover:text-white mb-1.5"><Phone size={14} /> {BRAND.phone}</a>
|
||||
<p className="text-[13px] text-cream/60">{BRAND.email}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-semibold text-white mb-3 text-sm flex items-center gap-1.5"><Clock size={14} /> Hours</div>
|
||||
<div className="font-semibold text-white mb-3 text-sm flex items-center gap-1.5"><Clock size={14} /> {t('shop.footer.hours')}</div>
|
||||
{BRAND.hours.map(h => (
|
||||
<div key={h.day} className="text-[13px] text-cream/75 mb-1">
|
||||
<span className="inline-block w-20">{h.day}</span> {h.open}
|
||||
@ -150,12 +154,12 @@ export default function ShopLayout() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-semibold text-white mb-3 text-sm">Customer Care</div>
|
||||
<Link to="/cs" className="flex items-center gap-1 text-[13px] text-cream/75 hover:text-white mb-1.5"><ChevronRight size={13} /> Contact / AI Help</Link>
|
||||
<Link to="/orders" className="flex items-center gap-1 text-[13px] text-cream/75 hover:text-white mb-1.5"><ChevronRight size={13} /> Order Status</Link>
|
||||
<Link to="/subscription" className="flex items-center gap-1 text-[13px] text-cream/75 hover:text-white mb-1.5"><ChevronRight size={13} /> Subscriptions</Link>
|
||||
<Link to="/app" className="flex items-center gap-1 text-[13px] text-gold hover:text-white mb-3 font-medium"><ChevronRight size={13} /> Get the App (QR)</Link>
|
||||
<div className="text-[11px] text-cream/50 mb-1.5">We accept</div>
|
||||
<div className="font-semibold text-white mb-3 text-sm">{t('shop.footer.customerCare')}</div>
|
||||
<Link to="/cs" className="flex items-center gap-1 text-[13px] text-cream/75 hover:text-white mb-1.5"><ChevronRight size={13} /> {t('shop.footer.contactAiHelp')}</Link>
|
||||
<Link to="/orders" className="flex items-center gap-1 text-[13px] text-cream/75 hover:text-white mb-1.5"><ChevronRight size={13} /> {t('shop.footer.orderStatus')}</Link>
|
||||
<Link to="/subscription" className="flex items-center gap-1 text-[13px] text-cream/75 hover:text-white mb-1.5"><ChevronRight size={13} /> {t('shop.footer.subscriptions')}</Link>
|
||||
<Link to="/app" className="flex items-center gap-1 text-[13px] text-gold hover:text-white mb-3 font-medium"><ChevronRight size={13} /> {t('shop.footer.getApp')}</Link>
|
||||
<div className="text-[11px] text-cream/50 mb-1.5">{t('shop.footer.weAccept')}</div>
|
||||
<PaymentBadges items={BRAND.payments} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { getWishlist, removeWishlist } from '../api/client'
|
||||
@ -6,6 +7,7 @@ import ProductCard from './components/ProductCard'
|
||||
import { useShop } from '../store/shop'
|
||||
|
||||
export default function Wishlist() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const nav = useNavigate()
|
||||
const { custToken } = useShop()
|
||||
@ -18,9 +20,9 @@ export default function Wishlist() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center gap-2 mb-6"><Heart className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900">Wishlist</h1></div>
|
||||
<div className="flex items-center gap-2 mb-6"><Heart className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900">{t('wishlist.title')}</h1></div>
|
||||
{!list.length ? (
|
||||
<div className="text-center text-gray-400 py-16">Your wishlist is empty. <Link to="/category" className="text-bloom">Start Shopping →</Link></div>
|
||||
<div className="text-center text-gray-400 py-16">{t('wishlist.empty')} <Link to="/category" className="text-bloom">{t('wishlist.startShopping')}</Link></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{list.map((p: any) => (
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Flower2, MapPin, Truck, Store as StoreIcon, Star, ArrowRight } from 'lucide-react'
|
||||
import { zoneLookup } from '../api/client'
|
||||
import { useShop } from '../store/shop'
|
||||
import { PetalLayer, Pressable } from './components/motion'
|
||||
import { BRAND } from './brand'
|
||||
import LanguageSwitcher from '../i18n/LanguageSwitcher'
|
||||
|
||||
// Floom 패턴: 진입 즉시 ZIP 입력 → 배송 가능 매장 필터링
|
||||
export default function ZipGate() {
|
||||
const { t } = useTranslation()
|
||||
const [zip, setZip] = useState('')
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -18,13 +21,13 @@ export default function ZipGate() {
|
||||
|
||||
const lookup = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setErr(''); setResult(null)
|
||||
if (!/^\d{5}$/.test(zip)) { setErr('Please enter a 5-digit ZIP code.'); return }
|
||||
if (!/^\d{5}$/.test(zip)) { setErr(t('zip.errInvalid')); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await zoneLookup(zip)
|
||||
setResult(r)
|
||||
if (!r?.deliverable) setErr('Sorry, we don\'t deliver to that area yet. Try store pickup or another ZIP.')
|
||||
} catch { setErr('Lookup failed. Please try again in a moment.') }
|
||||
if (!r?.deliverable) setErr(t('zip.errNotDeliverable'))
|
||||
} catch { setErr(t('zip.errFailed')) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
@ -32,6 +35,7 @@ export default function ZipGate() {
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-gradient-to-br from-blush-100 via-cream to-sage-100 flex items-center justify-center px-4 overflow-hidden">
|
||||
<div className="absolute top-5 right-5 z-20"><LanguageSwitcher variant="shop" /></div>
|
||||
<PetalLayer count={18} />
|
||||
|
||||
{/* 배경 보태니컬 글로우 */}
|
||||
@ -52,35 +56,35 @@ export default function ZipGate() {
|
||||
<Flower2 className="text-blush-500" size={44} />
|
||||
</motion.span>
|
||||
<h1 className="font-serif text-4xl font-bold text-blush-900 mt-3">Montvale Florist</h1>
|
||||
<p className="font-display text-[13px] tracking-[0.35em] uppercase text-sage-600 mt-1">Since {BRAND.founded}</p>
|
||||
<p className="font-display text-[13px] tracking-[0.35em] uppercase text-sage-600 mt-1">{t('zip.since', { year: BRAND.founded })}</p>
|
||||
</div>
|
||||
|
||||
<p className="font-display text-2xl text-[#6b5258] mb-1">{BRAND.tagline}</p>
|
||||
<p className="text-[#8a7077] text-sm mb-8">Tell us where you're sending flowers — we'll show what's available nearby.</p>
|
||||
<p className="text-[#8a7077] text-sm mb-8">{t('zip.lead')}</p>
|
||||
|
||||
<form onSubmit={lookup} className="bg-white/90 backdrop-blur rounded-4xl shadow-bloom p-7 border border-blush-100">
|
||||
<label className="flex items-center gap-2 text-sm text-blush-700 font-medium mb-3 justify-center"><MapPin size={16} /> Enter Delivery ZIP Code</label>
|
||||
<label className="flex items-center gap-2 text-sm text-blush-700 font-medium mb-3 justify-center"><MapPin size={16} /> {t('zip.enterZip')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={zip} onChange={e => setZip(e.target.value.replace(/\D/g, '').slice(0, 5))}
|
||||
placeholder="e.g. 07645" inputMode="numeric" autoFocus
|
||||
placeholder={t('zip.placeholder')} inputMode="numeric" autoFocus
|
||||
className="flex-1 px-4 py-3.5 rounded-2xl bg-blush-50 border border-blush-100 text-center text-lg tracking-[0.3em] outline-none focus:border-blush-400 transition-colors" />
|
||||
<Pressable type="submit" disabled={loading}
|
||||
className="px-7 rounded-2xl bg-blush-500 text-white font-semibold hover:bg-blush-600 disabled:opacity-60 flex items-center gap-1">
|
||||
{loading ? '…' : <>Go <ArrowRight size={16} /></>}
|
||||
{loading ? '…' : <>{t('zip.go')} <ArrowRight size={16} /></>}
|
||||
</Pressable>
|
||||
</div>
|
||||
{err && <p className="text-blush-500 text-xs mt-3">{err}</p>}
|
||||
|
||||
{result?.deliverable && (
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="mt-6 text-left overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 text-sm text-sage-700 font-medium mb-3"><Truck size={15} /> Available stores</div>
|
||||
<div className="flex items-center gap-1.5 text-sm text-sage-700 font-medium mb-3"><Truck size={15} /> {t('zip.availableStores')}</div>
|
||||
<div className="space-y-2">
|
||||
{(result.stores || []).map((s: any) => (
|
||||
<Pressable key={s.storeId} onClick={() => choose(s)}
|
||||
className="w-full flex items-center justify-between bg-blush-50 hover:bg-blush-100 border border-blush-100 rounded-2xl px-4 py-3.5 text-left">
|
||||
<span>
|
||||
<span className="font-medium text-sm flex items-center gap-1.5 text-blush-900"><StoreIcon size={14} className="text-blush-500" />{s.storeName}</span>
|
||||
<span className="block text-xs text-[#8a7077] mt-0.5">{s.radiusMi}mi radius · same-day by {s.sameDayCutoff} ({s.timezone})</span>
|
||||
<span className="block text-xs text-[#8a7077] mt-0.5">{t('zip.radiusSameDay', { radius: s.radiusMi, cutoff: s.sameDayCutoff, tz: s.timezone })}</span>
|
||||
</span>
|
||||
<ArrowRight size={16} className="text-blush-500" />
|
||||
</Pressable>
|
||||
@ -92,9 +96,9 @@ export default function ZipGate() {
|
||||
|
||||
<div className="flex items-center justify-center gap-2 mt-6 text-[12px] text-[#8a7077]">
|
||||
<span className="flex text-gold">{[1, 2, 3, 4, 5].map(i => <Star key={i} size={13} fill="currentColor" />)}</span>
|
||||
{BRAND.rating}★ · {BRAND.reviewCount.toLocaleString()} reviews
|
||||
{BRAND.rating}★ · {t('zip.reviews', { n: BRAND.reviewCount.toLocaleString() })}
|
||||
</div>
|
||||
<button onClick={() => nav('/home')} className="text-xs text-[#a08a90] hover:text-blush-600 mt-4 underline-offset-2 hover:underline">Browse with store pickup →</button>
|
||||
<button onClick={() => nav('/home')} className="text-xs text-[#a08a90] hover:text-blush-600 mt-4 underline-offset-2 hover:underline">{t('zip.browsePickup')}</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user