# Local Testing — Serving SPQ Builds Two approaches depending on which version you're testing. ## v1: Vanilla JS (standalone HTML files) Served from `/mnt/seagate8tb/Websites/ShopProQuote/` (no build step, just HTML+JS+CSS). ```bash cd /mnt/seagate8tb/Websites/ShopProQuote python3 -m http.server 4173 --bind 0.0.0.0 ``` ## v2: React SPA (built with Vite) Served from `/mnt/seagate8tb/Websites/ShopProQuote/spq-v2/dist/`. **Plain http.server breaks login** because the built frontend connects to PocketBase at `/pb` (relative path). The simple server returns 404 for `/pb/*` requests. You need a Python reverse proxy that: 1. Serves static files from `dist/` 2. Has SPA fallback (all non-file routes → `index.html`) 3. Proxies `/pb/*` → PocketBase at `127.0.0.1:8091/api/*` 4. Sends CORS headers on every response (including OPTIONS preflight) ### Template: `/tmp/spq-backup-server.py` ```python #!/usr/bin/env python3 """Serve SPQ dist + proxy /pb to PocketBase with CORS.""" from http.server import HTTPServer, SimpleHTTPRequestHandler import urllib.request import os DIST = '/mnt/seagate8tb/Websites/ShopProQuote/spq-v2/dist' PB = 'http://127.0.0.1:8091' class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=DIST, **kwargs) def end_headers(self): self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type') super().end_headers() def do_OPTIONS(self): self.send_response(204) self.end_headers() def do_GET(self): if self.path.startswith('/pb') or self.path.startswith('/api') or self.path.startswith('/deepseek'): self.proxy() else: full = os.path.join(DIST, self.path.lstrip('/')) if not os.path.exists(full) or (os.path.isdir(full) and self.path != '/'): self.path = '/index.html' super().do_GET() def do_POST(self): if self.path.startswith('/pb') or self.path.startswith('/api') or self.path.startswith('/deepseek'): self.proxy() else: self.send_error(405) def do_PUT(self): self.proxy() def do_PATCH(self): self.proxy() def do_DELETE(self): self.proxy() def proxy(self): # PB SDK calls /pb/api/collections/... — path already includes /api/ # DeepSeek calls go to api.deepseek.com if self.path.startswith('/deepseek'): url = 'https://api.deepseek.com' + self.path[len('/deepseek'):] else: url = PB + self.path if self.path.startswith('/pb'): url = PB + self.path[3:] # /pb/api/... → /api/... on PB if not url.startswith(PB + '/api'): url = PB + '/api' + self.path[3:] body = None if self.headers.get('Content-Length'): body = self.rfile.read(int(self.headers['Content-Length'])) req = urllib.request.Request(url, data=body, method=self.command) for k, v in self.headers.items(): if k.lower() not in ('host', 'connection', 'origin', 'referer'): req.add_header(k, v) # Add DeepSeek API key if self.path.startswith('/deepseek'): with open('/tmp/deepseek_key.txt') as f: req.add_header('Authorization', f'Bearer {f.read().strip()}') try: resp = urllib.request.urlopen(req) self.send_response(resp.status) for k, v in resp.headers.items(): if k.lower() not in ('transfer-encoding', 'connection'): self.send_header(k, v) self.end_headers() self.wfile.write(resp.read()) except urllib.error.HTTPError as e: self.send_response(e.code) self.end_headers() self.wfile.write(e.read()) if __name__ == '__main__': server = HTTPServer(('0.0.0.0', 4173), Handler) print('Serving SPQ on http://0.0.0.0:4173 (PB proxy → 8091)') server.serve_forever() ``` ### Usage **IMPORTANT**: The proxy also routes `/deepseek/*` → `https://api.deepseek.com/` using the API key from `/tmp/deepseek_key.txt`. The key is extracted from `/etc/nginx/sites-enabled/shopproquote` on startup. Without this, AI Write, Generate Priorities, and AI Suggest all fail silently. ```bash # Extract API key and start proxy python3 -c " import re with open('/etc/nginx/sites-enabled/shopproquote') as f: m = re.search(r'Bearer\s+(sk-\S+)', f.read()) if m: with open('/tmp/deepseek_key.txt','w') as out: out.write(m.group(1).rstrip('\"')) print('Key saved') " python3 /tmp/spq-backup-server.py & # Verify curl -s -o /dev/null -w '%{http_code}' http://localhost:4173/ # → 200 curl -s -X POST http://localhost:4173/pb/collections/users/auth-with-password \ -H 'Content-Type: application/json' \ -d '{"identity":"demo@shop.com","password":"test1234"}' # → {"token":"..."} (login works through proxy) # Verify DeepSeek curl -s -X POST http://localhost:4173/deepseek/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"Say hello"}],"temperature":0,"thinking":{"type":"disabled"},"max_tokens":50}' # → {"choices":[{"message":{"content":"Hello!"}}]} (AI works) ### Pitfalls - **No `--bind 0.0.0.0` on plain http.server** → LAN devices get connection refused. The proxy template above binds `0.0.0.0` by default. - **Missing CORS headers** → browser blocks all PB requests. The proxy adds CORS on every response + handles OPTIONS preflight. - **Missing SPA fallback** → `/dashboard`, `/quote` etc. return 404 on page refresh. The proxy falls back to `index.html` for non-file paths. - **PB URL mismatch** — v2's `pocketbase.ts` uses `VITE_PB_URL` env var (defaults to `/pb`). If the build was made with a different URL, adjust the proxy or rebuild. - **Backup paths vary** — the timestamped backup folder (`ShopProQuote.backup-YYYYMMDD-HHMM`) changes per backup. Update DIST path in the script. - **Dashboard 400 / Something went wrong** — if the PocketBase quotes collection is missing the created system field, the sort=-created query returns HTTP 400. Fix: use sort=-id instead. Same issue applies to repairOrders collection. - **AI features return no results** — the /deepseek/ path must be proxied to https://api.deepseek.com/ with the API key from /tmp/deepseek_key.txt. The AI module at src/lib/ai.ts calls /deepseek/v1/chat/completions with model: deepseek-v4-flash and thinking: disabled. Without proxy setup, all AI calls return null. - **React input focus loss** — if a ServiceRow-like component is defined as a nested function inside its parent, every zustand state update causes a re-render that creates a new function identity. React unmounts/remounts the DOM, killing input focus. Fix: extract to a file-level component wrapped in memo, pass all state via props. ## V2 Feature Coverage As of 2026-06-27, spq-v2 covers ~90% of v1's feature set. See `references/v1-v2-feature-gap.md` for a detailed comparison table of what's built vs what's still only in v1. Missing PocketBase collections (`repair_orders`, `invoices`, `service_advisors`, `vehicles`) cause error states on some pages — create them via admin UI to unlock full functionality. For mass porting patterns (parallel delegation), see `references/mass-port-delegation.md`. ## Common Port Use **4173** for local SPQ testing. Check availability: ```bash ss -tlnp | grep 4173 || echo "port free" ``` Kill any existing server: `pkill -f "spq-backup-server.py"` or `pkill -f "python3 -m http.server 4173"`