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