fix(security): 고객 SPA 딥링크(/app·/cart·/events 등) 403 해결 — 비-API GET permitAll(SPA 셸), 인증 API는 보호 유지

This commit is contained in:
GUARDiA 2026-06-13 23:42:45 +09:00
parent 736b479239
commit ac3aa6baf6
28 changed files with 313 additions and 308 deletions

View File

@ -81,6 +81,11 @@ public class SecurityConfig {
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
// mall API (장바구니·주문·결제·배송··CS·회원·AI) 인증 사용자 // mall API (장바구니·주문·결제·배송··CS·회원·AI) 인증 사용자
.requestMatchers("/api/mall/**").authenticated() .requestMatchers("/api/mall/**").authenticated()
// 나머지 모든 API/WS/Actuator는 인증 (아래 SPA permit 보다 먼저 API 노출 방지)
.requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated()
// 스토어프론트 SPA 딥링크(/app·/cart·/events·/category·/product·/checkout·/mypage·/orders·/search )
// 정적 index.html 포워드(공개). 실제 인증은 클라이언트 ProtectedRoute + API 규칙에서 강제.
.requestMatchers(HttpMethod.GET, "/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

View File

@ -22,7 +22,7 @@ export default function AdminApp() {
setLoading(true); setErr('') setLoading(true); setErr('')
fetch(`${ITSM_APP_BASE}/api/app/public-latest`) fetch(`${ITSM_APP_BASE}/api/app/public-latest`)
.then(r => r.json()).then((d: AppInfo) => setInfo({ ...d, qr_url: fixUrl(d.qr_url), landing_url: fixUrl(d.landing_url), download_url: fixUrl(d.download_url) })) .then(r => r.json()).then((d: AppInfo) => setInfo({ ...d, qr_url: fixUrl(d.qr_url), landing_url: fixUrl(d.landing_url), download_url: fixUrl(d.download_url) }))
.catch(() => setErr('중앙 앱 저장소(ITSM)에 연결할 수 없습니다.')) .catch(() => setErr('Unable to connect to the central app repository (ITSM).'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
@ -35,36 +35,36 @@ export default function AdminApp() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><Smartphone className="text-brand" size={22} /><h1 className="text-xl font-bold"> </h1></div> <div className="flex items-center gap-2"><Smartphone className="text-brand" size={22} /><h1 className="text-xl font-bold">Admin App Install</h1></div>
<button onClick={load} className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-slate-300 hover:text-brand"><RefreshCw size={15} /> </button> <button onClick={load} className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-slate-300 hover:text-brand"><RefreshCw size={15} /> Refresh</button>
</div> </div>
<p className="text-sm text-slate-400 mb-6">/ . QR을 . · <span className="text-brand">GUARDiA Manager</span> ().</p> <p className="text-sm text-slate-400 mb-6">Mobile app for store and corporate admins. Scan the QR code to open the install page. App uploads and version management are centralized in <span className="text-brand">GUARDiA Manager</span> (read-only).</p>
{loading && <div className="text-slate-400 text-sm"> </div>} {loading && <div className="text-slate-400 text-sm">Loading</div>}
{err && <div className="bg-card border border-edge rounded-xl p-6 text-rose-400 text-sm">{err}</div>} {err && <div className="bg-card border border-edge rounded-xl p-6 text-rose-400 text-sm">{err}</div>}
{!loading && !err && info && !info.has_version && ( {!loading && !err && info && !info.has_version && (
<div className="bg-card border border-edge rounded-xl p-8 text-center text-slate-400"> <div className="bg-card border border-edge rounded-xl p-8 text-center text-slate-400">
<Smartphone size={36} className="mx-auto mb-3 text-slate-600" /> <Smartphone size={36} className="mx-auto mb-3 text-slate-600" />
.<br /><span className="text-xs">GUARDiA Manager에서 APK를 QR이 .</span> No app version has been registered yet.<br /><span className="text-xs">Once an APK is uploaded in GUARDiA Manager, the QR code will appear here.</span>
</div> </div>
)} )}
{!loading && !err && info?.has_version && ( {!loading && !err && info?.has_version && (
<div className="bg-card border border-edge rounded-xl p-6 grid md:grid-cols-[200px_1fr] gap-6 items-start"> <div className="bg-card border border-edge rounded-xl p-6 grid md:grid-cols-[200px_1fr] gap-6 items-start">
<div className="bg-white rounded-xl p-3 flex items-center justify-center"> <div className="bg-white rounded-xl p-3 flex items-center justify-center">
{info.qr_url ? <img src={info.qr_url} alt="관리자앱 QR" className="w-44 h-44" /> : <Smartphone size={64} className="text-slate-400" />} {info.qr_url ? <img src={info.qr_url} alt="Admin App QR" className="w-44 h-44" /> : <Smartphone size={64} className="text-slate-400" />}
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"><span className="text-lg font-bold">{info.app_name || 'GUARDiA Mall Admin'}</span><span className="px-2 py-0.5 rounded-md bg-brand2 text-white text-xs font-semibold">v{info.version}</span></div> <div className="flex items-center gap-2 mb-1"><span className="text-lg font-bold">{info.app_name || 'GUARDiA Mall Admin'}</span><span className="px-2 py-0.5 rounded-md bg-brand2 text-white text-xs font-semibold">v{info.version}</span></div>
<div className="text-xs text-slate-400 mb-4">{info.platform} {info.file_size_mb ? `· ${info.file_size_mb}MB` : ''}{info.download_count != null && ` · 다운로드 ${info.download_count}`}</div> <div className="text-xs text-slate-400 mb-4">{info.platform} {info.file_size_mb ? `· ${info.file_size_mb}MB` : ''}{info.download_count != null && ` · ${info.download_count} downloads`}</div>
{info.release_notes && ( {info.release_notes && (
<div className="mb-4"><div className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider mb-1"></div><div className="text-sm text-slate-300 whitespace-pre-line bg-ink border border-edge rounded-lg p-3 max-h-32 overflow-auto">{info.release_notes}</div></div> <div className="mb-4"><div className="text-[11px] font-semibold text-slate-500 uppercase tracking-wider mb-1">Release Notes</div><div className="text-sm text-slate-300 whitespace-pre-line bg-ink border border-edge rounded-lg p-3 max-h-32 overflow-auto">{info.release_notes}</div></div>
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{info.landing_url && <a href={info.landing_url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand text-ink text-sm font-semibold"><ExternalLink size={15} /> </a>} {info.landing_url && <a href={info.landing_url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand text-ink text-sm font-semibold"><ExternalLink size={15} /> Install Page</a>}
{info.download_url && <a href={info.download_url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-slate-300 hover:text-brand"><Download size={15} /> APK </a>} {info.download_url && <a href={info.download_url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-slate-300 hover:text-brand"><Download size={15} /> Download APK</a>}
<button onClick={copyLink} className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-slate-300 hover:text-brand">{copied ? <Check size={15} className="text-accent" /> : <Copy size={15} />}{copied ? '복사됨' : '링크 복사'}</button> <button onClick={copyLink} className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-edge text-sm text-slate-300 hover:text-brand">{copied ? <Check size={15} className="text-accent" /> : <Copy size={15} />}{copied ? 'Copied' : 'Copy Link'}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,24 +7,24 @@ import {
import { getMe } from '../api/client' import { getMe } from '../api/client'
const links = [ const links = [
{ to: '/admin/dashboard', label: '대시보드', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/stores', label: '매장 관리', icon: Store, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/stores', label: 'Stores', icon: Store, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/products', label: '상품 관리', icon: Flower2, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/products', label: 'Products', icon: Flower2, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/inventory', label: '매장별 재고', icon: Boxes, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/inventory', label: 'Store Inventory', icon: Boxes, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/orders', label: '주문 (라이브)', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/orders', label: 'Orders (Live)', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/transfers', label: '재고 이양', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/transfers', label: 'Inventory Transfers', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/members', label: '회원 (CRM)', icon: Users, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/members', label: 'Members (CRM)', icon: Users, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/loyalty', label: '등급·포인트', icon: Crown, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/loyalty', label: 'Tier & Points', icon: Crown, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/events', label: '이벤트·캠페인', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/events', label: 'Events & Campaigns', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/subscriptions', label: '구독', icon: Repeat, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/subscriptions', label: 'Subscriptions', icon: Repeat, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/schedule', label: '스케줄·서지', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/schedule', label: 'Schedule & Surge', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/analytics', label: '매출 분석 (BI)', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/analytics', label: 'Revenue Analytics (BI)', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] },
] ]
const adminLinks = [ const adminLinks = [
{ to: '/admin/users', label: '사용자/권한', icon: UserCog, roles: ['ADMIN'] }, { to: '/admin/users', label: 'User Management', icon: UserCog, roles: ['ADMIN'] },
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/audit', label: 'Audit Log', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/settings', label: '게이트웨이 설정', icon: Settings, roles: ['ADMIN'] }, { to: '/admin/settings', label: 'Gateway Settings', icon: Settings, roles: ['ADMIN'] },
{ to: '/admin/app', label: '관리자앱 설치', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] }, { to: '/admin/app', label: 'Admin App Install', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] },
] ]
const linkClass = ({ isActive }: { isActive: boolean }) => 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'}` `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'}`
@ -49,7 +49,7 @@ export default function AdminLayout() {
}, [token]) }, [token])
if (!token) return <Navigate to="/admin/login" replace /> if (!token) return <Navigate to="/admin/login" replace />
// USER 역할은 관리자 영역 접근 차단 // Block USER role from accessing the admin area
if (role && role === 'USER') return <Navigate to="/admin/login" replace /> if (role && role === 'USER') return <Navigate to="/admin/login" replace />
const visible = links.filter(l => !role || l.roles.includes(role)) const visible = links.filter(l => !role || l.roles.includes(role))
@ -65,23 +65,23 @@ export default function AdminLayout() {
<aside className="w-60 bg-panel border-r border-edge flex flex-col"> <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"> <div className="h-16 flex items-center gap-2 px-5 border-b border-edge">
<Flower2 className="text-brand" size={22} /> <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"> </div></div> <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>
<nav className="flex-1 py-2 overflow-auto"> <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, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
{visibleAdmin.length > 0 && (<> {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"></div> <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>)} {visibleAdmin.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)} </>)}
</nav> </nav>
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">Ollama · </div> <div className="p-4 text-[11px] text-slate-500 border-t border-edge">Ollama On-Premise · Omnichannel</div>
</aside> </aside>
<div className="flex-1 flex flex-col overflow-hidden"> <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"> <header className="h-16 bg-panel border-b border-edge flex items-center justify-between px-6">
<div className="text-sm text-slate-400"> e- · AI · BI</div> <div className="text-sm text-slate-400">US Multi-Store Florist Omnichannel E-Commerce · AI Demand Forecasting · BI</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<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> <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} /> </button> <button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand"><LogOut size={16} /> Sign Out</button>
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">

View File

@ -20,15 +20,15 @@ export default function AdminLogin() {
const res = await login(username, password) const res = await login(username, password)
const token = res.data?.data?.token const token = res.data?.data?.token
if (!token) throw new Error('no token') if (!token) throw new Error('no token')
// 관리자 토큰은 고객 세션(mall_token)과 분리된 키에 저장 — 인터셉터가 /admin 영역에서 사용 // Store the admin token under a key separate from the customer session (mall_token) — used by the interceptor in the /admin area
localStorage.setItem('mall_admin_token', token) localStorage.setItem('mall_admin_token', token)
const me: any = await getMe().catch(() => null) const me: any = await getMe().catch(() => null)
if (me?.role) localStorage.setItem('mall_role', me.role) if (me?.role) localStorage.setItem('mall_role', me.role)
if (me?.username) localStorage.setItem('mall_admin_user', me.username) if (me?.username) localStorage.setItem('mall_admin_user', me.username)
if (me?.role === 'USER') { setErr('관리자 권한이 없습니다.'); localStorage.removeItem('mall_admin_token'); return } if (me?.role === 'USER') { setErr('You do not have admin privileges.'); localStorage.removeItem('mall_admin_token'); return }
nav('/admin/dashboard') nav('/admin/dashboard')
} catch { } catch {
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.') setErr('Sign-in failed — please check your username and password.')
} }
} }
@ -37,18 +37,18 @@ export default function AdminLogin() {
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8"> <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"> <div className="flex items-center gap-2 justify-center mb-6">
<Flower2 className="text-brand" size={28} /> <Flower2 className="text-brand" size={28} />
<span className="text-xl font-bold">GUARDiA Mall </span> <span className="text-xl font-bold">GUARDiA Mall Console</span>
</div> </div>
<p className="text-center text-sm text-slate-400 mb-6">· </p> <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"></label> <label className="block text-xs text-slate-400 mb-1">Username</label>
<input value={username} onChange={e => setUsername(e.target.value)} <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" /> 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"></label> <label className="block text-xs text-slate-400 mb-1">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} <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" /> 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>} {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"></button> <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"> <a href="/" className="text-brand"></a></p> <p className="text-center text-[11px] text-slate-500 mt-4">Customer storefront is <a href="/" className="text-brand">here</a></p>
</form> </form>
</div> </div>
) )

View File

@ -12,31 +12,31 @@ export default function Analytics() {
const { data: top } = useQuery({ queryKey: ['an-top'], queryFn: () => getTopProducts(10) }) const { data: top } = useQuery({ queryKey: ['an-top'], queryFn: () => getTopProducts(10) })
const { data: byTier } = useQuery({ queryKey: ['an-tier'], queryFn: () => getLoyaltyByTier(days) }) const { data: byTier } = useQuery({ queryKey: ['an-tier'], queryFn: () => getLoyaltyByTier(days) })
const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, 매출: s.sales, 주문: s.orderCount })) const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, Revenue: s.sales, Orders: s.orderCount }))
const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), 매출: t.sales })) const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), Revenue: t.sales }))
const topRows = (top || []).map((p: any) => ({ name: p.name, 판매: p.salesCount })) const topRows = (top || []).map((p: any) => ({ name: p.name, Sold: p.salesCount }))
const tierRows = (byTier || []).map((t: any) => ({ name: t.tier, 매출: t.sales, 고객: t.customers })) const tierRows = (byTier || []).map((t: any) => ({ name: t.tier, Revenue: t.sales, Customers: t.customers }))
const totalSales = (storeSales || []).reduce((s: number, x: any) => s + (x.sales || 0), 0) const totalSales = (storeSales || []).reduce((s: number, x: any) => s + (x.sales || 0), 0)
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><BarChart3 className="text-brand" size={22} /><h1 className="text-xl font-bold"> (BI)</h1></div> <div className="flex items-center gap-2"><BarChart3 className="text-brand" size={22} /><h1 className="text-xl font-bold">Revenue Analytics (BI)</h1></div>
<select value={days} onChange={e => setDays(Number(e.target.value))} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm"> <select value={days} onChange={e => setDays(Number(e.target.value))} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm">
{[7, 14, 30, 90].map(d => <option key={d} value={d}> {d}</option>)} {[7, 14, 30, 90].map(d => <option key={d} value={d}>Last {d} days</option>)}
</select> </select>
</div> </div>
<div className="bg-card border border-edge rounded-xl p-5 mb-5"> <div className="bg-card border border-edge rounded-xl p-5 mb-5">
<div className="text-xs text-slate-400"> </div> <div className="text-xs text-slate-400">Total Revenue (Period)</div>
<div className="text-3xl font-bold">{money(totalSales)}</div> <div className="text-3xl font-bold">{money(totalSales)}</div>
</div> </div>
<div className="grid lg:grid-cols-2 gap-5"> <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"> </h2><Chart type="AREA" rows={trendRows} dataKeys={['매출']} /></div> <div className="bg-card border border-edge rounded-xl p-5"><h2 className="text-sm font-semibold mb-3">Revenue Trend</h2><Chart type="AREA" rows={trendRows} dataKeys={['Revenue']} /></div>
<div className="bg-card border border-edge rounded-xl p-5"><h2 className="text-sm font-semibold mb-3"> </h2><Chart type="BAR" rows={storeRows} dataKeys={['매출', '주문']} /></div> <div className="bg-card border border-edge rounded-xl p-5"><h2 className="text-sm font-semibold mb-3">Revenue by Store</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"> (BI)</h2><Chart type="BAR" rows={tierRows} dataKeys={['매출', '고객']} /></div> <div className="bg-card border border-edge rounded-xl p-5"><h2 className="text-sm font-semibold mb-3">Revenue by Tier (BI)</h2><Chart type="BAR" rows={tierRows} dataKeys={['Revenue', 'Customers']} /></div>
<div className="bg-card border border-edge rounded-xl p-5"><h2 className="text-sm font-semibold mb-3"> Top 10</h2><Chart type="BAR" rows={topRows} dataKeys={['판매']} /></div> <div className="bg-card border border-edge rounded-xl p-5"><h2 className="text-sm font-semibold mb-3">Best Sellers Top 10</h2><Chart type="BAR" rows={topRows} dataKeys={['Sold']} /></div>
</div> </div>
</div> </div>
) )

View File

@ -11,16 +11,16 @@ export default function AuditLog() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><ScrollText className="text-brand" size={22} /><h1 className="text-xl font-bold"> </h1></div> <div className="flex items-center gap-2"><ScrollText className="text-brand" size={22} /><h1 className="text-xl font-bold">Audit Log</h1></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 bg-card border border-edge rounded-lg px-3 py-2"><Search size={14} className="text-slate-500" /><input value={actor} onChange={e => setActor(e.target.value)} placeholder="행위자" className="bg-transparent text-sm outline-none w-28" /></div> <div className="flex items-center gap-2 bg-card border border-edge rounded-lg px-3 py-2"><Search size={14} className="text-slate-500" /><input value={actor} onChange={e => setActor(e.target.value)} placeholder="Actor" className="bg-transparent text-sm outline-none w-28" /></div>
<input value={action} onChange={e => setAction(e.target.value)} placeholder="액션 필터" className="bg-card border border-edge rounded-lg px-3 py-2 text-sm w-32" /> <input value={action} onChange={e => setAction(e.target.value)} placeholder="Filter action" className="bg-card border border-edge rounded-lg px-3 py-2 text-sm w-32" />
</div> </div>
</div> </div>
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-left"></th><th className="text-left"></th><th className="text-left"></th><th className="text-left"></th> <th className="text-left p-3">Time</th><th className="text-left">Actor</th><th className="text-left">Action</th><th className="text-left">Target</th><th className="text-left">Details</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(logs || []).map((l: any, i: number) => ( {(logs || []).map((l: any, i: number) => (
@ -32,7 +32,7 @@ export default function AuditLog() {
<td className="text-slate-500 text-xs max-w-md truncate">{l.detail || l.details || ''}</td> <td className="text-slate-500 text-xs max-w-md truncate">{l.detail || l.details || ''}</td>
</tr> </tr>
))} ))}
{!(logs || []).length && <tr><td colSpan={5} className="text-center text-slate-500 py-8"> .</td></tr>} {!(logs || []).length && <tr><td colSpan={5} className="text-center text-slate-500 py-8">No audit logs found.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -21,45 +21,45 @@ export default function Dashboard() {
const { data: top } = useQuery({ queryKey: ['top'], queryFn: () => getTopProducts(8) }) const { data: top } = useQuery({ queryKey: ['top'], queryFn: () => getTopProducts(8) })
const today = dash?.today || {} const today = dash?.today || {}
const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, 매출: s.sales, 주문: s.orderCount })) const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, Revenue: s.sales, Orders: s.orderCount }))
const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), 매출: t.sales, 주문: t.orderCount })) const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), Revenue: t.sales, Orders: t.orderCount }))
const topRows = (top || []).map((p: any) => ({ name: p.name, 판매량: p.salesCount })) const topRows = (top || []).map((p: any) => ({ name: p.name, 'Units Sold': p.salesCount }))
return ( return (
<div> <div>
<h1 className="text-xl font-bold mb-5"></h1> <h1 className="text-xl font-bold mb-5">Dashboard</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Kpi icon={DollarSign} label="오늘 매출" value={money(today.totalSales)} /> <Kpi icon={DollarSign} label="Today's Revenue" value={money(today.totalSales)} />
<Kpi icon={ShoppingBag} label="오늘 주문" value={`${today.orderCount || 0}`} /> <Kpi icon={ShoppingBag} label="Today's Orders" value={`${today.orderCount || 0}`} />
<Kpi icon={TrendingUp} label="매장 수" value={`${(dash?.storeRanking || []).length}`} sub="활성 매장 매출 랭킹" /> <Kpi icon={TrendingUp} label="Stores" value={`${(dash?.storeRanking || []).length}`} sub="Active store revenue ranking" />
<Kpi icon={Star} label="대형 주문" value={`${(dash?.bigOrders || []).length}`} sub="$500+ 고가 주문" /> <Kpi icon={Star} label="Large Orders" value={`${(dash?.bigOrders || []).length}`} sub="$500+ high-value orders" />
</div> </div>
<div className="grid lg:grid-cols-2 gap-5 mb-6"> <div className="grid lg:grid-cols-2 gap-5 mb-6">
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<h2 className="text-sm font-semibold mb-3"> ( 7)</h2> <h2 className="text-sm font-semibold mb-3">Revenue by Store (Last 7 Days)</h2>
<Chart type="BAR" rows={storeRows} dataKeys={['매출', '주문']} /> <Chart type="BAR" rows={storeRows} dataKeys={['Revenue', 'Orders']} />
</div> </div>
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<h2 className="text-sm font-semibold mb-3"> (14)</h2> <h2 className="text-sm font-semibold mb-3">Revenue Trend (14 Days)</h2>
<Chart type="AREA" rows={trendRows} dataKeys={['매출']} /> <Chart type="AREA" rows={trendRows} dataKeys={['Revenue']} />
</div> </div>
</div> </div>
<div className="grid lg:grid-cols-2 gap-5"> <div className="grid lg:grid-cols-2 gap-5">
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<h2 className="text-sm font-semibold mb-3"></h2> <h2 className="text-sm font-semibold mb-3">Best Sellers</h2>
<Chart type="BAR" rows={topRows} dataKeys={['판매량']} /> <Chart type="BAR" rows={topRows} dataKeys={['Units Sold']} />
</div> </div>
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<h2 className="text-sm font-semibold mb-3"> </h2> <h2 className="text-sm font-semibold mb-3">Store Revenue Ranking</h2>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge"><th className="text-left py-2"></th><th className="text-right"></th><th className="text-right"></th></tr></thead> <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>
<tbody> <tbody>
{(dash?.storeRanking || []).map((s: any, i: number) => ( {(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> <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"> </td></tr>} {!(dash?.storeRanking || []).length && <tr><td colSpan={3} className="text-center text-slate-500 py-6">No data</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -24,40 +24,40 @@ export default function Events() {
const del = async (id: number) => { await deleteEvent(id).catch(() => {}); refresh() } const del = async (id: number) => { await deleteEvent(id).catch(() => {}); refresh() }
const showPerf = async (id: number) => { const r = await getEventPerformance(id).catch(() => null); setPerf(r) } const showPerf = async (id: number) => { const r = await getEventPerformance(id).catch(() => null); setPerf(r) }
const genCopy = async () => { const genCopy = async () => {
const r = await aiEventCopy(form.eventType, form.title || '봄 시즌', 'friendly').catch(() => null) const r = await aiEventCopy(form.eventType, form.title || 'Spring Season', 'friendly').catch(() => null)
if (r) { setForm({ ...form, title: r.headline || form.title, description: r.subtext || form.description }); setAiCopyMsg(`AI 카피 생성 (${r.source})`) } if (r) { setForm({ ...form, title: r.headline || form.title, description: r.subtext || form.description }); setAiCopyMsg(`AI copy generated (${r.source})`) }
} }
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><Megaphone className="text-brand" size={22} /><h1 className="text-xl font-bold"> · </h1></div> <div className="flex items-center gap-2"><Megaphone className="text-brand" size={22} /><h1 className="text-xl font-bold">Events & Campaigns</h1></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select value={status} onChange={e => setStatus(e.target.value)} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm"> <select value={status} onChange={e => setStatus(e.target.value)} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm">
<option value=""></option>{['DRAFT', 'PUBLISHED', 'ENDED'].map(s => <option key={s} value={s}>{s}</option>)} <option value="">All</option>{['DRAFT', 'PUBLISHED', 'ENDED'].map(s => <option key={s} value={s}>{s}</option>)}
</select> </select>
<button onClick={() => setOpen(!open)} className="flex items-center gap-1.5 bg-brand text-ink text-sm font-semibold px-3 py-2 rounded-lg"><Plus size={15} /> </button> <button onClick={() => setOpen(!open)} className="flex items-center gap-1.5 bg-brand text-ink text-sm font-semibold px-3 py-2 rounded-lg"><Plus size={15} /> New Event</button>
</div> </div>
</div> </div>
{open && ( {open && (
<div className="bg-card border border-edge rounded-xl p-5 mb-5 space-y-3"> <div className="bg-card border border-edge rounded-xl p-5 mb-5 space-y-3">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="코드" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="Code" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="제목" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm col-span-2" /> <input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="Title" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm col-span-2" />
</div> </div>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="설명" rows={2} className="w-full bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="Description" rows={2} className="w-full bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<select value={form.eventType} onChange={e => setForm({ ...form, eventType: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm">{TYPES.map(t => <option key={t}>{t}</option>)}</select> <select value={form.eventType} onChange={e => setForm({ ...form, eventType: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm">{TYPES.map(t => <option key={t}>{t}</option>)}</select>
<input type="date" value={form.startDate} onChange={e => setForm({ ...form, startDate: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input type="date" value={form.startDate} onChange={e => setForm({ ...form, startDate: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<input type="date" value={form.endDate} onChange={e => setForm({ ...form, endDate: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input type="date" value={form.endDate} onChange={e => setForm({ ...form, endDate: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<input value={form.targetTiers} onChange={e => setForm({ ...form, targetTiers: e.target.value })} placeholder="대상등급(GOLD,VIP)" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input value={form.targetTiers} onChange={e => setForm({ ...form, targetTiers: e.target.value })} placeholder="Target Tiers (GOLD, VIP)" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={genCopy} className="flex items-center gap-1.5 bg-brand2 text-white text-sm px-3 py-2 rounded-lg"><Sparkles size={14} /> AI </button> <button onClick={genCopy} className="flex items-center gap-1.5 bg-brand2 text-white text-sm px-3 py-2 rounded-lg"><Sparkles size={14} /> Generate AI Copy</button>
{aiCopyMsg && <span className="text-xs text-accent">{aiCopyMsg}</span>} {aiCopyMsg && <span className="text-xs text-accent">{aiCopyMsg}</span>}
<button onClick={create} className="ml-auto bg-brand text-ink font-semibold text-sm px-5 py-2 rounded-lg"></button> <button onClick={create} className="ml-auto bg-brand text-ink font-semibold text-sm px-5 py-2 rounded-lg">Create</button>
<button onClick={() => setOpen(false)} className="border border-edge text-slate-400 text-sm px-4 py-2 rounded-lg"></button> <button onClick={() => setOpen(false)} className="border border-edge text-slate-400 text-sm px-4 py-2 rounded-lg">Cancel</button>
</div> </div>
</div> </div>
)} )}
@ -65,16 +65,16 @@ export default function Events() {
{perf && ( {perf && (
<div className="bg-card border border-edge rounded-xl p-4 mb-5 flex items-center gap-6 text-sm"> <div className="bg-card border border-edge rounded-xl p-4 mb-5 flex items-center gap-6 text-sm">
<span className="flex items-center gap-1.5 text-brand"><BarChart3 size={15} /> {perf.title}</span> <span className="flex items-center gap-1.5 text-brand"><BarChart3 size={15} /> {perf.title}</span>
<span className="text-slate-400"> {perf.participations} · {perf.participants}</span> <span className="text-slate-400">Participations {perf.participations} · Participants {perf.participants}</span>
<span className="text-slate-400"> {perf.linkedOrders} · {money(perf.attributedSales)}</span> <span className="text-slate-400">Linked Orders {perf.linkedOrders} · Attributed Sales {money(perf.attributedSales)}</span>
<button onClick={() => setPerf(null)} className="ml-auto text-slate-500 text-xs"></button> <button onClick={() => setPerf(null)} className="ml-auto text-slate-500 text-xs">Close</button>
</div> </div>
)} )}
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-left"></th><th className="text-left"></th><th className="text-left"></th><th className="text-left"></th><th className="text-center"></th><th className="text-center"></th> <th className="text-left p-3">Code</th><th className="text-left">Title</th><th className="text-left">Type</th><th className="text-left">Period</th><th className="text-left">Target</th><th className="text-center">Status</th><th className="text-center">Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(events || []).map((ev: any) => ( {(events || []).map((ev: any) => (
@ -83,19 +83,19 @@ export default function Events() {
<td>{ev.title}</td> <td>{ev.title}</td>
<td className="text-slate-400 text-xs">{ev.eventType}</td> <td className="text-slate-400 text-xs">{ev.eventType}</td>
<td className="text-slate-400 text-xs">{ev.startDate}~{ev.endDate}</td> <td className="text-slate-400 text-xs">{ev.startDate}~{ev.endDate}</td>
<td className="text-slate-400 text-xs">{ev.targetTiers || '전체'}</td> <td className="text-slate-400 text-xs">{ev.targetTiers || 'All'}</td>
<td className="text-center"><StatusBadge status={ev.status} /></td> <td className="text-center"><StatusBadge status={ev.status} /></td>
<td className="text-center"> <td className="text-center">
<div className="inline-flex gap-1"> <div className="inline-flex gap-1">
{ev.status === 'DRAFT' && <button onClick={() => publish(ev.id)} className="text-xs bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded"></button>} {ev.status === 'DRAFT' && <button onClick={() => publish(ev.id)} className="text-xs bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded">Publish</button>}
{ev.status === 'PUBLISHED' && <button onClick={() => end(ev.id)} className="text-xs bg-slate-600/20 text-slate-400 px-2 py-1 rounded"></button>} {ev.status === 'PUBLISHED' && <button onClick={() => end(ev.id)} className="text-xs bg-slate-600/20 text-slate-400 px-2 py-1 rounded">End</button>}
<button onClick={() => showPerf(ev.id)} className="text-xs bg-panel border border-edge text-slate-300 px-2 py-1 rounded"></button> <button onClick={() => showPerf(ev.id)} className="text-xs bg-panel border border-edge text-slate-300 px-2 py-1 rounded">Performance</button>
<button onClick={() => del(ev.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded"></button> <button onClick={() => del(ev.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Delete</button>
</div> </div>
</td> </td>
</tr> </tr>
))} ))}
{!(events || []).length && <tr><td colSpan={7} className="text-center text-slate-500 py-8"> .</td></tr>} {!(events || []).length && <tr><td colSpan={7} className="text-center text-slate-500 py-8">No events found.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -16,23 +16,23 @@ export default function Inventory() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><Boxes className="text-brand" size={22} /><h1 className="text-xl font-bold"> (ON/OFF)</h1></div> <div className="flex items-center gap-2"><Boxes className="text-brand" size={22} /><h1 className="text-xl font-bold">Store Inventory (ON/OFF)</h1></div>
<select value={storeId} onChange={e => setStoreId(Number(e.target.value))} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm"> <select value={storeId} onChange={e => setStoreId(Number(e.target.value))} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm">
{(stores || []).map((s: any) => <option key={s.id} value={s.id}>{s.name}</option>)} {(stores || []).map((s: any) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select> </select>
</div> </div>
<p className="text-xs text-slate-500 mb-3">OFF로 .</p> <p className="text-xs text-slate-500 mb-3">Switching to OFF immediately marks the item as out of stock in that store's area.</p>
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-right"></th><th className="text-center"></th><th className="text-center"></th> <th className="text-left p-3">Product</th><th className="text-right">Stock</th><th className="text-center">Adjust</th><th className="text-center">Sale</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(inv || []).map((r: any) => { {(inv || []).map((r: any) => {
const available = r.available !== false const available = r.available !== false
return ( return (
<tr key={r.productId || r.id} className="border-b border-edge/50 hover:bg-panel/50"> <tr key={r.productId || r.id} className="border-b border-edge/50 hover:bg-panel/50">
<td className="p-3">{r.productName || `상품 #${r.productId}`}</td> <td className="p-3">{r.productName || `Product #${r.productId}`}</td>
<td className="text-right text-slate-300">{r.stock ?? r.quantity ?? '-'}</td> <td className="text-right text-slate-300">{r.stock ?? r.quantity ?? '-'}</td>
<td className="text-center"> <td className="text-center">
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center gap-1">
@ -48,7 +48,7 @@ export default function Inventory() {
</tr> </tr>
) )
})} })}
{!(inv || []).length && <tr><td colSpan={4} className="text-center text-slate-500 py-8"> .</td></tr>} {!(inv || []).length && <tr><td colSpan={4} className="text-center text-slate-500 py-8">No inventory items found.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -23,19 +23,19 @@ export default function Loyalty() {
const doAdjust = async () => { const doAdjust = async () => {
if (!owner || !points) return if (!owner || !points) return
const r = await adjustPoints(owner, points, reason).catch(() => null) const r = await adjustPoints(owner, points, reason).catch(() => null)
setAdjMsg(r ? `${owner} 포인트 조정 완료 · 잔액 ${r.balance}P` : '조정 실패') setAdjMsg(r ? `${owner} points adjusted · Balance ${r.balance}P` : 'Adjustment failed')
setOwner(''); setPoints(0); setReason('') setOwner(''); setPoints(0); setReason('')
} }
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-5"><Crown className="text-brand" size={22} /><h1 className="text-xl font-bold"> · </h1></div> <div className="flex items-center gap-2 mb-5"><Crown className="text-brand" size={22} /><h1 className="text-xl font-bold">Tier & Points Management</h1></div>
<div className="bg-card border border-edge rounded-xl overflow-hidden mb-6"> <div className="bg-card border border-edge rounded-xl overflow-hidden mb-6">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-right"></th><th className="text-right"></th> <th className="text-left p-3">Tier</th><th className="text-right">Min Spend</th><th className="text-right">Min Orders</th>
<th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-center"></th><th className="text-center"></th> <th className="text-right">Discount</th><th className="text-right">Earn Rate</th><th className="text-right">Free Shipping</th><th className="text-center">Priority Slot</th><th className="text-center">Edit</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(tiers || []).map((t: any) => ( {(tiers || []).map((t: any) => (
@ -45,39 +45,39 @@ export default function Loyalty() {
<td className="text-right text-slate-400">{t.minOrders12m}</td> <td className="text-right text-slate-400">{t.minOrders12m}</td>
<td className="text-right">{t.discountRate}%</td> <td className="text-right">{t.discountRate}%</td>
<td className="text-right">{t.pointEarnRate}%</td> <td className="text-right">{t.pointEarnRate}%</td>
<td className="text-right text-slate-400">{t.freeShipThreshold === 0 ? '항상' : t.freeShipThreshold ? money(t.freeShipThreshold) : '없음'}</td> <td className="text-right text-slate-400">{t.freeShipThreshold === 0 ? 'Always' : t.freeShipThreshold ? money(t.freeShipThreshold) : 'None'}</td>
<td className="text-center">{t.prioritySlot ? '✓' : ''}</td> <td className="text-center">{t.prioritySlot ? '✓' : ''}</td>
<td className="text-center"><button onClick={() => setEdit({ ...t })} className="text-xs text-brand"></button></td> <td className="text-center"><button onClick={() => setEdit({ ...t })} className="text-xs text-brand">Edit</button></td>
</tr> </tr>
))} ))}
{!(tiers || []).length && <tr><td colSpan={8} className="text-center text-slate-500 py-8"> .</td></tr>} {!(tiers || []).length && <tr><td colSpan={8} className="text-center text-slate-500 py-8">No tier data found.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
{edit && ( {edit && (
<div className="bg-card border border-edge rounded-xl p-5 mb-6"> <div className="bg-card border border-edge rounded-xl p-5 mb-6">
<div className="text-sm font-semibold mb-3"> : {edit.name} ({edit.tierCode})</div> <div className="text-sm font-semibold mb-3">Edit Tier: {edit.name} ({edit.tierCode})</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<Field label="최소구매($)" value={edit.minSpend12m} onChange={v => setEdit({ ...edit, minSpend12m: Number(v) })} /> <Field label="Min Spend ($)" value={edit.minSpend12m} onChange={v => setEdit({ ...edit, minSpend12m: Number(v) })} />
<Field label="최소주문" value={edit.minOrders12m} onChange={v => setEdit({ ...edit, minOrders12m: Number(v) })} /> <Field label="Min Orders" value={edit.minOrders12m} onChange={v => setEdit({ ...edit, minOrders12m: Number(v) })} />
<Field label="할인율(%)" value={edit.discountRate} onChange={v => setEdit({ ...edit, discountRate: Number(v) })} /> <Field label="Discount (%)" value={edit.discountRate} onChange={v => setEdit({ ...edit, discountRate: Number(v) })} />
<Field label="적립률(%)" value={edit.pointEarnRate} onChange={v => setEdit({ ...edit, pointEarnRate: Number(v) })} /> <Field label="Earn Rate (%)" value={edit.pointEarnRate} onChange={v => setEdit({ ...edit, pointEarnRate: Number(v) })} />
</div> </div>
<div className="flex gap-2 mt-4"> <div className="flex gap-2 mt-4">
<button onClick={save} className="bg-brand text-ink font-semibold px-5 py-2 rounded-lg text-sm"></button> <button onClick={save} className="bg-brand text-ink font-semibold px-5 py-2 rounded-lg text-sm">Save</button>
<button onClick={() => setEdit(null)} className="border border-edge px-5 py-2 rounded-lg text-sm text-slate-400"></button> <button onClick={() => setEdit(null)} className="border border-edge px-5 py-2 rounded-lg text-sm text-slate-400">Cancel</button>
</div> </div>
</div> </div>
)} )}
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<div className="flex items-center gap-2 text-sm font-semibold mb-3"><Coins size={16} className="text-brand" /> </div> <div className="flex items-center gap-2 text-sm font-semibold mb-3"><Coins size={16} className="text-brand" /> Manual Point Adjustment</div>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<input value={owner} onChange={e => setOwner(e.target.value)} placeholder="고객 아이디" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input value={owner} onChange={e => setOwner(e.target.value)} placeholder="Customer ID" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<input type="number" value={points || ''} onChange={e => setPoints(Number(e.target.value))} placeholder="포인트(+/-)" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input type="number" value={points || ''} onChange={e => setPoints(Number(e.target.value))} placeholder="Points (+/-)" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="사유" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<button onClick={doAdjust} className="bg-brand2 text-white rounded-lg px-3 py-2 text-sm font-semibold"></button> <button onClick={doAdjust} className="bg-brand2 text-white rounded-lg px-3 py-2 text-sm font-semibold">Adjust</button>
</div> </div>
{adjMsg && <div className="text-xs text-accent mt-2">{adjMsg}</div>} {adjMsg && <div className="text-xs text-accent mt-2">{adjMsg}</div>}
</div> </div>

View File

@ -10,22 +10,22 @@ export default function Members() {
const { data: byTier } = useQuery({ queryKey: ['by-tier'], queryFn: () => getLoyaltyByTier(30) }) const { data: byTier } = useQuery({ queryKey: ['by-tier'], queryFn: () => getLoyaltyByTier(30) })
const recalc = async () => { await recalcAllLoyalty().catch(() => {}); qc.invalidateQueries({ queryKey: ['by-tier'] }) } const recalc = async () => { await recalcAllLoyalty().catch(() => {}); qc.invalidateQueries({ queryKey: ['by-tier'] }) }
const rows = (byTier || []).map((t: any) => ({ name: t.tier, 고객수: t.customers, 매출: t.sales })) const rows = (byTier || []).map((t: any) => ({ name: t.tier, customers: t.customers, sales: t.sales }))
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><Users className="text-brand" size={22} /><h1 className="text-xl font-bold"> (CRM)</h1></div> <div className="flex items-center gap-2"><Users className="text-brand" size={22} /><h1 className="text-xl font-bold">Members (CRM)</h1></div>
<button onClick={recalc} className="flex items-center gap-1.5 bg-card border border-edge text-sm px-3 py-2 rounded-lg text-slate-300 hover:text-brand"><RefreshCw size={14} /> </button> <button onClick={recalc} className="flex items-center gap-1.5 bg-card border border-edge text-sm px-3 py-2 rounded-lg text-slate-300 hover:text-brand"><RefreshCw size={14} /> Recalculate All Tiers</button>
</div> </div>
<div className="grid lg:grid-cols-2 gap-5 mb-5"> <div className="grid lg:grid-cols-2 gap-5 mb-5">
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<h2 className="text-sm font-semibold mb-3"> (30)</h2> <h2 className="text-sm font-semibold mb-3">Customer Distribution by Tier (30 days)</h2>
<Chart type="BAR" rows={rows} dataKeys={['고객수']} /> <Chart type="BAR" rows={rows} dataKeys={['customers']} />
</div> </div>
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<h2 className="text-sm font-semibold mb-3"> </h2> <h2 className="text-sm font-semibold mb-3">Sales Contribution by Tier</h2>
<Chart type="PIE" rows={(byTier || []).map((t: any) => ({ name: t.tier, value: t.sales }))} dataKeys={['value']} /> <Chart type="PIE" rows={(byTier || []).map((t: any) => ({ name: t.tier, value: t.sales }))} dataKeys={['value']} />
</div> </div>
</div> </div>
@ -33,7 +33,7 @@ export default function Members() {
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-right"> </th><th className="text-right"> </th><th className="text-right"></th> <th className="text-left p-3">Tier</th><th className="text-right">Customers</th><th className="text-right">Orders</th><th className="text-right">Sales</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(byTier || []).map((t: any) => ( {(byTier || []).map((t: any) => (
@ -44,7 +44,7 @@ export default function Members() {
<td className="text-right">{money(t.sales)}</td> <td className="text-right">{money(t.sales)}</td>
</tr> </tr>
))} ))}
{!(byTier || []).length && <tr><td colSpan={4} className="text-center text-slate-500 py-8"> </td></tr>} {!(byTier || []).length && <tr><td colSpan={4} className="text-center text-slate-500 py-8">No data</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -43,11 +43,11 @@ export default function Orders() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><ShoppingBag className="text-brand" size={22} /><h1 className="text-xl font-bold"> ()</h1></div> <div className="flex items-center gap-2"><ShoppingBag className="text-brand" size={22} /><h1 className="text-xl font-bold">Orders (Live)</h1></div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`flex items-center gap-1.5 text-xs ${connected ? 'text-emerald-400' : 'text-slate-500'}`}><Radio size={13} /> {connected ? '실시간 연결됨' : '오프라인'}</span> <span className={`flex items-center gap-1.5 text-xs ${connected ? 'text-emerald-400' : 'text-slate-500'}`}><Radio size={13} /> {connected ? 'Connected' : 'Offline'}</span>
<select value={status} onChange={e => setStatus(e.target.value)} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm"> <select value={status} onChange={e => setStatus(e.target.value)} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm">
<option value=""></option> <option value="">All</option>
{['PENDING', 'PAID', 'PREPARING', 'SHIPPED', 'DELIVERED', 'CONFIRMED', 'CANCELLED'].map(s => <option key={s} value={s}>{s}</option>)} {['PENDING', 'PAID', 'PREPARING', 'SHIPPED', 'DELIVERED', 'CONFIRMED', 'CANCELLED'].map(s => <option key={s} value={s}>{s}</option>)}
</select> </select>
</div> </div>
@ -55,7 +55,7 @@ export default function Orders() {
{!!live.length && ( {!!live.length && (
<div className="bg-card border border-edge rounded-xl p-4 mb-5"> <div className="bg-card border border-edge rounded-xl p-4 mb-5">
<div className="text-xs text-slate-400 mb-2 flex items-center gap-1.5"><Radio size={12} className="text-brand" /> </div> <div className="text-xs text-slate-400 mb-2 flex items-center gap-1.5"><Radio size={12} className="text-brand" /> Live Events</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{live.map((m, i) => ( {live.map((m, i) => (
<span key={i} className="text-xs bg-panel border border-edge rounded-full px-3 py-1 text-slate-300"> <span key={i} className="text-xs bg-panel border border-edge rounded-full px-3 py-1 text-slate-300">
@ -69,8 +69,8 @@ export default function Orders() {
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-left"></th><th className="text-left"></th> <th className="text-left p-3">Order No.</th><th className="text-left">Store</th><th className="text-left">Fulfillment</th>
<th className="text-left"></th><th className="text-left"></th><th className="text-right"></th><th className="text-center"></th><th className="text-center"></th> <th className="text-left">Schedule</th><th className="text-left">Recipient</th><th className="text-right">Amount</th><th className="text-center">Status</th><th className="text-center">Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{list.map((o: any) => ( {list.map((o: any) => (

View File

@ -20,48 +20,48 @@ export default function Schedule() {
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-5"><CalendarClock className="text-brand" size={22} /><h1 className="text-xl font-bold"> · · </h1></div> <div className="flex items-center gap-2 mb-5"><CalendarClock className="text-brand" size={22} /><h1 className="text-xl font-bold">Schedule · Holidays · Surge</h1></div>
<div className="bg-card border border-edge rounded-xl p-5 mb-5"> <div className="bg-card border border-edge rounded-xl p-5 mb-5">
<div className="text-sm font-semibold mb-3">/ </div> <div className="text-sm font-semibold mb-3">Add Holiday / Surge</div>
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-5 gap-3">
<input type="date" value={form.holidayDate} onChange={e => setForm({ ...form, holidayDate: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input type="date" value={form.holidayDate} onChange={e => setForm({ ...form, holidayDate: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="이름" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Name" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<label className="flex items-center gap-2 text-sm text-slate-300"><input type="checkbox" checked={form.blocked} onChange={e => setForm({ ...form, blocked: e.target.checked })} /> </label> <label className="flex items-center gap-2 text-sm text-slate-300"><input type="checkbox" checked={form.blocked} onChange={e => setForm({ ...form, blocked: e.target.checked })} /> Block Delivery</label>
<input type="number" step="0.1" value={form.surgeMultiplier} onChange={e => setForm({ ...form, surgeMultiplier: Number(e.target.value) })} placeholder="서지 배수" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input type="number" step="0.1" value={form.surgeMultiplier} onChange={e => setForm({ ...form, surgeMultiplier: Number(e.target.value) })} placeholder="Surge Multiplier" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<button onClick={add} className="flex items-center gap-1.5 bg-brand text-ink font-semibold rounded-lg px-3 py-2 text-sm justify-center"><Plus size={15} /> </button> <button onClick={add} className="flex items-center gap-1.5 bg-brand text-ink font-semibold rounded-lg px-3 py-2 text-sm justify-center"><Plus size={15} /> Add</button>
</div> </div>
</div> </div>
<div className="bg-card border border-edge rounded-xl overflow-hidden mb-6"> <div className="bg-card border border-edge rounded-xl overflow-hidden mb-6">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-left"></th><th className="text-center"></th><th className="text-right"></th><th className="text-left"></th> <th className="text-left p-3">Date</th><th className="text-left">Name</th><th className="text-center">Block Delivery</th><th className="text-right">Surge Multiplier</th><th className="text-left">Store</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(holidays || []).map((h: any) => ( {(holidays || []).map((h: any) => (
<tr key={h.id || h.holidayDate} className="border-b border-edge/50"> <tr key={h.id || h.holidayDate} className="border-b border-edge/50">
<td className="p-3">{h.holidayDate}</td> <td className="p-3">{h.holidayDate}</td>
<td className="text-slate-400">{h.name}</td> <td className="text-slate-400">{h.name}</td>
<td className="text-center">{h.blocked ? <span className="text-rose-400"></span> : <span className="text-slate-500"></span>}</td> <td className="text-center">{h.blocked ? <span className="text-rose-400">Blocked</span> : <span className="text-slate-500"></span>}</td>
<td className="text-right">{h.surgeMultiplier > 1 ? <span className="text-amber-400">×{h.surgeMultiplier}</span> : ''}</td> <td className="text-right">{h.surgeMultiplier > 1 ? <span className="text-amber-400">×{h.surgeMultiplier}</span> : ''}</td>
<td className="text-slate-400 text-xs">{h.storeId ? storeName(h.storeId) : '전체'}</td> <td className="text-slate-400 text-xs">{h.storeId ? storeName(h.storeId) : 'All'}</td>
</tr> </tr>
))} ))}
{!(holidays || []).length && <tr><td colSpan={5} className="text-center text-slate-500 py-8"> .</td></tr>} {!(holidays || []).length && <tr><td colSpan={5} className="text-center text-slate-500 py-8">No holidays registered.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Sparkles size={16} className="text-brand" /><span className="text-sm font-semibold">AI </span> <Sparkles size={16} className="text-brand" /><span className="text-sm font-semibold">AI Peak-Season Demand Forecast</span>
<select value={season} onChange={e => setSeason(e.target.value)} className="ml-auto bg-panel border border-edge rounded-lg px-3 py-1.5 text-sm">{SEASONS.map(s => <option key={s}>{s}</option>)}</select> <select value={season} onChange={e => setSeason(e.target.value)} className="ml-auto bg-panel border border-edge rounded-lg px-3 py-1.5 text-sm">{SEASONS.map(s => <option key={s}>{s}</option>)}</select>
<button onClick={runForecast} className="bg-brand2 text-white text-sm px-3 py-1.5 rounded-lg"> </button> <button onClick={runForecast} className="bg-brand2 text-white text-sm px-3 py-1.5 rounded-lg">Run Forecast</button>
</div> </div>
{forecast && ( {forecast && (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge"><th className="text-left py-2"></th><th className="text-right"> </th><th className="text-right"> </th><th className="text-right"></th></tr></thead> <thead><tr className="text-slate-400 text-xs border-b border-edge"><th className="text-left py-2">Store</th><th className="text-right">Recent Orders</th><th className="text-right">Forecast Orders</th><th className="text-right">Multiplier</th></tr></thead>
<tbody> <tbody>
{(forecast.forecast || []).map((f: any, i: number) => ( {(forecast.forecast || []).map((f: any, i: number) => (
<tr key={i} className="border-b border-edge/50"><td className="py-2">{storeName(f.storeId)}</td><td className="text-right text-slate-400">{f.recentOrders}</td><td className="text-right text-brand">{f.predictedOrders}</td><td className="text-right text-amber-400">×{f.multiplier}</td></tr> <tr key={i} className="border-b border-edge/50"><td className="py-2">{storeName(f.storeId)}</td><td className="text-right text-slate-400">{f.recentOrders}</td><td className="text-right text-brand">{f.predictedOrders}</td><td className="text-right text-amber-400">×{f.multiplier}</td></tr>

View File

@ -18,11 +18,11 @@ export default function Settings() {
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-5"><SettingsIcon className="text-brand" size={22} /><h1 className="text-xl font-bold"> · </h1></div> <div className="flex items-center gap-2 mb-5"><SettingsIcon className="text-brand" size={22} /><h1 className="text-xl font-bold">Gateways · Settings</h1></div>
<div className="bg-card border border-edge rounded-xl p-5 mb-5"> <div className="bg-card border border-edge rounded-xl p-5 mb-5">
<div className="flex items-center gap-2 text-sm font-semibold mb-3"><Plug size={16} className="text-brand" /> (provider)</div> <div className="flex items-center gap-2 text-sm font-semibold mb-3"><Plug size={16} className="text-brand" /> Gateway Adapters (provider)</div>
<p className="text-xs text-slate-500 mb-3 flex items-center gap-1.5"><ShieldCheck size={13} className="text-accent" /> API . ( mock = 0)</p> <p className="text-xs text-slate-500 mb-3 flex items-center gap-1.5"><ShieldCheck size={13} className="text-accent" /> API keys are stored only in environment variables and are never shown on screen. (Default mock = 0 external calls)</p>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
{provList.map((p: any, i: number) => ( {provList.map((p: any, i: number) => (
<div key={i} className="bg-panel border border-edge rounded-lg p-3 flex items-center justify-between"> <div key={i} className="bg-panel border border-edge rounded-lg p-3 flex items-center justify-between">
@ -30,15 +30,15 @@ export default function Settings() {
<div className="text-xs text-slate-400 uppercase">{p.adapter}</div> <div className="text-xs text-slate-400 uppercase">{p.adapter}</div>
<div className="font-semibold">{p.provider || 'mock'}</div> <div className="font-semibold">{p.provider || 'mock'}</div>
</div> </div>
<span className={`text-xs px-2 py-1 rounded ${p.provider && p.provider !== 'mock' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-slate-600/20 text-slate-400'}`}>{p.provider && p.provider !== 'mock' ? '실연동' : 'mock'}</span> <span className={`text-xs px-2 py-1 rounded ${p.provider && p.provider !== 'mock' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-slate-600/20 text-slate-400'}`}>{p.provider && p.provider !== 'mock' ? 'Live' : 'mock'}</span>
</div> </div>
))} ))}
{!provList.length && <div className="text-slate-500 text-sm">provider .</div>} {!provList.length && <div className="text-slate-500 text-sm">Unable to load provider information.</div>}
</div> </div>
</div> </div>
<div className="bg-card border border-edge rounded-xl p-5"> <div className="bg-card border border-edge rounded-xl p-5">
<div className="text-sm font-semibold mb-3"> </div> <div className="text-sm font-semibold mb-3">System Settings</div>
<div className="space-y-2"> <div className="space-y-2">
{(settings || []).map((s: any) => { {(settings || []).map((s: any) => {
const key = s.key || s.settingKey const key = s.key || s.settingKey
@ -47,11 +47,11 @@ export default function Settings() {
<div key={key} className="flex items-center gap-3"> <div key={key} className="flex items-center gap-3">
<span className="text-sm text-slate-400 w-56 font-mono text-xs">{key}</span> <span className="text-sm text-slate-400 w-56 font-mono text-xs">{key}</span>
<input value={cur} onChange={e => setEdits({ ...edits, [key]: e.target.value })} className="flex-1 bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input value={cur} onChange={e => setEdits({ ...edits, [key]: e.target.value })} className="flex-1 bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<button onClick={() => save(key)} className="bg-brand2 text-white text-xs px-3 py-2 rounded-lg"></button> <button onClick={() => save(key)} className="bg-brand2 text-white text-xs px-3 py-2 rounded-lg">Save</button>
</div> </div>
) )
})} })}
{!(settings || []).length && <div className="text-slate-500 text-sm py-4"> .</div>} {!(settings || []).length && <div className="text-slate-500 text-sm py-4">No settings available.</div>}
</div> </div>
</div> </div>
</div> </div>

View File

@ -10,12 +10,12 @@ export default function Stores() {
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-5"><StoreIcon className="text-brand" size={22} /><h1 className="text-xl font-bold"> </h1></div> <div className="flex items-center gap-2 mb-5"><StoreIcon className="text-brand" size={22} /><h1 className="text-xl font-bold">Stores</h1></div>
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-left"></th><th className="text-left"></th> <th className="text-left p-3">Code</th><th className="text-left">Store</th><th className="text-left">Location</th>
<th className="text-left"></th><th className="text-right"></th><th className="text-right">(mi)</th><th className="text-right"></th><th className="text-center"></th> <th className="text-left">Hours</th><th className="text-right">Same-Day Cutoff</th><th className="text-right">Radius (mi)</th><th className="text-right">Daily Capacity</th><th className="text-center">Status</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(stores || []).map((s: any) => ( {(stores || []).map((s: any) => (
@ -29,12 +29,12 @@ export default function Stores() {
<td className="text-right text-slate-400">{s.dailyCapacity}</td> <td className="text-right text-slate-400">{s.dailyCapacity}</td>
<td className="text-center"> <td className="text-center">
<button onClick={() => toggle(s.id, s.active)} className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs ${s.active ? 'bg-emerald-500/15 text-emerald-400' : 'bg-slate-600/20 text-slate-400'}`}> <button onClick={() => toggle(s.id, s.active)} className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs ${s.active ? 'bg-emerald-500/15 text-emerald-400' : 'bg-slate-600/20 text-slate-400'}`}>
<Power size={12} /> {s.active ? '운영' : '중지'} <Power size={12} /> {s.active ? 'Active' : 'Inactive'}
</button> </button>
</td> </td>
</tr> </tr>
))} ))}
{!(stores || []).length && <tr><td colSpan={8} className="text-center text-slate-500 py-8"> .</td></tr>} {!(stores || []).length && <tr><td colSpan={8} className="text-center text-slate-500 py-8">No stores found.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -10,11 +10,11 @@ export default function Subscriptions() {
return ( return (
<div> <div>
<div className="flex items-center gap-2 mb-5"><Repeat className="text-brand" size={22} /><h1 className="text-xl font-bold"> </h1></div> <div className="flex items-center gap-2 mb-5"><Repeat className="text-brand" size={22} /><h1 className="text-xl font-bold">Subscriptions</h1></div>
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3"></th><th className="text-left"></th><th className="text-left"></th><th className="text-left"></th><th className="text-right"></th><th className="text-center"></th> <th className="text-left p-3">Customer</th><th className="text-left">Product</th><th className="text-left">Frequency</th><th className="text-left">Next Delivery</th><th className="text-right">Amount</th><th className="text-center">Status</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{list.map((s: any) => ( {list.map((s: any) => (
@ -27,7 +27,7 @@ export default function Subscriptions() {
<td className="text-center"><StatusBadge status={s.status} /></td> <td className="text-center"><StatusBadge status={s.status} /></td>
</tr> </tr>
))} ))}
{!list.length && <tr><td colSpan={6} className="text-center text-slate-500 py-8"> .</td></tr>} {!list.length && <tr><td colSpan={6} className="text-center text-slate-500 py-8">No subscriptions found.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -23,22 +23,22 @@ export default function Transfers() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><ArrowLeftRight className="text-brand" size={22} /><h1 className="text-xl font-bold"> </h1></div> <div className="flex items-center gap-2"><ArrowLeftRight className="text-brand" size={22} /><h1 className="text-xl font-bold">Inventory Transfers</h1></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={aiSuggest} className="flex items-center gap-1.5 bg-brand2 text-white text-sm px-3 py-2 rounded-lg"><Sparkles size={14} /> AI </button> <button onClick={aiSuggest} className="flex items-center gap-1.5 bg-brand2 text-white text-sm px-3 py-2 rounded-lg"><Sparkles size={14} /> AI Transfer Suggestions</button>
<select value={status} onChange={e => setStatus(e.target.value)} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm"> <select value={status} onChange={e => setStatus(e.target.value)} className="bg-card border border-edge rounded-lg px-3 py-2 text-sm">
<option value=""></option>{['REQUESTED', 'APPROVED', 'REJECTED', 'COMPLETED'].map(s => <option key={s} value={s}>{s}</option>)} <option value="">All</option>{['REQUESTED', 'APPROVED', 'REJECTED', 'COMPLETED'].map(s => <option key={s} value={s}>{s}</option>)}
</select> </select>
</div> </div>
</div> </div>
{!!recs.length && ( {!!recs.length && (
<div className="bg-card border border-edge rounded-xl p-4 mb-5"> <div className="bg-card border border-edge rounded-xl p-4 mb-5">
<div className="text-xs text-brand mb-2 flex items-center gap-1.5"><Sparkles size={12} /> AI </div> <div className="text-xs text-brand mb-2 flex items-center gap-1.5"><Sparkles size={12} /> AI Recommended Transfers</div>
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
{recs.map((r, i) => ( {recs.map((r, i) => (
<div key={i} className="flex items-center justify-between text-slate-300"> <div key={i} className="flex items-center justify-between text-slate-300">
<span> #{r.productId}: {storeName(r.fromStoreId)} {storeName(r.toStoreId)} ×{r.suggestedQty}</span> <span>Product #{r.productId}: {storeName(r.fromStoreId)} {storeName(r.toStoreId)} ×{r.suggestedQty}</span>
<span className="text-xs text-slate-500">{r.reason}</span> <span className="text-xs text-slate-500">{r.reason}</span>
</div> </div>
))} ))}
@ -49,7 +49,7 @@ export default function Transfers() {
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">ID</th><th className="text-left"></th><th className="text-left"></th><th className="text-right"></th><th className="text-left"></th><th className="text-center"></th><th className="text-center"></th> <th className="text-left p-3">ID</th><th className="text-left">From To</th><th className="text-left">Product</th><th className="text-right">Quantity</th><th className="text-left">Reason</th><th className="text-center">Status</th><th className="text-center">Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(transfers || []).map((t: any) => ( {(transfers || []).map((t: any) => (
@ -62,13 +62,13 @@ export default function Transfers() {
<td className="text-center"><StatusBadge status={t.status} /></td> <td className="text-center"><StatusBadge status={t.status} /></td>
<td className="text-center"> <td className="text-center">
{t.status === 'REQUESTED' && (<div className="inline-flex gap-1"> {t.status === 'REQUESTED' && (<div className="inline-flex gap-1">
<button onClick={() => approve(t.id)} className="text-xs bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded"></button> <button onClick={() => approve(t.id)} className="text-xs bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded">Approve</button>
<button onClick={() => reject(t.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded"></button> <button onClick={() => reject(t.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Reject</button>
</div>)} </div>)}
</td> </td>
</tr> </tr>
))} ))}
{!(transfers || []).length && <tr><td colSpan={7} className="text-center text-slate-500 py-8"> .</td></tr>} {!(transfers || []).length && <tr><td colSpan={7} className="text-center text-slate-500 py-8">No transfer requests.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -15,29 +15,29 @@ export default function UserManagement() {
const add = async () => { if (!nu.username || !nu.password) return; await createUser(nu).catch(() => {}); setNu({ username: '', password: '', role: 'USER' }); setOpen(false); refresh() } const add = async () => { if (!nu.username || !nu.password) return; await createUser(nu).catch(() => {}); setNu({ username: '', password: '', role: 'USER' }); setOpen(false); refresh() }
const setRole = async (id: number, role: string) => { await updateUserRole(id, role).catch(() => {}); refresh() } const setRole = async (id: number, role: string) => { await updateUserRole(id, role).catch(() => {}); refresh() }
const toggleActive = async (id: number, active: boolean) => { await updateUserActive(id, !active).catch(() => {}); refresh() } const toggleActive = async (id: number, active: boolean) => { await updateUserActive(id, !active).catch(() => {}); refresh() }
const reset = async (id: number) => { const pw = prompt('새 비밀번호'); if (pw) { await resetPassword(id, pw).catch(() => {}); alert('변경됨') } } const reset = async (id: number) => { const pw = prompt('New password'); if (pw) { await resetPassword(id, pw).catch(() => {}); alert('Password updated') } }
const del = async (id: number) => { if (confirm('삭제하시겠습니까?')) { await deleteUser(id).catch(() => {}); refresh() } } const del = async (id: number) => { if (confirm('Are you sure you want to delete?')) { await deleteUser(id).catch(() => {}); refresh() } }
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><UserCog className="text-brand" size={22} /><h1 className="text-xl font-bold"> / (RBAC)</h1></div> <div className="flex items-center gap-2"><UserCog className="text-brand" size={22} /><h1 className="text-xl font-bold">User Management (RBAC)</h1></div>
<button onClick={() => setOpen(!open)} className="flex items-center gap-1.5 bg-brand text-ink text-sm font-semibold px-3 py-2 rounded-lg"><Plus size={15} /> </button> <button onClick={() => setOpen(!open)} className="flex items-center gap-1.5 bg-brand text-ink text-sm font-semibold px-3 py-2 rounded-lg"><Plus size={15} /> Add User</button>
</div> </div>
{open && ( {open && (
<div className="bg-card border border-edge rounded-xl p-5 mb-5 grid grid-cols-4 gap-3"> <div className="bg-card border border-edge rounded-xl p-5 mb-5 grid grid-cols-4 gap-3">
<input value={nu.username} onChange={e => setNu({ ...nu, username: e.target.value })} placeholder="아이디" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input value={nu.username} onChange={e => setNu({ ...nu, username: e.target.value })} placeholder="Username" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<input type="password" value={nu.password} onChange={e => setNu({ ...nu, password: e.target.value })} placeholder="비밀번호" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> <input type="password" value={nu.password} onChange={e => setNu({ ...nu, password: e.target.value })} placeholder="Password" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" />
<select value={nu.role} onChange={e => setNu({ ...nu, role: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm">{ROLES.map(r => <option key={r}>{r}</option>)}</select> <select value={nu.role} onChange={e => setNu({ ...nu, role: e.target.value })} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm">{ROLES.map(r => <option key={r}>{r}</option>)}</select>
<button onClick={add} className="bg-brand text-ink font-semibold rounded-lg px-3 py-2 text-sm"></button> <button onClick={add} className="bg-brand text-ink font-semibold rounded-lg px-3 py-2 text-sm">Create</button>
</div> </div>
)} )}
<div className="bg-card border border-edge rounded-xl overflow-hidden"> <div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel"> <thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">ID</th><th className="text-left"></th><th className="text-left"></th><th className="text-center"></th><th className="text-center"></th> <th className="text-left p-3">ID</th><th className="text-left">Username</th><th className="text-left">Role</th><th className="text-center">Status</th><th className="text-center">Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{(users || []).map((u: any) => ( {(users || []).map((u: any) => (
@ -48,17 +48,17 @@ export default function UserManagement() {
<select value={u.role} onChange={e => setRole(u.id, e.target.value)} className="bg-panel border border-edge rounded px-2 py-1 text-xs">{ROLES.map(r => <option key={r}>{r}</option>)}</select> <select value={u.role} onChange={e => setRole(u.id, e.target.value)} className="bg-panel border border-edge rounded px-2 py-1 text-xs">{ROLES.map(r => <option key={r}>{r}</option>)}</select>
</td> </td>
<td className="text-center"> <td className="text-center">
<button onClick={() => toggleActive(u.id, u.active)} className={`px-2 py-1 rounded text-xs ${u.active !== false ? 'bg-emerald-500/15 text-emerald-400' : 'bg-slate-600/20 text-slate-400'}`}>{u.active !== false ? '활성' : '비활성'}</button> <button onClick={() => toggleActive(u.id, u.active)} className={`px-2 py-1 rounded text-xs ${u.active !== false ? 'bg-emerald-500/15 text-emerald-400' : 'bg-slate-600/20 text-slate-400'}`}>{u.active !== false ? 'Active' : 'Inactive'}</button>
</td> </td>
<td className="text-center"> <td className="text-center">
<div className="inline-flex gap-1"> <div className="inline-flex gap-1">
<button onClick={() => reset(u.id)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300"></button> <button onClick={() => reset(u.id)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300">Password</button>
<button onClick={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded"></button> <button onClick={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Delete</button>
</div> </div>
</td> </td>
</tr> </tr>
))} ))}
{!(users || []).length && <tr><td colSpan={5} className="text-center text-slate-500 py-8"> .</td></tr>} {!(users || []).length && <tr><td colSpan={5} className="text-center text-slate-500 py-8">No users found.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -12,7 +12,7 @@ interface Row { [k: string]: any }
export default function Chart({ type = 'BAR', rows, dataKeys, xKey = 'name', height = 260 }: export default function Chart({ type = 'BAR', rows, dataKeys, xKey = 'name', height = 260 }:
{ type?: string; rows: Row[]; dataKeys: string[]; xKey?: string; height?: number }) { { type?: string; rows: Row[]; dataKeys: string[]; xKey?: string; height?: number }) {
if (!rows || !rows.length) { if (!rows || !rows.length) {
return <div className="flex items-center justify-center text-slate-500 text-sm" style={{ height }}> </div> return <div className="flex items-center justify-center text-slate-500 text-sm" style={{ height }}>No data</div>
} }
const t = (type || 'BAR').toUpperCase() const t = (type || 'BAR').toUpperCase()

View File

@ -26,10 +26,10 @@ const COLORS: Record<string, string> = {
VIP: 'bg-violet-500/15 text-violet-300 border-violet-500/30', VIP: 'bg-violet-500/15 text-violet-300 border-violet-500/30',
} }
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
PENDING: '대기', PAID: '결제완료', PREPARING: '준비중', SHIPPED: '배송중', DELIVERED: '배송완료', PENDING: 'Pending', PAID: 'Paid', PREPARING: 'Preparing', SHIPPED: 'Out for Delivery', DELIVERED: 'Delivered',
CONFIRMED: '구매확정', CANCELLED: '취소', REFUNDED: '환불', ACTIVE: '활성', PAUSED: '일시정지', CONFIRMED: 'Confirmed', CANCELLED: 'Cancelled', REFUNDED: 'Refunded', ACTIVE: 'Active', PAUSED: 'Paused',
REQUESTED: '요청', APPROVED: '승인', REJECTED: '반려', COMPLETED: '완료', REQUESTED: 'Requested', APPROVED: 'Approved', REJECTED: 'Rejected', COMPLETED: 'Completed',
PUBLISHED: '게시', DRAFT: '초안', ENDED: '종료', OPEN: '접수', IN_PROGRESS: '처리중', RESOLVED: '해결', PUBLISHED: 'Published', DRAFT: 'Draft', ENDED: 'Ended', OPEN: 'Open', IN_PROGRESS: 'Processing', RESOLVED: 'Resolved',
} }
export default function StatusBadge({ status }: { status?: string }) { export default function StatusBadge({ status }: { status?: string }) {
if (!status) return null if (!status) return null

View File

@ -17,8 +17,8 @@ export default function Cart() {
if (!custToken) return ( if (!custToken) return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center"> <div className="max-w-3xl mx-auto px-4 py-20 text-center">
<ShoppingCart className="mx-auto text-bloom/40 mb-3" size={48} /> <ShoppingCart className="mx-auto text-bloom/40 mb-3" size={48} />
<p className="text-gray-500 mb-4"> .</p> <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"> / </Link> <Link to="/account" className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold">Sign In / Register</Link>
</div> </div>
) )
@ -27,9 +27,9 @@ export default function Cart() {
return ( return (
<div className="max-w-4xl mx-auto px-4 py-8"> <div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="font-serif text-2xl font-bold mb-6"></h1> <h1 className="font-serif text-2xl font-bold mb-6">Cart</h1>
{!list.length ? ( {!list.length ? (
<div className="text-center text-gray-400 py-16"> . <Link to="/category" className="text-bloom"> </Link></div> <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="grid md:grid-cols-3 gap-6"> <div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-3"> <div className="md:col-span-2 space-y-3">
@ -39,8 +39,8 @@ export default function Cart() {
{i.thumbnail ? <img src={i.thumbnail} className="w-full h-full object-cover" /> : <Flower2 className="text-bloom/30" size={32} />} {i.thumbnail ? <img src={i.thumbnail} className="w-full h-full object-cover" /> : <Flower2 className="text-bloom/30" size={32} />}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="font-medium text-sm">{i.productName || `상품 #${i.productId}`}</div> <div className="font-medium text-sm">{i.productName || `Product #${i.productId}`}</div>
<div className="text-xs text-gray-500">{i.sizeCode}{i.cardMessage ? ` · 카드: ${i.cardMessage.slice(0, 20)}` : ''}</div> <div className="text-xs text-gray-500">{i.sizeCode}{i.cardMessage ? ` · Card: ${i.cardMessage.slice(0, 20)}` : ''}</div>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
<div className="flex items-center border border-blush-100 rounded-full text-sm"> <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> <button onClick={() => setQty(i.id, (i.quantity || 1) - 1)} className="px-2.5 py-1 text-bloom2"></button>
@ -55,10 +55,10 @@ export default function Cart() {
))} ))}
</div> </div>
<div className="bg-white rounded-2xl border border-blush-100/60 p-5 h-fit"> <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"></span><span className="font-medium">{money(subtotal)}</span></div> <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">/</span><span className="text-gray-400"> </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></span><span className="text-bloom2">{money(subtotal)}</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"></button> <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> </div>
</div> </div>
)} )}

View File

@ -78,10 +78,10 @@ export default function Checkout() {
const placeOrder = async () => { const placeOrder = async () => {
setErr('') setErr('')
if (!custToken) { nav('/account'); return } if (!custToken) { nav('/account'); return }
if (!items.length) { setErr('장바구니가 비어 있습니다.'); return } if (!items.length) { setErr('Your cart is empty.'); return }
if (!date || !slot) { setErr('배송/픽업 날짜와 시간대를 선택하세요.'); return } if (!date || !slot) { setErr('Please select a delivery/pickup date and time slot.'); return }
if (fulfillment === 'DELIVERY' && (!address || !receiverName)) { setErr('받는 분과 주소를 입력하세요.'); return } if (fulfillment === 'DELIVERY' && (!address || !receiverName)) { setErr('Please enter the recipient and delivery address.'); return }
if (payMethod === 'CARD' && !card.complete) { setErr('카드 정보를 입력하세요.'); return } if (payMethod === 'CARD' && !card.complete) { setErr('Please enter your card details.'); return }
setPlacing(true) setPlacing(true)
try { try {
const order = await checkout({ const order = await checkout({
@ -100,7 +100,7 @@ export default function Checkout() {
setCartCount(0) setCartCount(0)
setDone(order) setDone(order)
} catch (e: any) { } catch (e: any) {
setErr(e?.response?.data?.message || '주문에 실패했습니다. 잠시 후 다시 시도하세요.') setErr(e?.response?.data?.message || 'Order failed. Please try again in a moment.')
} finally { setPlacing(false) } } finally { setPlacing(false) }
} }
@ -109,30 +109,30 @@ export default function Checkout() {
if (done) return ( if (done) return (
<div className="max-w-lg mx-auto px-4 py-20 text-center"> <div className="max-w-lg mx-auto px-4 py-20 text-center">
<CheckCircle2 className="mx-auto text-leaf mb-4" size={56} /> <CheckCircle2 className="mx-auto text-leaf mb-4" size={56} />
<h1 className="font-serif text-2xl font-bold mb-2"> </h1> <h1 className="font-serif text-2xl font-bold mb-2">Your order has been placed</h1>
<p className="text-gray-500 mb-1"> <span className="font-semibold text-bloom2">{done.orderNo || `#${done.id}`}</span></p> <p className="text-gray-500 mb-1">Order Number <span className="font-semibold text-bloom2">{done.orderNo || `#${done.id}`}</span></p>
<p className="text-sm text-gray-500 mb-6">{date} · {slot?.label} · {money(grand)}</p> <p className="text-sm text-gray-500 mb-6">{date} · {slot?.label} · {money(grand)}</p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<button onClick={() => nav('/orders')} className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold"> </button> <button onClick={() => nav('/orders')} className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold">Order History</button>
<button onClick={() => nav('/home')} className="border border-blush-100 px-6 py-2.5 rounded-full text-bloom2"> </button> <button onClick={() => nav('/home')} className="border border-blush-100 px-6 py-2.5 rounded-full text-bloom2">Continue Shopping</button>
</div> </div>
</div> </div>
) )
return ( return (
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-5xl mx-auto px-4 py-8">
<h1 className="font-serif text-2xl font-bold mb-6"> / </h1> <h1 className="font-serif text-2xl font-bold mb-6">Checkout</h1>
<div className="grid md:grid-cols-3 gap-6"> <div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-5"> <div className="md:col-span-2 space-y-5">
{/* 수령 방식 */} {/* 수령 방식 */}
<section className="bg-white rounded-2xl border border-blush-100/60 p-5"> <section className="bg-white rounded-2xl border border-blush-100/60 p-5">
<div className="text-sm font-semibold mb-3"> </div> <div className="text-sm font-semibold mb-3">Fulfillment Method</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button onClick={() => setFulfillment('DELIVERY')} className={`flex items-center gap-2 justify-center py-3 rounded-xl border ${fulfillment === 'DELIVERY' ? 'border-bloom bg-petal text-bloom2' : 'border-blush-100'}`}><Truck size={18} /> </button> <button onClick={() => setFulfillment('DELIVERY')} className={`flex items-center gap-2 justify-center py-3 rounded-xl border ${fulfillment === 'DELIVERY' ? 'border-bloom bg-petal text-bloom2' : 'border-blush-100'}`}><Truck size={18} /> Local Delivery</button>
<button onClick={() => setFulfillment('PICKUP')} className={`flex items-center gap-2 justify-center py-3 rounded-xl border ${fulfillment === 'PICKUP' ? 'border-bloom bg-petal text-bloom2' : 'border-blush-100'}`}><StoreIcon size={18} /> </button> <button onClick={() => setFulfillment('PICKUP')} className={`flex items-center gap-2 justify-center py-3 rounded-xl border ${fulfillment === 'PICKUP' ? 'border-bloom bg-petal text-bloom2' : 'border-blush-100'}`}><StoreIcon size={18} /> Store Pickup</button>
</div> </div>
<div className="mt-3"> <div className="mt-3">
<label className="block text-xs text-gray-500 mb-1"></label> <label className="block text-xs text-gray-500 mb-1">Store</label>
<select value={storeId} onChange={e => setStoreId(Number(e.target.value))} className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm bg-white"> <select value={storeId} onChange={e => setStoreId(Number(e.target.value))} className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm bg-white">
{(stores || []).map((s: any) => <option key={s.id} value={s.id}>{s.name} ({s.city}, {s.state})</option>)} {(stores || []).map((s: any) => <option key={s.id} value={s.id}>{s.name} ({s.city}, {s.state})</option>)}
</select> </select>
@ -141,7 +141,7 @@ export default function Checkout() {
{/* 날짜 + 타임슬롯 (UrbanStems/Ode) */} {/* 날짜 + 타임슬롯 (UrbanStems/Ode) */}
<section className="bg-white rounded-2xl border border-blush-100/60 p-5"> <section className="bg-white rounded-2xl border border-blush-100/60 p-5">
<div className="flex items-center gap-2 text-sm font-semibold mb-3"><CalendarDays size={16} className="text-bloom" /> / </div> <div className="flex items-center gap-2 text-sm font-semibold mb-3"><CalendarDays size={16} className="text-bloom" /> Delivery / Pickup Date</div>
<div className="flex gap-2 overflow-x-auto pb-2"> <div className="flex gap-2 overflow-x-auto pb-2">
{nextDays(14).map(d => { {nextDays(14).map(d => {
const blocked = holidaySet.has(d) const blocked = holidaySet.has(d)
@ -152,17 +152,17 @@ export default function Checkout() {
className={`shrink-0 w-16 py-2 rounded-xl border text-center text-xs ${blocked ? 'opacity-30 cursor-not-allowed border-blush-100' : sel ? 'border-bloom bg-bloom text-white' : 'border-blush-100 hover:border-bloom'}`}> className={`shrink-0 w-16 py-2 rounded-xl border text-center text-xs ${blocked ? 'opacity-30 cursor-not-allowed border-blush-100' : sel ? 'border-bloom bg-bloom text-white' : 'border-blush-100 hover:border-bloom'}`}>
<div className="font-semibold">{dd.toLocaleDateString('en-US', { weekday: 'short' })}</div> <div className="font-semibold">{dd.toLocaleDateString('en-US', { weekday: 'short' })}</div>
<div className="text-base">{dd.getDate()}</div> <div className="text-base">{dd.getDate()}</div>
{blocked && <div className="text-[9px]"></div>} {blocked && <div className="text-[9px]">Closed</div>}
</button> </button>
) )
})} })}
</div> </div>
{date && avail && ( {date && avail && (
<div className="mt-3"> <div className="mt-3">
{avail.blocked && <div className="flex items-center gap-1.5 text-blush-500 text-xs mb-2"><AlertTriangle size={13} /> (/).</div>} {avail.blocked && <div className="flex items-center gap-1.5 text-blush-500 text-xs mb-2"><AlertTriangle size={13} /> Delivery is unavailable on this date (peak season / closed).</div>}
{avail.surgeMultiplier > 1 && <div className="text-xs text-amber-600 mb-2"> ×{avail.surgeMultiplier} </div>} {avail.surgeMultiplier > 1 && <div className="text-xs text-amber-600 mb-2"> Peak-season surge pricing ×{avail.surgeMultiplier} applied</div>}
{avail.sameDayAvailable && <div className="text-xs text-leaf mb-2"> ( {avail.sameDayCutoff})</div>} {avail.sameDayAvailable && <div className="text-xs text-leaf mb-2">Same-Day Delivery available (order by {avail.sameDayCutoff})</div>}
<div className="flex items-center gap-2 text-sm font-medium mb-2"><Clock size={14} className="text-bloom" /> </div> <div className="flex items-center gap-2 text-sm font-medium mb-2"><Clock size={14} className="text-bloom" /> Delivery Time Slot</div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{(avail.slots || []).map((s: any) => { {(avail.slots || []).map((s: any) => {
const full = s.available === false || (s.capacity != null && s.booked >= s.capacity) const full = s.available === false || (s.capacity != null && s.booked >= s.capacity)
@ -173,7 +173,7 @@ export default function Checkout() {
</button> </button>
) )
})} })}
{!(avail.slots || []).length && <div className="col-span-3 text-gray-400 text-xs py-2"> .</div>} {!(avail.slots || []).length && <div className="col-span-3 text-gray-400 text-xs py-2">No time slots available.</div>}
</div> </div>
</div> </div>
)} )}
@ -182,19 +182,19 @@ export default function Checkout() {
{/* 받는 분 / 주소 (배송) */} {/* 받는 분 / 주소 (배송) */}
{fulfillment === 'DELIVERY' && ( {fulfillment === 'DELIVERY' && (
<section className="bg-white rounded-2xl border border-blush-100/60 p-5 space-y-3"> <section className="bg-white rounded-2xl border border-blush-100/60 p-5 space-y-3">
<div className="text-sm font-semibold"> </div> <div className="text-sm font-semibold">Recipient Information</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<input value={receiverName} onChange={e => setReceiverName(e.target.value)} placeholder="받는 분 이름" className="px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" /> <input value={receiverName} onChange={e => setReceiverName(e.target.value)} placeholder="Recipient Name" className="px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
<input value={receiverPhone} onChange={e => setReceiverPhone(e.target.value)} placeholder="연락처" className="px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" /> <input value={receiverPhone} onChange={e => setReceiverPhone(e.target.value)} placeholder="Phone" className="px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<input value={address} onChange={e => setAddress(e.target.value)} placeholder="배송 주소" className="flex-1 px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" /> <input value={address} onChange={e => setAddress(e.target.value)} placeholder="Delivery Address" className="flex-1 px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
<input value={deliveryZip} onChange={e => setDeliveryZip(e.target.value.replace(/\D/g, '').slice(0, 5))} placeholder="ZIP" className="w-24 px-3 py-2 rounded-xl border border-blush-100 text-sm text-center outline-none focus:border-bloom" /> <input value={deliveryZip} onChange={e => setDeliveryZip(e.target.value.replace(/\D/g, '').slice(0, 5))} placeholder="ZIP" className="w-24 px-3 py-2 rounded-xl border border-blush-100 text-sm text-center outline-none focus:border-bloom" />
<button onClick={verifyAddr} className="px-3 rounded-xl border border-bloom text-bloom text-sm font-semibold flex items-center gap-1"><MapPin size={14} /> </button> <button onClick={verifyAddr} className="px-3 rounded-xl border border-bloom text-bloom text-sm font-semibold flex items-center gap-1"><MapPin size={14} /> Verify</button>
</div> </div>
{addrCheck && ( {addrCheck && (
<div className={`text-xs ${addrCheck.valid ? 'text-leaf' : 'text-blush-500'}`}> <div className={`text-xs ${addrCheck.valid ? 'text-leaf' : 'text-blush-500'}`}>
{addrCheck.valid ? `검증됨: ${addrCheck.normalized || address} (${addrCheck.provider})` : `주소 검증 실패 (${addrCheck.provider})`} {addrCheck.valid ? `Verified: ${addrCheck.normalized || address} (${addrCheck.provider})` : `Address verification failed (${addrCheck.provider})`}
</div> </div>
)} )}
</section> </section>
@ -202,14 +202,14 @@ export default function Checkout() {
{/* 카드 메시지 / 메모 */} {/* 카드 메시지 / 메모 */}
<section className="bg-white rounded-2xl border border-blush-100/60 p-5 space-y-3"> <section className="bg-white rounded-2xl border border-blush-100/60 p-5 space-y-3">
<div className="flex items-center justify-between"><div className="text-sm font-semibold"> </div><span className="text-xs text-bloom flex items-center gap-1"><Sparkles size={12} /> AI </span></div> <div className="flex items-center justify-between"><div className="text-sm font-semibold">Gift Card Message</div><span className="text-xs text-bloom flex items-center gap-1"><Sparkles size={12} /> AI writing is on the product page</span></div>
<textarea value={cardMessage} onChange={e => setCardMessage(e.target.value)} rows={2} maxLength={200} placeholder="받는 분께 전할 메시지" className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" /> <textarea value={cardMessage} onChange={e => setCardMessage(e.target.value)} rows={2} maxLength={200} placeholder="Message for the recipient" className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
<input value={memo} onChange={e => setMemo(e.target.value)} placeholder="배송 요청사항 (선택)" className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" /> <input value={memo} onChange={e => setMemo(e.target.value)} placeholder="Special Instructions (optional)" className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
</section> </section>
{/* 결제수단 (Montvale: Visa·Mastercard·Amex·Discover·Apple Pay·Google Pay) */} {/* 결제수단 (Montvale: Visa·Mastercard·Amex·Discover·Apple Pay·Google Pay) */}
<section className="bg-white rounded-2xl border border-blush-100/60 p-5"> <section className="bg-white rounded-2xl border border-blush-100/60 p-5">
<div className="text-sm font-semibold mb-3"> (Payment)</div> <div className="text-sm font-semibold mb-3">Payment Method</div>
<PaymentSection <PaymentSection
method={payMethod} method={payMethod}
onMethod={setPayMethod} onMethod={setPayMethod}
@ -222,35 +222,35 @@ export default function Checkout() {
{/* 요약 */} {/* 요약 */}
<aside className="bg-white rounded-2xl border border-blush-100/60 p-5 h-fit sticky top-20"> <aside className="bg-white rounded-2xl border border-blush-100/60 p-5 h-fit sticky top-20">
<div className="text-sm font-semibold mb-3"> </div> <div className="text-sm font-semibold mb-3">Order Summary</div>
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
<Row k="소계" v={money(subtotal)} /> <Row k="Subtotal" v={money(subtotal)} />
{surge > 0 && <Row k="서지 프라이싱" v={`+${money(surge)}`} amber />} {surge > 0 && <Row k="Surge Pricing" v={`+${money(surge)}`} amber />}
<Row k="세금" v={money(taxAmount)} sub={tax ? `${tax.provider} ${(tax.rate * 100).toFixed(1)}%` : ''} /> <Row k="Tax" v={money(taxAmount)} sub={tax ? `${tax.provider} ${(tax.rate * 100).toFixed(1)}%` : ''} />
{tierDiscount > 0 && <Row k={`등급 할인(${loyalty?.tierName || ''} ${discountRate}%)`} v={`-${money(tierDiscount)}`} green />} {tierDiscount > 0 && <Row k={`Tier Discount (${loyalty?.tierName || ''} ${discountRate}%)`} v={`-${money(tierDiscount)}`} green />}
{usePointsAmt > 0 && <Row k="포인트 사용" v={`-${money(usePointsAmt)}`} green />} {usePointsAmt > 0 && <Row k="Points Used" v={`-${money(usePointsAmt)}`} green />}
</div> </div>
{/* 마일리지/포인트 */} {/* 마일리지/포인트 */}
{!!custToken && ( {!!custToken && (
<div className="mt-4 bg-petal rounded-xl p-3"> <div className="mt-4 bg-petal rounded-xl p-3">
<div className="flex items-center justify-between text-xs text-bloom2 mb-1"> <div className="flex items-center justify-between text-xs text-bloom2 mb-1">
<span> {pointBalance.toLocaleString()}P</span> <span>Points Balance {pointBalance.toLocaleString()} pts</span>
<button onClick={() => setUsePointsAmt(maxUsablePoints)} className="text-bloom font-semibold"> </button> <button onClick={() => setUsePointsAmt(maxUsablePoints)} className="text-bloom font-semibold">Use All</button>
</div> </div>
<input type="range" min={0} max={maxUsablePoints} value={usePointsAmt} onChange={e => setUsePointsAmt(Number(e.target.value))} className="w-full accent-bloom" /> <input type="range" min={0} max={maxUsablePoints} value={usePointsAmt} onChange={e => setUsePointsAmt(Number(e.target.value))} className="w-full accent-bloom" />
<div className="text-xs text-gray-500 text-right">{usePointsAmt.toLocaleString()}P </div> <div className="text-xs text-gray-500 text-right">{usePointsAmt.toLocaleString()} pts used</div>
</div> </div>
)} )}
<div className="border-t border-blush-100/60 mt-4 pt-3 flex justify-between font-bold text-base"> <div className="border-t border-blush-100/60 mt-4 pt-3 flex justify-between font-bold text-base">
<span> </span><span className="text-bloom2">{money(grand)}</span> <span>Order Total</span><span className="text-bloom2">{money(grand)}</span>
</div> </div>
{err && <div className="text-blush-500 text-xs mt-3">{err}</div>} {err && <div className="text-blush-500 text-xs mt-3">{err}</div>}
<button onClick={placeOrder} disabled={placing} className="w-full mt-4 bg-bloom text-white font-semibold py-3 rounded-full hover:bg-bloom2 disabled:opacity-60"> <button onClick={placeOrder} disabled={placing} className="w-full mt-4 bg-bloom text-white font-semibold py-3 rounded-full hover:bg-bloom2 disabled:opacity-60">
{placing ? '결제 중…' : `${money(grand)} 결제하기`} {placing ? 'Processing…' : `Place Order · ${money(grand)}`}
</button> </button>
<p className="text-[11px] text-gray-400 text-center mt-2"> GUARDiA PaymentGateway( ) · </p> <p className="text-[11px] text-gray-400 text-center mt-2">Payments processed via GUARDiA PaymentGateway (secure adapter) · Card details not stored</p>
</aside> </aside>
</div> </div>
</div> </div>

View File

@ -18,8 +18,8 @@ export default function Cs() {
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
// AI 카드메시지 도우미 // AI 카드메시지 도우미
const [occasion, setOccasion] = useState('생일') const [occasion, setOccasion] = useState('Birthday')
const [tone, setTone] = useState('따뜻하게') const [tone, setTone] = useState('Warm')
const [recipient, setRecipient] = useState('') const [recipient, setRecipient] = useState('')
const [cardSuggestions, setCardSuggestions] = useState<string[]>([]) const [cardSuggestions, setCardSuggestions] = useState<string[]>([])
@ -30,9 +30,9 @@ export default function Cs() {
if (!custToken) { nav('/account'); return } if (!custToken) { nav('/account'); return }
try { try {
const r = await createCs({ orderNo, category, subject, content }) const r = await createCs({ orderNo, category, subject, content })
setMsg(r?.aiReply ? `AI 자동응답: ${r.aiReply}` : '문의가 접수되었습니다. (ITSM SR 연계)') setMsg(r?.aiReply ? `AI auto-reply: ${r.aiReply}` : 'Your request has been submitted.')
setSubject(''); setContent(''); qc.invalidateQueries({ queryKey: ['cs'] }) setSubject(''); setContent(''); qc.invalidateQueries({ queryKey: ['cs'] })
} catch { setMsg('접수에 실패했습니다.') } } catch { setMsg('Failed to submit your request.') }
} }
const genCard = async () => { const genCard = async () => {
@ -42,17 +42,17 @@ export default function Cs() {
return ( return (
<div className="max-w-3xl mx-auto px-4 py-8"> <div className="max-w-3xl mx-auto px-4 py-8">
<div className="flex items-center gap-2 mb-6"><Headset className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900"> (1:1)</h1></div> <div className="flex items-center gap-2 mb-6"><Headset className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900">Customer Support (1:1)</h1></div>
{/* AI 카드 메시지 작성 도우미 */} {/* AI 카드 메시지 작성 도우미 */}
<div className="bg-petal rounded-2xl p-5 mb-6"> <div className="bg-petal rounded-2xl p-5 mb-6">
<div className="flex items-center gap-2 text-bloom2 font-medium text-sm mb-3"><Sparkles size={16} /> AI </div> <div className="flex items-center gap-2 text-bloom2 font-medium text-sm mb-3"><Sparkles size={16} /> AI Card Message Helper</div>
<div className="grid grid-cols-3 gap-2 mb-3"> <div className="grid grid-cols-3 gap-2 mb-3">
<input value={occasion} onChange={e => setOccasion(e.target.value)} placeholder="상황(생일 등)" className="px-3 py-2 rounded-lg border border-blush-100 text-sm bg-white outline-none" /> <input value={occasion} onChange={e => setOccasion(e.target.value)} placeholder="Occasion (e.g. Birthday)" className="px-3 py-2 rounded-lg border border-blush-100 text-sm bg-white outline-none" />
<input value={tone} onChange={e => setTone(e.target.value)} placeholder="" className="px-3 py-2 rounded-lg border border-blush-100 text-sm bg-white outline-none" /> <input value={tone} onChange={e => setTone(e.target.value)} placeholder="Tone" className="px-3 py-2 rounded-lg border border-blush-100 text-sm bg-white outline-none" />
<input value={recipient} onChange={e => setRecipient(e.target.value)} placeholder="받는 분" className="px-3 py-2 rounded-lg border border-blush-100 text-sm bg-white outline-none" /> <input value={recipient} onChange={e => setRecipient(e.target.value)} placeholder="Recipient" className="px-3 py-2 rounded-lg border border-blush-100 text-sm bg-white outline-none" />
</div> </div>
<button onClick={genCard} className="bg-bloom text-white text-sm font-semibold px-4 py-2 rounded-full"> </button> <button onClick={genCard} className="bg-bloom text-white text-sm font-semibold px-4 py-2 rounded-full">Suggest Messages</button>
{!!cardSuggestions.length && ( {!!cardSuggestions.length && (
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
{cardSuggestions.map((m, i) => <li key={i} className="bg-white rounded-lg px-3 py-2 text-sm text-gray-700">{m}</li>)} {cardSuggestions.map((m, i) => <li key={i} className="bg-white rounded-lg px-3 py-2 text-sm text-gray-700">{m}</li>)}
@ -66,16 +66,16 @@ export default function Cs() {
<select value={category} onChange={e => setCategory(e.target.value)} className="px-3 py-2 rounded-xl border border-blush-100 text-sm bg-white"> <select value={category} onChange={e => setCategory(e.target.value)} className="px-3 py-2 rounded-xl border border-blush-100 text-sm bg-white">
{CATS.map(c => <option key={c} value={c}>{c}</option>)} {CATS.map(c => <option key={c} value={c}>{c}</option>)}
</select> </select>
<input value={orderNo} onChange={e => setOrderNo(e.target.value)} placeholder="주문번호 (선택)" className="px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" /> <input value={orderNo} onChange={e => setOrderNo(e.target.value)} placeholder="Order number (optional)" className="px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
</div> </div>
<input value={subject} onChange={e => setSubject(e.target.value)} required placeholder="제목" className="w-full px-3 py-2.5 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" /> <input value={subject} onChange={e => setSubject(e.target.value)} required placeholder="Subject" className="w-full px-3 py-2.5 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
<textarea value={content} onChange={e => setContent(e.target.value)} rows={4} required placeholder="문의 내용을 적어주세요. AI가 먼저 답변을 시도합니다." className="w-full px-3 py-2.5 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" /> <textarea value={content} onChange={e => setContent(e.target.value)} rows={4} required placeholder="Tell us how we can help. Our AI will try to answer first." className="w-full px-3 py-2.5 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
{msg && <div className="text-sm text-leaf bg-leaf/10 rounded-lg px-3 py-2">{msg}</div>} {msg && <div className="text-sm text-leaf bg-leaf/10 rounded-lg px-3 py-2">{msg}</div>}
<button className="flex items-center gap-1.5 bg-bloom text-white font-semibold px-6 py-2.5 rounded-full hover:bg-bloom2"><Send size={16} /> </button> <button className="flex items-center gap-1.5 bg-bloom text-white font-semibold px-6 py-2.5 rounded-full hover:bg-bloom2"><Send size={16} /> Submit Request</button>
{!custToken && <p className="text-xs text-gray-400"> . <Link to="/account" className="text-bloom"></Link></p>} {!custToken && <p className="text-xs text-gray-400">Please log in to submit a request. <Link to="/account" className="text-bloom">Log In</Link></p>}
</form> </form>
<h2 className="font-serif text-lg font-bold mb-3"> </h2> <h2 className="font-serif text-lg font-bold mb-3">My Requests</h2>
<div className="space-y-2"> <div className="space-y-2">
{(tickets || []).map((t: any) => ( {(tickets || []).map((t: any) => (
<div key={t.id} className="bg-white rounded-2xl border border-blush-100/60 p-4"> <div key={t.id} className="bg-white rounded-2xl border border-blush-100/60 p-4">
@ -84,11 +84,11 @@ export default function Cs() {
<StatusBadge status={t.status} /> <StatusBadge status={t.status} />
</div> </div>
<p className="text-sm text-gray-600 mt-1">{t.content}</p> <p className="text-sm text-gray-600 mt-1">{t.content}</p>
{t.aiReply && <div className="mt-2 text-xs text-bloom2 bg-petal rounded-lg px-3 py-2"><b>AI :</b> {t.aiReply}</div>} {t.aiReply && <div className="mt-2 text-xs text-bloom2 bg-petal rounded-lg px-3 py-2"><b>AI Reply:</b> {t.aiReply}</div>}
{t.itsmSrId && <div className="text-[11px] text-gray-400 mt-1">ITSM SR: {t.itsmSrId}</div>} {t.itsmSrId && <div className="text-[11px] text-gray-400 mt-1">ITSM SR: {t.itsmSrId}</div>}
</div> </div>
))} ))}
{!(tickets || []).length && <div className="text-gray-400 text-sm py-6 text-center"> .</div>} {!(tickets || []).length && <div className="text-gray-400 text-sm py-6 text-center">No requests submitted yet.</div>}
</div> </div>
</div> </div>
) )

View File

@ -23,7 +23,7 @@ export default function CustomerApp() {
fetch(`${ITSM_APP_BASE}/api/app/public-latest`) fetch(`${ITSM_APP_BASE}/api/app/public-latest`)
.then(r => r.json()) .then(r => r.json())
.then((d: AppInfo) => setInfo({ ...d, qr_url: fixUrl(d.qr_url), landing_url: fixUrl(d.landing_url), download_url: fixUrl(d.download_url) })) .then((d: AppInfo) => setInfo({ ...d, qr_url: fixUrl(d.qr_url), landing_url: fixUrl(d.landing_url), download_url: fixUrl(d.download_url) }))
.catch(() => setErr('앱 저장소에 연결할 수 없습니다. 잠시 후 다시 시도하세요.')) .catch(() => setErr('Unable to connect to the app store. Please try again in a moment.'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
@ -36,25 +36,25 @@ export default function CustomerApp() {
return ( return (
<div className="max-w-3xl mx-auto px-4 py-10"> <div className="max-w-3xl mx-auto px-4 py-10">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="flex items-center gap-2 justify-center mb-2"><Smartphone className="text-bloom" size={26} /><h1 className="font-serif text-2xl font-bold text-bloom2"> </h1></div> <div className="flex items-center gap-2 justify-center mb-2"><Smartphone className="text-bloom" size={26} /><h1 className="font-serif text-2xl font-bold text-bloom2">Order with the App</h1></div>
<p className="text-sm text-gray-500">QR <span className="text-bloom font-semibold">GUARDiA Mall</span> .<br /> · · .</p> <p className="text-sm text-gray-500">Scan the QR code to open the <span className="text-bloom font-semibold">GUARDiA Mall</span> customer app install page.<br />Enjoy same-day delivery alerts, easy reordering, and subscription management right in the app.</p>
</div> </div>
{loading && <div className="text-center text-gray-400 py-10"> </div>} {loading && <div className="text-center text-gray-400 py-10">Loading</div>}
{err && <div className="bg-petal border border-blush-100 rounded-2xl p-6 text-center text-blush-500 text-sm">{err}</div>} {err && <div className="bg-petal border border-blush-100 rounded-2xl p-6 text-center text-blush-500 text-sm">{err}</div>}
{!loading && !err && info && !info.has_version && ( {!loading && !err && info && !info.has_version && (
<div className="bg-white border border-blush-100/60 rounded-2xl p-10 text-center text-gray-400"> <div className="bg-white border border-blush-100/60 rounded-2xl p-10 text-center text-gray-400">
<Flower2 size={40} className="mx-auto mb-3 text-bloom/30" /> <Flower2 size={40} className="mx-auto mb-3 text-bloom/30" />
.<br /> No app version has been published yet.<br />
<span className="text-xs"> · GUARDiA Manager에서 .</span> <span className="text-xs">App uploads and version management are handled in GUARDiA Manager.</span>
</div> </div>
)} )}
{!loading && !err && info?.has_version && ( {!loading && !err && info?.has_version && (
<div className="bg-white border border-blush-100/60 rounded-2xl p-6 grid md:grid-cols-[200px_1fr] gap-6 items-start shadow-sm"> <div className="bg-white border border-blush-100/60 rounded-2xl p-6 grid md:grid-cols-[200px_1fr] gap-6 items-start shadow-sm">
<div className="bg-petal rounded-2xl p-3 flex items-center justify-center"> <div className="bg-petal rounded-2xl p-3 flex items-center justify-center">
{info.qr_url ? <img src={info.qr_url} alt="앱 설치 QR" className="w-44 h-44" /> : <Smartphone size={64} className="text-bloom/40" />} {info.qr_url ? <img src={info.qr_url} alt="App install QR code" className="w-44 h-44" /> : <Smartphone size={64} className="text-bloom/40" />}
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@ -63,19 +63,19 @@ export default function CustomerApp() {
</div> </div>
<div className="text-xs text-gray-500 mb-4"> <div className="text-xs text-gray-500 mb-4">
{info.platform} {info.file_size_mb ? `· ${info.file_size_mb}MB` : ''} {info.platform} {info.file_size_mb ? `· ${info.file_size_mb}MB` : ''}
{info.download_count != null && ` · 다운로드 ${info.download_count}`} {info.download_count != null && ` · ${info.download_count} downloads`}
</div> </div>
{info.release_notes && ( {info.release_notes && (
<div className="mb-4"> <div className="mb-4">
<div className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1"></div> <div className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-1">What's New</div>
<div className="text-sm text-gray-600 whitespace-pre-line bg-petal rounded-lg p-3 max-h-32 overflow-auto">{info.release_notes}</div> <div className="text-sm text-gray-600 whitespace-pre-line bg-petal rounded-lg p-3 max-h-32 overflow-auto">{info.release_notes}</div>
</div> </div>
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{info.landing_url && <a href={info.landing_url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 px-4 py-2 rounded-full bg-bloom text-white text-sm font-semibold hover:bg-bloom2"><ExternalLink size={15} /> </a>} {info.landing_url && <a href={info.landing_url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 px-4 py-2 rounded-full bg-bloom text-white text-sm font-semibold hover:bg-bloom2"><ExternalLink size={15} /> Install Page</a>}
{info.download_url && <a href={info.download_url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 px-4 py-2 rounded-full border border-blush-100 text-bloom2 text-sm hover:bg-petal"><Download size={15} /> APK </a>} {info.download_url && <a href={info.download_url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 px-4 py-2 rounded-full border border-blush-100 text-bloom2 text-sm hover:bg-petal"><Download size={15} /> Download APK</a>}
<button onClick={copyLink} className="flex items-center gap-1.5 px-4 py-2 rounded-full border border-blush-100 text-bloom2 text-sm hover:bg-petal"> <button onClick={copyLink} className="flex items-center gap-1.5 px-4 py-2 rounded-full border border-blush-100 text-bloom2 text-sm hover:bg-petal">
{copied ? <Check size={15} className="text-leaf" /> : <Copy size={15} />}{copied ? '복사됨' : '링크 복사'} {copied ? <Check size={15} className="text-leaf" /> : <Copy size={15} />}{copied ? 'Copied' : 'Copy Link'}
</button> </button>
<button onClick={load} className="flex items-center gap-1.5 px-3 py-2 rounded-full border border-blush-100 text-gray-500 text-sm hover:bg-petal"><RefreshCw size={15} /></button> <button onClick={load} className="flex items-center gap-1.5 px-3 py-2 rounded-full border border-blush-100 text-gray-500 text-sm hover:bg-petal"><RefreshCw size={15} /></button>
</div> </div>

View File

@ -25,26 +25,26 @@ export default function Mypage() {
return ( return (
<div className="max-w-4xl mx-auto px-4 py-8"> <div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2"><User className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900"></h1></div> <div className="flex items-center gap-2"><User className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900">My Account</h1></div>
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-bloom"><LogOut size={16} /> </button> <button onClick={logout} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-bloom"><LogOut size={16} /> Log Out</button>
</div> </div>
{/* 등급 카드 */} {/* 등급 카드 */}
<div className={`rounded-2xl bg-gradient-to-r ${TIER_COLOR[tier] || TIER_COLOR.BASIC} text-white p-6 mb-5`}> <div className={`rounded-2xl bg-gradient-to-r ${TIER_COLOR[tier] || TIER_COLOR.BASIC} text-white p-6 mb-5`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="text-xs uppercase tracking-widest text-white/70"> </div> <div className="text-xs uppercase tracking-widest text-white/70">Membership Tier</div>
<div className="font-serif text-2xl font-bold flex items-center gap-2"><Crown size={24} /> {loy?.tierName || tier}</div> <div className="font-serif text-2xl font-bold flex items-center gap-2"><Crown size={24} /> {loy?.tierName || tier}</div>
<div className="text-sm text-white/85 mt-1"> 12 {money(loy?.spend12m)} · {loy?.orderCount12m || 0}</div> <div className="text-sm text-white/85 mt-1">Spent in last 12 months {money(loy?.spend12m)} · {loy?.orderCount12m || 0} orders</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-xs text-white/70"> </div> <div className="text-xs text-white/70">Points Balance</div>
<div className="text-3xl font-bold">{(loy?.pointBalance || 0).toLocaleString()}<span className="text-base">P</span></div> <div className="text-3xl font-bold">{(loy?.pointBalance || 0).toLocaleString()}<span className="text-base">P</span></div>
</div> </div>
</div> </div>
{next && !next.isTop && ( {next && !next.isTop && (
<div className="mt-4 text-xs text-white/85 bg-white/15 rounded-lg px-3 py-2"> <div className="mt-4 text-xs text-white/85 bg-white/15 rounded-lg px-3 py-2">
<b>{next.nextTierName}</b> {money(next.spendNeeded)} {next.ordersNeeded} . Spend {money(next.spendNeeded)} more or place {next.ordersNeeded} more orders to reach <b>{next.nextTierName}</b>.
</div> </div>
)} )}
</div> </div>
@ -52,26 +52,26 @@ export default function Mypage() {
{/* 등급 혜택 */} {/* 등급 혜택 */}
{loy?.benefit && ( {loy?.benefit && (
<div className="bg-white rounded-2xl border border-blush-100/60 p-5 mb-5"> <div className="bg-white rounded-2xl border border-blush-100/60 p-5 mb-5">
<div className="text-sm font-semibold mb-3 flex items-center gap-2"><Gift size={16} className="text-bloom" /> </div> <div className="text-sm font-semibold mb-3 flex items-center gap-2"><Gift size={16} className="text-bloom" /> My Tier Benefits</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<Benefit label="할인율" value={`${loy.benefit.discountRate}%`} /> <Benefit label="Discount" value={`${loy.benefit.discountRate}%`} />
<Benefit label="적립률" value={`${loy.benefit.pointEarnRate}%`} /> <Benefit label="Earn Rate" value={`${loy.benefit.pointEarnRate}%`} />
<Benefit label="무료배송" value={loy.benefit.freeShipThreshold === 0 ? '항상' : loy.benefit.freeShipThreshold ? money(loy.benefit.freeShipThreshold) + ' 이상' : '없음'} /> <Benefit label="Free Shipping" value={loy.benefit.freeShipThreshold === 0 ? 'Always' : loy.benefit.freeShipThreshold ? money(loy.benefit.freeShipThreshold) + '+' : 'None'} />
<Benefit label="우선 슬롯" value={loy.benefit.prioritySlot ? '제공' : ''} /> <Benefit label="Priority Slot" value={loy.benefit.prioritySlot ? 'Included' : ''} />
</div> </div>
</div> </div>
)} )}
{/* 빠른 메뉴 */} {/* 빠른 메뉴 */}
<div className="grid grid-cols-3 gap-3 mb-6"> <div className="grid grid-cols-3 gap-3 mb-6">
<Quick to="/orders" icon={Package} label="주문 내역" /> <Quick to="/orders" icon={Package} label="Order History" />
<Quick to="/wishlist" icon={Heart} label="찜 목록" /> <Quick to="/wishlist" icon={Heart} label="Wishlist" />
<Quick to="/subscription" icon={Repeat} label="구독 관리" /> <Quick to="/subscription" icon={Repeat} label="Manage Subscription" />
</div> </div>
{/* 포인트 이력 */} {/* 포인트 이력 */}
<div className="bg-white rounded-2xl border border-blush-100/60 p-5"> <div className="bg-white rounded-2xl border border-blush-100/60 p-5">
<div className="text-sm font-semibold mb-3 flex items-center gap-2"><Coins size={16} className="text-bloom" /> / </div> <div className="text-sm font-semibold mb-3 flex items-center gap-2"><Coins size={16} className="text-bloom" /> Points Earned / Used History</div>
<div className="divide-y divide-blush-100/60"> <div className="divide-y divide-blush-100/60">
{(history || []).map((h: any) => ( {(history || []).map((h: any) => (
<div key={h.id} className="flex items-center justify-between py-2 text-sm"> <div key={h.id} className="flex items-center justify-between py-2 text-sm">
@ -83,11 +83,11 @@ export default function Mypage() {
<span className={h.points >= 0 ? 'text-leaf font-semibold' : 'text-blush-500 font-semibold'}>{h.points >= 0 ? '+' : ''}{h.points}P</span> <span className={h.points >= 0 ? 'text-leaf font-semibold' : 'text-blush-500 font-semibold'}>{h.points >= 0 ? '+' : ''}{h.points}P</span>
</div> </div>
))} ))}
{!(history || []).length && <div className="text-gray-400 text-sm py-6 text-center"> .</div>} {!(history || []).length && <div className="text-gray-400 text-sm py-6 text-center">No points history yet.</div>}
</div> </div>
</div> </div>
{member?.username && <div className="text-center text-xs text-gray-400 mt-6">{member.displayName || member.username} </div>} {member?.username && <div className="text-center text-xs text-gray-400 mt-6">{member.displayName || member.username}</div>}
</div> </div>
) )
} }

View File

@ -18,16 +18,16 @@ export default function Search() {
<div className="max-w-6xl mx-auto px-4 py-8"> <div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Sparkles className="text-bloom" size={20} /> <Sparkles className="text-bloom" size={20} />
<h1 className="font-serif text-2xl font-bold">"{q}" </h1> <h1 className="font-serif text-2xl font-bold">Search Results for "{q}"</h1>
</div> </div>
{data?.parsed && ( {data?.parsed && (
<div className="text-xs text-bloom2 mb-5">AI : <span className="font-medium">{JSON.stringify(data.parsed)}</span> · {data.source}</div> <div className="text-xs text-bloom2 mb-5">AI understood: <span className="font-medium">{JSON.stringify(data.parsed)}</span> · {data.source}</div>
)} )}
{isLoading && <div className="text-gray-400 py-10 text-center">AI </div>} {isLoading && <div className="text-gray-400 py-10 text-center">AI is searching</div>}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{items.map((p: any) => <ProductCard key={p.id} p={p} />)} {items.map((p: any) => <ProductCard key={p.id} p={p} />)}
</div> </div>
{!isLoading && !items.length && <div className="text-center text-gray-400 py-16"> .</div>} {!isLoading && !items.length && <div className="text-center text-gray-400 py-16">No results found.</div>}
</div> </div>
) )
} }

View File

@ -6,7 +6,7 @@ import { money, useShop } from '../store/shop'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
const FREQ = [['WEEKLY', '주간'], ['BIWEEKLY', '격주'], ['MONTHLY', '월간']] const FREQ = [['WEEKLY', 'Weekly'], ['BIWEEKLY', 'Every 2 Weeks'], ['MONTHLY', 'Monthly']]
export default function Subscription() { export default function Subscription() {
const qc = useQueryClient() const qc = useQueryClient()
@ -40,36 +40,36 @@ export default function Subscription() {
return ( return (
<div className="max-w-4xl mx-auto px-4 py-8"> <div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center gap-2 mb-2"><Repeat className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900"> </h1></div> <div className="flex items-center gap-2 mb-2"><Repeat className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900">Flower Subscription</h1></div>
<p className="text-sm text-gray-500 mb-6">·· (BloomsyBox ).</p> <p className="text-sm text-gray-500 mb-6">Get fresh flowers delivered weekly, every two weeks, or monthly.</p>
{!custToken && <div className="bg-petal rounded-2xl p-6 text-center text-sm text-gray-600 mb-6"> . <Link to="/account" className="text-bloom font-semibold"> </Link></div>} {!custToken && <div className="bg-petal rounded-2xl p-6 text-center text-sm text-gray-600 mb-6">Please log in to start a subscription. <Link to="/account" className="text-bloom font-semibold">Log In </Link></div>}
<div className="bg-white rounded-2xl border border-blush-100/60 p-5 mb-6"> <div className="bg-white rounded-2xl border border-blush-100/60 p-5 mb-6">
{!open ? ( {!open ? (
<button onClick={() => custToken ? setOpen(true) : nav('/account')} className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold"> </button> <button onClick={() => custToken ? setOpen(true) : nav('/account')} className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold">Start a New Subscription</button>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="text-sm font-medium mb-2"> </div> <div className="text-sm font-medium mb-2">Delivery Frequency</div>
<div className="flex gap-2">{FREQ.map(([v, l]) => <button key={v} onClick={() => setFreq(v)} className={`px-4 py-2 rounded-full text-sm border ${freq === v ? 'bg-bloom text-white border-bloom' : 'border-blush-100'}`}>{l}</button>)}</div> <div className="flex gap-2">{FREQ.map(([v, l]) => <button key={v} onClick={() => setFreq(v)} className={`px-4 py-2 rounded-full text-sm border ${freq === v ? 'bg-bloom text-white border-bloom' : 'border-blush-100'}`}>{l}</button>)}</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium mb-2"> </div> <div className="text-sm font-medium mb-2">Choose a Product</div>
<select value={productId} onChange={e => setProductId(Number(e.target.value))} className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm bg-white"> <select value={productId} onChange={e => setProductId(Number(e.target.value))} className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm bg-white">
<option value={0}> </option> <option value={0}>Best Seller (Recommended)</option>
{products.map((p: any) => <option key={p.id} value={p.id}>{p.name} {money(p.price)}</option>)} {products.map((p: any) => <option key={p.id} value={p.id}>{p.name} {money(p.price)}</option>)}
</select> </select>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={create} className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold"> </button> <button onClick={create} className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold">Start Subscription</button>
<button onClick={() => setOpen(false)} className="border border-blush-100 px-6 py-2.5 rounded-full text-gray-600"></button> <button onClick={() => setOpen(false)} className="border border-blush-100 px-6 py-2.5 rounded-full text-gray-600">Cancel</button>
</div> </div>
</div> </div>
)} )}
</div> </div>
<h2 className="font-serif text-lg font-bold mb-3"> </h2> <h2 className="font-serif text-lg font-bold mb-3">My Subscriptions</h2>
<div className="space-y-3"> <div className="space-y-3">
{(subs || []).map((s: any) => ( {(subs || []).map((s: any) => (
<div key={s.id} className="bg-white rounded-2xl border border-blush-100/60 p-4 flex items-center gap-4"> <div key={s.id} className="bg-white rounded-2xl border border-blush-100/60 p-4 flex items-center gap-4">

View File

@ -18,9 +18,9 @@ export default function Wishlist() {
return ( return (
<div className="max-w-6xl mx-auto px-4 py-8"> <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"> </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">Wishlist</h1></div>
{!list.length ? ( {!list.length ? (
<div className="text-center text-gray-400 py-16"> . <Link to="/category" className="text-bloom"> </Link></div> <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="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{list.map((p: any) => ( {list.map((p: any) => (