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

8
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
mock
.git
.gitignore
Dockerfile
docker-compose*
*.md

7
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
*.local
.env
.env.*
!.env.example

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# ── Polaris frontend ─────────────────────────────────────────────
# Stage 1: build the Vite bundle
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY . .
RUN npm run build
# Stage 2: serve static + proxy /api -> api:8000
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]

37
frontend/README.md Normal file
View File

@@ -0,0 +1,37 @@
# Polaris — Frontend
React 18 + Vite + TypeScript + Tailwind. Two surfaces sharing one API client (`src/lib/api.ts`):
- **Storefront** `/` — home hero, shop grid (category filter + search), product detail, cart, checkout, order tracking.
- **Admin** `/admin` — JWT login, today KPI dashboard, top products, supplier performance, product import, lifecycle view, order list with tracking simulation.
## Layout
```
src/lib/api.ts shared fetch wrapper → /api, Cache-Control: no-store
src/lib/types.ts models mirroring db/schema.sql
src/store/cart.tsx cart context (localStorage-persisted)
src/pages/* storefront pages
src/admin/* admin console pages
nginx.conf static + proxy /api → api:8000 (compose service `api`)
Dockerfile multi-stage: node build → nginx runtime
mock/mock_api.py standalone mock API for local verification (not shipped in image)
```
## Dev
```bash
npm install
npm run dev # Vite dev server proxies /api → http://localhost:8000
```
Run the mock API in another terminal: `python3 mock/mock_api.py` (admin: `admin@polaris.shop` / `polaris-admin`).
## Build / verify
```bash
npm run build # tsc --noEmit (no TS errors required) + vite build → dist/
docker build -t polaris-frontend .
```
The nginx image serves `dist/` and proxies `/api/` to the `api` service on port 8000.

21
frontend/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Polaris — automated e-commerce. Curated products, transparent pricing, and live order tracking from supplier to doorstep."
/>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23818cf8' d='M12 2.2l2.95 6.3 6.85.87-5.04 4.73 1.29 6.78L12 17.6l-6.05 3.28 1.29-6.78L2.2 9.37l6.85-.87z'/%3E%3C/svg%3E"
/>
<title>Polaris — Automated Commerce</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

359
frontend/mock/mock_api.py Normal file
View File

@@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""Polaris mock API — serves /api endpoints for FRONTEND VERIFICATION ONLY.
Run: python3 mock_api.py [port] (default 8000)
Not part of the frontend Docker image (.dockerignore excludes mock/).
Mimics the contract in CONTRACT.md against db/schema.sql shapes.
"""
import json
import random
import time
import uuid
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
ADMIN_EMAIL = "admin@polaris.shop"
ADMIN_PASSWORD = "polaris-admin"
TOKEN = "mock-jwt-payload-not-for-production"
CATS = ["Home", "Gadgets", "Office", "Audio"]
PRODUCT_SEEDS = [
("Aurora Desk Lamp", "Home", "AuroraLight", 12.50, 38.50),
("Nimbus Smart Scale", "Home", "NimbusLab", 18.20, 52.00),
("Polaris Pro Keyboard", "Gadgets", "Keystroke", 24.90, 79.00),
("Stellar Mouse MX", "Gadgets", "Keystroke", 11.30, 44.00),
("Comet USB-C Hub", "Gadgets", "PortWorks", 15.60, 59.00),
("Galaxy Standing Desk", "Office", "WorkSpan", 145.00, 389.00),
("Nova Ergonomic Chair", "Office", "WorkSpan", 98.00, 279.00),
("Lunar Notebook Set", "Office", "PaperNest", 4.80, 16.50),
("Eclipse Headphones", "Audio", "SoundOrbit", 32.00, 129.00),
("Pulsar Bluetooth Speaker", "Audio", "SoundOrbit", 21.50, 74.00),
("Quasar Webcam 4K", "Gadgets", "ClearSight", 39.00, 119.00),
("Vega Monitor Arm", "Office", "WorkSpan", 27.00, 89.00),
]
LIFECYCLE = [
"discovered", "analyzing", "approved", "imported", "content_generated",
"price_calculated", "quality_check", "published", "published", "published",
"monitored", "paused",
]
STATUS_FLOW = ["new", "paid", "fraud_check", "supplier_order", "confirmed", "shipped", "delivered"]
def now_iso(days_ago=0, hours_ago=0):
return (datetime.now(timezone.utc) - timedelta(days=days_ago, hours=hours_ago)).isoformat()
def build_products():
products = []
for i, (title, cat, brand, cost, retail) in enumerate(PRODUCT_SEEDS, start=1):
products.append({
"id": f"prd-{i:03d}",
"sku": f"POL-{i:04d}",
"title": title,
"description": f"{title} — sourced, vetted and dynamically priced by the Polaris pipeline. Ships from a top-scoring supplier with live tracking.",
"category": cat,
"brand": brand,
"manufacturer": brand,
"upc_ean_gtin": f"8{i:013d}",
"images": [],
"dimensions": {"l_cm": 20, "w_cm": 14, "h_cm": 8},
"weight_grams": 300 + i * 40,
"cost": cost,
"retail_price": retail,
"min_price": round(cost * 1.8, 2),
"max_price": round(retail * 1.35, 2),
"status": LIFECYCLE[i % len(LIFECYCLE)],
"lifecycle": {"steps_completed": i % 8},
"created_at": now_iso(days_ago=20 - i % 14),
"updated_at": now_iso(hours_ago=i),
})
return products
PRODUCTS = build_products()
SUPPLIERS = [
{"id": "sup-001", "name": "Starline Wholesale", "adapter_type": "csv_http", "api_endpoint": "https://feeds.starline.example/products.csv", "reliability_score": 0.94, "status": "active", "created_at": now_iso(days_ago=40)},
{"id": "sup-002", "name": "NorthBridge Supply", "adapter_type": "xml_ftp", "api_endpoint": "ftp://nb.example/feed.xml", "reliability_score": 0.87, "status": "active", "created_at": now_iso(days_ago=31)},
{"id": "sup-003", "name": "Meridian Goods", "adapter_type": "json_api", "api_endpoint": "https://api.meridian.example/v2", "reliability_score": 0.71, "status": "paused", "created_at": now_iso(days_ago=12)},
]
SUPPLIER_PERF = [
{"supplier_id": "sup-001", "supplier_name": "Starline Wholesale", "fulfillment_rate": 0.96, "avg_shipping_days": 2.1, "cancellation_rate": 0.02, "stock_accuracy": 0.93, "return_rate": 0.03, "updated_at": now_iso()},
{"supplier_id": "sup-002", "supplier_name": "NorthBridge Supply", "fulfillment_rate": 0.89, "avg_shipping_days": 3.4, "cancellation_rate": 0.07, "stock_accuracy": 0.84, "return_rate": 0.05, "updated_at": now_iso()},
{"supplier_id": "sup-003", "supplier_name": "Meridian Goods", "fulfillment_rate": 0.72, "avg_shipping_days": 5.2, "cancellation_rate": 0.16, "stock_accuracy": 0.66, "return_rate": 0.09, "updated_at": now_iso()},
]
ORDERS = [
{
"id": "ord-1001", "order_number": "POL-1001", "customer_id": "cus-1", "customer_email": "ada@example.com",
"items": [{"product_id": "prd-001", "qty": 2, "unit_price": 38.50, "title": "Aurora Desk Lamp"}],
"retail_total": 77.00, "supplier_id": "sup-001", "supplier_cost": 25.00, "shipping_cost": 6.99, "fees": 1.5,
"profit": 43.51, "status": "delivered", "tracking": "1Z999AA10123456784", "carrier": "UPS",
"created_at": now_iso(days_ago=6), "updated_at": now_iso(days_ago=3),
},
{
"id": "ord-1002", "order_number": "POL-1002", "customer_id": "cus-2", "customer_email": "grace@example.com",
"items": [{"product_id": "prd-003", "qty": 1, "unit_price": 79.00, "title": "Polaris Pro Keyboard"}, {"product_id": "prd-004", "qty": 1, "unit_price": 44.00, "title": "Stellar Mouse MX"}],
"retail_total": 123.00, "supplier_id": "sup-002", "supplier_cost": 36.20, "shipping_cost": 0.0, "fees": 2.4,
"profit": 84.40, "status": "shipped", "tracking": "9400100000000000000001", "carrier": "USPS",
"created_at": now_iso(days_ago=1, hours_ago=5), "updated_at": now_iso(hours_ago=2),
},
{
"id": "ord-1003", "order_number": "POL-1003", "customer_id": "cus-3", "customer_email": "linus@example.com",
"items": [{"product_id": "prd-009", "qty": 1, "unit_price": 129.00, "title": "Eclipse Headphones"}],
"retail_total": 129.00, "supplier_id": "sup-001", "supplier_cost": 32.00, "shipping_cost": 0.0, "fees": 2.5,
"profit": 94.50, "status": "confirmed", "tracking": None, "carrier": None,
"created_at": now_iso(hours_ago=9), "updated_at": now_iso(hours_ago=4),
},
{
"id": "ord-1004", "order_number": "POL-1004", "customer_id": "cus-4", "customer_email": "katherine@example.com",
"items": [{"product_id": "prd-010", "qty": 2, "unit_price": 74.00, "title": "Pulsar Bluetooth Speaker"}],
"retail_total": 148.00, "supplier_id": "sup-002", "supplier_cost": 43.00, "shipping_cost": 7.99, "fees": 2.9,
"profit": 94.11, "status": "fraud_check", "tracking": None, "carrier": None,
"created_at": now_iso(hours_ago=1), "updated_at": now_iso(hours_ago=1),
},
]
def summary_payload():
return {
"revenue": 4210.44,
"orders": 17,
"gross_profit": 987.12,
"net_profit": 812.90,
"margin": 19.3,
"aov": 247.67,
"conversion": 3.1,
"refunds": 2,
"refunded_orders": 2,
}
def top_products_payload():
rows = [
("prd-009", "Eclipse Headphones", "POL-0009", 6, 774.00, 582.00),
("prd-003", "Polaris Pro Keyboard", "POL-0003", 5, 395.00, 270.50),
("prd-001", "Aurora Desk Lamp", "POL-0001", 8, 308.00, 208.00),
("prd-006", "Galaxy Standing Desk", "POL-0006", 1, 389.00, 244.00),
("prd-010", "Pulsar Bluetooth Speaker", "POL-0010", 4, 296.00, 210.00),
]
return [
{"product_id": pid, "title": t, "sku": sku, "units_sold": u, "revenue": r, "profit": p}
for pid, t, sku, u, r, p in rows
]
class Handler(BaseHTTPRequestHandler):
server_version = "PolarisMock/0.1"
def log_message(self, fmt, *args): # quieter logs
pass
# ── helpers ──
def send_json(self, obj, status=200):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def read_body(self):
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length))
except Exception:
return {}
def not_found(self):
self.send_json({"detail": "Not found"}, 404)
# ── routing ──
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, Cache-Control")
self.end_headers()
def do_GET(self):
u = urlparse(self.path)
path = u.path.rstrip("/") or "/"
q = parse_qs(u.query)
if path == "/api/health":
return self.send_json({"status": "ok", "service": "polaris-api-mock", "version": "0.1.0"})
if path == "/api/products":
status = (q.get("status") or [None])[0]
cat = (q.get("category") or [None])[0]
search = (q.get("search") or [None])[0]
try:
limit = int((q.get("limit") or ["100"])[0])
except ValueError:
limit = 100
rows = [p for p in PRODUCTS
if not status or p["status"] == status]
if cat:
rows = [p for p in rows if p["category"] == cat]
if search:
s = search.lower()
rows = [p for p in rows if s in p["title"].lower() or s in p["sku"].lower()]
return self.send_json(rows[:limit])
if path.startswith("/api/products/") and path.count("/") == 3 and path.endswith("/price-history"):
pid = path.split("/")[3]
prod = next((p for p in PRODUCTS if p["id"] == pid), None)
if not prod:
return self.not_found()
retail = prod["retail_price"] or 50
history = []
for i in range(8):
history.append({
"price": round(retail * (0.86 + 0.14 * (i / 7)) + random.uniform(-1.5, 1.5), 2),
"reason": "market reprice" if i % 2 else "initial pricing",
"created_at": now_iso(days_ago=7 - i),
})
return self.send_json(history)
if path.startswith("/api/products/"):
pid = path.split("/")[3]
prod = next((p for p in PRODUCTS if p["id"] == pid), None)
return self.send_json(prod) if prod else self.not_found()
if path == "/api/suppliers":
return self.send_json(SUPPLIERS)
if path == "/api/orders":
return self.send_json(ORDERS)
if path.startswith("/api/orders/"):
oid = path.split("/")[3]
order = next((o for o in ORDERS if o["id"] == oid or o["order_number"] == oid), None)
return self.send_json(order) if order else self.not_found()
if path == "/api/analytics/summary":
return self.send_json(summary_payload())
if path == "/api/analytics/top-products":
return self.send_json(top_products_payload())
if path == "/api/analytics/supplier-performance":
return self.send_json(SUPPLIER_PERF)
return self.not_found()
def do_POST(self):
u = urlparse(self.path)
path = u.path.rstrip("/") or "/"
body = self.read_body()
if path == "/api/auth/login":
email = (body.get("email") or "").strip().lower()
password = body.get("password") or ""
if email == ADMIN_EMAIL and password == ADMIN_PASSWORD:
return self.send_json({"access_token": TOKEN, "token_type": "bearer", "email": email})
return self.send_json({"detail": "Invalid email or password"}, 401)
if path == "/api/auth/register":
return self.send_json({"access_token": TOKEN, "token_type": "bearer", "email": body.get("email")})
if path == "/api/orders":
email = body.get("customer_email") or "guest@example.com"
items = []
total = 0.0
for it in body.get("items") or []:
prod = next((p for p in PRODUCTS if p["id"] == it.get("product_id")), None)
qty = max(1, int(it.get("qty") or 1))
price = (prod["retail_price"] if prod else 29.99)
total += price * qty
items.append({"product_id": it.get("product_id"), "qty": qty, "unit_price": price, "title": prod["title"] if prod else None})
order = {
"id": f"ord-{random.randint(2000, 9999)}",
"order_number": f"POL-{random.randint(100000, 999999)}",
"customer_id": None,
"customer_email": email,
"items": items,
"retail_total": round(total, 2),
"supplier_id": "sup-001",
"supplier_cost": None,
"shipping_cost": 0.0,
"fees": 0.0,
"profit": None,
"status": "new",
"tracking": None,
"carrier": None,
"created_at": now_iso(),
"updated_at": now_iso(),
}
ORDERS.insert(0, order)
return self.send_json(order, 201)
if path.startswith("/api/orders/") and path.endswith("/tracking"):
oid = path.split("/")[3]
order = next((o for o in ORDERS if o["id"] == oid or o["order_number"] == oid), None)
if not order:
return self.not_found()
idx = STATUS_FLOW.index(order["status"]) if order["status"] in STATUS_FLOW else 0
order["status"] = STATUS_FLOW[min(idx + 1, len(STATUS_FLOW) - 1)]
if order["status"] in ("shipped", "delivered") and not order["tracking"]:
order["tracking"] = f"1Z{random.randint(10**15, 10**16 - 1)}"
order["carrier"] = "UPS"
order["updated_at"] = now_iso()
return self.send_json(order)
if path.startswith("/api/products/") and path.count("/") == 3 and (
path.endswith("/publish") or path.endswith("/pause")
):
pid = path.split("/")[3]
prod = next((p for p in PRODUCTS if p["id"] == pid), None)
if not prod:
return self.not_found()
prod["status"] = "published" if path.endswith("/publish") else "paused"
prod["updated_at"] = now_iso()
return self.send_json(prod)
if path == "/api/products/import":
imported = random.randint(3, 9)
for i in range(imported):
title = f"Imported Feed Item {len(PRODUCTS) + 1}"
PRODUCTS.append({
"id": f"prd-{100 + len(PRODUCTS)}",
"sku": f"IMP-{len(PRODUCTS):04d}",
"title": title,
"description": "Imported via feed — awaiting pipeline analysis.",
"category": random.choice(CATS),
"brand": body.get("supplier_id") or "FeedImport",
"manufacturer": None,
"upc_ean_gtin": None,
"images": [],
"dimensions": None,
"weight_grams": None,
"cost": 0.0,
"retail_price": None,
"min_price": None,
"max_price": None,
"status": "discovered",
"lifecycle": {},
"created_at": now_iso(),
"updated_at": now_iso(),
})
return self.send_json({"message": f"Feed accepted — {imported} products staged for the pipeline.", "imported": imported}, 201)
return self.not_found()
if __name__ == "__main__":
port = int(__import__("sys").argv[1]) if len(__import__("sys").argv) > 1 else 8000
print(f"Polaris mock API on :{port} (admin: {ADMIN_EMAIL} / {ADMIN_PASSWORD})")
HTTPServer(("0.0.0.0", port), Handler).serve_forever()

42
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,42 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok";
}
# API traffic → FastAPI backend (compose service `api` on :8000)
location /api/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Hashed build assets: cache aggressively
location /assets/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
try_files $uri =404;
}
# SPA fallback; never cache the HTML shell
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store";
}
}

2769
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "polaris-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Polaris — automated e-commerce reseller: storefront + admin console",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.4",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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" />

View File

@@ -0,0 +1,44 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
night: {
950: '#03050d',
900: '#04060f',
800: '#070b18',
700: '#0b1126',
600: '#111a38',
500: '#1a2447',
},
aurora: {
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
},
},
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'-apple-system',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
},
boxShadow: {
glass: '0 8px 32px rgba(2, 6, 23, 0.55)',
glow: '0 0 40px rgba(99, 102, 241, 0.35)',
},
},
},
plugins: [],
}

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// VITE_API_URL defaults to '/api' — nginx proxies /api -> api:8000 in the
// compose network. For local dev, the proxy below forwards /api to a
// backend running on localhost:8000.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})