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:
drjones
2026-08-25 20:28:26 -07:00
parent 035bb3a2e9
commit b09dcfd929
39 changed files with 6067 additions and 0 deletions

40
frontend/src/App.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />