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

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