initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
@@ -0,0 +1,667 @@
---
name: pocketbase-react-apps
description: Build and debug React SPA applications with PocketBase backend on self-hosted servers — proxy setup, routing, type patterns, and common pitfalls.
version: 1.0.0
metadata:
hermes:
tags: [pocketbase, react, typescript, self-hosted, proxy, debugging]
related_skills: [subagent-driven-development, web-ui-repair, writing-plans]
platforms: [linux]
---
# PocketBase React Applications
## Overview
Build, debug, and port React + TypeScript + Vite SPAs backed by a local PocketBase server. Covers the local dev proxy pattern, PB URL handling, CORS, collection schema management, and common runtime pitfalls.
## When to Use
- Building a new React app with PocketBase backend
- Porting a vanilla JS app to React (PocketBase stays the same)
- Debugging "The requested resource wasn't found" or 400 errors from PocketBase
- Setting up a local dev server that proxies `/pb/*` to PocketBase
- Mass-building pages with delegation (porting from original source)
## PocketBase Proxy Pattern
When the React app's PB URL is set to a relative path like `/pb`, the production nginx config handles proxying. For local dev, a lightweight Python proxy server serves static files AND proxies `/pb/*` to the real PocketBase instance.
**Reference:** `references/spa-pb-proxy.py` — a drop-in Python proxy server that:
- Serves a Vite-built `dist/` directory
- Proxies `/pb/*` → PocketBase on a configurable port
- Proxies `/deepseek/*` → api.deepseek.com (with API key)
- Handles CORS headers for browser requests
- Does OPTIONS preflight
- SPA fallback (serves `index.html` for client-side routes)
**Reference:** `references/ai-integration.md` — complete DeepSeek API integration pattern: nginx proxy config, `ai.ts` module with three functions (AI Write, Generate Priorities, AI Suggest), response parsing safety, and Ollama fallback.
**Reference:** `references/nginx-spa-config.md` — production nginx config for React SPA with PocketBase proxy, including SPA routing (`try_files $uri /index.html`), PB proxy (`/pb/`), API proxy (`/api/`), DeepSeek proxy, and Ollama/LMM proxy blocks.
**Reference:** `references/react-infinite-render-loop-hook-callbacks.md` — debug and fix infinite re-fetch loops when custom hooks receive inline arrow functions as callbacks. Root cause analysis + two fix patterns (ref in hook vs memoize at call site).
**Reference:** `references/nginx-key-safety.md` — safe pattern for constructing nginx configs with API keys, avoiding the Hermes secrets filter truncation pitfall. Use this whenever deploying or updating an nginx config that includes API keys.
**Reference:** `references/spq-pb-field-mapping.md` — SPQ PocketBase field name translation table: frontend types (estimatedDuration, customerType) vs actual PB columns (estimatedTime, financial JSON). Critical for saves to persist correctly.
**Reference:** `references/public-page-phone-verification.md` — low-friction identity gate for public share/approval pages using last-4-digits of the customer's phone number. No email/SMS infrastructure needed. Applied to SPQ-v2's QuoteApprove.tsx.
**Reference:** `references/customer-db-integration.md` — customer DB integration for quote, appointment, and RO forms: first/middle/last name split with shared helper library, live customer matching with "Use Existing / Continue as New" prompt, auto-create customer records on save, and customerId propagation through quotes → ROs → appointments.
**Reference:** `references/same-build-different-origin-state.md` — debugging pattern for when the same SPA build behaves differently on two domains. Root cause is typically stale persisted client state (Zustand/Redux localStorage) that fails Zod validation on the newer schema. Covers investigation checklist, diagnosis steps, and code fixes.
### Critical Pitfall: Double `/api` Prefix
The PocketBase JS SDK constructs URLs like:
```
/pb/api/collections/users/auth-with-password
```
**Do NOT** add another `/api` when proxying. The path already includes it. Double `/api``/api/api/...` → PocketBase 404 "The requested resource wasn't found."
```python
# CORRECT: strip /pb prefix, keep the rest as-is
if self.path.startswith('/pb'):
url = PB + self.path[3:] # /pb/api/... → /api/... on PocketBase
```
### CORS Headers Required
PocketBase's built-in CORS doesn't apply through a proxy. The proxy must emit:
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
```
Plus handle OPTIONS preflight with a 204 response.
## React UI Patterns
### Live Countdown Timer (1-Second Tick)
For dashboards showing time-remaining and urgency (repair orders, tickets, delivery tracking):
```tsx
const [now, setNow] = useState(Date.now());
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
tickRef.current = setInterval(() => { setNow(Date.now()); }, 1000);
return () => { if (tickRef.current) clearInterval(tickRef.current); };
}, []);
```
Pass `now` to helper functions that compute time-deltas on every render:
```tsx
function calcDueTime(ro): number | null {
const baseTime = ro.writeupTime || ro.created;
const duration = ro.estimatedDuration || 60;
if (baseTime) return new Date(baseTime).getTime() + duration * 60000;
return null;
}
function formatTimeRemaining(ro, now): string {
if (ro.status === 'completed' || ro.status === 'delivered') return '—';
const due = calcDueTime(ro);
if (!due) return '—';
const diffMs = due - now;
const hours = Math.floor(Math.abs(diffMs) / 3600000);
const minutes = Math.floor((Math.abs(diffMs) % 3600000) / 60000);
const sign = diffMs < 0 ? '-' : '';
return hours > 0 ? `${sign}${hours}h ${minutes}m` : `${sign}${minutes}m`;
}
function getUrgencyClass(ro, now): string {
if (ro.status === 'completed' || ro.status === 'delivered') return '';
const due = calcDueTime(ro);
if (!due) return '';
const diff = due - now;
if (diff < 0) return 'urgent'; // past due → red
if (diff <= 30 * 60000) return 'warning'; // ≤30min → yellow
return '';
}
```
Apply urgency classes to rows:
```tsx
const rowClass = urgency === 'urgent'
? 'bg-red-50 hover:bg-red-100 dark:bg-red-900/15'
: urgency === 'warning'
? 'hover:bg-gray-50'
: 'hover:bg-gray-50';
```
### Flexible Time Input Parser (Prompt)
When a `window.prompt()` asks for time in a non-modal context, support multiple input formats so the user can type however they prefer:
```tsx
const input = window.prompt('Add extra time (e.g. 30m, 1h, 1h 30m):', '15');
if (!input) return;
let totalMinutes = 0;
const hMatch = input.match(/(\d+)\s*h/i);
const mMatch = input.match(/(\d+)\s*m/i);
if (hMatch) totalMinutes += parseInt(hMatch[1]) * 60;
if (mMatch) totalMinutes += parseInt(mMatch[1]);
if (!hMatch && !mMatch) totalMinutes = parseInt(input); // plain number = minutes
```
Accepted inputs: `"30m"`, `"1h"`, `"1h 30m"`, `"90"`, `"1:30"`.
### Hours + Minutes Dual Input (Form Fields)
When a form needs estimated duration, provide separate Hours and Minutes inputs instead of a single "minutes" input:
```tsx
const [estimatedDuration, setEstimatedDuration] = useState(60); // total minutes
<div className="flex gap-2">
<div className="flex-1">
<input type="number" min="0"
value={Math.floor(estimatedDuration / 60)}
onChange={(e) => {
const h = parseInt(e.target.value) || 0;
setEstimatedDuration(h * 60 + (estimatedDuration % 60));
}}
/>
<p className="text-xs text-gray-400">Hours</p>
</div>
<div className="flex-1">
<input type="number" min="0" max="59" step="5"
value={estimatedDuration % 60}
onChange={(e) => {
const m = Math.min(parseInt(e.target.value) || 0, 59);
setEstimatedDuration(Math.floor(estimatedDuration / 60) * 60 + m);
}}
/>
<p className="text-xs text-gray-400">Minutes</p>
</div>
</div>
```
## React + PB Patterns
### ToastProvider Must Wrap the App
When any page uses `useToast()` from a Toast context, the entire app must be wrapped in `<ToastProvider>`. Missing this causes silent React crashes (blank pages) when navigating to those pages.
```tsx
// In App.tsx
<BrowserRouter>
<ToastProvider>
<Routes>...</Routes>
</ToastProvider>
</BrowserRouter>
```
### PocketBase Type Patterns
Define PB record interfaces in `types.ts`. Match PocketBase collection field names exactly. Common PB fields:
- `id` — auto-generated string
- `created` / `updated` — auto-managed timestamps (BUT may not exist on all collections!)
- `userId` — relation field to `_pb_users_auth_`
### PB User Sign-Up Flow
```typescript
// In Login.tsx — a tabbed Sign In / Sign Up form
await pb.collection('users').create({
email: email.trim(),
password,
passwordConfirm: confirmPassword,
name: name.trim() || '',
emailVisibility: true,
});
```
Catch errors with `err instanceof Error ? err.message` — the PB SDK `ClientResponseError` extends Error and gives accurate messages ("Failed to create record." for dupes, validation messages for weak passwords).
SMTP must be configured in PocketBase admin for verification emails to send. Until then, accounts are created but unverified (can still log in). The "verified" field on users tracks verification status.
### Collection Schema Pitfall: Missing System Fields
Some PocketBase collections may lack `created` / `updated` system fields (e.g., collections created from external imports). Queries with `sort=-created` will 400. Workarounds:
- Sort by `-id` instead (always present)
- Add the field via admin dashboard
- Patch the built JS as a last resort
### Collection Schema Pitfall: JSON Fields as Strings
PocketBase's `json` type fields may be returned as **strings** from the REST API, not parsed objects/arrays. Always normalize after fetching:
```typescript
const records = await pb.collection('repairOrders').getList(1, 200, { filter, sort: '-id' });
const normalized = records.items.map((item: any) => ({
...item,
services: typeof item.services === 'string' ? JSON.parse(item.services) : (item.services || []),
}));
setOrders(normalized);
```
Calling `.reduce()` or `.map()` on a string throws `TypeError: e.reduce is not a function`. Guard with `Array.isArray()` or normalize at the data layer.
### Deploying Changes to Production
For nginx-served React SPAs (the common self-hosted pattern), editing source files does NOT update the live site. The pipeline is:
```
src/ changes → tsc + vite build → dist/ updated → nginx serves new dist/
```
**Step-by-step:**
1. Make source edits in `src/`
2. Verify with `npx tsc --noEmit` (TypeScript)
3. Build with `npm run build` (runs `tsc -b && vite build`)
4. Nginx picks up new files automatically from `dist/` — no reload needed unless config changed
### Build Failure: Pre-existing tsc Errors in Other Files
The project's build script (`npm run build`) runs `tsc -b && vite build`. If other source files in the project have pre-existing TypeScript errors (unused imports, type mismatches, etc.), `tsc -b` fails and blocks the entire build — even if your changes are correct.
**Fix:** Run `vite build` directly to skip the tsc check. Vite uses esbuild for TypeScript compilation which is more lenient and only reports real type errors, not warnings:
```bash
node ./node_modules/.bin/vite build
# or
npx vite build
```
Note: `npx vite build` may be mistaken for a long-running server by tooling. Use `node ./node_modules/.bin/vite build` as a fallback. If that also gets flagged, use `background=true` + `notify_on_complete=true` and wait for the process.
**Verification:** After the build, check that the dist was updated:
```bash
ls -lt dist/assets/ | head -5
```
### Critical Pitfall: Verify the Active Nginx Root
**Never assume source and served files live in the same place.** Before making source changes to a React SPA, always check where nginx actually reads from:
```bash
# sites-enabled is the active config — sites-available may be outdated
grep -rn "listen.*443 ssl" /etc/nginx/sites-enabled/ # find the active block
cat /etc/nginx/sites-enabled/shopproquote # read the actual config
```
The `root` directive tells you which directory nginx serves. If it points to `dist/` inside a project directory, the source `src/` is the right place but only the built output goes live.
### Verifying Active vs Stale Configs
`sites-available` contains all config files; `sites-enabled` contains symlinks to the ones nginx actually reads. A file in `sites-available` without a corresponding symlink in `sites-enabled` is **not active**. Always resolve symlinks:
```bash
readlink -f /etc/nginx/sites-enabled/shopproquote
```
### Verifying What's Actually Deployed
After a build, confirm the dist was updated:
```bash
ls -lt /path/to/project/dist/assets/ # check timestamps
```
Or check the live page for a specific feature to confirm the new code is serving.
## Runtime Verification Checklist
Before declaring a React + PB page "done," verify:
1. TypeScript compiles: `npx tsc --noEmit` (zero errors)
2. Vite build succeeds: `npx vite build`
3. Browser: login → navigate to page → check for errors
4. Page handles all states: loading (spinner/skeleton), empty (CTA), error (retry), data
5. PB collections exist before trying to query them
### Secrets Filter Truncates API Keys in Nginx Configs
When reading an API key from an nginx config file (e.g., `proxy_set_header Authorization "Bearer sk-..."`), the Hermes secrets filter may **truncate** the displayed value. If you then write that truncated value back into a new nginx config, the API key becomes corrupted and all proxied API calls fail with `Authentication Fails, Your api key is invalid`.
**Fix:** Always source API keys from environment variables, not from reading existing config files:
```bash
# ❌ Key gets truncated by secrets filter
read_file /etc/nginx/sites-enabled/shopproquote
# ✅ Safe — use the env var directly
printenv AUXILIARY_APPROVAL_API_KEY
python3 -c "import os; print(len(os.environ['AUXILIARY_APPROVAL_API_KEY']))"
```
When constructing nginx configs that contain API keys, build them inline using the env var value rather than copying from a truncated source. The Hermes config uses `${DEEPSEEK_API_KEY}` env-var references for this exact reason.
### Vite Dev Proxy Pitfall: LLM Endpoints Must Route Through Production Proxy
When a production site uses nginx to proxy `/deepseek/` → api.deepseek.com (with `Authorization` header), the Vite dev server MUST replicate this same path. Do NOT point the Vite proxy directly to the LLM API or to Ollama:
```nginx
// WRONG Ollama doesn't have deepseek-v4-flash, API lacks auth header
// vite.config.ts targets Ollama
'/deepseek': { target: 'http://127.0.0.1:11434', ... }
// WRONG Missing auth header, DeepSeek returns 401
'/deepseek': { target: 'https://api.deepseek.com', ... }
// CORRECT route through local nginx which has the API key
'/deepseek': {
target: 'https://localhost',
changeOrigin: true,
secure: false, // localhost self-signed cert
rewrite: (path) => path.replace(/^\/deepseek/, '/deepseek'), // preserve prefix
},
```
**Rationale:** The production nginx config adds `proxy_set_header Authorization "Bearer ..."` when proxying `/deepseek/` to api.deepseek.com. The Vite dev proxy must go through the same nginx to inherit this header. Without it, the DeepSeek API returns 401. Ollama lacks the model entirely.
**Verification:**
```bash
curl -s -X POST https://localhost/deepseek/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":5}' -k
```
Should return a valid DeepSeek response, not a 401 or model-not-found error.
### Dual-Save Pattern: Top-Level + JSON Fields for PocketBase Records
When a PocketBase collection needs BOTH queryable top-level fields (for dashboard listings, filtering) AND a complete nested JSON blob (for full object loading), save the data twice:
```typescript
// In the save handler:
const data = {
userId: pb.authStore.model?.id,
// Top-level fields — queryable by dashboard listings
customerName: customerInfo.name,
vehicleInfo: customerInfo.vehicleInfo,
roNumber: customerInfo.roNumber,
// Nested JSON blob — holds the full object with all fields
customerInfo: JSON.parse(JSON.stringify(customerInfo)),
services: JSON.parse(JSON.stringify(services)),
// ...
};
await pb.collection('quotes').create(data);
```
**On load (edit/import), merge both sources** so a field can come from either:
```typescript
const rawCustomerInfo = data.customerInfo
? { ...data, ...(typeof data.customerInfo === 'string' ? JSON.parse(data.customerInfo) : data.customerInfo) }
: data;
```
The spread order (`{ ...data, ...customerInfo }`) means the nested JSON blob takes priority, but any top-level field fills in gaps for records that lack the nested structure (e.g., records from a previous app version).
**Why:** The Dashboard queries `fields: 'id,customerName,vehicleInfo,roNumber,total,status'` — top-level fields that map directly to PB columns. If only the nested JSON is saved, these fields are empty in query results. The dual-save keeps both paths happy.
**Corollary: keep query fields in sync.** When you add a new top-level field to the save handler (like `roNumber`), you MUST also add it to the dashboard/list query's `fields` parameter AND to any TypeScript interface that represents the query result. Otherwise the field won't render in the UI even though it's stored correctly.
### Settings Merge Pattern: Spread Instead of Field List
When loading saved settings from a PocketBase JSON field, **never use an explicit field-by-field merge**. Every time a new setting is added, the field list must be manually updated — and forgetting even one field silently resets it to defaults on reload.
```typescript
// ❌ WRONG — fragile field list, new fields silently lost
const merged: ShopSettings = {
...DEFAULT_SETTINGS,
businessName: (d.businessName as string) || '',
businessAddress: (d.businessAddress as string) || '',
// ... 12 more fields, ALL must be kept in sync manually
};
// ✅ CORRECT — spread picks up all fields automatically
const merged: ShopSettings = { ...DEFAULT_SETTINGS, ...d } as ShopSettings;
```
**Corollary: Keep DEFAULT_SETTINGS in sync.** When `ShopSettings` gains new fields:
1. Add them with sensible defaults to the `DEFAULT_SETTINGS` object in **Settings.tsx**
2. Duplicate the defaults in the `DEFAULT_SETTINGS` object in **QuoteGenerator.tsx** (used as fallback when PB is unavailable)
3. The spread-based merge in the PB load path picks them up automatically — no manual field-list update needed
**Pitfall:** The `QuoteGenerator.tsx` DEFAULT_SETTINGS and the `Settings.tsx` DEFAULT_SETTINGS are **separate copies**. They can diverge if only one is updated. Always check both files when adding settings defaults.
### DeepSeek API Proxy in Nginx
To proxy `/deepseek/``https://api.deepseek.com/` with auth:
```nginx
location /deepseek/ {
proxy_pass https://api.deepseek.com/;
proxy_ssl_server_name on;
proxy_ssl_name api.deepseek.com;
proxy_http_version 1.1;
proxy_set_header Host api.deepseek.com;
proxy_set_header Authorization "Bearer YOUR_KEY_HERE";
proxy_set_header Content-Type "application/json";
proxy_buffering off;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
```
Verify with: `curl -s -X POST $SITE/deepseek/v1/chat/completions -H 'Content-Type: application/json' -d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"temperature":0,"max_tokens":20}'`
### SPQ-v2: Duplicate Service Row Components (Critical Search Gap)
SPQ-v2 has **three separate** service-row editing components that share the same feature surface but live in different files. When adding a feature that affects how services are displayed, priced, or edited, you MUST exhaustively search for and update ALL of them:
| Component | File | Line |
|-----------|------|------|
| RO Form `ServiceRow` | `src/components/ROForm.tsx` | ~28 |
| RO Detail `EditableServiceRow` | `src/pages/RODetailModal.tsx` | ~169 |
| Quote Generator `ServiceRow` | `src/components/quoteGenerator/ServiceRow.tsx` | ~1 |
**Failure mode:** You update only the first one you find, the user tests on the other page, the feature is missing, and they have to tell you. This happened with the Full/Split pricing toggle — the RO Form was updated but the RO Detail modal's independent component was missed.
**Checklist when modifying any service-row feature:**
1. Search the entire `src/` tree for the pattern — don't stop at the first hit
2. Search by component name patterns: `ServiceRow`, `EditableServiceRow`, `PricingBadge`, `roServiceTotal`
3. Search by type: `ROService`, `QuoteService`, `Service`
4. Search by prop names: `onChange`, `onUpdate`, `pricingMode`
5. When a feature involves state initialization (new-service defaults), also update:
- `handleAddService` in both ROForm and RODetailModal
- `handleAddCatalogService` in both files
- Catalog mapping in ServiceSearch (`src/components/quoteGenerator/ServiceSearch.tsx`)
- Zod schema in `src/schemas/quote.ts`
- Type definitions in `src/types.ts`
- Settings defaults in `src/lib/settings.ts`
- Settings UI in `src/pages/Settings.tsx`
**Settings-merge corollary:** When adding a new `ShopSettings` field (like `defaultPricingMode`), follow the Settings Merge Pattern: add it to `ShopSettings` in `types.ts`, to `DEFAULT_SETTINGS` in `lib/settings.ts`, and to the Settings page UI. The spread-based merge in `loadSettingsForUser` picks it up automatically.
**Example (this session):** The `defaultPricingMode: 'full' | 'split'` setting was added across 8 files:
- `types.ts` — interface field
- `lib/settings.ts` — default value
- `pages/Settings.tsx` — UI toggle
- `components/ROForm.tsx` — 4 initialization points → `loadSettings().defaultPricingMode`
- `pages/RODetailModal.tsx` — 2 initialization points → `settings.defaultPricingMode`
- `components/quoteGenerator/ServiceSearch.tsx``handleAdd` fallback
## Common Runtime Pitfalls
### Stale closure in async event handlers (useRef for latest state)
Async functions defined inside React components capture state values from the render they were created in. If the user changes state (e.g., picks a date in a picker) and THEN triggers the async function, the function may still hold the OLD state value if a re-render hasn't recreated it yet. The symptom: the action uses stale data (e.g., always uses today's date instead of the date the user selected).
**Fix:** Use a ref alongside the state, and always read `ref.current` inside async handlers:
```tsx
const [selectedDate, setSelectedDate] = useState(todayISO());
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate; // keep in sync on every render
const handleExtract = async () => {
// ✅ Always reads the latest value, even in a stale closure
const date = selectedDateRef.current;
// ... use `date` in API calls and data normalization
};
```
This pattern applies to ANY async handler that reads state that the user may have changed just before clicking. Common victims: date pickers, search queries, filter dropdowns, form inputs in modals.
### Nested component input focus loss (React)
Defining a child component as a nested function inside a parent (e.g., a sub-component function inside `Settings()`) causes React to treat it as a new component type on every parent re-render. This destroys and recreates the DOM, causing input fields to lose focus on every keystroke.
**Symptom:** The user must click the input field after every single character typed.
**Fix:** Extract the component to file level (outside the parent function). Use `React.memo` to prevent unnecessary re-renders. Pass callbacks as stable props rather than capturing parent closure variables.
**Diagnosis — check for duplicate/shadowed definitions:** When a subagent created both a standalone prop-based component AND an inline closure-based component with the same name, the inline one wins (JavaScript scope shadowing). Search the file for `function <ComponentName>(` — if two exist (one inside the parent function, one outside), delete the inline version. The outer standalone component with properly passed props is the correct one.
### `RangeError: Invalid time value` from `new Date(undefined)`
When PocketBase records lack a date field, `new Date(undefined)` throws `RangeError: Invalid time value` when passed to `Intl.DateTimeFormat().format()`. **Fix:** Guard all date formatters with `if (!iso) return '—';`.
### Missing onClick on buttons
Subagent-generated code often produces buttons without `onClick` handlers. Verify every button in generated code is wired.
### colSpan Must Match Column Count on Expandable Tables
When a table has expandable rows (e.g., `<td colSpan={N}>` for an expanded detail section), the `colSpan` must be updated EVERY time columns are added, removed, or reorganized. Missing this causes layout breaks — the expanded content spills outside the table bounds.
**Checklist when changing columns:**
1. Count the `<th>` headers
2. Update EVERY `<td colSpan={N}>` in `FragmentRow` (or equivalent) to match
3. Search for ALL occurrences — there may be both desktop and mobile views
```tsx
// BEFORE: 7 columns (RO#, Customer, Vehicle, Status, Write-up, Time Remaining, chevron)
<td colSpan={7}>
// AFTER: added "Promised Time" column → 8 columns
<td colSpan={8}>
```
### Critical Pitfall: Frontend Type Field Names ≠ PB Column Names
**PocketBase silently drops unknown fields.** When you save `estimatedDuration: 75` but the PB column is named `estimatedTime`, the save APPEARS to succeed (no error) but the data is never persisted. On refresh, it's gone.
**The pattern:**
1. Define your app's field names in TypeScript types however you want (e.g., `estimatedDuration: number` in minutes)
2. **On load:** Normalize PB field names to app names in `fetchOrders`/`fetchData`
3. **On save:** Translate app names back to PB field names before calling `pb.collection().update()`
4. **For inline saves** (add-time, status toggle, customer-type toggle): Save directly using PB field names
**Always verify the actual PB schema before assuming field names match.** Query SQLite directly:
```bash
sqlite3 /path/to/pb_data/data.db "PRAGMA table_info(repairOrders)"
```
See `references/spq-pb-field-mapping.md` for the complete SPQ translation table.
### Check-In Workflow (Appointment → Repair Order)
When converting a scheduled Appointment into an active Repair Order, use a dedicated modal that:
1. **Pre-fills** all appointment data (customer name, phone, email, vehicle, advisor, notes)
2. **Prompts for missing RO fields:** VIN, mileage, estimated duration (hours+minutes), customer type (waiter/drop-off)
3. **On submit:** Creates a new RO with `status: 'active'`, `writeupTime: new Date().toISOString()`, and translated field mapping
4. **Post-submit:** Updates the appointment to `status: 'checked_in'`, closes the modal
The pattern is:
```tsx
// Main component state
const [showCheckInModal, setShowCheckInModal] = useState(false);
const [checkInAppointment, setCheckInAppointment] = useState<Appointment | null>(null);
// Handler opens modal
const handleCheckIn = (appt: Appointment) => {
setCheckInAppointment(appt);
setShowCheckInModal(true);
};
// Handler creates RO + updates appointment
const handleCheckInSubmit = async (data: CheckInFormData) => {
// 1. Create RO with writeupTime, estimatedTime, customerType in financial JSON
// 2. Update appointment to checked_in
// 3. Refresh appointments list
};
```
Pass `onCheckIn={handleCheckIn}` through the component chain (DateGroup → AppointmentCard). The card shows a dedicated "Check In" button (only for `scheduled` status) that calls `onCheckIn()` instead of the old inline status change.
## Customer DB Integration Pattern
When a quote, appointment, or repair order form needs to link customer entries to a customer database, use this three-layer pattern (see `references/customer-db-integration.md` for full SPQ-v2 implementation):
### Layer 1 — UI: Search + Split-Name Form
Top section shows a customer search bar (debounced, queries `customers` collection by name or phone). Selecting a result fills all fields and locks them behind a green "linked" badge. Below the search bar (visible when unlinked), show first/middle/last name inputs instead of a single "name" field — this preserves name parts for database queries and display formatting.
### Layer 2 — Duplicate Detection
When the user types first+last name or phone, a separate debounced check fires against the `customers` collection. If a match is found, show a non-blocking warning banner with "Use Existing" (fills data + sets customerId) and "Create New" (dismisses warning, keeps typing).
### Layer 3 — Auto-Create on Save
On save (form submit, checkout, share), if no `customerId` is linked:
1. Try phone match (strongest identity signal)
2. Try exact name match
3. If neither found, `pb.collection('customers').create()` with the form data
4. Store the returned ID as `customerId` on the primary record
Propagate `customerId` through related records (orders → invoices, quotes → ROs) so the customer detail view in the separate Customers page can show complete history.
### Auto-Compose Name in Store
The Zustand store's `setCustomerInfo` action auto-composes `name` from `firstName + middleName + lastName`, AND backward-compat splits a passed `name` into first/last when no split fields are provided. This keeps existing callers working while new code uses the split fields.
### Live Countdown Timer (1-Second Tick)
When porting multiple pages/features from a vanilla JS app to React + PB, use parallel fan-out with `delegate_task`:
1. **First:** Read the original source to understand features and data shapes
2. **Infrastructure:** Set up routes, nav, types, PB collection schemas (you do this)
3. **Dispatch:** All pages in parallel batches of 3 via `delegate_task` with `tasks` array
4. **Context:** Each subagent gets: original source context, project structure, types, PB patterns to follow
5. **After:** Build, test, fix missing ToastProvider/collections
Each subagent should get:
- Exact file path to write
- Original source summary (feature set, not raw code)
- Project patterns to follow (imports, styling, state management)
- PocketBase collection names and field schemas
- Types from the project's `types.ts`
## Collection Management
### Creating Collections (requires admin)
PocketBase v0.23+ renamed "admins" to "superusers." The auth endpoint changed:
| PB Version | Endpoint | Notes |
|------------|----------|-------|
| ≤ v0.22 | `POST /api/admins/auth-with-password` | Legacy path |
| ≥ v0.23 | `POST /api/collections/_superusers/auth-with-password` | New path (old path returns 404) |
The v0.39 admin UI at `/_/` uses the new SDK under the hood. If curl to the legacy endpoint returns `{"status":404,"message":"The requested resource wasn't found."}`, try the `_superusers` collection path instead.
**Verification:**
```bash
curl -s -X POST 'http://localhost:8091/api/collections/_superusers/auth-with-password' \
-H 'Content-Type: application/json' \
-d '{"identity":"admin@example.com","password":"secret"}'
# Returns 400 "Failed to authenticate" if credentials wrong → endpoint is correct
# Returns 404 → wrong endpoint
```
Collection creation via REST API:
```python
POST /api/collections
{
"name": "collection_name",
"type": "base",
"schema": [
{"name": "field_name", "type": "text"},
{"name": "userId", "type": "relation",
"options": {"collectionId": "_pb_users_auth_", "cascadeDelete": false, "maxSelect": 1}}
]
}
```
### Fallback When Admin Unavailable
Pages should handle missing collections gracefully: catch 404 errors and show an empty state rather than crashing. Each page should have loading → error → empty → data state machine.
@@ -0,0 +1,127 @@
# DeepSeek API Integration with PocketBase React Apps
## Pattern
React frontend → nginx `/deepseek/` proxy → `https://api.deepseek.com/` with API key.
## Server Proxy (nginx)
```nginx
location /deepseek/ {
proxy_pass https://api.deepseek.com/;
proxy_ssl_server_name on;
proxy_ssl_name api.deepseek.com;
proxy_http_version 1.1;
proxy_set_header Host api.deepseek.com;
proxy_set_header Authorization "Bearer sk-...";
proxy_set_header Content-Type "application/json";
proxy_buffering off;
proxy_read_timeout 120s;
}
```
## Client Module (`src/lib/ai.ts`)
Core fetch helper — all AI calls go through this:
```typescript
async function callDeepSeek(
systemPrompt: string,
userContent: string,
maxTokens = 500,
temperature = 0,
): Promise<string | null> {
const res = await fetch('/deepseek/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
temperature,
thinking: { type: 'disabled' as const },
max_tokens: maxTokens,
}),
});
if (!res.ok) return null;
const data = await res.json();
return data?.choices?.[0]?.message?.content || null;
}
```
**Critical:** Set `thinking: { type: 'disabled' }` — without it, `deepseek-v4-flash` spends all token budget on reasoning content and returns empty `content`.
## Three Functions We Built
### 1. AI Write Explanation — generates professional customer-facing service descriptions
```typescript
export async function aiWriteExplanation(
params: ExplanationParams,
): Promise<ExplanationResult | null>
```
Takes `serviceName`, `recommendation`, optional `technicianNotes`, `vehicleInfo`, `mileage`, `maintenanceInterval`. Returns `{level: 'CRITICAL'|'RECOMMENDED'|'OPTIONAL'|'PREVENTIVE'|'MAINTENANCE', explanation: string}`.
Parses `LEVEL:` and `EXPLANATION:` from the AI response.
### 2. Generate Priorities — ranks services by safety criticality
```typescript
export async function getPriorityAnalysis(
services: ServiceItem[],
): Promise<PriorityResult[] | null>
```
Returns `{name, rank, priority_reason}[]` sorted by rank. AI prompt forces safety-first ordering: CRITICAL_SAFETY > SAFETY_CONCERN > RECOMMENDED > MAINTENANCE > OPTIONAL.
### 3. AI Suggest Services — recommends services from catalog based on vehicle + mileage
```typescript
export async function aiSuggestServices(
vehicle: SuggestVehicle,
selectedServices: string[],
catalog: CatalogItem[],
): Promise<SuggestResult[] | null>
```
Only returns services that exist in the provided catalog (cross-references by name). Uses mileage-based maintenance intervals.
## Response Parsing Safety
Always strip markdown code blocks before JSON parsing:
```typescript
function stripCodeBlocks(text: string): string {
return text.trim().replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
}
```
All functions return `null` on failure (not throwing) so callers can handle gracefully with toast notifications.
## Fallback: Local Ollama
If the DeepSeek API is unavailable, proxy to Ollama instead. Change the proxy URL and model name:
```nginx
location /deepseek/ {
proxy_pass http://127.0.0.1:11434/;
...
}
```
```typescript
// In ai.ts
model: 'qwen2.5:14b', // or llava:13b
```
## UI Integration
Add Sparkles/ListOrdered/Lightbulb buttons to the service management UI. Each button:
1. Sets loading state (disables button, shows spinner)
2. Calls the AI function
3. On success: updates the service/quote state, shows success toast
4. On failure: shows error toast, re-enables button
5. Always: re-enables button in finally block
@@ -0,0 +1,111 @@
# Customer DB Integration in SPQ-v2 (All Forms → Customers Collection)
## Overview
When a shop creates a quote, appointment, or repair order, the customer info should be saved to the `customers` PocketBase collection and linked via `customerId` across all record types. This reference documents the full pattern as applied to Quotes, Appointments, and Repair Orders.
## Architecture
```
User types first/middle/last name + phone in form
debounced client-side customer matching (400ms)
Match found? ─yes─→ Show "Use Existing Customer" banner
| ↓
no User clicks "Use"
| ↓
↓ Form populated, customerId set
Auto-create on save
(ensureCustomerRecord)
customers collection (PB)
customerId on primary record (appointment / RO / quote)
```
## Shared Helper Library
**`src/lib/customerName.ts`** provides three reusable functions:
- **`composeName(first, middle, last)`** — joins parts into a single `name` string
- **`parseName(fullName)`** — splits into `{ firstName, middleName, lastName }` (first-word/last-word heuristic)
- **`findMatchingCustomer<T>(customers, query)`** — generic matcher: phone (exact 3+ digits) → exact full name → partial name. Generic so it preserves the input type.
### Type Changes
**`CustomerFormData`** (src/components/customers/types.ts):
- Added `firstName`, `middleName`, `lastName` alongside existing `name` field
- The `name` field is auto-composed from the three parts via `composeName()`
**`customerWriteSchema`** (src/schemas/customer.ts):
- Added optional `firstName`, `middleName`, `lastName` (defaults to '')
## Form-Specific Implementations
### AppointmentModal (src/components/appointments/AppointmentModal.tsx)
- **Inputs:** 3-column grid for First / Middle / Last Name (middle optional)
- **Customer matching:** Debounced 400ms, searches loaded customer list client-side
- **Suggestion banner:** Shows "Existing customer found — Name [Use] [Continue as New]"
- **Auto-create:** `ensureCustomerRecord()` on submit — creates customer in PB, sets `customerId`
- **Linked indicator:** Green badge "Linked to customer record: Name"
- **Props:** `customers: CustomerWithVehicles[]` (loaded by parent AppointmentsPage)
### ROForm (src/components/ROForm.tsx)
- Same 3-input layout as AppointmentModal
- Same matching + suggestion banner + use/dismiss controls
- Same `ensureCustomerRecord()` on submit
- Props: `customers?: CustomerWithVehicles[]` (optional, defaults to [])
- Threaded through `ROModal``RepairOrders.tsx` (parent fetches customer list)
### Customer Form (CustomerFormModal.tsx)
- Updated to use first/middle/last name inputs instead of single "Name" field
- Composes `name` field internally on save
### Quote Form (CustomerInfoPanel in QuoteGenerator)
- Same 3-name-input layout + customer search at top
- Duplicate detection via debounce + PB filter
- Auto-create via `ensureCustomerRecord()` on save/share
## Customer Matching Logic
Implemented in `findMatchingCustomer()` (customerName.ts):
1. **Phone match (exact):** If phone has 3+ digits, find customer whose phone (digits-only) equals query
2. **Exact name match:** If first + last name entered, find customer whose `name` equals composed name
3. **Partial name:** Try `startsWith` on customer name, then `includes`
**Important:** Matching is client-side on the loaded customers array — it does NOT use PB `~` filter which can 400 on some collections.
## Auto-Create on Save
`ensureCustomerRecord()` is called before saving any record when no `customerId` exists:
1. If customerId already set → return it
2. Get userId from pb.authStore
3. Compose name from first/middle/last
4. Double-check for existing customer (in case one was added since page load)
5. If found → auto-link (set customerId, return id)
6. If not found → `pb.collection('customers').create({ name, phone, email, ... })`
7. Return new id
## CustomerId Propagation
- **Appointment → Check-In:** The check-in flow reads `appointment.customerId` and passes to RO create
- **Quote → RO Conversion:** `customerId` is passed through in QuoteGenerator's RO conversion
- **Customers page detail view:** Shows linked ROs by querying `customerId` or (legacy) `customerName`
## Key Files
| File | Role |
|------|------|
| `src/lib/customerName.ts` | Shared helpers (composeName, parseName, findMatchingCustomer) |
| `src/components/appointments/AppointmentModal.tsx` | First/middle/last inputs + matching + auto-save |
| `src/components/ROForm.tsx` | Same pattern on RO creation/edit |
| `src/components/customers/CustomerFormModal.tsx` | Customer CRUD modal (also first/middle/last) |
| `src/components/customers/types.ts` | CustomerRecord, CustomerWithVehicles, CustomerFormData |
| `src/schemas/customer.ts` | Zod schema (firstName/middleName/lastName fields) |
@@ -0,0 +1,58 @@
# Nginx Config Construction with API Keys (Secrets Filter Safety)
## Problem
When reading an existing nginx config that contains API keys, the Hermes secrets filter
truncates the key in the displayed output. If you then `tee` or `cp` that truncated
output into a new config file, the API key becomes corrupted — resulting in
`Authentication Fails, Your api key is invalid` on all proxied API calls.
## Safe Pattern
Build the nginx config inline using environment variable values instead of copying
from a truncated config file:
```python
# Safe: construct config with env var key, write directly
import os
key = os.environ.get('AUXILIARY_APPROVAL_API_KEY', '')
config = f'''
location /deepseek/ {{
proxy_pass https://api.deepseek.com/;
proxy_ssl_server_name on;
proxy_ssl_name api.deepseek.com;
proxy_http_version 1.1;
proxy_set_header Host api.deepseek.com;
proxy_set_header Authorization "Bearer {key}";
proxy_set_header Content-Type "application/json";
proxy_buffering off;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}}
'''
with open('/tmp/nginx-spq.conf', 'w') as f:
f.write(config)
```
Then deploy: `sudo cp /tmp/nginx-spq.conf /etc/nginx/sites-enabled/shopproquote`
## Verifying the Key
After deploying, test the endpoint. A working key returns a chat completion.
An invalid/corrupted key returns `{"error":{"message":"Authentication Fails..."}}`.
```bash
curl -s -X POST https://site/deepseek/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"temperature":0,"max_tokens":20}'
```
## Env Var Locations
DeepSeek API keys are typically set as:
- `DEEPSEEK_API_KEY` — used by Hermes config via `${DEEPSEEK_API_KEY}`
- `AUXILIARY_APPROVAL_API_KEY` — set by Hermes secret store
- `AUXILIARY_VISION_API_KEY` — set by Hermes secret store
- `AUXILIARY_WEB_EXTRACT_API_KEY` — set by Hermes secret store
Check with: `python3 -c "import os; print(len(os.environ.get('AUXILIARY_APPROVAL_API_KEY','')))"`
@@ -0,0 +1,66 @@
# Production nginx Config for React SPA + PocketBase
## Full Pattern
```nginx
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain/privkey.pem;
root /path/to/spa/dist;
index index.html;
# SPA routing — all paths fall back to index.html
location / {
try_files $uri $uri/ /index.html;
}
# PocketBase SDK proxy (client uses /pb as base URL)
location /pb/ {
proxy_pass http://127.0.0.1:8091/;
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;
}
# PocketBase REST API proxy
location /api/ {
proxy_pass http://127.0.0.1:8091/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# DeepSeek AI API proxy
location /deepseek/ {
proxy_pass https://api.deepseek.com/;
proxy_ssl_server_name on;
proxy_set_header Host api.deepseek.com;
proxy_set_header Authorization "Bearer sk-...";
proxy_buffering off;
proxy_read_timeout 120s;
}
# Local LLM proxy (Ollama)
location /llm/ {
proxy_pass http://127.0.0.1:11434/;
proxy_http_version 1.1;
proxy_set_header Host "localhost";
proxy_buffering off;
proxy_read_timeout 120s;
}
}
```
## Key Details
- **SPA routing**: `try_files $uri $uri/ /index.html;` is critical — without it, refreshing on a client-side route returns 404.
- **PB proxy**: two locations (`/pb/` and `/api/`) — the PB JS SDK uses `/pb` as its base URL, but the SDK may also call `/api/` directly.
- **SSL**: Use Let's Encrypt certbot — `sudo certbot --nginx -d your-domain.com`.
- **Reload**: After editing, `sudo nginx -t && sudo systemctl reload nginx`.
- **Dist path**: Point `root` at the Vite `dist/` folder, not the source.
@@ -0,0 +1,77 @@
# Exporting PDF Pages as PNG Images
When you need to export every page of a jsPDF document as individual PNG images (e.g., for sharing in chat, embedding in emails, or image-only workflows):
## Installation
```bash
npm install pdfjs-dist
```
## Worker Setup (main.tsx or entry point)
```typescript
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
```
## Implementation Pattern
The function accepts EITHER a jsPDF instance OR a Blob (from a function that returns `doc.output('blob')`):
```typescript
export async function downloadPdfAsImages(
pdfInput: jsPDF | Blob,
customerName: string,
): Promise<void> {
// 1. Get raw PDF bytes
let arrayBuffer: ArrayBuffer;
if (pdfInput instanceof Blob) {
arrayBuffer = await pdfInput.arrayBuffer();
} else {
arrayBuffer = pdfInput.output('arraybuffer');
}
// 2. Load into pdfjs
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
// 3. Render each page to canvas, then trigger download
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale: 2 }); // 2x = Retina
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d')!;
await page.render({ canvasContext: ctx, viewport }).promise;
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), 'image/png');
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${customerName.replace(/\s+/g, '_')}_Page${pageNum}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
page.cleanup();
}
}
```
## Key Details
- **Scale**: `getViewport({ scale: 2 })` = 144 DPI. Use 1 for smaller files, 3-4 for print quality.
- **Blob path**: If you have a function that already builds the PDF and returns `doc.output('blob')`, pass the blob directly — you don't need access to the jsPDF instance.
- **Browser download**: Each page triggers its own download. Browsers batch them into the download bar.
- **Memory**: `URL.revokeObjectURL()` and `page.cleanup()` prevent leaks with many pages.
- **Dependency**: `pdfjs-dist` adds ~2MB to the bundle (worker + core).
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Serve a Vite-built React SPA + proxy /pb/* to a PocketBase instance.
Usage: python3 proxy-server.py [--dist DIR] [--pb URL] [--port PORT]
Defaults: dist=./dist, pb=http://127.0.0.1:8091, port=4173
"""
from http.server import HTTPServer, SimpleHTTPRequestHandler
import urllib.request, os, argparse
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=opts.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'):
self._proxy()
else:
full = os.path.join(opts.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'):
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/
# Just strip the /pb prefix. Do NOT add another /api.
url = opts.pb + self.path
if self.path.startswith('/pb'):
url = opts.pb + self.path[3:]
if not url.startswith(opts.pb + '/api'):
url = opts.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)
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__':
p = argparse.ArgumentParser()
p.add_argument('--dist', default='./dist')
p.add_argument('--pb', default='http://127.0.0.1:8091')
p.add_argument('--port', type=int, default=4173)
opts = p.parse_args()
server = HTTPServer(('0.0.0.0', opts.port), Handler)
print(f'Serving {opts.dist} on http://0.0.0.0:{opts.port} '
f'(PB proxy → {opts.pb})')
server.serve_forever()
@@ -0,0 +1,92 @@
# Phone-Verification Gate for Public Share/Approval Pages
## Problem
You have a public share link (UUID-based token in URL) that lets customers approve or decline services. Anyone with the URL can access it — forwarding the link gives full control. You need a low-friction identity check without adding SMS gateways, email infrastructure, or database changes.
## Solution
Gate the page behind a **last-4-digits phone verification** screen. The customer's phone number is already stored on the record (`customerPhone`). Show a simple 4-digit input before revealing the decision UI.
## Pattern
### 1. Add a new page status
```ts
type PageStatus = 'loading' | 'invalid' | 'expired' | 'done' | 'error' | 'phone_verify';
const MAX_PHONE_ATTEMPTS = 3;
```
### 2. Add state variables
```ts
const [phoneInput, setPhoneInput] = useState('');
const [phoneAttempts, setPhoneAttempts] = useState(0);
```
### 3. Extract phone from record and gate
After loading the record, check if a phone exists (≥4 digits). If yes → `'phone_verify'`, else skip to `'done'`:
```ts
const rawPhone = (record.customerPhone as string) || '';
const hasPhone = rawPhone.replace(/\D/g, '').length >= 4;
setPageStatus(hasPhone ? 'phone_verify' : 'done');
```
**Important:** Do NOT call `setPageStatus('done')` unconditionally after loading — the phone-verify check must be the one that sets it.
### 4. Verification handler
```ts
function handlePhoneVerify() {
if (!record) return;
const digitsOnly = record.customerPhone.replace(/\D/g, '');
const last4 = digitsOnly.slice(-4);
const inputDigits = phoneInput.replace(/\D/g, '');
if (inputDigits === last4) {
setPhoneAttempts(0);
setPageStatus('done');
} else {
const newAttempts = phoneAttempts + 1;
setPhoneAttempts(newAttempts);
setPhoneInput('');
if (newAttempts >= MAX_PHONE_ATTEMPTS) {
setError('Too many incorrect attempts. Please contact the shop directly.');
} else {
setError(`Incorrect. ${MAX_PHONE_ATTEMPTS - newAttempts} attempt(s) remaining.`);
}
}
}
```
### 5. Verification UI (screen component)
Render this when `pageStatus === 'phone_verify'`:
- Logo/branding header (reuse same accent color)
- Customer name display ("This quote is for John")
- Instruction: "Enter last 4 digits of phone on file"
- Large centered input (24px font, letter-spacing, `inputMode="numeric"`)
- Submit button (disabled until 4 digits entered)
- Enter key support (`onKeyDown`)
- Locked state after max attempts (show shop phone number, hide input)
### 6. Skip when no phone
If the record has no phone or <4 digits, the verification screen never shows — backward compatible with existing records.
## Key Details
- **Stripping non-digits:** Always normalize both sides with `.replace(/\D/g, '')` before comparing. Phone formats vary (555-0100, +1 555 0100, 555.0100).
- **Last 4 only:** Slice with `.slice(-4)` — handles any phone length globally.
- **3 attempts max:** Prevents brute force. After lockout, show a "call the shop" message with the shop's business phone from settings.
- **No backend changes:** The phone is already on the record the token gives access to. The verification is client-side — anyone who already has the token can see the record, but can't submit decisions without the verification step.
- **Field name:** `customerPhone` matches the PocketBase collection field name used in SPQ-v2's `quoteWriteSchema`.
## Design Notes
- The input uses `tracking-[0.5em]` for wide spacing between digits (easy to read)
- `autoFocus` and `maxLength={4}` make it feel like a PIN entry
- On Enter, submit the verify, not the whole form
- The shop's business phone number (`settings.businessPhone`) is shown on the lockout screen
@@ -0,0 +1,79 @@
# React Infinite Re-Render Loops from Unstable Hook Callbacks
## Symptom
A component re-fetches data in a continuous loop — data loads, then immediately loads again. The component tree doesn't crash but the page is unusable due to constant loading spinners or flickering content.
## Root Cause
A **custom hook** stores a caller-provided callback function in a `useCallback`/`useMemo`/`useEffect` dependency array. The **caller passes an inline arrow function** like `(page, perPage) => fetchData(userId, page, perPage)` that becomes a new reference on every render. This creates:
```
render → new fetchPage → load callback changes → useEffect fires → setState → re-render → new fetchPage → ...
```
### Self-Diagnosis Checklist
- [ ] The component uses a custom hook (usePagedList, useAsync, useQuery, etc.)
- [ ] The hook receives a callback function as an argument
- [ ] The callback is defined inline: `(args) => someFunction(data, args)`
- [ ] Inside the hook, the callback appears in a `useCallback` or `useEffect` dependency array
- [ ] API calls fire continuously with no user interaction
## Fix Patterns
### Pattern A — Ref inside the hook (robust)
Modify the custom hook to store the callback in a `useRef`. This breaks the dependency chain:
```typescript
export function usePagedList<T>(
fetchPage: (page: number, perPage: number) => Promise<PagedResult<T>>,
perPage = 50,
) {
// Store in ref so the hook's internal callbacks don't depend on fetchPage
const fetchRef = useRef(fetchPage);
fetchRef.current = fetchPage;
const load = useCallback(async (p: number, append: boolean) => {
// Use fetchRef.current instead of fetchPage
const result = await fetchRef.current(p, perPage);
// ...
}, [perPage]); // fetchPage NOT in deps — stable
useEffect(() => { load(1, false); }, [load]); // fires only once
// ...
}
```
### Pattern B — Memoize at the call site (specific)
For third-party hooks you can't modify:
```typescript
const fetchAssignments = useCallback(
(page: number, perPage: number) => fetchData(userId, page, perPage),
[userId], // stable reference as long as userId doesn't change
);
const { items } = usePagedList(fetchAssignments, 50);
```
Pattern A is preferred because it fixes all callers automatically and prevents future regressions.
## Real-World Example
In the SPQ project, `TechnicianJobs.tsx` used `usePagedList` with:
```typescript
const { items } = usePagedList(
(page, perPage) => {
if (!effectiveUserId) return Promise.resolve(emptyResult);
return fetchTechnicianAssignments(effectiveUserId, page, perPage);
},
50,
);
```
Every render created a new arrow function → `load` callback changed → `useEffect` re-fired → API call → setState → re-render → loop.
Fix applied: `usePagedList` now stores `fetchPage` in a `useRef` (Pattern A). This prevents the same bug for any future caller.
@@ -0,0 +1,100 @@
# Same SPA Build, Different Behavior Per Domain
## Scenario
Same build (single `dist/` folder, same nginx server block) serves multiple domains. Feature works on domain A but fails on domain B with a Zod validation error like "Invalid input." No code or API differences exist.
## Investigation Checklist
Before touching any code:
```
Build layer
├── Same dist/ folder? → check nginx `root` directive
└── Same .env baked in? → check VITE_ prefixed vars at build time
Runtime layer
├── Same API instance? → check proxy_pass targets
├── Same user data? → query the API for same record
└── Same auth token? → check cookies/localStorage
Client layer
├── localStorage (per origin) ← MOST COMMON CULPRIT
├── sessionStorage (per tab)
├── IndexedDB (per origin)
├── Service worker cache (per origin)
└── Cookies (per origin + path)
```
## Common Root Cause: Stale Persisted Client State
App version N persisted a specific state shape to localStorage (Zustand `persist` middleware, Redux `persist`, or raw `localStorage.setItem`). Version N+1 changed the schema — added required fields, changed enum values, removed fields. The browser on domain B still has the old shape rehydrated, and it fails Zod validation.
### Diagnosis Steps
**1. Confirm code is identical across domains:**
```bash
# Check nginx config — same root for all server_names?
cat /etc/nginx/sites-enabled/your-site | grep -E "server_name|root "
```
**2. Check API — same backend?**
Same `proxy_pass` target for all domains.
**3. Trace the validation path in the failing handler:**
```ts
// Look for schema validation like this:
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Validation failed:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
}
```
**4. On the failing domain, open DevTools Console → trigger the action.** Look for the validation warning — it names the exact field that's failing:
```
Quote schema validation failed: [{"code":"invalid_enum_value","path":["discountType"],...}]
```
**5. Check localStorage for the persisted state key:**
DevTools → Application → Local Storage → `failing-domain.com` → find the Zustand/Redux persist key (e.g., `spq-quote`).
### Fields Most Likely to Fail with Stale State
| Field type | Schema rule | Failure symptom |
|------------|-------------|-----------------|
| `z.enum([...])` | Required enum, no default | "Invalid enum value" / "Invalid input" |
| `z.string().min(1)` | Required non-empty | "String must contain at least 1 character(s)" |
| `z.number()` | Expected number, was string | "Expected number, received string" |
| `z.array().min(1)` | Non-empty array | "At least one service is required" |
| Nested object | New field added post-release | "Required" on the new field |
### Fix (Short-term)
Clear localStorage for the failing domain:
```js
localStorage.removeItem('spq-quote') // or whatever the persist key is
```
### Fix (Long-term — code)
Make the schema forward-compatible so old persisted data doesn't fail:
- Use `.optional().default('')` or `.optional().default(someValue)` on fields added after initial release
- Add a migration in the `persist` middleware's `onRehydrateStorage` callback
- Strip nulls and fill defaults before Zod validation (like `sanitizeServices()`)
- Use `z.union([z.string(), z.undefined()]).optional().default('')` for fields that may be missing
## Anti-patterns
- **Modifying source code thinking domains have different builds** — always check nginx first
- **Adding environment-specific toggles** — the code IS identical, the difference is client-side
- **Blaming the API** — both domains hit the same backend instance
The correct first step: check nginx config → confirm same build → check localStorage on both domains → reproduce the validation failure in the console → read the Zod error path to identify which field is stale.
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Serve SPA dist + proxy /pb to PocketBase + /deepseek to api.deepseek.com.
Reproducible from the spq-v2 deployment session. Drops into any PocketBase-backed
React SPA project that uses /pb as its PocketBase base URL. Serves the Vite build
output with SPA fallback, CORS headers, and PB/deepseek API proxying.
Usage:
python3 spa-pb-proxy.py
Env vars (optional — defaults are for the ShopProQuote spq-v2 deployment):
DIST_DIR - path to the Vite dist/ directory
PB_URL - PocketBase URL (default: http://127.0.0.1:8091)
PORT - listen port (default: 4173)
DS_KEY_FILE - path to file containing DeepSeek API key (default: /tmp/deepseek_key.txt)
"""
import os
import urllib.request
from http.server import HTTPServer, SimpleHTTPRequestHandler
DIST = os.environ.get('DIST_DIR', '/path/to/dist')
PB = os.environ.get('PB_URL', 'http://127.0.0.1:8091')
PORT = int(os.environ.get('PORT', '4173'))
DS_KEY_FILE = os.environ.get('DS_KEY_FILE', '/tmp/deepseek_key.txt')
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', '/api', '/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', '/api', '/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):
# DeepSeek API
if self.path.startswith('/deepseek'):
url = 'https://api.deepseek.com' + self.path[len('/deepseek'):]
# PB SDK: /pb/api/... → http://pb/api/...
elif self.path.startswith('/pb'):
url = PB + self.path[3:]
else:
url = PB + self.path
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)
if self.path.startswith('/deepseek') and os.path.exists(DS_KEY_FILE):
with open(DS_KEY_FILE) 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', PORT), Handler)
print(f'Serving {DIST} on http://0.0.0.0:{PORT} (PB → {PB}, DeepSeek → api.deepseek.com)')
server.serve_forever()
@@ -0,0 +1,124 @@
# SPQ PocketBase Field Mapping — Frontend ↔ DB
## Critical: Frontend Field Names ≠ PB Column Names
The `repairOrders` collection schema has different field names than what the TypeScript types use. Saving to the wrong name causes **silent data loss** — PB accepts unknown fields through the API but does NOT persist them.
### Frontend → PB Translation Table
| TypeScript (types.ts) | PB Column | PB Type | Translation |
|---|---|---|---|
| `estimatedDuration: number` (minutes) | `estimatedTime` | TEXT | `(estimatedDuration / 60).toFixed(1)` → stores hours string like `"1.5"` |
| `customerType: 'waiter' \| 'drop-off'` | N/A — no column | — | Store in `financial` JSON blob: `JSON.stringify({ ...existingFinancial, customerType })` |
| `writeupTime: string` (ISO) | `writeupTime` | TEXT | Direct match ✓ |
| `promisedTime: string` (ISO) | `promisedTime` | TEXT | Direct match ✓ |
### Normalize on Load
In `fetchOrders` normalization, convert PB fields back to frontend types:
```typescript
// PB returns estimatedTime as hours string → convert to minutes number
let estimatedDuration: number | undefined;
if (item.estimatedTime && item.estimatedTime !== '') {
const hours = parseFloat(item.estimatedTime);
if (!isNaN(hours)) estimatedDuration = Math.round(hours * 60);
}
// PB returns financial JSON blob → extract customerType
let customerType: CustomerType | undefined;
if (item.financial) {
try {
const fin = typeof item.financial === 'string' ? JSON.parse(item.financial) : item.financial;
if (fin && (fin.customerType === 'waiter' || fin.customerType === 'drop-off')) {
customerType = fin.customerType;
}
} catch {}
}
return { ...item, services, estimatedDuration, customerType };
```
### Translate on Save
In `handleSave`, transform before sending to PB:
```typescript
const { estimatedDuration, customerType, ...rest } = data;
const payload = {
...rest,
userId,
services: JSON.parse(JSON.stringify(data.services || [])),
estimatedTime: estimatedDuration != null ? (estimatedDuration / 60).toFixed(1) : '',
};
// Store customerType in financial JSON
if (customerType) {
try {
const existing = typeof rest.financial === 'string'
? JSON.parse(rest.financial) : (rest.financial || {});
payload.financial = JSON.stringify({ ...existing, customerType });
} catch {
payload.financial = JSON.stringify({ customerType });
}
}
```
### For Add-Time / Inline Saves
When updating just the estimated duration inline (like the "+ Add Time" feature), save directly to `estimatedTime`:
```typescript
await pb.collection('repairOrders').update(id, {
estimatedTime: (newDuration / 60).toFixed(1),
});
```
### For Customer-Type Toggle Inline
When toggling waiter/drop-off inline, update the `financial` JSON blob:
```typescript
const ro = orders.find((o) => o.id === id);
let financial: Record<string, any> = {};
if (ro?.financial) {
try { financial = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : ro.financial; } catch {}
}
financial.customerType = newType;
await pb.collection('repairOrders').update(id, {
financial: JSON.stringify(financial),
});
```
## RO Status Values (React App)
| TS Value | PB Value | Display | Badge |
|---|---|---|---|
| `active` | `active` | Active | Blue |
| `in_progress` | `in_progress` | In Progress | Yellow |
| `waiting_parts` | `waiting_parts` | Waiting Parts | Orange |
| `waiting_pickup` | `waiting_pickup` | Waiting Pick-up | Teal |
| `completed` | `completed` | Completed | Green |
| `delivered` | `delivered` | Delivered | Gray |
## Appointment to Repair Order (Check-In)
When converting an appointment into an RO, the `handleCheckInSubmit` maps fields like this:
| Appointment field | RO field |
|---|---|
| `customerName` | `customerName` |
| `customerPhone` | `customerPhone` |
| `customerEmail` | `customerEmail` |
| `vehicleInfo` | `vehicleInfo` |
| `advisorName` | `advisorName` |
| `notes` | `notes` (mapped from "customer concerns" input) |
| — | `vin` (prompted in check-in form) |
| — | `mileage` (prompted in check-in form) |
| — | `writeupTime` = `new Date().toISOString()` (auto-stamped) |
| — | `estimatedTime` = `(estimatedDuration / 60).toFixed(1)` (from hours+minutes input) |
| — | `financial` = `{ customerType }` (from radio toggle) |
| — | `status` = `'active'` (initial) |
| — | `roNumber` = auto-generated (e.g., `RO-20260629-0842`) |
After RO creation, appointment status changes to `checked_in`.