Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- AGENTS.md +63 -0
- CLAUDE.md +63 -0
- web_demo/app.py +119 -21
- web_demo/static/app.js +9 -6
- web_demo/static/feedback/feedback.css +100 -0
- web_demo/static/feedback/feedback.js +138 -0
- web_demo/static/mobile/mobile.css +1 -0
- web_demo/static/mobile/session.js +2 -2
- web_demo/static/mobile/steps/confirm.js +1 -1
- web_demo/static/mobile/steps/form.js +21 -13
- web_demo/static/shared/measure-api.js +3 -3
- web_demo/static/styles.css +2 -1
- web_demo/supabase_client.py +49 -1
- web_demo/templates/admin.html +96 -2
- web_demo/templates/feedback.html +131 -0
- web_demo/templates/index.html +2 -2
AGENTS.md
CHANGED
|
@@ -192,6 +192,7 @@ For outer fingers the ROI is shrunk and rotation is centered on the proximal pha
|
|
| 192 |
| `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro → form → guide → capture → confirm → result). |
|
| 193 |
| `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
|
| 194 |
| `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
|
|
|
|
| 195 |
|
| 196 |
`/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
|
| 197 |
|
|
@@ -216,6 +217,68 @@ alter table public.measurements
|
|
| 216 |
add column if not exists feedback_message text;
|
| 217 |
```
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
---
|
| 220 |
|
| 221 |
## Important Technical Details
|
|
|
|
| 192 |
| `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro → form → guide → capture → confirm → result). |
|
| 193 |
| `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
|
| 194 |
| `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
|
| 195 |
+
| `/feedback` | `templates/feedback.html` | v8 public post-shipment fit-feedback form (all UAs, no redirect). |
|
| 196 |
|
| 197 |
`/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
|
| 198 |
|
|
|
|
| 217 |
add column if not exists feedback_message text;
|
| 218 |
```
|
| 219 |
|
| 220 |
+
### KOL identity field: email (the cross-table join key)
|
| 221 |
+
|
| 222 |
+
Both web surfaces collect a single required identity field, now labelled
|
| 223 |
+
**Email** (`type="email"`, `autocomplete="email"`, `inputmode="email"`,
|
| 224 |
+
posted as `kol_email`). The server normalizes it **trim + lowercase**.
|
| 225 |
+
Email — not name — is the key used to join `measurements` ↔ the v8
|
| 226 |
+
`feedback` table ↔ the ops shipping-records CSV (names mis-merge and the
|
| 227 |
+
shipping source is email-native). See `doc/v8/PRD.md`.
|
| 228 |
+
|
| 229 |
+
Two columns on `measurements`:
|
| 230 |
+
|
| 231 |
+
| Column | Notes |
|
| 232 |
+
|---|---|
|
| 233 |
+
| `kol_email` | `text`, normalized address; the join key for new rows. |
|
| 234 |
+
| `kol_name` | `text`, **legacy only** — pre-email historical rows; NULL on new rows. |
|
| 235 |
+
|
| 236 |
+
```sql
|
| 237 |
+
alter table public.measurements add column if not exists kol_email text;
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
Stored object paths slug the **email local-part only** (`_email_local_part`
|
| 241 |
+
in `web_demo/app.py`), never the full address, to keep PII out of bucket
|
| 242 |
+
paths. Admin grouping/display and CSV export prefer `kol_email` and fall
|
| 243 |
+
back to `kol_name` for legacy rows.
|
| 244 |
+
|
| 245 |
+
### v8 post-shipment feedback (`/feedback` + `feedback` table)
|
| 246 |
+
|
| 247 |
+
A **separate** flow from the post-result rating above — different
|
| 248 |
+
lifecycle (5–7 days after the ring ships, not right after measurement)
|
| 249 |
+
and a different table. Don't confuse the two:
|
| 250 |
+
|
| 251 |
+
| | Post-result rating | v8 fit feedback |
|
| 252 |
+
|---|---|---|
|
| 253 |
+
| Route (POST) | `/api/feedback` | `/api/fit-feedback` |
|
| 254 |
+
| Storage | columns on `measurements` (patched by `run_id`) | new `feedback` table (standalone row) |
|
| 255 |
+
| Question | "did the website work?" | "which finger did the shipped ring fit?" |
|
| 256 |
+
|
| 257 |
+
The `feedback` table is **intentionally decoupled** — no `run_id`, no
|
| 258 |
+
foreign key. The only link to `measurements` is the normalized
|
| 259 |
+
`kol_email`, joined at analysis time in a pandas notebook (see
|
| 260 |
+
`doc/v8/analysis.md`). `GET /feedback` renders `templates/feedback.html`
|
| 261 |
+
(single-page form, reuses `mobile.css`; **not** the `/m` step framework).
|
| 262 |
+
`POST /api/fit-feedback` normalizes the email, uploads the optional photo
|
| 263 |
+
under `feedback/` (local-part slug only, same PII stance), and inserts
|
| 264 |
+
via `save_feedback`. The photo upload + insert are **synchronous** (no
|
| 265 |
+
heavy compute, unlike `/api/measure`), so the row lands complete. RLS is
|
| 266 |
+
enabled on the table (the app's service key bypasses it; this blocks
|
| 267 |
+
anon-key reads of the email PII). The admin dashboard has a **Feedback**
|
| 268 |
+
tab (`/api/admin/feedback` → `list_feedback`) that lists the rows
|
| 269 |
+
read-only; deeper analysis is still pandas-only (`doc/v8/analysis.md`).
|
| 270 |
+
Table columns:
|
| 271 |
+
|
| 272 |
+
```sql
|
| 273 |
+
create table public.feedback (
|
| 274 |
+
id uuid primary key default gen_random_uuid(),
|
| 275 |
+
submitted_at timestamptz not null default now(),
|
| 276 |
+
kol_email text not null, -- normalized join key
|
| 277 |
+
received_size text, received_model text, best_fit_finger text,
|
| 278 |
+
fit_quality text, hand text, photo_url text, notes text
|
| 279 |
+
);
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
---
|
| 283 |
|
| 284 |
## Important Technical Details
|
CLAUDE.md
CHANGED
|
@@ -192,6 +192,7 @@ For outer fingers the ROI is shrunk and rotation is centered on the proximal pha
|
|
| 192 |
| `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro → form → guide → capture → confirm → result). |
|
| 193 |
| `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
|
| 194 |
| `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
|
|
|
|
| 195 |
|
| 196 |
`/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
|
| 197 |
|
|
@@ -216,6 +217,68 @@ alter table public.measurements
|
|
| 216 |
add column if not exists feedback_message text;
|
| 217 |
```
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
---
|
| 220 |
|
| 221 |
## Important Technical Details
|
|
|
|
| 192 |
| `/m` | `templates/mobile.html` | v6 paginated six-step flow (intro → form → guide → capture → confirm → result). |
|
| 193 |
| `/dev`, `/debug` | `templates/index.html` | Desktop with `dev_mode=True` (AI-explanation toggle visible). |
|
| 194 |
| `/admin` | `templates/admin.html` | Token-gated KOL dashboard. |
|
| 195 |
+
| `/feedback` | `templates/feedback.html` | v8 public post-shipment fit-feedback form (all UAs, no redirect). |
|
| 196 |
|
| 197 |
`/api/measure` is the single contract both surfaces speak. Any algorithm change in `measure_finger.py` improves both surfaces with zero front-end work. See `doc/v5/` for the in-browser capture coach (distance + level gates) and `doc/v6/` for the mobile flow.
|
| 198 |
|
|
|
|
| 217 |
add column if not exists feedback_message text;
|
| 218 |
```
|
| 219 |
|
| 220 |
+
### KOL identity field: email (the cross-table join key)
|
| 221 |
+
|
| 222 |
+
Both web surfaces collect a single required identity field, now labelled
|
| 223 |
+
**Email** (`type="email"`, `autocomplete="email"`, `inputmode="email"`,
|
| 224 |
+
posted as `kol_email`). The server normalizes it **trim + lowercase**.
|
| 225 |
+
Email — not name — is the key used to join `measurements` ↔ the v8
|
| 226 |
+
`feedback` table ↔ the ops shipping-records CSV (names mis-merge and the
|
| 227 |
+
shipping source is email-native). See `doc/v8/PRD.md`.
|
| 228 |
+
|
| 229 |
+
Two columns on `measurements`:
|
| 230 |
+
|
| 231 |
+
| Column | Notes |
|
| 232 |
+
|---|---|
|
| 233 |
+
| `kol_email` | `text`, normalized address; the join key for new rows. |
|
| 234 |
+
| `kol_name` | `text`, **legacy only** — pre-email historical rows; NULL on new rows. |
|
| 235 |
+
|
| 236 |
+
```sql
|
| 237 |
+
alter table public.measurements add column if not exists kol_email text;
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
Stored object paths slug the **email local-part only** (`_email_local_part`
|
| 241 |
+
in `web_demo/app.py`), never the full address, to keep PII out of bucket
|
| 242 |
+
paths. Admin grouping/display and CSV export prefer `kol_email` and fall
|
| 243 |
+
back to `kol_name` for legacy rows.
|
| 244 |
+
|
| 245 |
+
### v8 post-shipment feedback (`/feedback` + `feedback` table)
|
| 246 |
+
|
| 247 |
+
A **separate** flow from the post-result rating above — different
|
| 248 |
+
lifecycle (5–7 days after the ring ships, not right after measurement)
|
| 249 |
+
and a different table. Don't confuse the two:
|
| 250 |
+
|
| 251 |
+
| | Post-result rating | v8 fit feedback |
|
| 252 |
+
|---|---|---|
|
| 253 |
+
| Route (POST) | `/api/feedback` | `/api/fit-feedback` |
|
| 254 |
+
| Storage | columns on `measurements` (patched by `run_id`) | new `feedback` table (standalone row) |
|
| 255 |
+
| Question | "did the website work?" | "which finger did the shipped ring fit?" |
|
| 256 |
+
|
| 257 |
+
The `feedback` table is **intentionally decoupled** — no `run_id`, no
|
| 258 |
+
foreign key. The only link to `measurements` is the normalized
|
| 259 |
+
`kol_email`, joined at analysis time in a pandas notebook (see
|
| 260 |
+
`doc/v8/analysis.md`). `GET /feedback` renders `templates/feedback.html`
|
| 261 |
+
(single-page form, reuses `mobile.css`; **not** the `/m` step framework).
|
| 262 |
+
`POST /api/fit-feedback` normalizes the email, uploads the optional photo
|
| 263 |
+
under `feedback/` (local-part slug only, same PII stance), and inserts
|
| 264 |
+
via `save_feedback`. The photo upload + insert are **synchronous** (no
|
| 265 |
+
heavy compute, unlike `/api/measure`), so the row lands complete. RLS is
|
| 266 |
+
enabled on the table (the app's service key bypasses it; this blocks
|
| 267 |
+
anon-key reads of the email PII). The admin dashboard has a **Feedback**
|
| 268 |
+
tab (`/api/admin/feedback` → `list_feedback`) that lists the rows
|
| 269 |
+
read-only; deeper analysis is still pandas-only (`doc/v8/analysis.md`).
|
| 270 |
+
Table columns:
|
| 271 |
+
|
| 272 |
+
```sql
|
| 273 |
+
create table public.feedback (
|
| 274 |
+
id uuid primary key default gen_random_uuid(),
|
| 275 |
+
submitted_at timestamptz not null default now(),
|
| 276 |
+
kol_email text not null, -- normalized join key
|
| 277 |
+
received_size text, received_model text, best_fit_finger text,
|
| 278 |
+
fit_quality text, hand text, photo_url text, notes text
|
| 279 |
+
);
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
---
|
| 283 |
|
| 284 |
## Important Technical Details
|
web_demo/app.py
CHANGED
|
@@ -36,11 +36,14 @@ from src.ai_recommendation import ai_explain_recommendation
|
|
| 36 |
from web_demo.supabase_client import (
|
| 37 |
upload_file,
|
| 38 |
save_measurement,
|
|
|
|
|
|
|
| 39 |
update_measurement_feedback,
|
| 40 |
FEEDBACK_OK,
|
| 41 |
FEEDBACK_NO_ROW,
|
| 42 |
FEEDBACK_DISABLED,
|
| 43 |
list_measurements,
|
|
|
|
| 44 |
list_measurements_for_stats,
|
| 45 |
update_ground_truth,
|
| 46 |
delete_measurement,
|
|
@@ -115,11 +118,21 @@ def _slugify(name: str) -> str:
|
|
| 115 |
return slug or "anon"
|
| 116 |
|
| 117 |
|
| 118 |
-
def
|
| 119 |
-
"""Return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
run_id = uuid.uuid4().hex[:8]
|
| 121 |
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 122 |
-
base_name = f"{_slugify(
|
| 123 |
return base_name, run_id
|
| 124 |
|
| 125 |
|
|
@@ -184,7 +197,10 @@ def _read_form_settings() -> Dict[str, Any]:
|
|
| 184 |
return {
|
| 185 |
"finger_index": request.form.get("finger_index", "index"),
|
| 186 |
"mode": request.form.get("mode", "single"),
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
| 188 |
"ring_model": ring_model,
|
| 189 |
"capture_method": capture_method,
|
| 190 |
"gate_telemetry": gate_telemetry,
|
|
@@ -231,6 +247,14 @@ def index_dev():
|
|
| 231 |
return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=True)
|
| 232 |
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
@app.route("/results/<path:filename>")
|
| 235 |
def serve_result(filename: str):
|
| 236 |
return send_from_directory(RESULTS_DIR, filename)
|
|
@@ -254,7 +278,7 @@ def api_measure():
|
|
| 254 |
return jsonify({"success": False, "error": "Unsupported file type"}), 400
|
| 255 |
|
| 256 |
settings = _read_form_settings()
|
| 257 |
-
base_name, run_id = _make_base_name(settings["
|
| 258 |
suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
|
| 259 |
upload_name = f"{base_name}{suffix}"
|
| 260 |
upload_path = UPLOAD_DIR / upload_name
|
|
@@ -269,7 +293,7 @@ def api_measure():
|
|
| 269 |
image=image,
|
| 270 |
input_image_url=f"/uploads/{upload_name}",
|
| 271 |
ring_model=settings["ring_model"],
|
| 272 |
-
|
| 273 |
capture_method=settings["capture_method"],
|
| 274 |
gate_telemetry=settings["gate_telemetry"],
|
| 275 |
upload_path=upload_path,
|
|
@@ -292,13 +316,13 @@ def api_measure_default():
|
|
| 292 |
return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
|
| 293 |
|
| 294 |
settings = _read_form_settings()
|
| 295 |
-
base_name, run_id = _make_base_name(settings["
|
| 296 |
|
| 297 |
common_kwargs = dict(
|
| 298 |
image=image,
|
| 299 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 300 |
ring_model=settings["ring_model"],
|
| 301 |
-
|
| 302 |
capture_method=settings["capture_method"],
|
| 303 |
gate_telemetry=settings["gate_telemetry"],
|
| 304 |
base_name=base_name,
|
|
@@ -314,7 +338,7 @@ def _run_measurement(
|
|
| 314 |
finger_index: str,
|
| 315 |
input_image_url: str,
|
| 316 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 317 |
-
|
| 318 |
capture_method: str = DEFAULT_CAPTURE_METHOD,
|
| 319 |
gate_telemetry: Optional[Dict[str, Any]] = None,
|
| 320 |
upload_path: Optional[Path] = None,
|
|
@@ -374,7 +398,9 @@ def _run_measurement(
|
|
| 374 |
result_png_name=result_png_name,
|
| 375 |
record={
|
| 376 |
"run_id": run_id,
|
| 377 |
-
|
|
|
|
|
|
|
| 378 |
"mode": "single",
|
| 379 |
"ring_model": ring_model,
|
| 380 |
"finger_index": finger_index,
|
|
@@ -395,7 +421,7 @@ def _run_multi_measurement(
|
|
| 395 |
image,
|
| 396 |
input_image_url: str,
|
| 397 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 398 |
-
|
| 399 |
capture_method: str = DEFAULT_CAPTURE_METHOD,
|
| 400 |
gate_telemetry: Optional[Dict[str, Any]] = None,
|
| 401 |
upload_path: Optional[Path] = None,
|
|
@@ -474,7 +500,8 @@ def _run_multi_measurement(
|
|
| 474 |
result_png_name=result_png_name,
|
| 475 |
record={
|
| 476 |
"run_id": run_id,
|
| 477 |
-
|
|
|
|
| 478 |
"mode": "multi",
|
| 479 |
"ring_model": ring_model,
|
| 480 |
"overall_best_size": result.get("overall_best_size"),
|
|
@@ -558,6 +585,62 @@ def api_feedback():
|
|
| 558 |
return jsonify({"success": False, "error": "Could not save feedback, please retry"}), 500
|
| 559 |
|
| 560 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
# ---------------------------------------------------------------------------
|
| 562 |
# Admin routes
|
| 563 |
# ---------------------------------------------------------------------------
|
|
@@ -583,6 +666,14 @@ def api_admin_measurements():
|
|
| 583 |
return jsonify(rows)
|
| 584 |
|
| 585 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
@app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
|
| 587 |
def api_admin_update_gt(measurement_id: str):
|
| 588 |
if not _check_admin_token():
|
|
@@ -622,12 +713,13 @@ def _parse_iso_to_utc_date(iso_str: str) -> Optional[date]:
|
|
| 622 |
return None
|
| 623 |
|
| 624 |
|
| 625 |
-
def _kol_key(
|
| 626 |
-
"""Normalize
|
| 627 |
-
don't count anonymous sample runs as a distinct KOL.
|
| 628 |
-
|
|
|
|
| 629 |
return None
|
| 630 |
-
s =
|
| 631 |
return s or None
|
| 632 |
|
| 633 |
|
|
@@ -683,12 +775,15 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
|
|
| 683 |
|
| 684 |
d = _parse_iso_to_utc_date(created_at)
|
| 685 |
|
| 686 |
-
|
|
|
|
|
|
|
|
|
|
| 687 |
in_window = d is not None and window_start <= d <= today
|
| 688 |
if kkey:
|
| 689 |
kol_counts[kkey] += 1
|
| 690 |
if kkey not in kol_display:
|
| 691 |
-
kol_display[kkey] = (
|
| 692 |
if in_window:
|
| 693 |
kol_counts_window[kkey] += 1
|
| 694 |
if created_at and (kkey not in kol_last_seen or created_at > kol_last_seen[kkey]):
|
|
@@ -770,7 +865,9 @@ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]
|
|
| 770 |
|
| 771 |
top_kols = [
|
| 772 |
{
|
| 773 |
-
|
|
|
|
|
|
|
| 774 |
"count": c,
|
| 775 |
"last_at": kol_last_seen.get(k),
|
| 776 |
}
|
|
@@ -876,7 +973,7 @@ def api_admin_export_csv():
|
|
| 876 |
rows = list_measurements(limit=5000)
|
| 877 |
output = io.StringIO()
|
| 878 |
fieldnames = [
|
| 879 |
-
"kol_name", "created_at", "mode", "ring_model",
|
| 880 |
"overall_best_size", "overall_range_min", "overall_range_max",
|
| 881 |
"index_size", "index_diameter", "index_confidence",
|
| 882 |
"middle_size", "middle_diameter", "middle_confidence",
|
|
@@ -890,6 +987,7 @@ def api_admin_export_csv():
|
|
| 890 |
writer.writeheader()
|
| 891 |
for row in rows:
|
| 892 |
flat = {
|
|
|
|
| 893 |
"kol_name": _csv_safe(row.get("kol_name", "")),
|
| 894 |
"created_at": row.get("created_at", ""),
|
| 895 |
"mode": row.get("mode", ""),
|
|
|
|
| 36 |
from web_demo.supabase_client import (
|
| 37 |
upload_file,
|
| 38 |
save_measurement,
|
| 39 |
+
save_feedback,
|
| 40 |
+
persistence_enabled,
|
| 41 |
update_measurement_feedback,
|
| 42 |
FEEDBACK_OK,
|
| 43 |
FEEDBACK_NO_ROW,
|
| 44 |
FEEDBACK_DISABLED,
|
| 45 |
list_measurements,
|
| 46 |
+
list_feedback,
|
| 47 |
list_measurements_for_stats,
|
| 48 |
update_ground_truth,
|
| 49 |
delete_measurement,
|
|
|
|
| 118 |
return slug or "anon"
|
| 119 |
|
| 120 |
|
| 121 |
+
def _email_local_part(email: str) -> str:
|
| 122 |
+
"""Return the part of an email before '@'. Used only for naming the
|
| 123 |
+
stored object — we deliberately keep the full address out of bucket
|
| 124 |
+
paths (it's PII; relevant for EU KOLs)."""
|
| 125 |
+
return (email or "").split("@", 1)[0]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _make_base_name(kol_email: str) -> Tuple[str, str]:
|
| 129 |
+
"""Return (base_name, run_id). base_name = '{slug}_{timestamp}_{shortid}'.
|
| 130 |
+
|
| 131 |
+
The slug is derived from the email local-part only — never the full
|
| 132 |
+
address — so stored object paths don't leak the domain/full email."""
|
| 133 |
run_id = uuid.uuid4().hex[:8]
|
| 134 |
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 135 |
+
base_name = f"{_slugify(_email_local_part(kol_email))}_{timestamp}_{run_id}"
|
| 136 |
return base_name, run_id
|
| 137 |
|
| 138 |
|
|
|
|
| 197 |
return {
|
| 198 |
"finger_index": request.form.get("finger_index", "index"),
|
| 199 |
"mode": request.form.get("mode", "single"),
|
| 200 |
+
# Email is the cross-table join key (see doc/v8/PRD.md). Normalize
|
| 201 |
+
# to trim + lowercase so the same address matches across the
|
| 202 |
+
# measurement, feedback, and shipping data sources.
|
| 203 |
+
"kol_email": request.form.get("kol_email", "").strip().lower(),
|
| 204 |
"ring_model": ring_model,
|
| 205 |
"capture_method": capture_method,
|
| 206 |
"gate_telemetry": gate_telemetry,
|
|
|
|
| 247 |
return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=True)
|
| 248 |
|
| 249 |
|
| 250 |
+
@app.route("/feedback")
|
| 251 |
+
def feedback_form():
|
| 252 |
+
"""Public post-shipment KOL fit-feedback form (v8). Mobile-first but
|
| 253 |
+
served to all UAs — desktop KOLs must be able to fill it too, so no
|
| 254 |
+
UA redirect here (unlike `/`)."""
|
| 255 |
+
return render_template("feedback.html")
|
| 256 |
+
|
| 257 |
+
|
| 258 |
@app.route("/results/<path:filename>")
|
| 259 |
def serve_result(filename: str):
|
| 260 |
return send_from_directory(RESULTS_DIR, filename)
|
|
|
|
| 278 |
return jsonify({"success": False, "error": "Unsupported file type"}), 400
|
| 279 |
|
| 280 |
settings = _read_form_settings()
|
| 281 |
+
base_name, run_id = _make_base_name(settings["kol_email"])
|
| 282 |
suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
|
| 283 |
upload_name = f"{base_name}{suffix}"
|
| 284 |
upload_path = UPLOAD_DIR / upload_name
|
|
|
|
| 293 |
image=image,
|
| 294 |
input_image_url=f"/uploads/{upload_name}",
|
| 295 |
ring_model=settings["ring_model"],
|
| 296 |
+
kol_email=settings["kol_email"],
|
| 297 |
capture_method=settings["capture_method"],
|
| 298 |
gate_telemetry=settings["gate_telemetry"],
|
| 299 |
upload_path=upload_path,
|
|
|
|
| 316 |
return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
|
| 317 |
|
| 318 |
settings = _read_form_settings()
|
| 319 |
+
base_name, run_id = _make_base_name(settings["kol_email"] or "sample")
|
| 320 |
|
| 321 |
common_kwargs = dict(
|
| 322 |
image=image,
|
| 323 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 324 |
ring_model=settings["ring_model"],
|
| 325 |
+
kol_email=settings["kol_email"],
|
| 326 |
capture_method=settings["capture_method"],
|
| 327 |
gate_telemetry=settings["gate_telemetry"],
|
| 328 |
base_name=base_name,
|
|
|
|
| 338 |
finger_index: str,
|
| 339 |
input_image_url: str,
|
| 340 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 341 |
+
kol_email: str = "",
|
| 342 |
capture_method: str = DEFAULT_CAPTURE_METHOD,
|
| 343 |
gate_telemetry: Optional[Dict[str, Any]] = None,
|
| 344 |
upload_path: Optional[Path] = None,
|
|
|
|
| 398 |
result_png_name=result_png_name,
|
| 399 |
record={
|
| 400 |
"run_id": run_id,
|
| 401 |
+
# kol_name is left unset for new rows — it is retained on the
|
| 402 |
+
# table only for pre-email historical data. Email is the key.
|
| 403 |
+
"kol_email": kol_email,
|
| 404 |
"mode": "single",
|
| 405 |
"ring_model": ring_model,
|
| 406 |
"finger_index": finger_index,
|
|
|
|
| 421 |
image,
|
| 422 |
input_image_url: str,
|
| 423 |
ring_model: str = DEFAULT_RING_MODEL,
|
| 424 |
+
kol_email: str = "",
|
| 425 |
capture_method: str = DEFAULT_CAPTURE_METHOD,
|
| 426 |
gate_telemetry: Optional[Dict[str, Any]] = None,
|
| 427 |
upload_path: Optional[Path] = None,
|
|
|
|
| 500 |
result_png_name=result_png_name,
|
| 501 |
record={
|
| 502 |
"run_id": run_id,
|
| 503 |
+
# kol_name left unset for new rows (see _run_measurement).
|
| 504 |
+
"kol_email": kol_email,
|
| 505 |
"mode": "multi",
|
| 506 |
"ring_model": ring_model,
|
| 507 |
"overall_best_size": result.get("overall_best_size"),
|
|
|
|
| 585 |
return jsonify({"success": False, "error": "Could not save feedback, please retry"}), 500
|
| 586 |
|
| 587 |
|
| 588 |
+
# Post-shipment fit-feedback (v8). Distinct from /api/feedback above: that
|
| 589 |
+
# attaches a post-measurement star rating to a measurements row; this writes
|
| 590 |
+
# a standalone row to the decoupled `feedback` table (no run_id, no FK). The
|
| 591 |
+
# only link between the two tables is the normalized kol_email. See
|
| 592 |
+
# doc/v8/PRD.md.
|
| 593 |
+
@app.route("/api/fit-feedback", methods=["POST"])
|
| 594 |
+
def api_fit_feedback():
|
| 595 |
+
# Multipart because the photo is optional; non-file fields arrive in
|
| 596 |
+
# request.form regardless.
|
| 597 |
+
kol_email = (request.form.get("kol_email") or "").strip().lower()
|
| 598 |
+
if not kol_email or "@" not in kol_email:
|
| 599 |
+
return jsonify({"success": False, "error": "A valid email is required"}), 400
|
| 600 |
+
|
| 601 |
+
def _field(name: str) -> Optional[str]:
|
| 602 |
+
v = (request.form.get(name) or "").strip()
|
| 603 |
+
return v or None
|
| 604 |
+
|
| 605 |
+
# Optional photo of the ring on the best-fit finger. Uploaded to the
|
| 606 |
+
# existing bucket under feedback/, slugging the email local-part only
|
| 607 |
+
# (never the full address) so the bucket path doesn't leak PII.
|
| 608 |
+
photo_url = None
|
| 609 |
+
file = request.files.get("photo")
|
| 610 |
+
if file and file.filename:
|
| 611 |
+
if not _allowed_file(file.filename):
|
| 612 |
+
return jsonify({"success": False, "error": "Unsupported photo type"}), 400
|
| 613 |
+
base_name, _ = _make_base_name(kol_email)
|
| 614 |
+
suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
|
| 615 |
+
photo_name = f"{base_name}{suffix}"
|
| 616 |
+
photo_path = UPLOAD_DIR / photo_name
|
| 617 |
+
file.save(str(photo_path))
|
| 618 |
+
# Synchronous upload (no heavy compute here, unlike /api/measure) so
|
| 619 |
+
# the inserted row already carries photo_url — no follow-up patch.
|
| 620 |
+
photo_url = upload_file(str(photo_path), f"feedback/{photo_name}")
|
| 621 |
+
|
| 622 |
+
record = {
|
| 623 |
+
"kol_email": kol_email,
|
| 624 |
+
"received_size": _field("received_size"),
|
| 625 |
+
"received_model": _field("received_model"),
|
| 626 |
+
"best_fit_finger": _field("best_fit_finger"),
|
| 627 |
+
"fit_quality": _field("fit_quality"),
|
| 628 |
+
"hand": _field("hand"),
|
| 629 |
+
"photo_url": photo_url,
|
| 630 |
+
"notes": _field("notes"),
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
row_id = save_feedback(record)
|
| 634 |
+
if row_id is not None:
|
| 635 |
+
return jsonify({"success": True})
|
| 636 |
+
if not persistence_enabled():
|
| 637 |
+
# RING_DISABLE_SUPABASE / unconfigured env (local dev) — the form did
|
| 638 |
+
# what it could; report success rather than a scary error.
|
| 639 |
+
return jsonify({"success": True})
|
| 640 |
+
# Persistence is on but the insert returned nothing / raised.
|
| 641 |
+
return jsonify({"success": False, "error": "Could not save feedback, please retry"}), 500
|
| 642 |
+
|
| 643 |
+
|
| 644 |
# ---------------------------------------------------------------------------
|
| 645 |
# Admin routes
|
| 646 |
# ---------------------------------------------------------------------------
|
|
|
|
| 666 |
return jsonify(rows)
|
| 667 |
|
| 668 |
|
| 669 |
+
@app.route("/api/admin/feedback")
|
| 670 |
+
def api_admin_feedback():
|
| 671 |
+
if not _check_admin_token():
|
| 672 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 673 |
+
rows = list_feedback(limit=500)
|
| 674 |
+
return jsonify(rows)
|
| 675 |
+
|
| 676 |
+
|
| 677 |
@app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
|
| 678 |
def api_admin_update_gt(measurement_id: str):
|
| 679 |
if not _check_admin_token():
|
|
|
|
| 713 |
return None
|
| 714 |
|
| 715 |
|
| 716 |
+
def _kol_key(identifier: Optional[str]) -> Optional[str]:
|
| 717 |
+
"""Normalize a KOL identifier for grouping (trim + lower). Empty → None
|
| 718 |
+
so we don't count anonymous sample runs as a distinct KOL. Callers pass
|
| 719 |
+
the email (the join key) with kol_name as a fallback for legacy rows."""
|
| 720 |
+
if not identifier:
|
| 721 |
return None
|
| 722 |
+
s = identifier.strip().lower()
|
| 723 |
return s or None
|
| 724 |
|
| 725 |
|
|
|
|
| 775 |
|
| 776 |
d = _parse_iso_to_utc_date(created_at)
|
| 777 |
|
| 778 |
+
# Prefer email (the join key); fall back to kol_name for rows that
|
| 779 |
+
# predate the email switch.
|
| 780 |
+
kol_ident = row.get("kol_email") or row.get("kol_name")
|
| 781 |
+
kkey = _kol_key(kol_ident)
|
| 782 |
in_window = d is not None and window_start <= d <= today
|
| 783 |
if kkey:
|
| 784 |
kol_counts[kkey] += 1
|
| 785 |
if kkey not in kol_display:
|
| 786 |
+
kol_display[kkey] = (kol_ident or "").strip()
|
| 787 |
if in_window:
|
| 788 |
kol_counts_window[kkey] += 1
|
| 789 |
if created_at and (kkey not in kol_last_seen or created_at > kol_last_seen[kkey]):
|
|
|
|
| 865 |
|
| 866 |
top_kols = [
|
| 867 |
{
|
| 868 |
+
# Display label is the email (the join key) or, for legacy
|
| 869 |
+
# rows, the name — hence kol_ident, not kol_name.
|
| 870 |
+
"kol_ident": kol_display.get(k, k),
|
| 871 |
"count": c,
|
| 872 |
"last_at": kol_last_seen.get(k),
|
| 873 |
}
|
|
|
|
| 973 |
rows = list_measurements(limit=5000)
|
| 974 |
output = io.StringIO()
|
| 975 |
fieldnames = [
|
| 976 |
+
"kol_email", "kol_name", "created_at", "mode", "ring_model",
|
| 977 |
"overall_best_size", "overall_range_min", "overall_range_max",
|
| 978 |
"index_size", "index_diameter", "index_confidence",
|
| 979 |
"middle_size", "middle_diameter", "middle_confidence",
|
|
|
|
| 987 |
writer.writeheader()
|
| 988 |
for row in rows:
|
| 989 |
flat = {
|
| 990 |
+
"kol_email": _csv_safe(row.get("kol_email", "")),
|
| 991 |
"kol_name": _csv_safe(row.get("kol_name", "")),
|
| 992 |
"created_at": row.get("created_at", ""),
|
| 993 |
"mode": row.get("mode", ""),
|
web_demo/static/app.js
CHANGED
|
@@ -111,7 +111,10 @@ const RING_SIZE_TABLES = {
|
|
| 111 |
};
|
| 112 |
const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
|
| 113 |
|
| 114 |
-
const
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
const buildMeasureSettings = () => {
|
| 117 |
const fingerSelect = form.querySelector('[name="finger_index"]');
|
|
@@ -127,7 +130,7 @@ const buildMeasureSettings = () => {
|
|
| 127 |
mode: mode,
|
| 128 |
ring_model: ringModel,
|
| 129 |
ai_explain: aiOn ? "1" : "0",
|
| 130 |
-
|
| 131 |
};
|
| 132 |
};
|
| 133 |
|
|
@@ -318,9 +321,9 @@ form.addEventListener("submit", async (event) => {
|
|
| 318 |
event.preventDefault();
|
| 319 |
|
| 320 |
const settings = buildMeasureSettings();
|
| 321 |
-
if (!settings.
|
| 322 |
-
setStatus("Please enter
|
| 323 |
-
|
| 324 |
return;
|
| 325 |
}
|
| 326 |
const formData = new FormData();
|
|
@@ -329,7 +332,7 @@ form.addEventListener("submit", async (event) => {
|
|
| 329 |
formData.append("mode", settings.mode);
|
| 330 |
formData.append("ring_model", settings.ring_model);
|
| 331 |
formData.append("ai_explain", settings.ai_explain);
|
| 332 |
-
formData.append("
|
| 333 |
|
| 334 |
const file = imageInput.files[0];
|
| 335 |
if (file) {
|
|
|
|
| 111 |
};
|
| 112 |
const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
|
| 113 |
|
| 114 |
+
const kolEmailInput = document.getElementById("kolEmailInput");
|
| 115 |
+
// Loose RFC-ish check — `something@something.tld`. Email is the
|
| 116 |
+
// cross-table join key (see doc/v8/PRD.md), so validate before submit.
|
| 117 |
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
| 118 |
|
| 119 |
const buildMeasureSettings = () => {
|
| 120 |
const fingerSelect = form.querySelector('[name="finger_index"]');
|
|
|
|
| 130 |
mode: mode,
|
| 131 |
ring_model: ringModel,
|
| 132 |
ai_explain: aiOn ? "1" : "0",
|
| 133 |
+
kol_email: kolEmailInput ? kolEmailInput.value.trim().toLowerCase() : "",
|
| 134 |
};
|
| 135 |
};
|
| 136 |
|
|
|
|
| 321 |
event.preventDefault();
|
| 322 |
|
| 323 |
const settings = buildMeasureSettings();
|
| 324 |
+
if (!EMAIL_RE.test(settings.kol_email)) {
|
| 325 |
+
setStatus("Please enter a valid email before measuring.", { error: true });
|
| 326 |
+
kolEmailInput.focus();
|
| 327 |
return;
|
| 328 |
}
|
| 329 |
const formData = new FormData();
|
|
|
|
| 332 |
formData.append("mode", settings.mode);
|
| 333 |
formData.append("ring_model", settings.ring_model);
|
| 334 |
formData.append("ai_explain", settings.ai_explain);
|
| 335 |
+
formData.append("kol_email", settings.kol_email);
|
| 336 |
|
| 337 |
const file = imageInput.files[0];
|
| 338 |
if (file) {
|
web_demo/static/feedback/feedback.css
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Page-specific styles for /feedback. The bulk of the look comes from
|
| 2 |
+
mobile.css (imported first): .step / .panel / .controls / .primary /
|
| 3 |
+
.form-error / hero-* are all reused so this form matches the /m flow.
|
| 4 |
+
This file only adds what the step CSS doesn't already cover: the two
|
| 5 |
+
control types unique to this form (textarea + file), the link-styled
|
| 6 |
+
primary button on the success screen, and the success screen itself. */
|
| 7 |
+
|
| 8 |
+
/* Slightly more breathing room between questions than the shared
|
| 9 |
+
.controls default (14px) — this form reads as a vertical
|
| 10 |
+
questionnaire, so the questions want clearer separation. */
|
| 11 |
+
.step-feedback .controls {
|
| 12 |
+
gap: 22px;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/* Textarea + file input inside .controls — mobile.css styles
|
| 16 |
+
input[type="text"|"email"] and select, but not these two. */
|
| 17 |
+
.controls textarea {
|
| 18 |
+
width: 100%;
|
| 19 |
+
border: 1px solid var(--border);
|
| 20 |
+
border-radius: 12px;
|
| 21 |
+
padding: 10px 14px;
|
| 22 |
+
/* ≥16px keeps iOS Safari from auto-zooming on focus. */
|
| 23 |
+
font-size: 1rem;
|
| 24 |
+
line-height: 1.4;
|
| 25 |
+
font-family: inherit;
|
| 26 |
+
background: white;
|
| 27 |
+
color: var(--ink);
|
| 28 |
+
resize: vertical;
|
| 29 |
+
min-height: 72px;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.controls input[type="file"] {
|
| 33 |
+
width: 100%;
|
| 34 |
+
font-size: 1rem;
|
| 35 |
+
color: var(--ink-soft);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Enlarge the native "Choose File" button — iOS Safari renders it small
|
| 39 |
+
by default. Both selectors cover Safari (-webkit-) and the standard. */
|
| 40 |
+
.controls input[type="file"]::-webkit-file-upload-button,
|
| 41 |
+
.controls input[type="file"]::file-selector-button {
|
| 42 |
+
font-size: 1rem;
|
| 43 |
+
padding: 10px 16px;
|
| 44 |
+
margin-right: 12px;
|
| 45 |
+
border: 1px solid var(--border);
|
| 46 |
+
border-radius: 10px;
|
| 47 |
+
background: white;
|
| 48 |
+
color: var(--accent);
|
| 49 |
+
cursor: pointer;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* Inline per-field validation error — sits directly under the offending
|
| 53 |
+
control (injected by feedback.js). Bottom-of-form errors are invisible
|
| 54 |
+
on mobile once the field is focused and the keyboard covers them, so
|
| 55 |
+
the message lives next to the field instead. */
|
| 56 |
+
.field-error {
|
| 57 |
+
display: block;
|
| 58 |
+
margin-top: 2px;
|
| 59 |
+
font-size: 0.82rem;
|
| 60 |
+
font-weight: 600;
|
| 61 |
+
color: var(--accent);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* Red ring on the control flagged invalid, so the field itself reads as
|
| 65 |
+
the error target, not just the text below it. */
|
| 66 |
+
.controls input[aria-invalid="true"],
|
| 67 |
+
.controls select[aria-invalid="true"] {
|
| 68 |
+
border-color: var(--accent);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* --- Success screen --------------------------------------------- */
|
| 72 |
+
|
| 73 |
+
.fb-success {
|
| 74 |
+
display: flex;
|
| 75 |
+
flex-direction: column;
|
| 76 |
+
justify-content: center;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.fb-success-inner {
|
| 80 |
+
text-align: center;
|
| 81 |
+
max-width: 36ch;
|
| 82 |
+
margin: 0 auto;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.fb-success .hero-sub {
|
| 86 |
+
margin: 0 auto 20px;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.fb-success-check {
|
| 90 |
+
width: 64px;
|
| 91 |
+
height: 64px;
|
| 92 |
+
margin: 0 auto 20px;
|
| 93 |
+
border-radius: 50%;
|
| 94 |
+
background: #d8f1de;
|
| 95 |
+
color: #1f6b34;
|
| 96 |
+
font-size: 34px;
|
| 97 |
+
line-height: 64px;
|
| 98 |
+
font-weight: 700;
|
| 99 |
+
}
|
| 100 |
+
|
web_demo/static/feedback/feedback.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// /feedback — post-shipment KOL fit-feedback form (v8).
|
| 2 |
+
//
|
| 3 |
+
// A single self-contained screen (no step framework — that's the /m
|
| 4 |
+
// capture coach's machinery and irrelevant here). Validates the
|
| 5 |
+
// required fields, POSTs multipart to /api/fit-feedback, and swaps the
|
| 6 |
+
// form for a thank-you + "measure now" CTA on success.
|
| 7 |
+
//
|
| 8 |
+
// Email is the cross-table join key (see doc/v8/PRD.md), so it is the
|
| 9 |
+
// one field validated for format; the rest are required-but-free.
|
| 10 |
+
//
|
| 11 |
+
// Validation errors render INLINE, directly under the offending field
|
| 12 |
+
// (not in one spot at the bottom): on mobile we focus the bad field, so
|
| 13 |
+
// the keyboard would hide a bottom-of-form message entirely.
|
| 14 |
+
|
| 15 |
+
// Loose RFC-ish check — `something@something.tld`. Mirrors the desktop /
|
| 16 |
+
// mobile measurement forms so a KOL who uses both sees the same rule.
|
| 17 |
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
| 18 |
+
|
| 19 |
+
const form = document.getElementById("feedbackForm");
|
| 20 |
+
const errorEl = document.getElementById("formError");
|
| 21 |
+
const submitBtn = document.getElementById("submitBtn");
|
| 22 |
+
|
| 23 |
+
const el = (id) => document.getElementById(id);
|
| 24 |
+
const val = (id) => (el(id).value || "").trim();
|
| 25 |
+
|
| 26 |
+
// Required selects/inputs and the human label used in the error message
|
| 27 |
+
// when one is left blank.
|
| 28 |
+
const REQUIRED = [
|
| 29 |
+
["receivedSize", "the ring size you received"],
|
| 30 |
+
["receivedModel", "the ring model"],
|
| 31 |
+
["bestFitFinger", "which finger fit best"],
|
| 32 |
+
["fitQuality", "how it fit"],
|
| 33 |
+
["hand", "which hand"],
|
| 34 |
+
];
|
| 35 |
+
|
| 36 |
+
// Every field that can produce a validation error, in DOM order.
|
| 37 |
+
const FIELD_IDS = ["kolEmail", ...REQUIRED.map(([id]) => id)];
|
| 38 |
+
|
| 39 |
+
// Inject an inline error slot under each validatable field and clear it
|
| 40 |
+
// as soon as the user edits that field.
|
| 41 |
+
const errSlots = {};
|
| 42 |
+
for (const id of FIELD_IDS) {
|
| 43 |
+
const input = el(id);
|
| 44 |
+
const slot = document.createElement("small");
|
| 45 |
+
slot.className = "field-error";
|
| 46 |
+
slot.hidden = true;
|
| 47 |
+
input.closest("label").appendChild(slot);
|
| 48 |
+
errSlots[id] = slot;
|
| 49 |
+
input.addEventListener(input.tagName === "SELECT" ? "change" : "input", () => {
|
| 50 |
+
slot.hidden = true;
|
| 51 |
+
input.removeAttribute("aria-invalid");
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function clearErrors() {
|
| 56 |
+
errorEl.hidden = true;
|
| 57 |
+
for (const id of FIELD_IDS) {
|
| 58 |
+
errSlots[id].hidden = true;
|
| 59 |
+
el(id).removeAttribute("aria-invalid");
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Inline error on a specific field: show the message under it, focus it,
|
| 64 |
+
// and scroll it to mid-screen so the message clears the on-screen keyboard.
|
| 65 |
+
function showFieldError(id, msg) {
|
| 66 |
+
const input = el(id);
|
| 67 |
+
const slot = errSlots[id];
|
| 68 |
+
slot.textContent = msg;
|
| 69 |
+
slot.hidden = false;
|
| 70 |
+
input.setAttribute("aria-invalid", "true");
|
| 71 |
+
input.focus({ preventScroll: true });
|
| 72 |
+
input.closest("label").scrollIntoView({ block: "center", behavior: "smooth" });
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Global error (network / server failures — not tied to one field).
|
| 76 |
+
function showFormError(msg) {
|
| 77 |
+
errorEl.textContent = msg;
|
| 78 |
+
errorEl.hidden = false;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// Returns [fieldId, message] for the first invalid field, or null.
|
| 82 |
+
function firstInvalid() {
|
| 83 |
+
if (!EMAIL_RE.test(val("kolEmail").toLowerCase())) {
|
| 84 |
+
return ["kolEmail", "Please enter a valid email."];
|
| 85 |
+
}
|
| 86 |
+
for (const [id, label] of REQUIRED) {
|
| 87 |
+
if (!val(id)) return [id, `Please tell us ${label}.`];
|
| 88 |
+
}
|
| 89 |
+
return null;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function showSuccess() {
|
| 93 |
+
document.getElementById("formView").hidden = true;
|
| 94 |
+
document.getElementById("formFoot").hidden = true;
|
| 95 |
+
document.getElementById("successView").hidden = false;
|
| 96 |
+
window.scrollTo(0, 0);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
form.addEventListener("submit", async (event) => {
|
| 100 |
+
event.preventDefault();
|
| 101 |
+
clearErrors();
|
| 102 |
+
|
| 103 |
+
const invalid = firstInvalid();
|
| 104 |
+
if (invalid) {
|
| 105 |
+
showFieldError(invalid[0], invalid[1]);
|
| 106 |
+
return;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const fd = new FormData();
|
| 110 |
+
fd.append("kol_email", val("kolEmail").toLowerCase());
|
| 111 |
+
fd.append("received_size", val("receivedSize"));
|
| 112 |
+
fd.append("received_model", val("receivedModel"));
|
| 113 |
+
fd.append("best_fit_finger", val("bestFitFinger"));
|
| 114 |
+
fd.append("fit_quality", val("fitQuality"));
|
| 115 |
+
fd.append("hand", val("hand"));
|
| 116 |
+
fd.append("notes", val("notes"));
|
| 117 |
+
const photo = el("photo").files[0];
|
| 118 |
+
if (photo) fd.append("photo", photo, photo.name);
|
| 119 |
+
|
| 120 |
+
submitBtn.disabled = true;
|
| 121 |
+
const originalLabel = submitBtn.textContent;
|
| 122 |
+
submitBtn.textContent = "Submitting…";
|
| 123 |
+
|
| 124 |
+
try {
|
| 125 |
+
const resp = await fetch("/api/fit-feedback", { method: "POST", body: fd });
|
| 126 |
+
const data = await resp.json().catch(() => ({}));
|
| 127 |
+
if (resp.ok && data.success) {
|
| 128 |
+
showSuccess();
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
showFormError(data.error || "Could not submit. Please try again.");
|
| 132 |
+
} catch (err) {
|
| 133 |
+
showFormError("Network error. Please check your connection and retry.");
|
| 134 |
+
} finally {
|
| 135 |
+
submitBtn.disabled = false;
|
| 136 |
+
submitBtn.textContent = originalLabel;
|
| 137 |
+
}
|
| 138 |
+
});
|
web_demo/static/mobile/mobile.css
CHANGED
|
@@ -208,6 +208,7 @@ body {
|
|
| 208 |
}
|
| 209 |
|
| 210 |
.controls input[type="text"],
|
|
|
|
| 211 |
.controls select {
|
| 212 |
/* Lock both controls to the same height. iOS Safari's native
|
| 213 |
<select> renders at a slightly shorter intrinsic height than
|
|
|
|
| 208 |
}
|
| 209 |
|
| 210 |
.controls input[type="text"],
|
| 211 |
+
.controls input[type="email"],
|
| 212 |
.controls select {
|
| 213 |
/* Lock both controls to the same height. iOS Safari's native
|
| 214 |
<select> renders at a slightly shorter intrinsic height than
|
web_demo/static/mobile/session.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
| 8 |
// next run starts fresh).
|
| 9 |
|
| 10 |
export const session = {
|
| 11 |
-
|
| 12 |
ringModel: "gen",
|
| 13 |
// Either an uploaded File or a Blob captured from the camera step.
|
| 14 |
imageBlob: null,
|
|
@@ -26,7 +26,7 @@ export const session = {
|
|
| 26 |
};
|
| 27 |
|
| 28 |
// Wipe just the photo + result — used by "Measure again" so the user
|
| 29 |
-
// keeps their entered
|
| 30 |
// re-typing. A full reset isn't a separate function: a page refresh
|
| 31 |
// reloads this module and reinitializes `session` to the defaults.
|
| 32 |
export function resetForRetake() {
|
|
|
|
| 8 |
// next run starts fresh).
|
| 9 |
|
| 10 |
export const session = {
|
| 11 |
+
kolEmail: "",
|
| 12 |
ringModel: "gen",
|
| 13 |
// Either an uploaded File or a Blob captured from the camera step.
|
| 14 |
imageBlob: null,
|
|
|
|
| 26 |
};
|
| 27 |
|
| 28 |
// Wipe just the photo + result — used by "Measure again" so the user
|
| 29 |
+
// keeps their entered email + ring model on the next capture without
|
| 30 |
// re-typing. A full reset isn't a separate function: a page refresh
|
| 31 |
// reloads this module and reinitializes `session` to the defaults.
|
| 32 |
export function resetForRetake() {
|
web_demo/static/mobile/steps/confirm.js
CHANGED
|
@@ -94,7 +94,7 @@ export default {
|
|
| 94 |
try {
|
| 95 |
const result = await postMeasure({
|
| 96 |
blob: session.imageBlob,
|
| 97 |
-
|
| 98 |
ring_model: session.ringModel,
|
| 99 |
gate_telemetry: session.gateTelemetry,
|
| 100 |
capture_method: session.imageSource === "camera" ? "camera" : "upload",
|
|
|
|
| 94 |
try {
|
| 95 |
const result = await postMeasure({
|
| 96 |
blob: session.imageBlob,
|
| 97 |
+
kol_email: session.kolEmail,
|
| 98 |
ring_model: session.ringModel,
|
| 99 |
gate_telemetry: session.gateTelemetry,
|
| 100 |
capture_method: session.imageSource === "camera" ? "camera" : "upload",
|
web_demo/static/mobile/steps/form.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
-
// Step 2 —
|
| 2 |
// Mirrors the desktop hero-card's top "controls" block.
|
| 3 |
// Persists answers in session so back-nav preserves them.
|
|
|
|
| 4 |
|
| 5 |
import { session } from "../session.js";
|
| 6 |
|
|
@@ -24,12 +25,13 @@ export default {
|
|
| 24 |
</select>
|
| 25 |
</label>
|
| 26 |
<label>
|
| 27 |
-
<span>
|
| 28 |
<input
|
| 29 |
-
type="
|
| 30 |
-
id="
|
| 31 |
-
placeholder="
|
| 32 |
-
autocomplete="
|
|
|
|
| 33 |
/>
|
| 34 |
</label>
|
| 35 |
</div>
|
|
@@ -42,25 +44,31 @@ export default {
|
|
| 42 |
</section>
|
| 43 |
`;
|
| 44 |
|
| 45 |
-
const
|
| 46 |
const modelSelect = container.querySelector("#formRingModel");
|
| 47 |
const errorEl = container.querySelector("#formError");
|
| 48 |
|
| 49 |
// Pre-fill from session — back-nav from upload/camera should not
|
| 50 |
// ask the user to retype.
|
| 51 |
-
|
| 52 |
modelSelect.value = session.ringModel || "gen";
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
container.querySelector(".step-back").addEventListener("click", nav.back);
|
| 55 |
container.querySelector(".step-next").addEventListener("click", () => {
|
| 56 |
-
const
|
| 57 |
-
if (!
|
| 58 |
-
errorEl.textContent = "Please enter a
|
| 59 |
errorEl.hidden = false;
|
| 60 |
-
|
| 61 |
return;
|
| 62 |
}
|
| 63 |
-
session.
|
| 64 |
session.ringModel = modelSelect.value;
|
| 65 |
nav.next();
|
| 66 |
});
|
|
|
|
| 1 |
+
// Step 2 — Email + Ring Model.
|
| 2 |
// Mirrors the desktop hero-card's top "controls" block.
|
| 3 |
// Persists answers in session so back-nav preserves them.
|
| 4 |
+
// Email (not name) is the cross-table join key — see doc/v8/PRD.md.
|
| 5 |
|
| 6 |
import { session } from "../session.js";
|
| 7 |
|
|
|
|
| 25 |
</select>
|
| 26 |
</label>
|
| 27 |
<label>
|
| 28 |
+
<span>Email</span>
|
| 29 |
<input
|
| 30 |
+
type="email"
|
| 31 |
+
id="formKolEmail"
|
| 32 |
+
placeholder="you@example.com"
|
| 33 |
+
autocomplete="email"
|
| 34 |
+
inputmode="email"
|
| 35 |
/>
|
| 36 |
</label>
|
| 37 |
</div>
|
|
|
|
| 44 |
</section>
|
| 45 |
`;
|
| 46 |
|
| 47 |
+
const emailInput = container.querySelector("#formKolEmail");
|
| 48 |
const modelSelect = container.querySelector("#formRingModel");
|
| 49 |
const errorEl = container.querySelector("#formError");
|
| 50 |
|
| 51 |
// Pre-fill from session — back-nav from upload/camera should not
|
| 52 |
// ask the user to retype.
|
| 53 |
+
emailInput.value = session.kolEmail || "";
|
| 54 |
modelSelect.value = session.ringModel || "gen";
|
| 55 |
|
| 56 |
+
// Loose RFC-ish check — `something@something.tld`. The browser's
|
| 57 |
+
// native type="email" validity is the first line of defense; this
|
| 58 |
+
// guards against the field being left blank or obviously malformed
|
| 59 |
+
// since email is the cross-table join key.
|
| 60 |
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
| 61 |
+
|
| 62 |
container.querySelector(".step-back").addEventListener("click", nav.back);
|
| 63 |
container.querySelector(".step-next").addEventListener("click", () => {
|
| 64 |
+
const email = emailInput.value.trim().toLowerCase();
|
| 65 |
+
if (!EMAIL_RE.test(email)) {
|
| 66 |
+
errorEl.textContent = "Please enter a valid email before continuing.";
|
| 67 |
errorEl.hidden = false;
|
| 68 |
+
emailInput.focus();
|
| 69 |
return;
|
| 70 |
}
|
| 71 |
+
session.kolEmail = email;
|
| 72 |
session.ringModel = modelSelect.value;
|
| 73 |
nav.next();
|
| 74 |
});
|
web_demo/static/shared/measure-api.js
CHANGED
|
@@ -20,17 +20,17 @@ const DEFAULTS = {
|
|
| 20 |
|
| 21 |
export async function postMeasure({
|
| 22 |
blob,
|
| 23 |
-
|
| 24 |
gate_telemetry = null,
|
| 25 |
...overrides
|
| 26 |
} = {}) {
|
| 27 |
if (!blob) throw new Error("postMeasure: blob is required");
|
| 28 |
-
if (!
|
| 29 |
|
| 30 |
const settings = { ...DEFAULTS, ...overrides };
|
| 31 |
const formData = new FormData();
|
| 32 |
formData.append("image", blob, "capture.jpg");
|
| 33 |
-
formData.append("
|
| 34 |
formData.append("finger_index", settings.finger_index);
|
| 35 |
formData.append("mode", settings.mode);
|
| 36 |
formData.append("edge_method", settings.edge_method);
|
|
|
|
| 20 |
|
| 21 |
export async function postMeasure({
|
| 22 |
blob,
|
| 23 |
+
kol_email,
|
| 24 |
gate_telemetry = null,
|
| 25 |
...overrides
|
| 26 |
} = {}) {
|
| 27 |
if (!blob) throw new Error("postMeasure: blob is required");
|
| 28 |
+
if (!kol_email) throw new Error("postMeasure: kol_email is required");
|
| 29 |
|
| 30 |
const settings = { ...DEFAULTS, ...overrides };
|
| 31 |
const formData = new FormData();
|
| 32 |
formData.append("image", blob, "capture.jpg");
|
| 33 |
+
formData.append("kol_email", kol_email);
|
| 34 |
formData.append("finger_index", settings.finger_index);
|
| 35 |
formData.append("mode", settings.mode);
|
| 36 |
formData.append("edge_method", settings.edge_method);
|
web_demo/static/styles.css
CHANGED
|
@@ -169,7 +169,8 @@ body {
|
|
| 169 |
}
|
| 170 |
|
| 171 |
select,
|
| 172 |
-
.controls input[type="text"]
|
|
|
|
| 173 |
border: 1px solid var(--border);
|
| 174 |
border-radius: 12px;
|
| 175 |
padding: 10px 12px;
|
|
|
|
| 169 |
}
|
| 170 |
|
| 171 |
select,
|
| 172 |
+
.controls input[type="text"],
|
| 173 |
+
.controls input[type="email"] {
|
| 174 |
border: 1px solid var(--border);
|
| 175 |
border-radius: 12px;
|
| 176 |
padding: 10px 12px;
|
web_demo/supabase_client.py
CHANGED
|
@@ -53,6 +53,14 @@ def _get_client():
|
|
| 53 |
return _client
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
BUCKET = "ring-measurements"
|
| 57 |
|
| 58 |
|
|
@@ -101,6 +109,46 @@ def save_measurement(record: Dict[str, Any]) -> Optional[str]:
|
|
| 101 |
return None
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
|
| 105 |
"""Fetch measurements for admin page, newest first."""
|
| 106 |
client = _get_client()
|
|
@@ -121,7 +169,7 @@ def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]
|
|
| 121 |
|
| 122 |
|
| 123 |
STATS_COLUMNS = (
|
| 124 |
-
"id,created_at,kol_name,mode,ring_model,confidence,fail_reason,"
|
| 125 |
"overall_best_size,ring_fit,gt_index_size,gt_middle_size,gt_ring_size,"
|
| 126 |
"finger_index,photo_url,per_finger,feedback_rating,feedback_message"
|
| 127 |
)
|
|
|
|
| 53 |
return _client
|
| 54 |
|
| 55 |
|
| 56 |
+
def persistence_enabled() -> bool:
|
| 57 |
+
"""True when Supabase persistence is active (env configured and not
|
| 58 |
+
opted out via RING_DISABLE_SUPABASE). Lets an endpoint distinguish
|
| 59 |
+
'disabled in this env' (still report success to the user) from a real
|
| 60 |
+
write failure (surface an error)."""
|
| 61 |
+
return _get_client() is not None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
BUCKET = "ring-measurements"
|
| 65 |
|
| 66 |
|
|
|
|
| 109 |
return None
|
| 110 |
|
| 111 |
|
| 112 |
+
def save_feedback(record: Dict[str, Any]) -> Optional[str]:
|
| 113 |
+
"""Insert a post-shipment fit-feedback row into the `feedback` table.
|
| 114 |
+
|
| 115 |
+
Intentionally decoupled from `measurements` (no run_id, no FK) — the
|
| 116 |
+
only link between the two is the normalized `kol_email`, joined at
|
| 117 |
+
analysis time. See doc/v8/PRD.md. Returns row UUID or None (None also
|
| 118 |
+
when persistence is disabled, which the caller treats as success).
|
| 119 |
+
"""
|
| 120 |
+
client = _get_client()
|
| 121 |
+
if client is None:
|
| 122 |
+
return None
|
| 123 |
+
try:
|
| 124 |
+
resp = client.table("feedback").insert(record).execute()
|
| 125 |
+
if resp.data and len(resp.data) > 0:
|
| 126 |
+
return resp.data[0].get("id")
|
| 127 |
+
return None
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error("Failed to save feedback: %s", e)
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def list_feedback(limit: int = 500, offset: int = 0) -> List[Dict[str, Any]]:
|
| 134 |
+
"""Fetch post-shipment feedback rows for the admin page, newest first."""
|
| 135 |
+
client = _get_client()
|
| 136 |
+
if client is None:
|
| 137 |
+
return []
|
| 138 |
+
try:
|
| 139 |
+
resp = (
|
| 140 |
+
client.table("feedback")
|
| 141 |
+
.select("*")
|
| 142 |
+
.order("submitted_at", desc=True)
|
| 143 |
+
.range(offset, offset + limit - 1)
|
| 144 |
+
.execute()
|
| 145 |
+
)
|
| 146 |
+
return resp.data or []
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.error("Failed to list feedback: %s", e)
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
|
| 152 |
def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
|
| 153 |
"""Fetch measurements for admin page, newest first."""
|
| 154 |
client = _get_client()
|
|
|
|
| 169 |
|
| 170 |
|
| 171 |
STATS_COLUMNS = (
|
| 172 |
+
"id,created_at,kol_name,kol_email,mode,ring_model,confidence,fail_reason,"
|
| 173 |
"overall_best_size,ring_fit,gt_index_size,gt_middle_size,gt_ring_size,"
|
| 174 |
"finger_index,photo_url,per_finger,feedback_rating,feedback_message"
|
| 175 |
)
|
web_demo/templates/admin.html
CHANGED
|
@@ -175,6 +175,7 @@
|
|
| 175 |
<div class="tabs">
|
| 176 |
<button class="tab active" data-pane="recordsPane">Records</button>
|
| 177 |
<button class="tab" data-pane="dashboardPane">Dashboard</button>
|
|
|
|
| 178 |
</div>
|
| 179 |
|
| 180 |
<!-- Records pane -->
|
|
@@ -265,6 +266,36 @@
|
|
| 265 |
</div>
|
| 266 |
</div>
|
| 267 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
</div>
|
| 269 |
|
| 270 |
<script>
|
|
@@ -294,6 +325,7 @@
|
|
| 294 |
document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
|
| 295 |
loadStats();
|
| 296 |
loadData();
|
|
|
|
| 297 |
return true;
|
| 298 |
};
|
| 299 |
|
|
@@ -357,7 +389,7 @@
|
|
| 357 |
? `<img class="thumb" loading="lazy" src="${r.result_url}" onclick="window.open('${r.result_url}')" />`
|
| 358 |
: "-";
|
| 359 |
return `<tr data-id="${r.id}">
|
| 360 |
-
<td><strong>${r.kol_name || "-"}</strong></td>
|
| 361 |
<td>${fmtDate(r.created_at)}</td>
|
| 362 |
<td>${r.ring_model || "-"}</td>
|
| 363 |
<td>${photoThumb}</td>
|
|
@@ -445,6 +477,68 @@
|
|
| 445 |
});
|
| 446 |
});
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
// ------------------------------------------------------------------
|
| 449 |
// Dashboard
|
| 450 |
// ------------------------------------------------------------------
|
|
@@ -504,7 +598,7 @@
|
|
| 504 |
}
|
| 505 |
topKolList.innerHTML = kols.map((k) => {
|
| 506 |
const last = k.last_at ? new Date(k.last_at).toISOString().slice(0, 10) : "";
|
| 507 |
-
return `<li><span class="name">${k.
|
| 508 |
}).join("");
|
| 509 |
};
|
| 510 |
|
|
|
|
| 175 |
<div class="tabs">
|
| 176 |
<button class="tab active" data-pane="recordsPane">Records</button>
|
| 177 |
<button class="tab" data-pane="dashboardPane">Dashboard</button>
|
| 178 |
+
<button class="tab" data-pane="feedbackPane">Feedback</button>
|
| 179 |
</div>
|
| 180 |
|
| 181 |
<!-- Records pane -->
|
|
|
|
| 266 |
</div>
|
| 267 |
</div>
|
| 268 |
</div>
|
| 269 |
+
|
| 270 |
+
<!-- Feedback pane (v8 post-shipment fit feedback) -->
|
| 271 |
+
<div class="pane" id="feedbackPane">
|
| 272 |
+
<div class="toolbar">
|
| 273 |
+
<a href="/">Back to Demo</a>
|
| 274 |
+
<button id="fbRefreshBtn">Refresh</button>
|
| 275 |
+
<span class="count" id="fbCountLabel">Loading...</span>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div class="scroll-wrap">
|
| 279 |
+
<table>
|
| 280 |
+
<thead>
|
| 281 |
+
<tr>
|
| 282 |
+
<th>Email</th>
|
| 283 |
+
<th>Submitted</th>
|
| 284 |
+
<th>Size</th>
|
| 285 |
+
<th>Model</th>
|
| 286 |
+
<th>Best finger</th>
|
| 287 |
+
<th>Fit</th>
|
| 288 |
+
<th>Hand</th>
|
| 289 |
+
<th>Photo</th>
|
| 290 |
+
<th>Notes</th>
|
| 291 |
+
</tr>
|
| 292 |
+
</thead>
|
| 293 |
+
<tbody id="fbTableBody">
|
| 294 |
+
<tr><td colspan="9" class="empty">Loading...</td></tr>
|
| 295 |
+
</tbody>
|
| 296 |
+
</table>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
</div>
|
| 300 |
|
| 301 |
<script>
|
|
|
|
| 325 |
document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
|
| 326 |
loadStats();
|
| 327 |
loadData();
|
| 328 |
+
loadFeedback();
|
| 329 |
return true;
|
| 330 |
};
|
| 331 |
|
|
|
|
| 389 |
? `<img class="thumb" loading="lazy" src="${r.result_url}" onclick="window.open('${r.result_url}')" />`
|
| 390 |
: "-";
|
| 391 |
return `<tr data-id="${r.id}">
|
| 392 |
+
<td><strong>${r.kol_email || r.kol_name || "-"}</strong></td>
|
| 393 |
<td>${fmtDate(r.created_at)}</td>
|
| 394 |
<td>${r.ring_model || "-"}</td>
|
| 395 |
<td>${photoThumb}</td>
|
|
|
|
| 477 |
});
|
| 478 |
});
|
| 479 |
|
| 480 |
+
// ------------------------------------------------------------------
|
| 481 |
+
// Feedback (v8 post-shipment fit feedback)
|
| 482 |
+
// ------------------------------------------------------------------
|
| 483 |
+
const fbTableBody = document.getElementById("fbTableBody");
|
| 484 |
+
const fbCountLabel = document.getElementById("fbCountLabel");
|
| 485 |
+
|
| 486 |
+
// Map the stored value codes back to human labels (mirrors the
|
| 487 |
+
// /feedback form's <option> text).
|
| 488 |
+
const FB_MODEL = { gen: "Gen1/Gen2", air: "Air" };
|
| 489 |
+
const FB_FINGER = {
|
| 490 |
+
thumb: "Thumb", index: "Index", middle: "Middle", ring: "Ring",
|
| 491 |
+
pinky: "Pinky", none_too_small: "None (too small)", none_too_big: "None (too big)",
|
| 492 |
+
};
|
| 493 |
+
const FB_FIT = {
|
| 494 |
+
perfect: "Perfect", snug: "Snug", loose: "Loose but stays on",
|
| 495 |
+
falls_off: "Falls off", too_tight: "Can't get it on",
|
| 496 |
+
};
|
| 497 |
+
const FB_HAND = { left: "Left", right: "Right" };
|
| 498 |
+
const fbLabel = (map, v) => (v ? (map[v] || v) : "-");
|
| 499 |
+
|
| 500 |
+
const fbRowHtml = (r) => {
|
| 501 |
+
const photoThumb = r.photo_url
|
| 502 |
+
? `<img class="thumb" loading="lazy" src="${esc(r.photo_url)}" onclick="window.open('${esc(r.photo_url)}')" />`
|
| 503 |
+
: "-";
|
| 504 |
+
return `<tr>
|
| 505 |
+
<td><strong>${esc(r.kol_email) || "-"}</strong></td>
|
| 506 |
+
<td>${fmtDate(r.submitted_at)}</td>
|
| 507 |
+
<td>${esc(r.received_size) || "-"}</td>
|
| 508 |
+
<td>${fbLabel(FB_MODEL, r.received_model)}</td>
|
| 509 |
+
<td>${fbLabel(FB_FINGER, r.best_fit_finger)}</td>
|
| 510 |
+
<td>${fbLabel(FB_FIT, r.fit_quality)}</td>
|
| 511 |
+
<td>${fbLabel(FB_HAND, r.hand)}</td>
|
| 512 |
+
<td>${photoThumb}</td>
|
| 513 |
+
<td>${fmtComment(r.notes)}</td>
|
| 514 |
+
</tr>`;
|
| 515 |
+
};
|
| 516 |
+
|
| 517 |
+
const loadFeedback = async () => {
|
| 518 |
+
try {
|
| 519 |
+
const resp = await fetch(`/api/admin/feedback?token=${encodeURIComponent(adminToken)}`);
|
| 520 |
+
if (resp.status === 401) {
|
| 521 |
+
sessionStorage.removeItem("admin_token");
|
| 522 |
+
loginGate.style.display = "";
|
| 523 |
+
adminContent.style.display = "none";
|
| 524 |
+
loginError.textContent = "Session expired. Please log in again.";
|
| 525 |
+
return;
|
| 526 |
+
}
|
| 527 |
+
const rows = await resp.json();
|
| 528 |
+
if (!rows.length) {
|
| 529 |
+
fbCountLabel.textContent = "0 feedback rows";
|
| 530 |
+
fbTableBody.innerHTML = '<tr><td colspan="9" class="empty">No feedback yet</td></tr>';
|
| 531 |
+
return;
|
| 532 |
+
}
|
| 533 |
+
fbCountLabel.textContent = `${rows.length} feedback rows`;
|
| 534 |
+
fbTableBody.innerHTML = rows.map(fbRowHtml).join("");
|
| 535 |
+
} catch (e) {
|
| 536 |
+
fbTableBody.innerHTML = `<tr><td colspan="9" class="empty">Error loading feedback: ${e.message}</td></tr>`;
|
| 537 |
+
}
|
| 538 |
+
};
|
| 539 |
+
|
| 540 |
+
document.getElementById("fbRefreshBtn").addEventListener("click", loadFeedback);
|
| 541 |
+
|
| 542 |
// ------------------------------------------------------------------
|
| 543 |
// Dashboard
|
| 544 |
// ------------------------------------------------------------------
|
|
|
|
| 598 |
}
|
| 599 |
topKolList.innerHTML = kols.map((k) => {
|
| 600 |
const last = k.last_at ? new Date(k.last_at).toISOString().slice(0, 10) : "";
|
| 601 |
+
return `<li><span class="name">${k.kol_ident}</span><span class="count">${k.count} · ${last}</span></li>`;
|
| 602 |
}).join("");
|
| 603 |
};
|
| 604 |
|
web_demo/templates/feedback.html
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
| 6 |
+
<meta name="theme-color" content="#f5f1e7" />
|
| 7 |
+
<title>Ring Fit Feedback — Femometer Smart Ring</title>
|
| 8 |
+
<meta name="description" content="Tell us how your ring fit so we can improve sizing." />
|
| 9 |
+
<meta name="robots" content="noindex" />
|
| 10 |
+
<!-- Reuse the /m flow's stylesheet so this form matches the mobile
|
| 11 |
+
capture flow's look; feedback.css only adds the success screen +
|
| 12 |
+
a couple of form niceties the step CSS doesn't cover. -->
|
| 13 |
+
<link rel="stylesheet" href="/static/mobile/mobile.css" />
|
| 14 |
+
<link rel="stylesheet" href="/static/feedback/feedback.css" />
|
| 15 |
+
</head>
|
| 16 |
+
<body>
|
| 17 |
+
<main class="step step-feedback">
|
| 18 |
+
<!-- Form view -->
|
| 19 |
+
<div class="step-body" id="formView">
|
| 20 |
+
<p class="hero-eyebrow">Femometer Smart Ring</p>
|
| 21 |
+
<h1 class="hero-headline">How did your ring fit?</h1>
|
| 22 |
+
|
| 23 |
+
<form class="panel" id="feedbackForm" novalidate>
|
| 24 |
+
<div class="controls">
|
| 25 |
+
<label>
|
| 26 |
+
<span>Email</span>
|
| 27 |
+
<input
|
| 28 |
+
type="email"
|
| 29 |
+
id="kolEmail"
|
| 30 |
+
placeholder="you@example.com"
|
| 31 |
+
autocomplete="email"
|
| 32 |
+
inputmode="email"
|
| 33 |
+
required
|
| 34 |
+
/>
|
| 35 |
+
</label>
|
| 36 |
+
|
| 37 |
+
<label>
|
| 38 |
+
<span>Ring size received</span>
|
| 39 |
+
<input
|
| 40 |
+
type="text"
|
| 41 |
+
id="receivedSize"
|
| 42 |
+
placeholder="e.g. 7 (read it off the package)"
|
| 43 |
+
autocomplete="off"
|
| 44 |
+
/>
|
| 45 |
+
</label>
|
| 46 |
+
|
| 47 |
+
<label>
|
| 48 |
+
<span>Ring model</span>
|
| 49 |
+
<select id="receivedModel">
|
| 50 |
+
<option value="" disabled selected>Choose…</option>
|
| 51 |
+
<option value="gen">Gen1/Gen2</option>
|
| 52 |
+
<option value="air">Air</option>
|
| 53 |
+
</select>
|
| 54 |
+
</label>
|
| 55 |
+
|
| 56 |
+
<label>
|
| 57 |
+
<span>Which finger did it fit best?</span>
|
| 58 |
+
<select id="bestFitFinger">
|
| 59 |
+
<option value="" disabled selected>Choose…</option>
|
| 60 |
+
<option value="index">Index</option>
|
| 61 |
+
<option value="middle">Middle</option>
|
| 62 |
+
<option value="ring">Ring</option>
|
| 63 |
+
<option value="pinky">Pinky</option>
|
| 64 |
+
<option value="thumb">Thumb</option>
|
| 65 |
+
<option value="none_too_small">None — too small for all</option>
|
| 66 |
+
<option value="none_too_big">None — too big for all</option>
|
| 67 |
+
</select>
|
| 68 |
+
</label>
|
| 69 |
+
|
| 70 |
+
<label>
|
| 71 |
+
<span>How did it fit on that finger?</span>
|
| 72 |
+
<select id="fitQuality">
|
| 73 |
+
<option value="" disabled selected>Choose…</option>
|
| 74 |
+
<option value="perfect">Perfect</option>
|
| 75 |
+
<option value="snug">Snug</option>
|
| 76 |
+
<option value="loose">Loose but stays on</option>
|
| 77 |
+
<option value="falls_off">Falls off</option>
|
| 78 |
+
<option value="too_tight">Can't get it on</option>
|
| 79 |
+
</select>
|
| 80 |
+
</label>
|
| 81 |
+
|
| 82 |
+
<label>
|
| 83 |
+
<span>Which hand?</span>
|
| 84 |
+
<select id="hand">
|
| 85 |
+
<option value="" disabled selected>Choose…</option>
|
| 86 |
+
<option value="left">Left</option>
|
| 87 |
+
<option value="right">Right</option>
|
| 88 |
+
</select>
|
| 89 |
+
</label>
|
| 90 |
+
|
| 91 |
+
<label>
|
| 92 |
+
<span>Photo of the ring on your finger (optional)</span>
|
| 93 |
+
<input type="file" id="photo" accept="image/*" />
|
| 94 |
+
</label>
|
| 95 |
+
|
| 96 |
+
<label>
|
| 97 |
+
<span>Anything else? (optional)</span>
|
| 98 |
+
<textarea
|
| 99 |
+
id="notes"
|
| 100 |
+
rows="3"
|
| 101 |
+
placeholder="Tell us anything the questions above missed."
|
| 102 |
+
></textarea>
|
| 103 |
+
</label>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<p class="form-error" id="formError" hidden></p>
|
| 107 |
+
</form>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div class="step-foot" id="formFoot">
|
| 111 |
+
<button type="submit" class="primary" id="submitBtn" form="feedbackForm">
|
| 112 |
+
Submit feedback
|
| 113 |
+
</button>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<!-- Success view (swapped in on submit) -->
|
| 117 |
+
<div class="step-body fb-success" id="successView" hidden>
|
| 118 |
+
<div class="fb-success-inner">
|
| 119 |
+
<div class="fb-success-check">✓</div>
|
| 120 |
+
<h1 class="hero-headline">Thank you!</h1>
|
| 121 |
+
<p class="hero-sub">
|
| 122 |
+
Your feedback is in. It directly helps us improve ring-size
|
| 123 |
+
accuracy.
|
| 124 |
+
</p>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</main>
|
| 128 |
+
|
| 129 |
+
<script type="module" src="/static/feedback/feedback.js"></script>
|
| 130 |
+
</body>
|
| 131 |
+
</html>
|
web_demo/templates/index.html
CHANGED
|
@@ -43,8 +43,8 @@
|
|
| 43 |
|
| 44 |
<div class="controls">
|
| 45 |
<label>
|
| 46 |
-
<span>
|
| 47 |
-
<input type="
|
| 48 |
</label>
|
| 49 |
<label>
|
| 50 |
<span>Ring Model</span>
|
|
|
|
| 43 |
|
| 44 |
<div class="controls">
|
| 45 |
<label>
|
| 46 |
+
<span>Email</span>
|
| 47 |
+
<input type="email" name="kol_email" id="kolEmailInput" placeholder="you@example.com" autocomplete="email" inputmode="email" />
|
| 48 |
</label>
|
| 49 |
<label>
|
| 50 |
<span>Ring Model</span>
|