Add Polaris frontend: storefront + admin console (React 18/Vite/TS/Tailwind)
- Shared src/lib/api.ts fetch wrapper -> /api with Cache-Control: no-store - Storefront: home hero, shop grid (category filter + search), product detail, cart, checkout (POST /api/orders -> confirmation + order number), order tracking - Admin: JWT login, today KPI dashboard (revenue/orders/gross/net/margin/aov/refunds), top products, supplier performance, product import, lifecycle view, order list with tracking simulation - Multi-stage Dockerfile (Vite build -> nginx static + /api proxy -> api:8000) - nginx.conf with SPA fallback and no-store HTML caching - mock/mock_api.py for local verification (excluded from image)
This commit is contained in:
40
frontend/src/App.tsx
Normal file
40
frontend/src/App.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Link, Routes, Route } from 'react-router-dom';
|
||||
import StoreLayout from './components/StoreLayout';
|
||||
import Home from './pages/Home';
|
||||
import Shop from './pages/Shop';
|
||||
import ProductDetail from './pages/ProductDetail';
|
||||
import Cart from './pages/Cart';
|
||||
import Checkout from './pages/Checkout';
|
||||
import TrackOrder from './pages/TrackOrder';
|
||||
import AdminApp from './admin/AdminApp';
|
||||
import { IconStar } from './components/Icons';
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center px-4 py-24 text-center">
|
||||
<IconStar className="mb-4 h-10 w-10 text-indigo-400" />
|
||||
<h1 className="text-2xl font-bold text-white">Lost among the stars</h1>
|
||||
<p className="mt-2 text-slate-400">That page doesn't exist in this constellation.</p>
|
||||
<Link to="/" className="btn-primary mt-6">Back to the storefront</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Admin console — fully separate surface sharing src/lib/api.ts */}
|
||||
<Route path="/admin/*" element={<AdminApp />} />
|
||||
{/* Storefront */}
|
||||
<Route element={<StoreLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="shop" element={<Shop />} />
|
||||
<Route path="product/:id" element={<ProductDetail />} />
|
||||
<Route path="cart" element={<Cart />} />
|
||||
<Route path="checkout" element={<Checkout />} />
|
||||
<Route path="track" element={<TrackOrder />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
27
frontend/src/admin/AdminApp.tsx
Normal file
27
frontend/src/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { auth } from '../lib/api';
|
||||
import AdminLayout from './AdminLayout';
|
||||
import Dashboard from './Dashboard';
|
||||
import Login from './Login';
|
||||
import Orders from './Orders';
|
||||
import Products from './Products';
|
||||
|
||||
export default function AdminApp() {
|
||||
const [authed, setAuthed] = useState<boolean>(() => Boolean(auth.token()));
|
||||
|
||||
if (!authed) {
|
||||
return <Login onSuccess={() => setAuthed(true)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
96
frontend/src/admin/AdminLayout.tsx
Normal file
96
frontend/src/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { auth } from '../lib/api';
|
||||
import {
|
||||
IconBoxes,
|
||||
IconClipboardList,
|
||||
IconExternalLink,
|
||||
IconDashboard,
|
||||
IconLogOut,
|
||||
IconStar,
|
||||
} from '../components/Icons';
|
||||
|
||||
const item = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 rounded-xl border px-4 py-2.5 text-sm font-medium transition ${
|
||||
isActive
|
||||
? 'border-indigo-400/20 bg-indigo-500/15 text-indigo-200'
|
||||
: 'border-transparent text-slate-400 hover:bg-white/[0.05] hover:text-white'
|
||||
}`;
|
||||
|
||||
const Brand = () => (
|
||||
<Link to="/admin" className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-glow">
|
||||
<IconStar className="h-5 w-5 text-white" />
|
||||
</span>
|
||||
<span className="text-lg font-bold tracking-tight text-white">
|
||||
Polaris<span className="text-indigo-400">.</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = () => {
|
||||
auth.clear();
|
||||
navigate('/admin', { replace: true });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const nav = (
|
||||
<>
|
||||
<NavLink to="/admin" end className={item}>
|
||||
<IconDashboard className="h-4 w-4" /> Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/admin/products" className={item}>
|
||||
<IconBoxes className="h-4 w-4" /> Products
|
||||
</NavLink>
|
||||
<NavLink to="/admin/orders" className={item}>
|
||||
<IconClipboardList className="h-4 w-4" /> Orders
|
||||
</NavLink>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-app flex min-h-screen">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 flex-col border-r border-white/[0.06] bg-night-900/50 px-4 py-6 backdrop-blur-xl lg:flex">
|
||||
<Brand />
|
||||
<nav className="mt-8 flex flex-1 flex-col gap-1.5">{nav}</nav>
|
||||
<div className="space-y-1.5 border-t border-white/[0.06] pt-4">
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-400 transition hover:bg-white/[0.05] hover:text-white"
|
||||
>
|
||||
<IconExternalLink className="h-4 w-4" /> View storefront
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-400 transition hover:bg-rose-500/10 hover:text-rose-300"
|
||||
>
|
||||
<IconLogOut className="h-4 w-4" /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Mobile header */}
|
||||
<header className="sticky top-0 z-30 border-b border-white/[0.06] bg-night-950/70 backdrop-blur-xl lg:hidden">
|
||||
<div className="flex h-16 items-center justify-between px-4">
|
||||
<Brand />
|
||||
<button type="button" onClick={logout} className="btn-ghost !px-3 !py-2 text-xs">
|
||||
<IconLogOut className="h-4 w-4" /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex gap-1 overflow-x-auto px-4 pb-3">{nav}</nav>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
frontend/src/admin/Dashboard.tsx
Normal file
207
frontend/src/admin/Dashboard.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, asList } from '../lib/api';
|
||||
import type { AnalyticsSummary, SupplierPerformance, TopProduct } from '../lib/types';
|
||||
import { money, num, pct } from '../lib/format';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import {
|
||||
IconBarChart,
|
||||
IconClipboardList,
|
||||
IconDollarSign,
|
||||
IconRefresh,
|
||||
IconRotateCcw,
|
||||
IconShoppingBag,
|
||||
IconTrendingUp,
|
||||
IconWallet,
|
||||
} from '../components/Icons';
|
||||
|
||||
interface DashboardData {
|
||||
summary: AnalyticsSummary | null;
|
||||
topProducts: TopProduct[];
|
||||
suppliers: SupplierPerformance[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardData>({ summary: null, topProducts: [], suppliers: [], error: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [summary, topProducts, suppliers] = await Promise.all([
|
||||
api.get<AnalyticsSummary>('/analytics/summary'),
|
||||
api.get<TopProduct[]>('/analytics/top-products'),
|
||||
api.get<SupplierPerformance[]>('/analytics/supplier-performance'),
|
||||
]);
|
||||
setData({ summary, topProducts: asList(topProducts), suppliers: asList(suppliers), error: null });
|
||||
} catch (e) {
|
||||
setData((d) => ({ ...d, error: e instanceof Error ? e.message : 'Failed to load analytics' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-8 w-8 text-indigo-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.error && !data.summary) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Could not load the dashboard"
|
||||
hint={data.error}
|
||||
action={
|
||||
<button type="button" className="btn-primary" onClick={() => void load()}>
|
||||
<IconRefresh className="h-4 w-4" /> Retry
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const s = data.summary;
|
||||
const gross = s?.gross_profit ?? s?.profit ?? null;
|
||||
const net = s?.net_profit ?? s?.profit ?? null;
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Revenue', value: money(s?.revenue), icon: IconDollarSign, tone: 'from-indigo-500/25 to-indigo-500/5 text-indigo-300' },
|
||||
{ label: 'Orders', value: num(s?.orders), icon: IconClipboardList, tone: 'from-sky-500/25 to-sky-500/5 text-sky-300' },
|
||||
{ label: 'Gross profit', value: money(gross), icon: IconTrendingUp, tone: 'from-violet-500/25 to-violet-500/5 text-violet-300' },
|
||||
{ label: 'Net profit', value: money(net), icon: IconWallet, tone: 'from-emerald-500/25 to-emerald-500/5 text-emerald-300' },
|
||||
{ label: 'Margin', value: pct(s?.margin), icon: IconBarChart, tone: 'from-fuchsia-500/25 to-fuchsia-500/5 text-fuchsia-300' },
|
||||
{ label: 'AOV', value: money(s?.aov), icon: IconShoppingBag, tone: 'from-amber-500/25 to-amber-500/5 text-amber-300' },
|
||||
{ label: 'Refunds', value: num(s?.refunds ?? 0), icon: IconRotateCcw, tone: 'from-rose-500/25 to-rose-500/5 text-rose-300' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Today</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">Live snapshot of the Polaris pipeline.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} className="btn-ghost !py-2 text-sm">
|
||||
<IconRefresh className="h-4 w-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="glass glass-hover p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br ${k.tone}`}>
|
||||
<k.icon className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-xl font-extrabold text-white">{k.value}</p>
|
||||
<p className="mt-0.5 text-xs font-medium text-slate-400">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<section className="glass overflow-hidden">
|
||||
<header className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h2 className="font-semibold text-white">Top products</h2>
|
||||
<p className="text-xs text-slate-500">By revenue today</p>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[480px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="th">#</th>
|
||||
<th className="th">Product</th>
|
||||
<th className="th text-right">Units</th>
|
||||
<th className="th text-right">Revenue</th>
|
||||
<th className="th text-right">Profit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.topProducts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="td py-8 text-center text-slate-500">
|
||||
No sales yet today.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.topProducts.slice(0, 10).map((p, i) => (
|
||||
<tr key={p.product_id ?? i} className="border-b border-white/[0.04] transition hover:bg-white/[0.03]">
|
||||
<td className="td font-bold text-slate-500">{i + 1}</td>
|
||||
<td className="td">
|
||||
<p className="font-medium text-slate-200">{p.title}</p>
|
||||
{p.sku ? <p className="text-xs text-slate-500">{p.sku}</p> : null}
|
||||
</td>
|
||||
<td className="td text-right">{num(p.units_sold)}</td>
|
||||
<td className="td text-right font-semibold text-white">{money(p.revenue)}</td>
|
||||
<td className="td text-right text-emerald-300">{money(p.profit)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass overflow-hidden">
|
||||
<header className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h2 className="font-semibold text-white">Supplier performance</h2>
|
||||
<p className="text-xs text-slate-500">Rolling reliability snapshot</p>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="th">Supplier</th>
|
||||
<th className="th text-right">Fulfillment</th>
|
||||
<th className="th text-right">Ship (d)</th>
|
||||
<th className="th text-right">Cancels</th>
|
||||
<th className="th text-right">Stock acc.</th>
|
||||
<th className="th text-right">Returns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.suppliers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="td py-8 text-center text-slate-500">
|
||||
No supplier data yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.suppliers.map((sp) => (
|
||||
<tr key={sp.supplier_id} className="border-b border-white/[0.04] transition hover:bg-white/[0.03]">
|
||||
<td className="td">
|
||||
<p className="font-medium text-slate-200">{sp.supplier_name ?? sp.supplier_id}</p>
|
||||
<div className="mt-1.5 h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-400"
|
||||
style={{ width: `${Math.min(100, Math.max(0, (sp.fulfillment_rate ?? 0) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="td text-right font-semibold text-white">{pct(sp.fulfillment_rate)}</td>
|
||||
<td className="td text-right">{sp.avg_shipping_days ?? '—'}</td>
|
||||
<td className="td text-right text-rose-300">{pct(sp.cancellation_rate)}</td>
|
||||
<td className="td text-right text-emerald-300">{pct(sp.stock_accuracy)}</td>
|
||||
<td className="td text-right text-amber-300">{pct(sp.return_rate)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/admin/Login.tsx
Normal file
88
frontend/src/admin/Login.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, auth } from '../lib/api';
|
||||
import type { LoginResponse } from '../lib/types';
|
||||
import { IconLock, IconStar } from '../components/Icons';
|
||||
|
||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.post<LoginResponse>('/auth/login', { email, password });
|
||||
auth.save(res.access_token);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-app flex min-h-screen items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-glow">
|
||||
<IconStar className="h-7 w-7 text-white" />
|
||||
</span>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-white">Polaris Admin</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">Mission control for the reseller pipeline.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="glass space-y-4 p-6">
|
||||
<div>
|
||||
<label className="label" htmlFor="admin-email">Email</label>
|
||||
<input
|
||||
id="admin-email"
|
||||
type="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@polaris.shop"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="admin-password">Password</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn-primary w-full !py-3">
|
||||
<IconLock className="h-4 w-4" />
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-slate-500">
|
||||
<Link to="/" className="transition hover:text-indigo-300">
|
||||
← Back to storefront
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/admin/Orders.tsx
Normal file
137
frontend/src/admin/Orders.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, asList } from '../lib/api';
|
||||
import type { Order, OrderStatus } from '../lib/types';
|
||||
import { date, money, orderStatusLabel } from '../lib/format';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { IconRefresh, IconZap } from '../components/Icons';
|
||||
|
||||
const FLOWABLE: OrderStatus[] = ['new', 'paid', 'fraud_check', 'supplier_order', 'confirmed', 'shipped'];
|
||||
|
||||
export default function Orders() {
|
||||
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [simulating, setSimulating] = useState<string | null>(null);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<Order[]>('/orders');
|
||||
setOrders(asList(res));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load orders');
|
||||
setOrders([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const simulate = async (order: Order) => {
|
||||
setSimulating(order.id);
|
||||
try {
|
||||
await api.post<unknown>(`/orders/${order.id}/tracking`);
|
||||
await load();
|
||||
setFlash(`Tracking simulated for ${order.order_number} — status advanced.`);
|
||||
} catch (e) {
|
||||
setFlash(e instanceof Error ? e.message : 'Simulation failed');
|
||||
} finally {
|
||||
setSimulating(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Orders</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">{orders?.length ?? 0} orders in the pipeline.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} className="btn-ghost !py-2 text-sm">
|
||||
<IconRefresh className="h-4 w-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{flash ? (
|
||||
<div className="rounded-xl border border-indigo-400/30 bg-indigo-500/10 p-3 text-sm text-indigo-200">
|
||||
{flash}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-300">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="glass overflow-hidden">
|
||||
{orders === null ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-7 w-7 text-indigo-400" />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<EmptyState title="No orders yet" hint="Storefront orders will appear here in real time." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="th">Order</th>
|
||||
<th className="th">Placed</th>
|
||||
<th className="th text-right">Items</th>
|
||||
<th className="th text-right">Total</th>
|
||||
<th className="th text-right">Profit</th>
|
||||
<th className="th">Status</th>
|
||||
<th className="th">Tracking</th>
|
||||
<th className="th text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="border-b border-white/[0.04] transition hover:bg-white/[0.03]">
|
||||
<td className="td">
|
||||
<p className="font-mono font-semibold text-indigo-300">{o.order_number}</p>
|
||||
{o.customer_email ? <p className="text-xs text-slate-500">{o.customer_email}</p> : null}
|
||||
</td>
|
||||
<td className="td whitespace-nowrap">{date(o.created_at)}</td>
|
||||
<td className="td text-right">{o.items?.reduce((n, it) => n + it.qty, 0) ?? 0}</td>
|
||||
<td className="td text-right font-semibold text-white">{money(o.retail_total)}</td>
|
||||
<td className="td text-right text-emerald-300">{money(o.profit)}</td>
|
||||
<td className="td">
|
||||
<StatusBadge kind="order" status={o.status} />
|
||||
</td>
|
||||
<td className="td">
|
||||
{o.tracking ? (
|
||||
<div>
|
||||
<p className="font-mono text-xs text-slate-300">{o.tracking}</p>
|
||||
{o.carrier ? <p className="text-[11px] text-slate-500">{o.carrier}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="td text-right">
|
||||
{FLOWABLE.includes(o.status) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={simulating === o.id}
|
||||
onClick={() => void simulate(o)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-indigo-400/30 bg-indigo-500/10 px-3 py-1.5 text-xs font-semibold text-indigo-300 transition hover:bg-indigo-500/20 disabled:opacity-50"
|
||||
>
|
||||
<IconZap className="h-3.5 w-3.5" />
|
||||
{simulating === o.id ? 'Simulating…' : 'Simulate tracking'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">{orderStatusLabel(o.status)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
frontend/src/admin/Products.tsx
Normal file
248
frontend/src/admin/Products.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, asList, qs } from '../lib/api';
|
||||
import type { Product, ProductStatus, Supplier } from '../lib/types';
|
||||
import { money, PRODUCT_STATUSES, productStatusLabel } from '../lib/format';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { IconRefresh, IconUpload } from '../components/Icons';
|
||||
|
||||
const STATUS_FILTERS: (ProductStatus | '')[] = ['', ...PRODUCT_STATUSES];
|
||||
|
||||
export default function Products() {
|
||||
// ── lifecycle ──
|
||||
const [products, setProducts] = useState<Product[] | null>(null);
|
||||
const [status, setStatus] = useState<ProductStatus | ''>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
// ── import ──
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [supplierId, setSupplierId] = useState('');
|
||||
const [feedType, setFeedType] = useState<'csv' | 'xml' | 'json'>('csv');
|
||||
const [payload, setPayload] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importMsg, setImportMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<Product[]>(`/products${qs({ status, limit: 200 })}`);
|
||||
setProducts(asList(res));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
||||
setProducts([]);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Supplier[]>('/suppliers')
|
||||
.then((res) => {
|
||||
const list = asList(res);
|
||||
setSuppliers(list);
|
||||
if (list.length > 0) setSupplierId(list[0].id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggle = async (p: Product) => {
|
||||
const isLive = p.status === 'published' || p.status === 'monitored';
|
||||
setBusyId(p.id);
|
||||
try {
|
||||
await api.post<unknown>(`/products/${p.id}/${isLive ? 'pause' : 'publish'}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Action failed');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const doImport = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setImporting(true);
|
||||
setImportMsg(null);
|
||||
try {
|
||||
const res = await api.post<{ imported?: number; message?: string }>('/products/import', {
|
||||
supplier_id: supplierId,
|
||||
feed_type: feedType,
|
||||
data_or_url: payload,
|
||||
});
|
||||
setImportMsg({
|
||||
ok: true,
|
||||
text: res?.message ?? `Import accepted${res?.imported != null ? ` — ${res.imported} products` : ''}. Refresh to see new products.`,
|
||||
});
|
||||
setPayload('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setImportMsg({ ok: false, text: err instanceof Error ? err.message : 'Import failed' });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Products</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">Import feeds and manage the product lifecycle.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Import ── */}
|
||||
<section className="glass p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUpload className="h-5 w-5 text-indigo-300" />
|
||||
<h2 className="font-semibold text-white">Import a product feed</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Paste feed data or a URL — the pipeline analyzes and prices every item automatically.
|
||||
</p>
|
||||
<form onSubmit={doImport} className="mt-5 grid gap-4 lg:grid-cols-[200px_160px_1fr_auto]">
|
||||
<div>
|
||||
<label className="label" htmlFor="imp-supplier">Supplier</label>
|
||||
<select id="imp-supplier" className="input" value={supplierId} onChange={(e) => setSupplierId(e.target.value)} required>
|
||||
{suppliers.length === 0 ? <option value="">No suppliers…</option> : null}
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="imp-type">Feed type</label>
|
||||
<select id="imp-type" className="input" value={feedType} onChange={(e) => setFeedType(e.target.value as 'csv' | 'xml' | 'json')}>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="xml">XML</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="imp-data">Feed data or URL</label>
|
||||
<textarea
|
||||
id="imp-data"
|
||||
className="input min-h-[96px] resize-none font-mono !text-xs"
|
||||
value={payload}
|
||||
onChange={(e) => setPayload(e.target.value)}
|
||||
placeholder={'sku,title,category,cost,retail_price\nAUR-01,Aurora Lamp,Home,12.50,38.50'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button type="submit" disabled={importing || suppliers.length === 0} className="btn-primary w-full">
|
||||
{importing ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{importMsg ? (
|
||||
<div
|
||||
className={`mt-4 rounded-xl border p-3 text-sm ${
|
||||
importMsg.ok
|
||||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-rose-400/30 bg-rose-500/10 text-rose-300'
|
||||
}`}
|
||||
>
|
||||
{importMsg.text}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── Lifecycle ── */}
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-white">Lifecycle</h2>
|
||||
<p className="text-xs text-slate-500">{products?.length ?? 0} products</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} className="btn-ghost !py-2 text-sm">
|
||||
<IconRefresh className="h-4 w-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<button
|
||||
key={s || 'all'}
|
||||
type="button"
|
||||
onClick={() => setStatus(s)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
|
||||
status === s
|
||||
? 'border-indigo-400/50 bg-indigo-500/20 text-indigo-200'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:bg-white/[0.07] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{s ? productStatusLabel(s) : 'All'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-300">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="glass mt-4 overflow-hidden">
|
||||
{products === null ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-7 w-7 text-indigo-400" />
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<EmptyState title="No products match this status" hint="Adjust the filter or import a new feed." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="th">Product</th>
|
||||
<th className="th">Category</th>
|
||||
<th className="th text-right">Cost</th>
|
||||
<th className="th text-right">Retail</th>
|
||||
<th className="th">Status</th>
|
||||
<th className="th text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => {
|
||||
const isLive = p.status === 'published' || p.status === 'monitored';
|
||||
return (
|
||||
<tr key={p.id} className="border-b border-white/[0.04] transition hover:bg-white/[0.03]">
|
||||
<td className="td">
|
||||
<p className="font-medium text-slate-200">{p.title}</p>
|
||||
<p className="text-xs text-slate-500">{p.sku}</p>
|
||||
</td>
|
||||
<td className="td">{p.category ?? '—'}</td>
|
||||
<td className="td text-right">{money(p.cost)}</td>
|
||||
<td className="td text-right font-semibold text-white">{money(p.retail_price)}</td>
|
||||
<td className="td">
|
||||
<StatusBadge kind="product" status={p.status} />
|
||||
</td>
|
||||
<td className="td text-right">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === p.id}
|
||||
onClick={() => void toggle(p)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50 ${
|
||||
isLive
|
||||
? 'border-rose-400/30 bg-rose-500/10 text-rose-300 hover:bg-rose-500/20'
|
||||
: 'border-emerald-400/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'
|
||||
}`}
|
||||
>
|
||||
{busyId === p.id ? '…' : isLive ? 'Pause' : 'Publish'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/EmptyState.tsx
Normal file
19
frontend/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, hint, action }: Props) {
|
||||
return (
|
||||
<div className="glass flex flex-col items-center gap-3 px-6 py-14 text-center">
|
||||
{icon ? <div className="text-slate-500">{icon}</div> : null}
|
||||
<p className="text-base font-semibold text-slate-100">{title}</p>
|
||||
{hint ? <p className="max-w-sm text-sm text-slate-500">{hint}</p> : null}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
frontend/src/components/Icons.tsx
Normal file
263
frontend/src/components/Icons.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
// Minimal inline icon set — keeps the build dependency-free.
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
type P = SVGProps<SVGSVGElement>;
|
||||
|
||||
function Svg({ children, ...p }: P) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...p}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconStar = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 2l2.9 6.3 6.9.8-5.1 4.7 1.3 6.8L12 17.3l-6 3.3 1.3-6.8L2.2 9.1l6.9-.8z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconSparkles = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z" />
|
||||
<path d="M19 14l.9 2.3 2.2.8-2.2.8L19 20l-.9-2.1-2.2-.8 2.2-.8z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconSearch = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconCart = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="9" cy="20" r="1.4" />
|
||||
<circle cx="17" cy="20" r="1.4" />
|
||||
<path d="M3 3h2l2.4 12.2a1.5 1.5 0 001.5 1.2h8.9a1.5 1.5 0 001.5-1.2L21 7H6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconTruck = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M1 8h13v8H1z" />
|
||||
<path d="M14 11h4l3 3v2h-7" />
|
||||
<circle cx="6" cy="18" r="1.8" />
|
||||
<circle cx="17.5" cy="18" r="1.8" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconPackage = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M21 8l-9-5-9 5v8l9 5 9-5V8z" />
|
||||
<path d="M3.3 8.3L12 13l8.7-4.7" />
|
||||
<path d="M12 13v9" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconArrowRight = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 12h16" />
|
||||
<path d="M13 5l7 7-7 7" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconArrowLeft = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M20 12H4" />
|
||||
<path d="M11 19l-7-7 7-7" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconPlus = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconMinus = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconX = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M18 6L6 18" />
|
||||
<path d="M6 6l12 12" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconCheck = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 12.5l5 5L20 6.5" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconRefresh = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M21 12a9 9 0 11-2.6-6.4" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconLogOut = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
|
||||
<path d="M16 17l5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconDashboard = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<rect x="3" y="3" width="7.5" height="9" rx="1.5" />
|
||||
<rect x="13.5" y="3" width="7.5" height="5.5" rx="1.5" />
|
||||
<rect x="13.5" y="12" width="7.5" height="9" rx="1.5" />
|
||||
<rect x="3" y="15.5" width="7.5" height="5.5" rx="1.5" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconBoxes = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M21 8l-9-5-9 5v8l9 5 9-5V8z" />
|
||||
<path d="M3.3 8.3L12 13l8.7-4.7" />
|
||||
<path d="M12 13v9" />
|
||||
<path d="M12 3v10" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconClipboardList = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<rect x="5" y="4" width="14" height="17" rx="2" />
|
||||
<path d="M9 2h6v4H9z" />
|
||||
<path d="M9 11h6" />
|
||||
<path d="M9 15h6" />
|
||||
<path d="M9 7.5h6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconUpload = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 16V4" />
|
||||
<path d="M7 9l5-5 5 5" />
|
||||
<path d="M4 20h16" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconExternalLink = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M14 4h6v6" />
|
||||
<path d="M20 4L10 14" />
|
||||
<path d="M20 14v5a2 2 0 01-2 2H6a2 2 0 01-2-2V7a2 2 0 012-2h5" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconCoffee = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 8h13v6a5 5 0 01-5 5H9a5 5 0 01-5-5V8z" />
|
||||
<path d="M17 9h2.5a2.5 2.5 0 010 5H17" />
|
||||
<path d="M7 3v2" />
|
||||
<path d="M11 3v2" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconLock = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 018 0v4" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconShield = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 2l8 3.5V11c0 5-3.4 9.3-8 11-4.6-1.7-8-6-8-11V5.5L12 2z" />
|
||||
<path d="M9 12l2 2 4-4.5" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconZap = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M13 2L4 14h6l-1 8 9-12h-6l1-8z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconTrendingUp = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M3 17l6-6 4 4 8-8" />
|
||||
<path d="M15 7h6v6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconTrendingDown = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M3 7l6 6 4-4 8 8" />
|
||||
<path d="M15 17h6v-6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconWallet = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<rect x="2.5" y="6" width="19" height="14" rx="2.5" />
|
||||
<path d="M16.5 12.5h.01" />
|
||||
<path d="M2.5 9.5h19" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconShoppingBag = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M6 7h12l1.2 13.2a1 1 0 01-1 1.1H5.8a1 1 0 01-1-1.1L6 7z" />
|
||||
<path d="M9 10V6a3 3 0 016 0v4" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconRotateCcw = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M3 12a9 9 0 109-9" />
|
||||
<path d="M3 3v6h6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconBarChart = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 20V10" />
|
||||
<path d="M10 20V4" />
|
||||
<path d="M16 20v-7" />
|
||||
<path d="M22 20H2" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconGlobe = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3a14 14 0 010 18" />
|
||||
<path d="M12 3a14 14 0 000 18" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconMenu = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 6h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 18h16" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const IconDollarSign = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 2v20" />
|
||||
<path d="M17 5.5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" />
|
||||
</Svg>
|
||||
);
|
||||
66
frontend/src/components/ProductCard.tsx
Normal file
66
frontend/src/components/ProductCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Product } from '../lib/types';
|
||||
import { money } from '../lib/format';
|
||||
import { useCart } from '../store/cart';
|
||||
import { IconPlus } from './Icons';
|
||||
|
||||
export default function ProductCard({ product }: { product: Product }) {
|
||||
const { add } = useCart();
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const img = product.images && product.images.length > 0 ? product.images[0] : undefined;
|
||||
const showImg = Boolean(img) && !imgFailed;
|
||||
|
||||
return (
|
||||
<div className="glass glass-hover group flex flex-col overflow-hidden">
|
||||
<Link
|
||||
to={`/product/${product.id}`}
|
||||
className="relative block aspect-[4/3] overflow-hidden bg-night-700"
|
||||
>
|
||||
{showImg ? (
|
||||
<img
|
||||
src={img}
|
||||
alt={product.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-600/40 via-night-700 to-violet-600/30">
|
||||
<span className="text-6xl font-black text-white/10">{product.title.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
{product.category ? (
|
||||
<span className="absolute left-3 top-3 rounded-full border border-white/10 bg-night-900/70 px-2.5 py-1 text-[11px] font-medium text-indigo-200 backdrop-blur">
|
||||
{product.category}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col gap-2.5 p-4">
|
||||
<Link
|
||||
to={`/product/${product.id}`}
|
||||
className="line-clamp-2 text-sm font-semibold leading-snug text-slate-100 transition group-hover:text-indigo-300"
|
||||
>
|
||||
{product.title}
|
||||
</Link>
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-white">{money(product.retail_price)}</p>
|
||||
{product.retail_price && product.cost ? (
|
||||
<p className="text-[11px] text-slate-500">ships free over $100</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => add(product)}
|
||||
className="btn-primary !px-3.5 !py-2 text-xs"
|
||||
title="Add to cart"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/Spinner.tsx
Normal file
17
frontend/src/components/Spinner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function Spinner({ className = 'h-5 w-5' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-20" />
|
||||
<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageLoader({ label = 'Loading…' }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-slate-400">
|
||||
<Spinner className="h-7 w-7 text-indigo-400" />
|
||||
<p className="text-sm">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/StatusBadge.tsx
Normal file
17
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { orderStatusLabel, orderStatusTone, productStatusLabel, productStatusTone } from '../lib/format';
|
||||
|
||||
interface Props {
|
||||
kind: 'order' | 'product';
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ kind, status }: Props) {
|
||||
const label = kind === 'order' ? orderStatusLabel(status) : productStatusLabel(status);
|
||||
const tone = kind === 'order' ? orderStatusTone(status) : productStatusTone(status);
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-0.5 text-xs font-medium ${tone}`}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/StoreLayout.tsx
Normal file
84
frontend/src/components/StoreLayout.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Link, NavLink, Outlet } from 'react-router-dom';
|
||||
import { useCart } from '../store/cart';
|
||||
import { IconCart, IconCoffee, IconExternalLink, IconStar, IconTruck } from './Icons';
|
||||
|
||||
const navLink = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-lg px-3 py-2 text-sm font-medium transition ${
|
||||
isActive ? 'bg-white/[0.08] text-white' : 'text-slate-400 hover:text-white'
|
||||
}`;
|
||||
|
||||
export default function StoreLayout() {
|
||||
const { count } = useCart();
|
||||
|
||||
return (
|
||||
<div className="bg-app flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-40 border-b border-white/[0.06] bg-night-950/70 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-glow">
|
||||
<IconStar className="h-5 w-5 text-white" />
|
||||
</span>
|
||||
<span className="text-lg font-bold tracking-tight text-white">
|
||||
Polaris<span className="text-indigo-400">.</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
<NavLink to="/" end className={navLink}>
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink to="/shop" className={navLink}>
|
||||
Shop
|
||||
</NavLink>
|
||||
<NavLink to="/track" className={navLink}>
|
||||
Track order
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/track" className="btn-ghost !px-3 !py-2 md:hidden" title="Track order">
|
||||
<IconTruck className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link to="/cart" className="btn-ghost relative !px-3.5 !py-2">
|
||||
<IconCart className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Cart</span>
|
||||
{count > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 px-1 text-[11px] font-bold text-white shadow-glow">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-white/[0.06] bg-night-900/60">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-4 py-8 sm:flex-row sm:px-6">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<IconStar className="h-4 w-4 text-indigo-400" />
|
||||
<span>Polaris — automated commerce, by starlight.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link to="/admin" className="text-slate-500 transition hover:text-indigo-300">
|
||||
Admin
|
||||
</Link>
|
||||
<a
|
||||
href="https://buymeacoffee.com/r26xrthzttg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-amber-200/90 transition hover:bg-white/[0.08]"
|
||||
>
|
||||
<IconCoffee className="h-4 w-4" />
|
||||
Buy me a coffee
|
||||
<IconExternalLink className="h-3.5 w-3.5 opacity-60" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend/src/index.css
Normal file
85
frontend/src/index.css
Normal file
@@ -0,0 +1,85 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
@apply bg-[#03050d] text-slate-200 antialiased;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-indigo-500/40 text-white;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #05070f;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #1a2447;
|
||||
border-radius: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #28356b;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Aurora field behind everything */
|
||||
.bg-app {
|
||||
background:
|
||||
radial-gradient(1100px 540px at 12% -10%, rgba(99, 102, 241, 0.16), transparent 62%),
|
||||
radial-gradient(900px 500px at 88% -8%, rgba(167, 139, 250, 0.13), transparent 60%),
|
||||
radial-gradient(760px 520px at 50% 112%, rgba(56, 189, 248, 0.07), transparent 62%),
|
||||
#03050d;
|
||||
}
|
||||
|
||||
.glass {
|
||||
@apply rounded-2xl border border-white/[0.07] bg-white/[0.04] shadow-glass backdrop-blur-xl;
|
||||
}
|
||||
|
||||
.glass-hover {
|
||||
@apply transition duration-300 hover:border-white/[0.15] hover:bg-white/[0.07];
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-indigo-300 via-violet-300 to-sky-300 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 px-5 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:brightness-110 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-2.5 text-sm font-semibold text-slate-200 transition hover:bg-white/[0.08] active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-rose-400/30 bg-rose-500/10 px-5 py-2.5 text-sm font-semibold text-rose-300 transition hover:bg-rose-500/20 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-xl border border-white/10 bg-night-800/80 px-4 py-2.5 text-sm text-slate-100 placeholder-slate-500 outline-none transition focus:border-indigo-400/60 focus:ring-2 focus:ring-indigo-500/20;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply mb-1.5 block text-xs font-medium uppercase tracking-wider text-slate-400;
|
||||
}
|
||||
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-1.5 text-xs font-medium text-indigo-200 backdrop-blur;
|
||||
}
|
||||
|
||||
.th {
|
||||
@apply px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-slate-500;
|
||||
}
|
||||
|
||||
.td {
|
||||
@apply px-4 py-3 text-sm text-slate-300;
|
||||
}
|
||||
}
|
||||
80
frontend/src/lib/api.ts
Normal file
80
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Shared API client — used by BOTH the storefront and the admin surfaces.
|
||||
// Every request goes to /api (VITE_API_URL, default '/api') and is sent with
|
||||
// Cache-Control: no-store / fetch cache: 'no-store' per platform policy.
|
||||
|
||||
const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? '/api';
|
||||
const TOKEN_KEY = 'polaris_admin_token';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Cache-Control', 'no-store');
|
||||
headers.set('Pragma', 'no-cache');
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
if (init.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { ...init, headers, cache: 'no-store' });
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const data = (await res.json()) as { detail?: unknown; message?: unknown };
|
||||
if (typeof data.detail === 'string') detail = data.detail;
|
||||
else if (typeof data.message === 'string') detail = data.message;
|
||||
} catch {
|
||||
// non-JSON error body — keep status text
|
||||
}
|
||||
throw new ApiError(res.status, detail);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
token: (): string | null => localStorage.getItem(TOKEN_KEY),
|
||||
save: (token: string) => localStorage.setItem(TOKEN_KEY, token),
|
||||
clear: () => localStorage.removeItem(TOKEN_KEY),
|
||||
};
|
||||
|
||||
/** Build a query string, skipping empty values. */
|
||||
export function qs(params: Record<string, string | number | undefined | null>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
|
||||
}
|
||||
const s = search.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
/** Tolerate both a bare list and a `{items: [...]}` envelope from the API. */
|
||||
export function asList<T>(res: T[] | { items?: T[] }): T[] {
|
||||
if (Array.isArray(res)) return res;
|
||||
if (res && Array.isArray(res.items)) return res.items;
|
||||
return [];
|
||||
}
|
||||
109
frontend/src/lib/format.ts
Normal file
109
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { OrderStatus, ProductStatus } from './types';
|
||||
|
||||
export function money(n: number | null | undefined, digits = 2): string {
|
||||
if (n === null || n === undefined || Number.isNaN(n)) return '—';
|
||||
return n.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export function num(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || Number.isNaN(n)) return '—';
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function pct(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || Number.isNaN(n)) return '—';
|
||||
return `${n.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function date(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function shortId(id: string | null | undefined): string {
|
||||
if (!id) return '—';
|
||||
return id.length > 10 ? id.slice(0, 8) : id;
|
||||
}
|
||||
|
||||
const ORDER_LABELS: Record<OrderStatus, string> = {
|
||||
new: 'New',
|
||||
paid: 'Paid',
|
||||
fraud_check: 'Fraud check',
|
||||
supplier_order: 'Supplier order',
|
||||
confirmed: 'Confirmed',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled',
|
||||
refunded: 'Refunded',
|
||||
};
|
||||
|
||||
const PRODUCT_LABELS: Record<ProductStatus, string> = {
|
||||
discovered: 'Discovered',
|
||||
analyzing: 'Analyzing',
|
||||
approved: 'Approved',
|
||||
imported: 'Imported',
|
||||
content_generated: 'Content ready',
|
||||
price_calculated: 'Priced',
|
||||
quality_check: 'Quality check',
|
||||
published: 'Published',
|
||||
monitored: 'Monitored',
|
||||
paused: 'Paused',
|
||||
sold: 'Sold',
|
||||
};
|
||||
|
||||
const ORDER_TONES: Record<OrderStatus, string> = {
|
||||
new: 'border-sky-400/40 bg-sky-400/10 text-sky-300',
|
||||
paid: 'border-indigo-400/40 bg-indigo-400/10 text-indigo-300',
|
||||
fraud_check: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
|
||||
supplier_order: 'border-violet-400/40 bg-violet-400/10 text-violet-300',
|
||||
confirmed: 'border-teal-400/40 bg-teal-400/10 text-teal-300',
|
||||
shipped: 'border-cyan-400/40 bg-cyan-400/10 text-cyan-300',
|
||||
delivered: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
|
||||
cancelled: 'border-rose-400/40 bg-rose-400/10 text-rose-300',
|
||||
refunded: 'border-orange-400/40 bg-orange-400/10 text-orange-300',
|
||||
};
|
||||
|
||||
const PRODUCT_TONES: Record<ProductStatus, string> = {
|
||||
discovered: 'border-slate-400/40 bg-slate-400/10 text-slate-300',
|
||||
analyzing: 'border-sky-400/40 bg-sky-400/10 text-sky-300',
|
||||
approved: 'border-violet-400/40 bg-violet-400/10 text-violet-300',
|
||||
imported: 'border-indigo-400/40 bg-indigo-400/10 text-indigo-300',
|
||||
content_generated: 'border-fuchsia-400/40 bg-fuchsia-400/10 text-fuchsia-300',
|
||||
price_calculated: 'border-cyan-400/40 bg-cyan-400/10 text-cyan-300',
|
||||
quality_check: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
|
||||
published: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
|
||||
monitored: 'border-teal-400/40 bg-teal-400/10 text-teal-300',
|
||||
paused: 'border-rose-400/40 bg-rose-400/10 text-rose-300',
|
||||
sold: 'border-orange-400/40 bg-orange-400/10 text-orange-300',
|
||||
};
|
||||
|
||||
export const ORDER_STATUSES = Object.keys(ORDER_LABELS) as OrderStatus[];
|
||||
export const PRODUCT_STATUSES = Object.keys(PRODUCT_LABELS) as ProductStatus[];
|
||||
|
||||
export function orderStatusLabel(s: string): string {
|
||||
return ORDER_LABELS[s as OrderStatus] ?? s;
|
||||
}
|
||||
|
||||
export function productStatusLabel(s: string): string {
|
||||
return PRODUCT_LABELS[s as ProductStatus] ?? s;
|
||||
}
|
||||
|
||||
export function orderStatusTone(s: string): string {
|
||||
return ORDER_TONES[s as OrderStatus] ?? 'border-slate-400/40 bg-slate-400/10 text-slate-300';
|
||||
}
|
||||
|
||||
export function productStatusTone(s: string): string {
|
||||
return PRODUCT_TONES[s as ProductStatus] ?? 'border-slate-400/40 bg-slate-400/10 text-slate-300';
|
||||
}
|
||||
141
frontend/src/lib/types.ts
Normal file
141
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// TypeScript models mirroring db/schema.sql (canonical) + the module contract.
|
||||
|
||||
export type ProductStatus =
|
||||
| 'discovered'
|
||||
| 'analyzing'
|
||||
| 'approved'
|
||||
| 'imported'
|
||||
| 'content_generated'
|
||||
| 'price_calculated'
|
||||
| 'quality_check'
|
||||
| 'published'
|
||||
| 'monitored'
|
||||
| 'paused'
|
||||
| 'sold';
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
sku: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
brand?: string | null;
|
||||
manufacturer?: string | null;
|
||||
upc_ean_gtin?: string | null;
|
||||
images: string[];
|
||||
dimensions?: Record<string, unknown> | null;
|
||||
weight_grams?: number | null;
|
||||
cost: number;
|
||||
retail_price?: number | null;
|
||||
min_price?: number | null;
|
||||
max_price?: number | null;
|
||||
status: ProductStatus;
|
||||
lifecycle?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Supplier {
|
||||
id: string;
|
||||
name: string;
|
||||
adapter_type: string;
|
||||
api_endpoint?: string | null;
|
||||
account_id?: string | null;
|
||||
reliability_score: number;
|
||||
status: 'active' | 'paused' | 'failed';
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface SupplierPerformance {
|
||||
supplier_id: string;
|
||||
supplier_name?: string;
|
||||
fulfillment_rate: number;
|
||||
avg_shipping_days?: number | null;
|
||||
cancellation_rate: number;
|
||||
stock_accuracy: number;
|
||||
return_rate: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
product_id: string;
|
||||
qty: number;
|
||||
unit_price?: number | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type OrderStatus =
|
||||
| 'new'
|
||||
| 'paid'
|
||||
| 'fraud_check'
|
||||
| 'supplier_order'
|
||||
| 'confirmed'
|
||||
| 'shipped'
|
||||
| 'delivered'
|
||||
| 'cancelled'
|
||||
| 'refunded';
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
order_number: string;
|
||||
customer_id?: string | null;
|
||||
customer_email?: string | null;
|
||||
items: OrderItem[];
|
||||
retail_total: number;
|
||||
supplier_id?: string | null;
|
||||
supplier_cost?: number | null;
|
||||
shipping_cost?: number | null;
|
||||
fees?: number | null;
|
||||
profit?: number | null;
|
||||
status: OrderStatus;
|
||||
tracking?: string | null;
|
||||
carrier?: string | null;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
revenue: number;
|
||||
orders: number;
|
||||
gross_profit?: number | null;
|
||||
profit?: number | null;
|
||||
net_profit?: number | null;
|
||||
margin?: number | null;
|
||||
aov?: number | null;
|
||||
conversion?: number | null;
|
||||
refunds?: number | null;
|
||||
refunded_orders?: number | null;
|
||||
}
|
||||
|
||||
export interface TopProduct {
|
||||
product_id: string;
|
||||
title: string;
|
||||
sku?: string;
|
||||
units_sold: number;
|
||||
revenue: number;
|
||||
profit?: number | null;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
customer_email: string;
|
||||
items: { product_id: string; qty: number }[];
|
||||
}
|
||||
|
||||
export interface ImportRequest {
|
||||
supplier_id: string;
|
||||
feed_type: 'csv' | 'xml' | 'json';
|
||||
data_or_url: string;
|
||||
}
|
||||
|
||||
export interface PricePoint {
|
||||
price: number;
|
||||
reason?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
16
frontend/src/main.tsx
Normal file
16
frontend/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { CartProvider } from './store/cart';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<CartProvider>
|
||||
<App />
|
||||
</CartProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
112
frontend/src/pages/Cart.tsx
Normal file
112
frontend/src/pages/Cart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { money } from '../lib/format';
|
||||
import { useCart } from '../store/cart';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { IconArrowRight, IconCart, IconMinus, IconPlus, IconX } from '../components/Icons';
|
||||
|
||||
export default function Cart() {
|
||||
const { lines, subtotal, setQty, remove } = useCart();
|
||||
|
||||
if (lines.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-16">
|
||||
<EmptyState
|
||||
icon={<IconCart className="h-10 w-10" />}
|
||||
title="Your cart is empty"
|
||||
hint="The collection is live — something worth a look."
|
||||
action={
|
||||
<Link to="/shop" className="btn-primary">
|
||||
Browse the shop <IconArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<h1 className="text-3xl font-bold text-white">Cart</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
{lines.length} {lines.length === 1 ? 'item' : 'items'} · {money(subtotal)}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{lines.map(({ product, qty }) => (
|
||||
<div key={product.id} className="glass flex items-center gap-4 p-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-indigo-600/25 via-night-700 to-violet-600/20">
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt={product.title}
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl font-black text-white/15">{product.title.charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={`/product/${product.id}`}
|
||||
className="truncate font-semibold text-white transition hover:text-indigo-300"
|
||||
>
|
||||
{product.title}
|
||||
</Link>
|
||||
<p className="mt-0.5 text-sm text-slate-500">
|
||||
{money(product.retail_price)} each
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-lg border border-white/10 bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty(product.id, qty - 1)}
|
||||
className="px-3 py-2 text-slate-400 transition hover:text-white"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<IconMinus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-semibold text-white">{qty}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty(product.id, qty + 1)}
|
||||
className="px-3 py-2 text-slate-400 transition hover:text-white"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hidden w-24 text-right font-semibold text-white sm:block">
|
||||
{money((product.retail_price ?? 0) * qty)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(product.id)}
|
||||
className="rounded-lg p-2 text-slate-500 transition hover:bg-rose-500/10 hover:text-rose-300"
|
||||
aria-label={`Remove ${product.title}`}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="glass mt-8 flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">Subtotal</p>
|
||||
<p className="text-2xl font-extrabold text-white">{money(subtotal)}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Shipping & taxes calculated at checkout.</p>
|
||||
</div>
|
||||
<Link to="/checkout" className="btn-primary !px-8 !py-3">
|
||||
Checkout <IconArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
frontend/src/pages/Checkout.tsx
Normal file
172
frontend/src/pages/Checkout.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import type { Order } from '../lib/types';
|
||||
import { money } from '../lib/format';
|
||||
import { useCart } from '../store/cart';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { IconCheck, IconCart, IconLock, IconTruck } from '../components/Icons';
|
||||
|
||||
export default function Checkout() {
|
||||
const { lines, subtotal, clear } = useCart();
|
||||
const [email, setEmail] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [placing, setPlacing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
|
||||
// ── Confirmation ──
|
||||
if (order) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-16">
|
||||
<div className="glass p-8 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/15">
|
||||
<IconCheck className="h-8 w-8 text-emerald-400" />
|
||||
</div>
|
||||
<h1 className="mt-5 text-2xl font-bold text-white">Order placed!</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Thanks{order.customer_email ? `, ${order.customer_email}` : ''} — your order is in the
|
||||
Polaris pipeline.
|
||||
</p>
|
||||
<div className="glass mt-6 rounded-xl border-indigo-400/20 bg-indigo-500/[0.07] p-4">
|
||||
<p className="text-xs uppercase tracking-wider text-slate-400">Order number</p>
|
||||
<p className="text-gradient mt-1 text-2xl font-extrabold">{order.order_number}</p>
|
||||
</div>
|
||||
{order.retail_total != null ? (
|
||||
<p className="mt-4 text-sm text-slate-400">
|
||||
Total charged: <span className="font-semibold text-white">{money(order.retail_total)}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<Link
|
||||
to={`/track?order=${encodeURIComponent(order.order_number)}`}
|
||||
className="btn-primary"
|
||||
>
|
||||
<IconTruck className="h-4 w-4" /> Track this order
|
||||
</Link>
|
||||
<Link to="/shop" className="btn-ghost">
|
||||
Continue shopping
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty cart ──
|
||||
if (lines.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-16">
|
||||
<EmptyState
|
||||
icon={<IconCart className="h-10 w-10" />}
|
||||
title="Nothing to check out"
|
||||
hint="Your cart is empty — add something first."
|
||||
action={
|
||||
<Link to="/shop" className="btn-primary">
|
||||
Browse the shop
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const shipping = subtotal >= 100 ? 0 : 7.99;
|
||||
const total = subtotal + shipping;
|
||||
|
||||
const place = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPlacing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.post<Order>('/orders', {
|
||||
customer_email: email,
|
||||
items: lines.map((l) => ({ product_id: l.product.id, qty: l.qty })),
|
||||
});
|
||||
clear();
|
||||
setOrder(created);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to place order');
|
||||
} finally {
|
||||
setPlacing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
|
||||
<h1 className="text-3xl font-bold text-white">Checkout</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Secure demo checkout — orders route straight into the Polaris pipeline.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-5">
|
||||
<form onSubmit={place} className="glass space-y-5 p-6 lg:col-span-3">
|
||||
<div>
|
||||
<label className="label" htmlFor="co-name">Full name</label>
|
||||
<input id="co-name" className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ada Lovelace" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="co-email">Email</label>
|
||||
<input id="co-email" type="email" className="input" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required />
|
||||
<p className="mt-1.5 text-xs text-slate-500">We send order updates and tracking to this address.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="co-addr">Shipping address</label>
|
||||
<textarea id="co-addr" className="input min-h-[88px] resize-none" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="123 Main St, City, ZIP" required />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<IconLock className="h-4 w-4 text-indigo-300" />
|
||||
Payment — demo
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
No card is charged. This is a demo storefront backed by the Polaris API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="submit" disabled={placing} className="btn-primary w-full !py-3.5">
|
||||
{placing ? 'Placing order…' : `Place order · ${money(total)}`}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<div className="glass sticky top-24 p-6">
|
||||
<h2 className="font-semibold text-white">Order summary</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{lines.map(({ product, qty }) => (
|
||||
<div key={product.id} className="flex justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate text-slate-400">
|
||||
{qty}× {product.title}
|
||||
</span>
|
||||
<span className="shrink-0 font-medium text-slate-200">{money((product.retail_price ?? 0) * qty)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 space-y-2 border-t border-white/[0.08] pt-4 text-sm">
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Subtotal</span>
|
||||
<span>{money(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Shipping (est.)</span>
|
||||
<span>{shipping === 0 ? 'Free' : money(shipping)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 text-base font-bold text-white">
|
||||
<span>Total</span>
|
||||
<span>{money(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
frontend/src/pages/Home.tsx
Normal file
129
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, asList } from '../lib/api';
|
||||
import type { Product } from '../lib/types';
|
||||
import ProductCard from '../components/ProductCard';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconBoxes,
|
||||
IconShield,
|
||||
IconSparkles,
|
||||
IconTruck,
|
||||
IconZap,
|
||||
} from '../components/Icons';
|
||||
|
||||
const pillars = [
|
||||
{ icon: IconZap, title: 'Automated sourcing', body: 'Suppliers are vetted, scored and routed automatically.' },
|
||||
{ icon: IconSparkles, title: 'Dynamic pricing', body: 'Every listing is priced against the market, continuously.' },
|
||||
{ icon: IconShield, title: 'Quality checked', body: 'Products pass a lifecycle gate before they ever go live.' },
|
||||
{ icon: IconTruck, title: 'Tracked fulfillment', body: 'Live tracking on every order, from supplier to doorstep.' },
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{ n: '01', title: 'Browse & order', body: 'Shop curated products at market-fair prices.' },
|
||||
{ n: '02', title: 'Automated fulfillment', body: 'Polaris routes your order to the best-scoring supplier.' },
|
||||
{ n: '03', title: 'Track to your door', body: 'Follow the shipment with live status and tracking.' },
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const [featured, setFeatured] = useState<Product[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Product[]>('/products?status=published&limit=4')
|
||||
.then((res) => setFeatured(asList(res)))
|
||||
.catch(() => setFeatured([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
{/* ── Hero ── */}
|
||||
<section className="relative py-20 text-center sm:py-28">
|
||||
<div className="pointer-events-none absolute left-1/2 top-0 h-[420px] w-[720px] -translate-x-1/2 rounded-full bg-indigo-600/20 blur-[120px]" />
|
||||
<span className="chip">
|
||||
<IconSparkles className="h-3.5 w-3.5 text-amber-300" />
|
||||
The North Star of automated e-commerce
|
||||
</span>
|
||||
<h1 className="mx-auto mt-6 max-w-3xl text-4xl font-extrabold leading-tight tracking-tight text-white sm:text-6xl">
|
||||
Curated products.
|
||||
<br />
|
||||
<span className="text-gradient">Automated</span> commerce.
|
||||
</h1>
|
||||
<p className="mx-auto mt-5 max-w-xl text-base text-slate-400 sm:text-lg">
|
||||
Polaris sources, prices and fulfills products from vetted suppliers — so every order
|
||||
ships fast, at a fair price, with tracking you can follow.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link to="/shop" className="btn-primary !px-6 !py-3 text-base">
|
||||
Shop the collection
|
||||
<IconArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link to="/track" className="btn-ghost !px-6 !py-3 text-base">
|
||||
<IconTruck className="h-4 w-4" />
|
||||
Track an order
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Pillars ── */}
|
||||
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{pillars.map((p) => (
|
||||
<div key={p.title} className="glass glass-hover p-5">
|
||||
<p.icon className="h-6 w-6 text-indigo-300" />
|
||||
<h3 className="mt-3 font-semibold text-white">{p.title}</h3>
|
||||
<p className="mt-1 text-sm leading-relaxed text-slate-400">{p.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* ── Featured ── */}
|
||||
<section className="py-16">
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">Featured now</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Fresh from the pipeline, live today.</p>
|
||||
</div>
|
||||
<Link to="/shop" className="btn-ghost !py-2 text-sm">
|
||||
View all
|
||||
<IconArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
{featured === null ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-7 w-7 text-indigo-400" />
|
||||
</div>
|
||||
) : featured.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<IconBoxes className="h-10 w-10" />}
|
||||
title="Nothing published yet"
|
||||
hint="The product pipeline is warming up — check back soon."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{featured.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── How it works ── */}
|
||||
<section className="pb-20">
|
||||
<div className="glass p-8 sm:p-10">
|
||||
<h2 className="text-center text-2xl font-bold text-white">How Polaris works</h2>
|
||||
<div className="mt-8 grid gap-8 sm:grid-cols-3">
|
||||
{steps.map((s) => (
|
||||
<div key={s.n} className="text-center">
|
||||
<div className="text-gradient mx-auto text-3xl font-black">{s.n}</div>
|
||||
<h3 className="mt-2 font-semibold text-white">{s.title}</h3>
|
||||
<p className="mx-auto mt-1 max-w-[240px] text-sm text-slate-400">{s.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
frontend/src/pages/ProductDetail.tsx
Normal file
184
frontend/src/pages/ProductDetail.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api, asList } from '../lib/api';
|
||||
import type { Product } from '../lib/types';
|
||||
import { money } from '../lib/format';
|
||||
import { useCart } from '../store/cart';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import { IconArrowLeft, IconCart, IconMinus, IconPlus, IconTrendingUp } from '../components/Icons';
|
||||
|
||||
interface PricePoint {
|
||||
price: number;
|
||||
reason?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export default function ProductDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { add } = useCart();
|
||||
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [history, setHistory] = useState<PricePoint[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const [qty, setQty] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.get<Product>(`/products/${id}`)
|
||||
.then((p) => {
|
||||
if (!cancelled) setProduct(p);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) setError(e.message);
|
||||
});
|
||||
api
|
||||
.get<PricePoint[]>(`/products/${id}/price-history`)
|
||||
.then((h) => {
|
||||
if (!cancelled) setHistory(asList(h).slice(-8));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-16">
|
||||
<EmptyState
|
||||
title="Product not found"
|
||||
hint={error}
|
||||
action={
|
||||
<Link to="/shop" className="btn-primary">
|
||||
<IconArrowLeft className="h-4 w-4" /> Back to shop
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-8 w-8 text-indigo-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const img = product.images?.[0];
|
||||
const showImg = img && !imgFailed;
|
||||
|
||||
const handleAdd = () => {
|
||||
add(product, qty);
|
||||
navigate('/cart');
|
||||
};
|
||||
|
||||
const maxHist = Math.max(...history.map((h) => h.price), 1);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
|
||||
<Link
|
||||
to="/shop"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-400 transition hover:text-white"
|
||||
>
|
||||
<IconArrowLeft className="h-4 w-4" /> Back to shop
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 grid gap-8 lg:grid-cols-2">
|
||||
{/* Media */}
|
||||
<div className="glass relative aspect-square overflow-hidden">
|
||||
{showImg ? (
|
||||
<img
|
||||
src={img}
|
||||
alt={product.title}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-indigo-600/25 via-night-700 to-violet-600/20">
|
||||
<span className="text-[8rem] font-black text-white/10">{product.title.charAt(0)}</span>
|
||||
</div>
|
||||
)}
|
||||
{product.category ? (
|
||||
<span className="absolute left-4 top-4 rounded-full border border-white/10 bg-night-900/70 px-3 py-1 text-xs font-medium text-indigo-200 backdrop-blur">
|
||||
{product.category}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex flex-col">
|
||||
{product.brand ? <p className="text-sm font-medium uppercase tracking-wider text-indigo-300">{product.brand}</p> : null}
|
||||
<h1 className="mt-1 text-3xl font-bold leading-tight text-white">{product.title}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">SKU {product.sku}</p>
|
||||
|
||||
<div className="mt-5 flex items-baseline gap-3">
|
||||
<span className="text-4xl font-extrabold text-white">{money(product.retail_price)}</span>
|
||||
{product.retail_price != null && product.cost > 0 && (
|
||||
<span className="text-sm text-emerald-400">
|
||||
{((product.retail_price - product.cost) / product.cost) * 100 >= 0
|
||||
? `${Math.round(((product.retail_price - product.cost) / product.cost) * 100)}% margin`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.description ? (
|
||||
<p className="mt-5 leading-relaxed text-slate-400">{product.description}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center rounded-xl border border-white/10 bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty((q) => Math.max(1, q - 1))}
|
||||
className="px-4 py-3 text-slate-400 transition hover:text-white"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<IconMinus className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="w-10 text-center font-semibold text-white">{qty}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQty((q) => Math.min(99, q + 1))}
|
||||
className="px-4 py-3 text-slate-400 transition hover:text-white"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<IconPlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={handleAdd} className="btn-primary !px-8 !py-3">
|
||||
<IconCart className="h-4 w-4" />
|
||||
Add to cart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{history.length > 1 ? (
|
||||
<div className="glass mt-10 p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<IconTrendingUp className="h-4 w-4 text-indigo-300" />
|
||||
Price history
|
||||
</div>
|
||||
<div className="mt-4 flex h-24 items-end gap-1.5">
|
||||
{history.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={`${money(h.price)}${h.reason ? ` — ${h.reason}` : ''}`}
|
||||
className="flex-1 rounded-t bg-gradient-to-t from-indigo-600/40 to-violet-400/70 transition hover:from-indigo-500/60 hover:to-violet-300"
|
||||
style={{ height: `${Math.max(10, (h.price / maxHist) * 100)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
frontend/src/pages/Shop.tsx
Normal file
114
frontend/src/pages/Shop.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, asList, qs } from '../lib/api';
|
||||
import type { Product } from '../lib/types';
|
||||
import ProductCard from '../components/ProductCard';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import { IconBoxes, IconSearch } from '../components/Icons';
|
||||
|
||||
export default function Shop() {
|
||||
const [products, setProducts] = useState<Product[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
|
||||
const load = useCallback(async (cat: string, q: string) => {
|
||||
try {
|
||||
const res = await api.get<Product[]>(
|
||||
`/products${qs({ status: 'published', category: cat, search: q, limit: 100 })}`,
|
||||
);
|
||||
const list = asList(res);
|
||||
setProducts(list);
|
||||
setCategories(
|
||||
Array.from(new Set(list.map((p) => p.category).filter((c): c is string => !!c))).sort(),
|
||||
);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
||||
setProducts([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => {
|
||||
void load(category, search);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, [category, search, load]);
|
||||
|
||||
const chips = ['', ...categories];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Shop</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Live products, sourced and priced by the Polaris pipeline.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full md:w-80">
|
||||
<IconSearch className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search products…"
|
||||
className="input !pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{chips.map((c) => (
|
||||
<button
|
||||
key={c || 'all'}
|
||||
type="button"
|
||||
onClick={() => setCategory(c)}
|
||||
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition ${
|
||||
category === c
|
||||
? 'border-indigo-400/50 bg-indigo-500/20 text-indigo-200'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:bg-white/[0.07] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{c || 'All'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{products === null ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-7 w-7 text-indigo-400" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
icon={<IconBoxes className="h-10 w-10" />}
|
||||
title="Could not load products"
|
||||
hint={error}
|
||||
action={
|
||||
<button type="button" className="btn-ghost" onClick={() => void load(category, search)}>
|
||||
Retry
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : products.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<IconBoxes className="h-10 w-10" />}
|
||||
title="No products found"
|
||||
hint="Try a different search term or category."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{products.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
frontend/src/pages/TrackOrder.tsx
Normal file
160
frontend/src/pages/TrackOrder.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import type { Order, OrderStatus } from '../lib/types';
|
||||
import { date, money, orderStatusLabel } from '../lib/format';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { Spinner } from '../components/Spinner';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { IconCheck, IconSearch, IconTruck } from '../components/Icons';
|
||||
|
||||
const FLOW: OrderStatus[] = ['new', 'paid', 'fraud_check', 'supplier_order', 'confirmed', 'shipped', 'delivered'];
|
||||
|
||||
export default function TrackOrder() {
|
||||
const [params] = useSearchParams();
|
||||
const [query, setQuery] = useState(params.get('order') ?? '');
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchOrder = async (id: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setOrder(null);
|
||||
try {
|
||||
setOrder(await api.get<Order>(`/orders/${encodeURIComponent(id.trim())}`));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Order not found');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const initial = params.get('order');
|
||||
if (initial) {
|
||||
void fetchOrder(initial);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) void fetchOrder(query);
|
||||
};
|
||||
|
||||
const stepIdx = order ? FLOW.indexOf(order.status) : -1;
|
||||
const terminal = order?.status === 'cancelled' || order?.status === 'refunded';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
|
||||
<h1 className="text-3xl font-bold text-white">Track an order</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Enter your order number (e.g. <span className="font-mono text-indigo-300">POL-123456</span>).
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit} className="mt-6 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<IconSearch className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Order number"
|
||||
className="input !pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary !px-6">
|
||||
Track
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-7 w-7 text-indigo-400" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState
|
||||
icon={<IconTruck className="h-10 w-10" />}
|
||||
title="Order not found"
|
||||
hint={error}
|
||||
/>
|
||||
) : order ? (
|
||||
<div className="space-y-4">
|
||||
<div className="glass flex flex-wrap items-center justify-between gap-4 p-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-slate-400">Order</p>
|
||||
<p className="text-xl font-bold text-white">{order.order_number}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">Placed {date(order.created_at)}</p>
|
||||
</div>
|
||||
<StatusBadge kind="order" status={order.status} />
|
||||
</div>
|
||||
|
||||
{terminal ? (
|
||||
<div className="rounded-2xl border border-rose-400/30 bg-rose-500/10 p-5 text-sm text-rose-200">
|
||||
This order was {orderStatusLabel(order.status).toLowerCase()} — no further tracking
|
||||
is expected.
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass p-6">
|
||||
<h2 className="font-semibold text-white">Status</h2>
|
||||
<div className="mt-6 flex items-start">
|
||||
{FLOW.map((s, i) => {
|
||||
const done = stepIdx >= 0 && i <= stepIdx;
|
||||
return (
|
||||
<div key={s} className="flex flex-1 flex-col items-center">
|
||||
<div className="flex w-full items-center">
|
||||
<div className={`h-px flex-1 ${i === 0 ? 'bg-transparent' : done ? 'bg-indigo-400/60' : 'bg-white/10'}`} />
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-bold transition ${
|
||||
done
|
||||
? 'border-indigo-400/60 bg-indigo-500/25 text-indigo-200'
|
||||
: 'border-white/10 bg-night-800 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{done ? <IconCheck className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<div className={`h-px flex-1 ${i === FLOW.length - 1 ? 'bg-transparent' : done ? 'bg-indigo-400/60' : 'bg-white/10'}`} />
|
||||
</div>
|
||||
<span className={`mt-2 text-center text-[10px] font-medium sm:text-xs ${done ? 'text-indigo-200' : 'text-slate-500'}`}>
|
||||
{orderStatusLabel(s)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="glass p-5">
|
||||
<p className="text-xs uppercase tracking-wider text-slate-400">Carrier</p>
|
||||
<p className="mt-1 font-semibold text-white">{order.carrier || '—'}</p>
|
||||
<p className="mt-3 text-xs uppercase tracking-wider text-slate-400">Tracking number</p>
|
||||
<p className="mt-1 font-mono text-sm text-indigo-300">{order.tracking || 'Awaiting pickup'}</p>
|
||||
</div>
|
||||
<div className="glass p-5">
|
||||
<p className="text-xs uppercase tracking-wider text-slate-400">Items</p>
|
||||
<ul className="mt-2 space-y-1.5 text-sm text-slate-300">
|
||||
{(order.items ?? []).map((it, i) => (
|
||||
<li key={i} className="flex justify-between gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-slate-400">
|
||||
{it.qty}× {it.title ?? it.product_id}
|
||||
</span>
|
||||
<span className="font-medium">{money(it.unit_price)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex justify-between border-t border-white/[0.08] pt-3 text-sm font-bold text-white">
|
||||
<span>Total</span>
|
||||
<span>{money(order.retail_total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/store/cart.tsx
Normal file
78
frontend/src/store/cart.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { Product } from '../lib/types';
|
||||
|
||||
export interface CartLine {
|
||||
product: Product;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface CartCtx {
|
||||
lines: CartLine[];
|
||||
count: number;
|
||||
subtotal: number;
|
||||
add: (p: Product, qty?: number) => void;
|
||||
remove: (id: string) => void;
|
||||
setQty: (id: string, qty: number) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<CartCtx | null>(null);
|
||||
const STORAGE_KEY = 'polaris_cart_v1';
|
||||
|
||||
function load(): CartLine[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as CartLine[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [lines, setLines] = useState<CartLine[]>(load);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(lines));
|
||||
} catch {
|
||||
/* storage unavailable — cart just won't persist */
|
||||
}
|
||||
}, [lines]);
|
||||
|
||||
const value = useMemo<CartCtx>(
|
||||
() => ({
|
||||
lines,
|
||||
count: lines.reduce((n, l) => n + l.qty, 0),
|
||||
subtotal: lines.reduce((n, l) => n + (l.product.retail_price ?? 0) * l.qty, 0),
|
||||
add: (p, qty = 1) =>
|
||||
setLines((prev) => {
|
||||
const i = prev.findIndex((l) => l.product.id === p.id);
|
||||
if (i >= 0) {
|
||||
const next = [...prev];
|
||||
next[i] = { ...next[i], qty: next[i].qty + qty };
|
||||
return next;
|
||||
}
|
||||
return [...prev, { product: p, qty }];
|
||||
}),
|
||||
remove: (id) => setLines((prev) => prev.filter((l) => l.product.id !== id)),
|
||||
setQty: (id, qty) =>
|
||||
setLines((prev) =>
|
||||
prev.map((l) =>
|
||||
l.product.id === id ? { ...l, qty: Math.max(1, Math.min(99, qty)) } : l,
|
||||
),
|
||||
),
|
||||
clear: () => setLines([]),
|
||||
}),
|
||||
[lines],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useCart(): CartCtx {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error('useCart must be used inside CartProvider');
|
||||
return ctx;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user