fix(security): 고객 SPA 딥링크(/app·/cart·/events 등) 403 해결 — 비-API GET permitAll(SPA 셸), 인증 API는 보호 유지
This commit is contained in:
parent
736b479239
commit
ac3aa6baf6
@ -81,6 +81,11 @@ public class SecurityConfig {
|
||||
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
|
||||
// 그 외 mall API (장바구니·주문·결제·배송·찜·CS·회원·AI) — 인증 사용자
|
||||
.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()
|
||||
)
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
@ -22,7 +22,7 @@ export default function AdminApp() {
|
||||
setLoading(true); setErr('')
|
||||
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) }))
|
||||
.catch(() => setErr('중앙 앱 저장소(ITSM)에 연결할 수 없습니다.'))
|
||||
.catch(() => setErr('Unable to connect to the central app repository (ITSM).'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(() => { load() }, [])
|
||||
@ -35,36 +35,36 @@ export default function AdminApp() {
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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} /> Refresh</button>
|
||||
</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>}
|
||||
|
||||
{!loading && !err && info && !info.has_version && (
|
||||
<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" />
|
||||
아직 등록된 앱 버전이 없습니다.<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>
|
||||
)}
|
||||
|
||||
{!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-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 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 && (
|
||||
<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">
|
||||
{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.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>}
|
||||
<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>
|
||||
{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} /> 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 ? 'Copied' : 'Copy Link'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,24 +7,24 @@ import {
|
||||
import { getMe } from '../api/client'
|
||||
|
||||
const links = [
|
||||
{ to: '/admin/dashboard', label: '대시보드', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/stores', label: '매장 관리', icon: Store, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/products', label: '상품 관리', icon: Flower2, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/inventory', label: '매장별 재고', icon: Boxes, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/orders', label: '주문 (라이브)', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/transfers', label: '재고 이양', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/members', label: '회원 (CRM)', icon: Users, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/loyalty', label: '등급·포인트', icon: Crown, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/events', label: '이벤트·캠페인', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/subscriptions', label: '구독', icon: Repeat, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/schedule', label: '스케줄·서지', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/analytics', label: '매출 분석 (BI)', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/stores', label: 'Stores', icon: Store, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/products', label: 'Products', icon: Flower2, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/inventory', label: 'Store Inventory', icon: Boxes, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/orders', label: 'Orders (Live)', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/transfers', label: 'Inventory Transfers', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/members', label: 'Members (CRM)', icon: Users, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/loyalty', label: 'Tier & Points', icon: Crown, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/events', label: 'Events & Campaigns', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/subscriptions', label: 'Subscriptions', icon: Repeat, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/schedule', label: 'Schedule & Surge', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/analytics', label: 'Revenue Analytics (BI)', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] },
|
||||
]
|
||||
const adminLinks = [
|
||||
{ to: '/admin/users', label: '사용자/권한', icon: UserCog, roles: ['ADMIN'] },
|
||||
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/settings', label: '게이트웨이 설정', icon: Settings, roles: ['ADMIN'] },
|
||||
{ to: '/admin/app', label: '관리자앱 설치', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/users', label: 'User Management', icon: UserCog, roles: ['ADMIN'] },
|
||||
{ to: '/admin/audit', label: 'Audit Log', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] },
|
||||
{ to: '/admin/settings', label: 'Gateway Settings', icon: Settings, roles: ['ADMIN'] },
|
||||
{ to: '/admin/app', label: 'Admin App Install', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] },
|
||||
]
|
||||
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'}`
|
||||
@ -49,7 +49,7 @@ export default function AdminLayout() {
|
||||
}, [token])
|
||||
|
||||
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 />
|
||||
|
||||
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">
|
||||
<div className="h-16 flex items-center gap-2 px-5 border-b border-edge">
|
||||
<Flower2 className="text-brand" size={22} />
|
||||
<div><div className="font-bold text-base leading-tight">GUARDiA Mall</div><div className="text-[11px] text-slate-400">관리자 콘솔</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>
|
||||
<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>)}
|
||||
{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>)}
|
||||
</>)}
|
||||
</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>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<header className="h-16 bg-panel border-b border-edge flex items-center justify-between px-6">
|
||||
<div className="text-sm text-slate-400">미국 다지점 꽃집 옴니채널 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">
|
||||
<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>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
|
||||
@ -20,15 +20,15 @@ export default function AdminLogin() {
|
||||
const res = await login(username, password)
|
||||
const token = res.data?.data?.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)
|
||||
const me: any = await getMe().catch(() => null)
|
||||
if (me?.role) localStorage.setItem('mall_role', me.role)
|
||||
if (me?.username) localStorage.setItem('mall_admin_user', me.username)
|
||||
if (me?.role === 'USER') { setErr('관리자 권한이 없습니다.'); 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')
|
||||
} 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">
|
||||
<div className="flex items-center gap-2 justify-center mb-6">
|
||||
<Flower2 className="text-brand" size={28} />
|
||||
<span className="text-xl font-bold">GUARDiA Mall 콘솔</span>
|
||||
<span className="text-xl font-bold">GUARDiA Mall Console</span>
|
||||
</div>
|
||||
<p className="text-center text-sm text-slate-400 mb-6">매장·대표 관리자 로그인</p>
|
||||
<label className="block text-xs text-slate-400 mb-1">아이디</label>
|
||||
<p className="text-center text-sm text-slate-400 mb-6">Store & Corporate Admin Sign In</p>
|
||||
<label className="block text-xs text-slate-400 mb-1">Username</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)}
|
||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||
<label className="block text-xs text-slate-400 mb-1">비밀번호</label>
|
||||
<label className="block text-xs text-slate-400 mb-1">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
|
||||
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">로그인</button>
|
||||
<p className="text-center text-[11px] text-slate-500 mt-4">고객 쇼핑몰은 <a href="/" className="text-brand">여기</a></p>
|
||||
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">Sign In</button>
|
||||
<p className="text-center text-[11px] text-slate-500 mt-4">Customer storefront is <a href="/" className="text-brand">here</a></p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -12,31 +12,31 @@ export default function Analytics() {
|
||||
const { data: top } = useQuery({ queryKey: ['an-top'], queryFn: () => getTopProducts(10) })
|
||||
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 trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), 매출: t.sales }))
|
||||
const topRows = (top || []).map((p: any) => ({ name: p.name, 판매: p.salesCount }))
|
||||
const tierRows = (byTier || []).map((t: any) => ({ name: t.tier, 매출: t.sales, 고객: t.customers }))
|
||||
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), Revenue: t.sales }))
|
||||
const topRows = (top || []).map((p: any) => ({ name: p.name, Sold: p.salesCount }))
|
||||
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)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
{[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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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">매장별 매출</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">등급별 매출 (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">베스트셀러 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">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">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">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">Best Sellers Top 10</h2><Chart type="BAR" rows={topRows} dataKeys={['Sold']} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -11,16 +11,16 @@ export default function AuditLog() {
|
||||
return (
|
||||
<div>
|
||||
<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 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>
|
||||
<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" />
|
||||
<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="Filter action" className="bg-card border border-edge rounded-lg px-3 py-2 text-sm w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<tbody>
|
||||
{(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>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -21,45 +21,45 @@ export default function Dashboard() {
|
||||
const { data: top } = useQuery({ queryKey: ['top'], queryFn: () => getTopProducts(8) })
|
||||
|
||||
const today = dash?.today || {}
|
||||
const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, 매출: s.sales, 주문: s.orderCount }))
|
||||
const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), 매출: t.sales, 주문: t.orderCount }))
|
||||
const topRows = (top || []).map((p: any) => ({ name: p.name, 판매량: p.salesCount }))
|
||||
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), Revenue: t.sales, Orders: t.orderCount }))
|
||||
const topRows = (top || []).map((p: any) => ({ name: p.name, 'Units Sold': p.salesCount }))
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Kpi icon={DollarSign} label="오늘 매출" value={money(today.totalSales)} />
|
||||
<Kpi icon={ShoppingBag} label="오늘 주문" value={`${today.orderCount || 0}건`} />
|
||||
<Kpi icon={TrendingUp} label="매장 수" value={`${(dash?.storeRanking || []).length}개`} sub="활성 매장 매출 랭킹" />
|
||||
<Kpi icon={Star} label="대형 주문" value={`${(dash?.bigOrders || []).length}건`} sub="$500+ 고가 주문" />
|
||||
<Kpi icon={DollarSign} label="Today's Revenue" value={money(today.totalSales)} />
|
||||
<Kpi icon={ShoppingBag} label="Today's Orders" value={`${today.orderCount || 0}`} />
|
||||
<Kpi icon={TrendingUp} label="Stores" value={`${(dash?.storeRanking || []).length}`} sub="Active store revenue ranking" />
|
||||
<Kpi icon={Star} label="Large Orders" value={`${(dash?.bigOrders || []).length}`} sub="$500+ high-value orders" />
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5 mb-6">
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-3">매장별 매출 (최근 7일)</h2>
|
||||
<Chart type="BAR" rows={storeRows} dataKeys={['매출', '주문']} />
|
||||
<h2 className="text-sm font-semibold mb-3">Revenue by Store (Last 7 Days)</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">매출 추이 (14일)</h2>
|
||||
<Chart type="AREA" rows={trendRows} dataKeys={['매출']} />
|
||||
<h2 className="text-sm font-semibold mb-3">Revenue Trend (14 Days)</h2>
|
||||
<Chart type="AREA" rows={trendRows} dataKeys={['Revenue']} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-3">베스트셀러</h2>
|
||||
<Chart type="BAR" rows={topRows} dataKeys={['판매량']} />
|
||||
<h2 className="text-sm font-semibold mb-3">Best Sellers</h2>
|
||||
<Chart type="BAR" rows={topRows} dataKeys={['Units Sold']} />
|
||||
</div>
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-3">매장 매출 랭킹</h2>
|
||||
<h2 className="text-sm font-semibold mb-3">Store Revenue Ranking</h2>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-slate-400 text-xs border-b border-edge"><th className="text-left py-2">매장</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>
|
||||
{(dash?.storeRanking || []).map((s: any, i: number) => (
|
||||
<tr key={i} className="border-b border-edge/50"><td className="py-2">{s.storeName || s.storeCode}</td><td className="text-right">{money(s.sales)}</td><td className="text-right text-slate-400">{s.orderCount}</td></tr>
|
||||
))}
|
||||
{!(dash?.storeRanking || []).length && <tr><td colSpan={3} className="text-center text-slate-500 py-6">데이터 없음</td></tr>}
|
||||
{!(dash?.storeRanking || []).length && <tr><td colSpan={3} className="text-center text-slate-500 py-6">No data</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -24,40 +24,40 @@ export default function Events() {
|
||||
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 genCopy = async () => {
|
||||
const r = await aiEventCopy(form.eventType, form.title || '봄 시즌', 'friendly').catch(() => null)
|
||||
if (r) { setForm({ ...form, title: r.headline || form.title, description: r.subtext || form.description }); setAiCopyMsg(`AI 카피 생성 (${r.source})`) }
|
||||
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 copy generated (${r.source})`) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{open && (
|
||||
<div className="bg-card border border-edge rounded-xl p-5 mb-5 space-y-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.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.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="Title" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm col-span-2" />
|
||||
</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">
|
||||
<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.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 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>}
|
||||
<button onClick={create} className="ml-auto bg-brand text-ink font-semibold text-sm px-5 py-2 rounded-lg">생성</button>
|
||||
<button onClick={() => setOpen(false)} className="border border-edge text-slate-400 text-sm px-4 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">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -65,16 +65,16 @@ export default function Events() {
|
||||
{perf && (
|
||||
<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="text-slate-400">참여 {perf.participations} · 참여자 {perf.participants}</span>
|
||||
<span className="text-slate-400">연계주문 {perf.linkedOrders} · 기여매출 {money(perf.attributedSales)}</span>
|
||||
<button onClick={() => setPerf(null)} className="ml-auto text-slate-500 text-xs">닫기</button>
|
||||
<span className="text-slate-400">Participations {perf.participations} · Participants {perf.participants}</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">Close</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<tbody>
|
||||
{(events || []).map((ev: any) => (
|
||||
@ -83,19 +83,19 @@ export default function Events() {
|
||||
<td>{ev.title}</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.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">
|
||||
<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 === 'PUBLISHED' && <button onClick={() => end(ev.id)} className="text-xs bg-slate-600/20 text-slate-400 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">성과</button>
|
||||
<button onClick={() => del(ev.id)} className="text-xs bg-rose-500/15 text-rose-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">End</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">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -16,23 +16,23 @@ export default function Inventory() {
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
{(stores || []).map((s: any) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</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">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<tbody>
|
||||
{(inv || []).map((r: any) => {
|
||||
const available = r.available !== false
|
||||
return (
|
||||
<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-center">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
@ -48,7 +48,7 @@ export default function Inventory() {
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -23,19 +23,19 @@ export default function Loyalty() {
|
||||
const doAdjust = async () => {
|
||||
if (!owner || !points) return
|
||||
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('')
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<table className="w-full text-sm">
|
||||
<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-right">적립률</th><th className="text-right">무료배송</th><th className="text-center">우선슬롯</th><th className="text-center">수정</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">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>
|
||||
<tbody>
|
||||
{(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">{t.discountRate}%</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"><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>
|
||||
))}
|
||||
{!(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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{edit && (
|
||||
<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">
|
||||
<Field label="최소구매($)" value={edit.minSpend12m} onChange={v => setEdit({ ...edit, minSpend12m: Number(v) })} />
|
||||
<Field label="최소주문" value={edit.minOrders12m} onChange={v => setEdit({ ...edit, minOrders12m: Number(v) })} />
|
||||
<Field label="할인율(%)" value={edit.discountRate} onChange={v => setEdit({ ...edit, discountRate: Number(v) })} />
|
||||
<Field label="적립률(%)" value={edit.pointEarnRate} onChange={v => setEdit({ ...edit, pointEarnRate: Number(v) })} />
|
||||
<Field label="Min Spend ($)" value={edit.minSpend12m} onChange={v => setEdit({ ...edit, minSpend12m: Number(v) })} />
|
||||
<Field label="Min Orders" value={edit.minOrders12m} onChange={v => setEdit({ ...edit, minOrders12m: Number(v) })} />
|
||||
<Field label="Discount (%)" value={edit.discountRate} onChange={v => setEdit({ ...edit, discountRate: Number(v) })} />
|
||||
<Field label="Earn Rate (%)" value={edit.pointEarnRate} onChange={v => setEdit({ ...edit, pointEarnRate: Number(v) })} />
|
||||
</div>
|
||||
<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={() => setEdit(null)} className="border border-edge px-5 py-2 rounded-lg text-sm text-slate-400">취소</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">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<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 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 value={reason} onChange={e => setReason(e.target.value)} placeholder="사유" 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>
|
||||
<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="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="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">Adjust</button>
|
||||
</div>
|
||||
{adjMsg && <div className="text-xs text-accent mt-2">{adjMsg}</div>}
|
||||
</div>
|
||||
|
||||
@ -10,22 +10,22 @@ export default function Members() {
|
||||
const { data: byTier } = useQuery({ queryKey: ['by-tier'], queryFn: () => getLoyaltyByTier(30) })
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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} /> Recalculate All Tiers</button>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5 mb-5">
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold mb-3">등급별 고객 분포 (30일)</h2>
|
||||
<Chart type="BAR" rows={rows} dataKeys={['고객수']} />
|
||||
<h2 className="text-sm font-semibold mb-3">Customer Distribution by Tier (30 days)</h2>
|
||||
<Chart type="BAR" rows={rows} dataKeys={['customers']} />
|
||||
</div>
|
||||
<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']} />
|
||||
</div>
|
||||
</div>
|
||||
@ -33,7 +33,7 @@ export default function Members() {
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<tbody>
|
||||
{(byTier || []).map((t: any) => (
|
||||
@ -44,7 +44,7 @@ export default function Members() {
|
||||
<td className="text-right">{money(t.sales)}</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -43,11 +43,11 @@ export default function Orders() {
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<option value="">전체</option>
|
||||
<option value="">All</option>
|
||||
{['PENDING', 'PAID', 'PREPARING', 'SHIPPED', 'DELIVERED', 'CONFIRMED', 'CANCELLED'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@ -55,7 +55,7 @@ export default function Orders() {
|
||||
|
||||
{!!live.length && (
|
||||
<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">
|
||||
{live.map((m, i) => (
|
||||
<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">
|
||||
<table className="w-full text-sm">
|
||||
<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-right">금액</th><th className="text-center">상태</th><th className="text-center">처리</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">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>
|
||||
<tbody>
|
||||
{list.map((o: any) => (
|
||||
|
||||
@ -20,48 +20,48 @@ export default function Schedule() {
|
||||
|
||||
return (
|
||||
<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="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">
|
||||
<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" />
|
||||
<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>
|
||||
<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" />
|
||||
<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>
|
||||
<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 })} /> Block Delivery</label>
|
||||
<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} /> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<tbody>
|
||||
{(holidays || []).map((h: any) => (
|
||||
<tr key={h.id || h.holidayDate} className="border-b border-edge/50">
|
||||
<td className="p-3">{h.holidayDate}</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-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>
|
||||
))}
|
||||
{!(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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-edge rounded-xl p-5">
|
||||
<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>
|
||||
<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>
|
||||
{forecast && (
|
||||
<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>
|
||||
{(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>
|
||||
|
||||
@ -18,11 +18,11 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<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="flex items-center gap-2 text-sm font-semibold mb-3"><Plug size={16} className="text-brand" /> 게이트웨이 어댑터 (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>
|
||||
<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 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">
|
||||
{provList.map((p: any, i: number) => (
|
||||
<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="font-semibold">{p.provider || 'mock'}</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>
|
||||
))}
|
||||
{!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 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">
|
||||
{(settings || []).map((s: any) => {
|
||||
const key = s.key || s.settingKey
|
||||
@ -47,11 +47,11 @@ export default function Settings() {
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<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" />
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
{!(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>
|
||||
|
||||
@ -10,12 +10,12 @@ export default function Stores() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<table className="w-full text-sm">
|
||||
<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-right">반경(mi)</th><th className="text-right">일용량</th><th className="text-center">상태</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">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>
|
||||
<tbody>
|
||||
{(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-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'}`}>
|
||||
<Power size={12} /> {s.active ? '운영' : '중지'}
|
||||
<Power size={12} /> {s.active ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -10,11 +10,11 @@ export default function Subscriptions() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<tbody>
|
||||
{list.map((s: any) => (
|
||||
@ -27,7 +27,7 @@ export default function Subscriptions() {
|
||||
<td className="text-center"><StatusBadge status={s.status} /></td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -23,22 +23,22 @@ export default function Transfers() {
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!recs.length && (
|
||||
<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">
|
||||
{recs.map((r, i) => (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
@ -49,7 +49,7 @@ export default function Transfers() {
|
||||
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<tbody>
|
||||
{(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">
|
||||
{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={() => reject(t.id)} className="text-xs bg-rose-500/15 text-rose-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">Reject</button>
|
||||
</div>)}
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -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 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 reset = async (id: number) => { const pw = prompt('새 비밀번호'); if (pw) { await resetPassword(id, pw).catch(() => {}); alert('변경됨') } }
|
||||
const del = async (id: number) => { if (confirm('삭제하시겠습니까?')) { await deleteUser(id).catch(() => {}); refresh() } }
|
||||
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('Are you sure you want to delete?')) { await deleteUser(id).catch(() => {}); refresh() } }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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} /> Add User</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<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 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 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="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>
|
||||
<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 className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<tbody>
|
||||
{(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>
|
||||
</td>
|
||||
<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 className="text-center">
|
||||
<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={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">삭제</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">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@ interface Row { [k: string]: any }
|
||||
export default function Chart({ type = 'BAR', rows, dataKeys, xKey = 'name', height = 260 }:
|
||||
{ type?: string; rows: Row[]; dataKeys: string[]; xKey?: string; height?: number }) {
|
||||
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()
|
||||
|
||||
|
||||
@ -26,10 +26,10 @@ const COLORS: Record<string, string> = {
|
||||
VIP: 'bg-violet-500/15 text-violet-300 border-violet-500/30',
|
||||
}
|
||||
const LABELS: Record<string, string> = {
|
||||
PENDING: '대기', PAID: '결제완료', PREPARING: '준비중', SHIPPED: '배송중', DELIVERED: '배송완료',
|
||||
CONFIRMED: '구매확정', CANCELLED: '취소', REFUNDED: '환불', ACTIVE: '활성', PAUSED: '일시정지',
|
||||
REQUESTED: '요청', APPROVED: '승인', REJECTED: '반려', COMPLETED: '완료',
|
||||
PUBLISHED: '게시', DRAFT: '초안', ENDED: '종료', OPEN: '접수', IN_PROGRESS: '처리중', RESOLVED: '해결',
|
||||
PENDING: 'Pending', PAID: 'Paid', PREPARING: 'Preparing', SHIPPED: 'Out for Delivery', DELIVERED: 'Delivered',
|
||||
CONFIRMED: 'Confirmed', CANCELLED: 'Cancelled', REFUNDED: 'Refunded', ACTIVE: 'Active', PAUSED: 'Paused',
|
||||
REQUESTED: 'Requested', APPROVED: 'Approved', REJECTED: 'Rejected', COMPLETED: 'Completed',
|
||||
PUBLISHED: 'Published', DRAFT: 'Draft', ENDED: 'Ended', OPEN: 'Open', IN_PROGRESS: 'Processing', RESOLVED: 'Resolved',
|
||||
}
|
||||
export default function StatusBadge({ status }: { status?: string }) {
|
||||
if (!status) return null
|
||||
|
||||
@ -17,8 +17,8 @@ export default function Cart() {
|
||||
if (!custToken) return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
|
||||
<ShoppingCart className="mx-auto text-bloom/40 mb-3" size={48} />
|
||||
<p className="text-gray-500 mb-4">로그인 후 장바구니를 이용하세요.</p>
|
||||
<Link to="/account" className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold">로그인 / 가입</Link>
|
||||
<p className="text-gray-500 mb-4">Please sign in to view your cart.</p>
|
||||
<Link to="/account" className="bg-bloom text-white px-6 py-2.5 rounded-full font-semibold">Sign In / Register</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -27,9 +27,9 @@ export default function Cart() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 className="font-serif text-2xl font-bold mb-6">장바구니</h1>
|
||||
<h1 className="font-serif text-2xl font-bold mb-6">Cart</h1>
|
||||
{!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="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} />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{i.productName || `상품 #${i.productId}`}</div>
|
||||
<div className="text-xs text-gray-500">{i.sizeCode}{i.cardMessage ? ` · 카드: ${i.cardMessage.slice(0, 20)}` : ''}</div>
|
||||
<div className="font-medium text-sm">{i.productName || `Product #${i.productId}`}</div>
|
||||
<div className="text-xs text-gray-500">{i.sizeCode}{i.cardMessage ? ` · Card: ${i.cardMessage.slice(0, 20)}` : ''}</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="flex items-center border border-blush-100 rounded-full text-sm">
|
||||
<button onClick={() => setQty(i.id, (i.quantity || 1) - 1)} className="px-2.5 py-1 text-bloom2">−</button>
|
||||
@ -55,10 +55,10 @@ export default function Cart() {
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-blush-100/60 p-5 h-fit">
|
||||
<div className="flex justify-between text-sm mb-2"><span className="text-gray-500">소계</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="border-t border-blush-100/60 pt-3 flex justify-between font-bold"><span>합계</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>
|
||||
<div className="flex justify-between text-sm mb-2"><span className="text-gray-500">Subtotal</span><span className="font-medium">{money(subtotal)}</span></div>
|
||||
<div className="flex justify-between text-sm mb-3"><span className="text-gray-500">Shipping/Tax</span><span className="text-gray-400">Calculated at checkout</span></div>
|
||||
<div className="border-t border-blush-100/60 pt-3 flex justify-between font-bold"><span>Total</span><span className="text-bloom2">{money(subtotal)}</span></div>
|
||||
<button onClick={() => nav('/checkout')} className="w-full mt-4 bg-bloom text-white font-semibold py-3 rounded-full hover:bg-bloom2">Checkout</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -78,10 +78,10 @@ export default function Checkout() {
|
||||
const placeOrder = async () => {
|
||||
setErr('')
|
||||
if (!custToken) { nav('/account'); return }
|
||||
if (!items.length) { setErr('장바구니가 비어 있습니다.'); return }
|
||||
if (!date || !slot) { setErr('배송/픽업 날짜와 시간대를 선택하세요.'); return }
|
||||
if (fulfillment === 'DELIVERY' && (!address || !receiverName)) { setErr('받는 분과 주소를 입력하세요.'); return }
|
||||
if (payMethod === 'CARD' && !card.complete) { setErr('카드 정보를 입력하세요.'); return }
|
||||
if (!items.length) { setErr('Your cart is empty.'); return }
|
||||
if (!date || !slot) { setErr('Please select a delivery/pickup date and time slot.'); return }
|
||||
if (fulfillment === 'DELIVERY' && (!address || !receiverName)) { setErr('Please enter the recipient and delivery address.'); return }
|
||||
if (payMethod === 'CARD' && !card.complete) { setErr('Please enter your card details.'); return }
|
||||
setPlacing(true)
|
||||
try {
|
||||
const order = await checkout({
|
||||
@ -100,7 +100,7 @@ export default function Checkout() {
|
||||
setCartCount(0)
|
||||
setDone(order)
|
||||
} catch (e: any) {
|
||||
setErr(e?.response?.data?.message || '주문에 실패했습니다. 잠시 후 다시 시도하세요.')
|
||||
setErr(e?.response?.data?.message || 'Order failed. Please try again in a moment.')
|
||||
} finally { setPlacing(false) }
|
||||
}
|
||||
|
||||
@ -109,30 +109,30 @@ export default function Checkout() {
|
||||
if (done) return (
|
||||
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
||||
<CheckCircle2 className="mx-auto text-leaf mb-4" size={56} />
|
||||
<h1 className="font-serif text-2xl font-bold mb-2">주문이 접수되었습니다</h1>
|
||||
<p className="text-gray-500 mb-1">주문번호 <span className="font-semibold text-bloom2">{done.orderNo || `#${done.id}`}</span></p>
|
||||
<h1 className="font-serif text-2xl font-bold mb-2">Your order has been placed</h1>
|
||||
<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>
|
||||
<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('/home')} className="border border-blush-100 px-6 py-2.5 rounded-full text-bloom2">계속 쇼핑</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">Continue Shopping</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<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="md:col-span-2 space-y-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">
|
||||
<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('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('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} /> Store Pickup</button>
|
||||
</div>
|
||||
<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">
|
||||
{(stores || []).map((s: any) => <option key={s.id} value={s.id}>{s.name} ({s.city}, {s.state})</option>)}
|
||||
</select>
|
||||
@ -141,7 +141,7 @@ export default function Checkout() {
|
||||
|
||||
{/* 날짜 + 타임슬롯 (UrbanStems/Ode) */}
|
||||
<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">
|
||||
{nextDays(14).map(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'}`}>
|
||||
<div className="font-semibold">{dd.toLocaleDateString('en-US', { weekday: 'short' })}</div>
|
||||
<div className="text-base">{dd.getDate()}</div>
|
||||
{blocked && <div className="text-[9px]">휴무</div>}
|
||||
{blocked && <div className="text-[9px]">Closed</div>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{date && avail && (
|
||||
<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.surgeMultiplier > 1 && <div className="text-xs text-amber-600 mb-2">⚡ 성수기 서지 프라이싱 ×{avail.surgeMultiplier} 적용</div>}
|
||||
{avail.sameDayAvailable && <div className="text-xs text-leaf mb-2">당일배송 가능 (마감 {avail.sameDayCutoff})</div>}
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-2"><Clock size={14} className="text-bloom" /> 시간대</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">⚡ Peak-season surge pricing ×{avail.surgeMultiplier} applied</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" /> Delivery Time Slot</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(avail.slots || []).map((s: any) => {
|
||||
const full = s.available === false || (s.capacity != null && s.booked >= s.capacity)
|
||||
@ -173,7 +173,7 @@ export default function Checkout() {
|
||||
</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>
|
||||
)}
|
||||
@ -182,19 +182,19 @@ export default function Checkout() {
|
||||
{/* 받는 분 / 주소 (배송) */}
|
||||
{fulfillment === 'DELIVERY' && (
|
||||
<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">
|
||||
<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={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={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="Phone" className="px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
|
||||
</div>
|
||||
<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" />
|
||||
<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>
|
||||
{addrCheck && (
|
||||
<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>
|
||||
)}
|
||||
</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">
|
||||
<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>
|
||||
<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" />
|
||||
<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" />
|
||||
<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="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="Special Instructions (optional)" className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
|
||||
</section>
|
||||
|
||||
{/* 결제수단 (Montvale: Visa·Mastercard·Amex·Discover·Apple Pay·Google Pay) */}
|
||||
<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
|
||||
method={payMethod}
|
||||
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">
|
||||
<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">
|
||||
<Row k="소계" v={money(subtotal)} />
|
||||
{surge > 0 && <Row k="서지 프라이싱" v={`+${money(surge)}`} amber />}
|
||||
<Row k="세금" v={money(taxAmount)} sub={tax ? `${tax.provider} ${(tax.rate * 100).toFixed(1)}%` : ''} />
|
||||
{tierDiscount > 0 && <Row k={`등급 할인(${loyalty?.tierName || ''} ${discountRate}%)`} v={`-${money(tierDiscount)}`} green />}
|
||||
{usePointsAmt > 0 && <Row k="포인트 사용" v={`-${money(usePointsAmt)}`} green />}
|
||||
<Row k="Subtotal" v={money(subtotal)} />
|
||||
{surge > 0 && <Row k="Surge Pricing" v={`+${money(surge)}`} amber />}
|
||||
<Row k="Tax" v={money(taxAmount)} sub={tax ? `${tax.provider} ${(tax.rate * 100).toFixed(1)}%` : ''} />
|
||||
{tierDiscount > 0 && <Row k={`Tier Discount (${loyalty?.tierName || ''} ${discountRate}%)`} v={`-${money(tierDiscount)}`} green />}
|
||||
{usePointsAmt > 0 && <Row k="Points Used" v={`-${money(usePointsAmt)}`} green />}
|
||||
</div>
|
||||
|
||||
{/* 마일리지/포인트 */}
|
||||
{!!custToken && (
|
||||
<div className="mt-4 bg-petal rounded-xl p-3">
|
||||
<div className="flex items-center justify-between text-xs text-bloom2 mb-1">
|
||||
<span>포인트 보유 {pointBalance.toLocaleString()}P</span>
|
||||
<button onClick={() => setUsePointsAmt(maxUsablePoints)} className="text-bloom font-semibold">전액 사용</button>
|
||||
<span>Points Balance {pointBalance.toLocaleString()} pts</span>
|
||||
<button onClick={() => setUsePointsAmt(maxUsablePoints)} className="text-bloom font-semibold">Use All</button>
|
||||
</div>
|
||||
<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 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>
|
||||
{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">
|
||||
{placing ? '결제 중…' : `${money(grand)} 결제하기`}
|
||||
{placing ? 'Processing…' : `Place Order · ${money(grand)}`}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,8 +18,8 @@ export default function Cs() {
|
||||
const [content, setContent] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
// AI 카드메시지 도우미
|
||||
const [occasion, setOccasion] = useState('생일')
|
||||
const [tone, setTone] = useState('따뜻하게')
|
||||
const [occasion, setOccasion] = useState('Birthday')
|
||||
const [tone, setTone] = useState('Warm')
|
||||
const [recipient, setRecipient] = useState('')
|
||||
const [cardSuggestions, setCardSuggestions] = useState<string[]>([])
|
||||
|
||||
@ -30,9 +30,9 @@ export default function Cs() {
|
||||
if (!custToken) { nav('/account'); return }
|
||||
try {
|
||||
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'] })
|
||||
} catch { setMsg('접수에 실패했습니다.') }
|
||||
} catch { setMsg('Failed to submit your request.') }
|
||||
}
|
||||
|
||||
const genCard = async () => {
|
||||
@ -42,17 +42,17 @@ export default function Cs() {
|
||||
|
||||
return (
|
||||
<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 카드 메시지 작성 도우미 */}
|
||||
<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">
|
||||
<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={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={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={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="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="Recipient" className="px-3 py-2 rounded-lg border border-blush-100 text-sm bg-white outline-none" />
|
||||
</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 && (
|
||||
<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>)}
|
||||
@ -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">
|
||||
{CATS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</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>
|
||||
<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" />
|
||||
<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" />
|
||||
<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="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>}
|
||||
<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>
|
||||
{!custToken && <p className="text-xs text-gray-400">로그인 후 문의를 접수할 수 있습니다. <Link to="/account" className="text-bloom">로그인</Link></p>}
|
||||
<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">Please log in to submit a request. <Link to="/account" className="text-bloom">Log In</Link></p>}
|
||||
</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">
|
||||
{(tickets || []).map((t: any) => (
|
||||
<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} />
|
||||
</div>
|
||||
<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>}
|
||||
</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>
|
||||
)
|
||||
|
||||
@ -23,7 +23,7 @@ export default function CustomerApp() {
|
||||
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) }))
|
||||
.catch(() => setErr('앱 저장소에 연결할 수 없습니다. 잠시 후 다시 시도하세요.'))
|
||||
.catch(() => setErr('Unable to connect to the app store. Please try again in a moment.'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(() => { load() }, [])
|
||||
@ -36,25 +36,25 @@ export default function CustomerApp() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-10">
|
||||
<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>
|
||||
<p className="text-sm text-gray-500">QR 코드를 스캔하면 <span className="text-bloom font-semibold">GUARDiA Mall</span> 고객 앱 설치 페이지로 이동합니다.<br />당일 배송 알림·간편 재주문·구독 관리를 앱에서 더 편리하게.</p>
|
||||
<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">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>
|
||||
|
||||
{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>}
|
||||
|
||||
{!loading && !err && info && !info.has_version && (
|
||||
<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" />
|
||||
아직 등록된 앱 버전이 없습니다.<br />
|
||||
<span className="text-xs">앱 업로드·버전 관리는 GUARDiA Manager에서 일원화됩니다.</span>
|
||||
No app version has been published yet.<br />
|
||||
<span className="text-xs">App uploads and version management are handled in GUARDiA Manager.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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-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 className="flex items-center gap-2 mb-1">
|
||||
@ -63,19 +63,19 @@ export default function CustomerApp() {
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-4">
|
||||
{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>
|
||||
{info.release_notes && (
|
||||
<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>
|
||||
)}
|
||||
<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.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.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} /> 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">
|
||||
{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 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>
|
||||
|
||||
@ -25,26 +25,26 @@ export default function Mypage() {
|
||||
return (
|
||||
<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 gap-2"><User className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900">마이페이지</h1></div>
|
||||
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-bloom"><LogOut size={16} /> 로그아웃</button>
|
||||
<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} /> Log Out</button>
|
||||
</div>
|
||||
|
||||
{/* 등급 카드 */}
|
||||
<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>
|
||||
<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="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 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>
|
||||
</div>
|
||||
{next && !next.isTop && (
|
||||
<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>
|
||||
@ -52,26 +52,26 @@ export default function Mypage() {
|
||||
{/* 등급 혜택 */}
|
||||
{loy?.benefit && (
|
||||
<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">
|
||||
<Benefit label="할인율" value={`${loy.benefit.discountRate}%`} />
|
||||
<Benefit label="적립률" value={`${loy.benefit.pointEarnRate}%`} />
|
||||
<Benefit label="무료배송" value={loy.benefit.freeShipThreshold === 0 ? '항상' : loy.benefit.freeShipThreshold ? money(loy.benefit.freeShipThreshold) + ' 이상' : '없음'} />
|
||||
<Benefit label="우선 슬롯" value={loy.benefit.prioritySlot ? '제공' : '–'} />
|
||||
<Benefit label="Discount" value={`${loy.benefit.discountRate}%`} />
|
||||
<Benefit label="Earn Rate" value={`${loy.benefit.pointEarnRate}%`} />
|
||||
<Benefit label="Free Shipping" value={loy.benefit.freeShipThreshold === 0 ? 'Always' : loy.benefit.freeShipThreshold ? money(loy.benefit.freeShipThreshold) + '+' : 'None'} />
|
||||
<Benefit label="Priority Slot" value={loy.benefit.prioritySlot ? 'Included' : '–'} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빠른 메뉴 */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<Quick to="/orders" icon={Package} label="주문 내역" />
|
||||
<Quick to="/wishlist" icon={Heart} label="찜 목록" />
|
||||
<Quick to="/subscription" icon={Repeat} label="구독 관리" />
|
||||
<Quick to="/orders" icon={Package} label="Order History" />
|
||||
<Quick to="/wishlist" icon={Heart} label="Wishlist" />
|
||||
<Quick to="/subscription" icon={Repeat} label="Manage Subscription" />
|
||||
</div>
|
||||
|
||||
{/* 포인트 이력 */}
|
||||
<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">
|
||||
{(history || []).map((h: any) => (
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -18,16 +18,16 @@ export default function Search() {
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="text-bloom" size={20} />
|
||||
<h1 className="font-serif text-2xl font-bold">"{q}" 검색 결과</h1>
|
||||
<h1 className="font-serif text-2xl font-bold">Search Results for "{q}"</h1>
|
||||
</div>
|
||||
{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">
|
||||
{items.map((p: any) => <ProductCard key={p.id} p={p} />)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import { money, useShop } from '../store/shop'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
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() {
|
||||
const qc = useQueryClient()
|
||||
@ -40,36 +40,36 @@ export default function Subscription() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<p className="text-sm text-gray-500 mb-6">주간·격주·월간으로 신선한 꽃을 정기 배송받으세요 (BloomsyBox 스타일).</p>
|
||||
<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">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">
|
||||
{!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>
|
||||
<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>
|
||||
<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">
|
||||
<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>)}
|
||||
</select>
|
||||
</div>
|
||||
<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={() => setOpen(false)} className="border border-blush-100 px-6 py-2.5 rounded-full text-gray-600">취소</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">Cancel</button>
|
||||
</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">
|
||||
{(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">
|
||||
|
||||
@ -18,9 +18,9 @@ export default function Wishlist() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center gap-2 mb-6"><Heart className="text-bloom" size={22} /><h1 className="font-serif text-2xl font-bold text-blush-900">찜 목록</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 ? (
|
||||
<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">
|
||||
{list.map((p: any) => (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user