i18n: ko/en 다국어 + 한글 맑은 고딕

This commit is contained in:
GUARDiA 2026-06-14 00:01:20 +09:00
parent ac3aa6baf6
commit e6d0eaecaf
21 changed files with 1369 additions and 571 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -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",

View File

@ -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"
},

View File

@ -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">

View File

@ -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>
)

View File

@ -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>

View 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
View 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"
}
}
}

View 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
View 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": "비활성"
}
}
}

View File

@ -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;

View File

@ -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(

View File

@ -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>
)}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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) => (

View File

@ -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>
)