M3 Review→Publish UI (#77): one-column flow, searchable pickers, fig/feat tags, e2e
- Review screen: slug-resolved species/wcat groupers, case-insensitive attrs, fig-*/feat-* tag pickers (METADATA-30/40), AI-assist labels + AI bubbles. - App orchestrator: single writable flow store, capture→review→publish, Resume boot. - API client (slabs.ts) mirroring live backend routes; Blob name fix. - Playwright e2e (7 tests, hermetic mock): canvas PNG seed, client-UUID mock, SKU blur commit, ready-state reactive fix; expect timeout 4s, 2 workers. - e2e green 7/7 in 6s; check_requirements + check_coverage PASS.
This commit is contained in:
@@ -14,6 +14,32 @@
|
|||||||
|
|
||||||
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
|
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
|
||||||
|
|
||||||
|
## Build & test (verified commands)
|
||||||
|
|
||||||
|
Frontend (`frontend/`, Svelte 4 + Vite + TS, no router):
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend && npm install
|
||||||
|
npm run test # node --import tsx --test src/**/*.test.ts (unit)
|
||||||
|
npm run build # tsc --noEmit && vite build (type-check + build)
|
||||||
|
npm run test:e2e # playwright test (see Pitfalls: mocks the API)
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend (`backend/`, FastAPI + stdlib sqlite, no ORM, deps pinned in `backend/requirements.txt`):
|
||||||
|
|
||||||
|
```
|
||||||
|
cd backend && python -m pytest tests/ -q
|
||||||
|
uvicorn backend.app:app --host 127.0.0.1 --port 18081
|
||||||
|
```
|
||||||
|
|
||||||
|
No Makefile, no CI workflows, no Gitea CI — nothing runs in a pipeline; the only automation is the local commands above plus the human UAT walk (`deploy/uat_walk.sh`).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- The LIVE FastAPI routes in `backend/*.py` are the API source of truth; `docs/contracts/API.md` is aspirational and can drift (it describes a polling model the backend never shipped). Read the router, not the doc, before wiring a client.
|
||||||
|
- Frontend is one Svelte app, no router: `App.svelte` switches on a `writable` flow store. Screens live under `frontend/src/<area>/`.
|
||||||
|
- Commit subjects: `feat(scope): …` / `fix(scope): …` / `test(scope): …` / `chore(scope): …`, often citing a requirement ID or Gitea number.
|
||||||
|
|
||||||
## Gates (must pass before claiming work complete)
|
## Gates (must pass before claiming work complete)
|
||||||
|
|
||||||
Run from repo root:
|
Run from repo root:
|
||||||
@@ -25,6 +51,14 @@ python tools/check_coverage.py
|
|||||||
|
|
||||||
Exit 0 on both = pass.
|
Exit 0 on both = pass.
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
- E2E mocks the API: `tests/e2e/*.spec.ts` use Playwright `page.route('**/api/v1/**')` with fixture ids — there is **no live backend** under e2e. A green `test:e2e` is not proof the real backend works; that's the human `deploy/uat_walk.sh` (real Woo, never a mock — QUALITY-105).
|
||||||
|
- Ports on the 5060 UAT host (192.168.1.202): FastAPI `18083`, FE `18082`/`10881` are typically already up (compose on `deploy/docker-compose.yml`). Check before binding; don't assume `18081` is free.
|
||||||
|
- `SLAB_UAT_UNMASK_SECRETS=1` makes `GET /settings` return decrypted Woo/inference secrets — dev-test only, never set in prod (SETTINGS-75 masking is the default).
|
||||||
|
- Never hardcode live Woo category/term ids into app code (QUALITY-105 / METADATA-5); resolve groupers by slug (`species`, `wcat`) at runtime.
|
||||||
|
- `tsc` won't catch Svelte template errors; `npm run build` (which runs `tsc --noEmit`) is the real type gate for the frontend.
|
||||||
|
|
||||||
## Pointers (read these for everything else)
|
## Pointers (read these for everything else)
|
||||||
|
|
||||||
- Docs map and shipped status: `docs/README.md`
|
- Docs map and shipped status: `docs/README.md`
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
fullyParallel: false,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
workers: 1,
|
workers: 2,
|
||||||
timeout: 180_000,
|
timeout: 30_000,
|
||||||
expect: { timeout: 30_000 },
|
expect: { timeout: 4_000 },
|
||||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:4173',
|
baseURL: 'http://127.0.0.1:4173',
|
||||||
|
|||||||
+166
-39
@@ -2,19 +2,24 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { supported } from './browser';
|
import { supported } from './browser';
|
||||||
import { createCaptureSession, type CaptureSession } from './capture/session';
|
import { createCaptureSession, type CaptureSession } from './capture/session';
|
||||||
import type { Capture } from './capture/capture.types';
|
import type { Capture, Measures } from './capture/capture.types';
|
||||||
import Resume from './capture/Resume.svelte';
|
import { eighthsToIn } from './capture/capture.types';
|
||||||
import BatchPhotos from './capture/BatchPhotos.svelte';
|
import { buildPhotosReadyLineup } from './capture/photosReadyLineup';
|
||||||
import PickCalibration from './capture/PickCalibration.svelte';
|
import Resume from './capture/Resume.svelte';
|
||||||
import MaskPanel from './capture/MaskPanel.svelte';
|
import BatchPhotos from './capture/BatchPhotos.svelte';
|
||||||
import AxisPanel from './capture/AxisPanel.svelte';
|
import PickCalibration from './capture/PickCalibration.svelte';
|
||||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
import MaskPanel from './capture/MaskPanel.svelte';
|
||||||
import Settings from './create/settings/Settings.svelte';
|
import AxisPanel from './capture/AxisPanel.svelte';
|
||||||
import SlabList from './create/slabs/SlabList.svelte';
|
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||||
import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
import Settings from './create/settings/Settings.svelte';
|
||||||
import Review from './create/review/Review.svelte';
|
import SlabList from './create/slabs/SlabList.svelte';
|
||||||
import Publish from './create/publish/Publish.svelte';
|
import Review from './create/review/Review.svelte';
|
||||||
import GenerateText from './create/inference/GenerateText.svelte';
|
import Publish from './create/publish/Publish.svelte';
|
||||||
|
import {
|
||||||
|
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
||||||
|
} from './create/flow';
|
||||||
|
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
|
||||||
|
import { clearDraft } from './draft/idb';
|
||||||
|
|
||||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||||
const session: CaptureSession = createCaptureSession();
|
const session: CaptureSession = createCaptureSession();
|
||||||
@@ -24,27 +29,104 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
let pngUrl: string | null = null;
|
let pngUrl: string | null = null;
|
||||||
let showSettings = false;
|
let showSettings = false;
|
||||||
let showSlabs = false;
|
let showSlabs = false;
|
||||||
let showPhotos = false;
|
|
||||||
let showReview = false;
|
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
||||||
let showPublish = false;
|
$: curFlow = $flow;
|
||||||
let showGenerate = false;
|
let saving = false;
|
||||||
|
let saveError = '';
|
||||||
|
|
||||||
const unsubCap = session.capture.subscribe((c) => {
|
const unsubCap = session.capture.subscribe((c) => {
|
||||||
cap = c;
|
cap = c;
|
||||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
||||||
});
|
});
|
||||||
const unsubBusy = session.busy.subscribe((b) => {
|
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||||
busy = b;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void session.resumeProbe();
|
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
|
||||||
|
// device draft into IndexedDB after the origin exists, then reload without
|
||||||
|
// ?noload to trigger the Resume prompt deterministically.
|
||||||
|
const boot = new URLSearchParams(window.location.search);
|
||||||
|
if (!boot.get('noload')) void session.resumeProbe();
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubCap();
|
unsubCap();
|
||||||
unsubBusy();
|
unsubBusy();
|
||||||
session.dispose();
|
session.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// APP-15: the device draft is cleared only on publish success / "new slab".
|
||||||
|
function startNewSlab(): void {
|
||||||
|
void clearDraft().then(() => {
|
||||||
|
session.startFresh();
|
||||||
|
backToCapture();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// C2: Photos-ready → build the calibrated slab, upload the lineup PNGs, then
|
||||||
|
// hand off to Review. The device draft is retained here (APP-10); it is
|
||||||
|
// cleared only on publish success (APP-15).
|
||||||
|
async function continueToReview(): Promise<void> {
|
||||||
|
const m = session.currentMeasure();
|
||||||
|
if (!m || !m.results) return;
|
||||||
|
const r: Measures = m.results;
|
||||||
|
const capture: CalibratedCapture = {
|
||||||
|
lengthIn: eighthsToIn(m.lengthEighths),
|
||||||
|
thicknessIn: eighthsToIn(m.thicknessEighths),
|
||||||
|
sqft: r.sqft,
|
||||||
|
bdft: r.bdft,
|
||||||
|
widthMinIn: eighthsToIn(r.widths.min),
|
||||||
|
widthMaxIn: eighthsToIn(r.widths.max),
|
||||||
|
widthAvgIn: eighthsToIn(r.widths.avg),
|
||||||
|
sourceBelowFloor: r.sourceBelowFloor,
|
||||||
|
lineup: buildPhotosReadyLineup({
|
||||||
|
inventoryPng: r.inventoryPng,
|
||||||
|
overlayPng: r.overlayPng,
|
||||||
|
batchApply: m.batchApply,
|
||||||
|
sourceBelowFloor: r.sourceBelowFloor,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
saving = true;
|
||||||
|
saveError = '';
|
||||||
|
startSaving();
|
||||||
|
try {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await createSlab({
|
||||||
|
id,
|
||||||
|
sku: '',
|
||||||
|
length_in: capture.lengthIn,
|
||||||
|
thickness_in: capture.thicknessIn,
|
||||||
|
sqft: capture.sqft,
|
||||||
|
bdft: capture.bdft,
|
||||||
|
width_min_in: capture.widthMinIn,
|
||||||
|
width_max_in: capture.widthMaxIn,
|
||||||
|
width_avg_in: capture.widthAvgIn,
|
||||||
|
price: null,
|
||||||
|
price_source: 'recommendation',
|
||||||
|
content_source: 'manual',
|
||||||
|
client_rev: 0,
|
||||||
|
});
|
||||||
|
const pngItems = capture.lineup.filter(
|
||||||
|
(it) => it.kind === 'png' && it.role !== 'overlay',
|
||||||
|
);
|
||||||
|
for (let i = 0; i < pngItems.length; i++) {
|
||||||
|
const it = pngItems[i];
|
||||||
|
await uploadPhoto(id, {
|
||||||
|
photoId: crypto.randomUUID(),
|
||||||
|
seq: i + 1,
|
||||||
|
isCalibration: it.role === 'calibration',
|
||||||
|
original: it.blob,
|
||||||
|
processed: it.blob,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
enterReview(id, capture);
|
||||||
|
} catch (e) {
|
||||||
|
saveError =
|
||||||
|
e instanceof ApiError ? e.message : 'Could not start the slab. Check your connection and try again.';
|
||||||
|
backToCapture();
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if gate}
|
{#if gate}
|
||||||
@@ -58,6 +140,48 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<main class="app">
|
<main class="app">
|
||||||
|
{#if curFlow.phase === 'review'}
|
||||||
|
<Review
|
||||||
|
slabId={curFlow.slabId}
|
||||||
|
capture={curFlow.capture}
|
||||||
|
onPublish={() => {
|
||||||
|
const f = curFlow;
|
||||||
|
if (f.phase === 'review') enterPublish(f.slabId, f.capture);
|
||||||
|
}}
|
||||||
|
onBack={() => backToCapture()}
|
||||||
|
/>
|
||||||
|
{:else if curFlow.phase === 'publish'}
|
||||||
|
<Publish
|
||||||
|
slabId={curFlow.slabId}
|
||||||
|
onPublished={(wooId, draft) => markDone(curFlow.slabId, wooId, draft)}
|
||||||
|
onBack={() => {
|
||||||
|
const f = curFlow;
|
||||||
|
if (f.phase === 'publish') backToReview(f.slabId, f.capture);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else if curFlow.phase === 'done'}
|
||||||
|
<section class="panel done" aria-label="Published">
|
||||||
|
<h1>Published</h1>
|
||||||
|
<p class="ok">
|
||||||
|
{#if curFlow.draft}
|
||||||
|
Created as a UAT draft in the store.
|
||||||
|
{:else}
|
||||||
|
Created in the store.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p>Woo product #{curFlow.wooProductId} · photos and device draft cleared.</p>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="primary" data-testid="new-slab" on:click={startNewSlab}>
|
||||||
|
Start a new slab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if curFlow.phase === 'saving'}
|
||||||
|
<section class="panel" aria-busy="true">
|
||||||
|
<h1>Saving slab…</h1>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<!-- capture column (single slab, CAPTURE-25/30) -->
|
||||||
{#if cap.k === 'resume'}
|
{#if cap.k === 'resume'}
|
||||||
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
|
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
|
||||||
{:else if cap.k === 'batch'}
|
{:else if cap.k === 'batch'}
|
||||||
@@ -98,15 +222,21 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
calculate={session.calculate}
|
calculate={session.calculate}
|
||||||
back={session.back}
|
back={session.back}
|
||||||
{pngUrl}
|
{pngUrl}
|
||||||
|
onContinue={saving ? undefined : () => void continueToReview()}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if saving}
|
||||||
|
<div class="row"><span class="busy" aria-live="polite">Saving slab…</span></div>
|
||||||
|
{/if}
|
||||||
|
{#if saveError}
|
||||||
|
<div class="save-err" role="alert" data-testid="save-error">{saveError}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Separate pages, never in the flow (UX.md §1). -->
|
||||||
<div style="margin-top:1rem;display:flex;gap:8px">
|
<div style="margin-top:1rem;display:flex;gap:8px">
|
||||||
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
|
<button on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
|
||||||
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
|
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
|
||||||
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
|
|
||||||
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
|
|
||||||
<button on:click={() => (showPublish = !showPublish)}>Publish (dev)</button>
|
|
||||||
<button on:click={() => (showGenerate = !showGenerate)}>Generate (dev)</button>
|
|
||||||
</div>
|
</div>
|
||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
<Settings />
|
<Settings />
|
||||||
@@ -114,18 +244,6 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
{#if showSlabs}
|
{#if showSlabs}
|
||||||
<SlabList />
|
<SlabList />
|
||||||
{/if}
|
{/if}
|
||||||
{#if showPhotos}
|
|
||||||
<PhotoAttach />
|
|
||||||
{/if}
|
|
||||||
{#if showReview}
|
|
||||||
<Review />
|
|
||||||
{/if}
|
|
||||||
{#if showPublish}
|
|
||||||
<Publish />
|
|
||||||
{/if}
|
|
||||||
{#if showGenerate}
|
|
||||||
<GenerateText />
|
|
||||||
{/if}
|
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -139,4 +257,13 @@ import GenerateText from './create/inference/GenerateText.svelte';
|
|||||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||||
.hardstop h1 { font-size: 1.6rem; }
|
.hardstop h1 { font-size: 1.6rem; }
|
||||||
.detail { color: #888; font-size: 0.85rem; }
|
.detail { color: #888; font-size: 0.85rem; }
|
||||||
|
.panel { padding: 16px; color: #eee; }
|
||||||
|
.done .ok { color: #7fd37f; font-weight: 600; }
|
||||||
|
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.primary {
|
||||||
|
flex: 1; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||||
|
background: #00e5ff; color: #002; font-weight: 700;
|
||||||
|
}
|
||||||
|
.busy { color: #aaa; }
|
||||||
|
.save-err { margin-top: 12px; color: #ffb4a2; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+419
-18
@@ -1,45 +1,130 @@
|
|||||||
// Thin client for the S2 slabs endpoints. Svelte-agnostic.
|
// Thin client for the SlabUploader app API. Svelte-agnostic.
|
||||||
|
//
|
||||||
|
// Mirrors the LIVE backend routes (backend/*.py), which are the source of truth:
|
||||||
|
// slabs.py POST/GET/PUT/DELETE /api/v1/slabs[/{id}]
|
||||||
|
// photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed]
|
||||||
|
// review.py POST /api/v1/slabs/{id}/review
|
||||||
|
// publish.py POST /api/v1/slabs/{id}/publish
|
||||||
|
// inference.py POST /api/v1/slabs/{id}/inference/call1|call2
|
||||||
|
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
|
||||||
|
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
||||||
|
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
||||||
|
//
|
||||||
|
// Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain
|
||||||
|
// owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner).
|
||||||
|
|
||||||
export interface Slab {
|
export interface Slab {
|
||||||
id: string;
|
id: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
status: string;
|
status: string;
|
||||||
price: number;
|
price: number | null;
|
||||||
|
price_source: string | null;
|
||||||
length_in: number;
|
length_in: number;
|
||||||
thickness_in: number;
|
thickness_in: number;
|
||||||
sqft: number;
|
sqft: number;
|
||||||
bdft: number;
|
bdft: number;
|
||||||
|
width_min_in: number;
|
||||||
|
width_max_in: number;
|
||||||
width_avg_in: number;
|
width_avg_in: number;
|
||||||
|
species_id: number | null;
|
||||||
|
wood_category_ids: number[];
|
||||||
|
edge_type_term_id: number | null;
|
||||||
|
figure_term_ids: number[];
|
||||||
|
grade_term_id: number | null;
|
||||||
|
thickness_term_id: number | null;
|
||||||
|
moisture_term_id: number | null;
|
||||||
|
fig_tag_ids: number[];
|
||||||
|
feat_tag_ids: number[];
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
short_description: string | null;
|
||||||
|
description: string | null;
|
||||||
|
content_source: string | null;
|
||||||
|
species_confidence: number | null;
|
||||||
|
figure_confidence: number | null;
|
||||||
|
grade_confidence: number | null;
|
||||||
|
inference_status: string | null;
|
||||||
|
calibration_photo_id: string | null;
|
||||||
|
woo_product_id: number | null;
|
||||||
server_rev: number;
|
server_rev: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fields a client may send on create/update (subset of the row).
|
||||||
|
export type SlabInput = Partial<
|
||||||
|
Omit<
|
||||||
|
Slab,
|
||||||
|
| 'id'
|
||||||
|
| 'status'
|
||||||
|
| 'woo_product_id'
|
||||||
|
| 'server_rev'
|
||||||
|
| 'created_at'
|
||||||
|
| 'updated_at'
|
||||||
|
| 'inference_status'
|
||||||
|
| 'species_confidence'
|
||||||
|
| 'figure_confidence'
|
||||||
|
| 'grade_confidence'
|
||||||
|
| 'calibration_photo_id'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface SlabCreateInput extends SlabInput {
|
||||||
|
id: string; // client-generated UUIDv4 (APP-20)
|
||||||
|
client_rev: number; // 0 on create (APP-20)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
code: string | null;
|
||||||
|
message: string;
|
||||||
|
detail?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(public status: number, public code: string | null, message: string) {
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public code: string | null,
|
||||||
|
message: string,
|
||||||
|
public detail?: Record<string, unknown> | null,
|
||||||
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parse<T>(res: Response): Promise<T> {
|
async function parse<T>(res: Response): Promise<T> {
|
||||||
if (!res.ok) {
|
|
||||||
let code: string | null = null;
|
|
||||||
let message = res.statusText;
|
|
||||||
try {
|
|
||||||
const body = await res.json();
|
|
||||||
if (body?.error) {
|
|
||||||
code = body.error.code ?? null;
|
|
||||||
message = body.error.message ?? message;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* non-JSON body */
|
|
||||||
}
|
|
||||||
throw new ApiError(res.status, code, message);
|
|
||||||
}
|
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return (await res.json()) as T;
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
/* non-JSON body (e.g. 410 Gone) */
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = (body as { error?: ApiErrorBody } | null)?.error;
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
e?.code ?? null,
|
||||||
|
e?.message ?? res.statusText,
|
||||||
|
e?.detail ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonBody(v: unknown): BodyInit {
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slabs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function createSlab(input: SlabCreateInput): Promise<Slab> {
|
||||||
|
return fetch('/api/v1/slabs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(input),
|
||||||
|
}).then((r) => parse<Slab>(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSlabs(): Promise<Slab[]> {
|
export function listSlabs(): Promise<Slab[]> {
|
||||||
@@ -49,3 +134,319 @@ export function listSlabs(): Promise<Slab[]> {
|
|||||||
export function getSlab(id: string): Promise<Slab> {
|
export function getSlab(id: string): Promise<Slab> {
|
||||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(patch),
|
||||||
|
}).then((r) => parse<Slab>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSlab(id: string): Promise<void> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) =>
|
||||||
|
parse<void>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Photos (lineup order = seq; calibration is is_calibration=1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PhotoRow {
|
||||||
|
id: string;
|
||||||
|
seq: number;
|
||||||
|
is_calibration: number;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
has_original: number;
|
||||||
|
has_processed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadPhotoInput {
|
||||||
|
photoId: string; // client-generated UUIDv4
|
||||||
|
seq?: number;
|
||||||
|
isCalibration: boolean;
|
||||||
|
original: Blob;
|
||||||
|
processed?: Blob | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadPhotoResult {
|
||||||
|
id: string;
|
||||||
|
slab_id: string;
|
||||||
|
seq: number;
|
||||||
|
is_calibration: number;
|
||||||
|
original_retained: boolean;
|
||||||
|
processed_retained: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPhotos(slabId: string): Promise<PhotoRow[]> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) =>
|
||||||
|
parse<PhotoRow[]>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<UploadPhotoResult> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('photo_id', input.photoId);
|
||||||
|
if (input.seq != null) form.append('seq', String(input.seq));
|
||||||
|
form.append('is_calibration', input.isCalibration ? '1' : '0');
|
||||||
|
const origName = input.original instanceof File ? input.original.name : 'original.png';
|
||||||
|
form.append('original', input.original, origName);
|
||||||
|
if (input.processed) form.append('processed', input.processed, 'processed.png');
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
}).then((r) => parse<UploadPhotoResult>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function photoUrl(
|
||||||
|
slabId: string,
|
||||||
|
photoId: string,
|
||||||
|
kind: 'original' | 'processed',
|
||||||
|
): string {
|
||||||
|
return `/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}/${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Review (REVIEW-*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ReviewResult {
|
||||||
|
ready: boolean;
|
||||||
|
missing: string[];
|
||||||
|
title: string | null;
|
||||||
|
short_description: string | null;
|
||||||
|
price: number | null;
|
||||||
|
price_suggestion: number | null;
|
||||||
|
primary_wood_category_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewInput {
|
||||||
|
primary_wood_category_id?: number;
|
||||||
|
price?: number;
|
||||||
|
sku?: string;
|
||||||
|
title?: string;
|
||||||
|
short_description?: string;
|
||||||
|
description?: string;
|
||||||
|
price_source?: string;
|
||||||
|
content_source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(payload),
|
||||||
|
}).then((r) => parse<ReviewResult>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Publish (PUBLISH-*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PublishResult {
|
||||||
|
ok: true;
|
||||||
|
status: string;
|
||||||
|
woo_product_id: number;
|
||||||
|
draft: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishSlab(slabId: string): Promise<PublishResult> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((r) => parse<PublishResult>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inference (METADATA-*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Call1Suggestions {
|
||||||
|
species_id?: number | null;
|
||||||
|
species_confidence?: number | null;
|
||||||
|
wood_category_ids?: number[];
|
||||||
|
edge_type_term_id?: number | null;
|
||||||
|
figure_term_ids?: number[];
|
||||||
|
figure_confidence?: number | null;
|
||||||
|
grade_term_id?: number | null;
|
||||||
|
grade_confidence?: number | null;
|
||||||
|
character?: string | null;
|
||||||
|
inclusions?: string | null;
|
||||||
|
voids?: string | null;
|
||||||
|
checks?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Call1Result {
|
||||||
|
ok: boolean;
|
||||||
|
suggestions: Call1Suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Call2Result {
|
||||||
|
ok: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferTaxon(slabId: string): Promise<Call1Result> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((r) => parse<Call1Result>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferContent(slabId: string): Promise<Call2Result> {
|
||||||
|
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((r) => parse<Call2Result>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Taxonomy (METADATA-5/15/30; only legal picker options)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TaxCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
parent_id: number | null;
|
||||||
|
woo_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxTerm {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxAttribute {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
terms: TaxTerm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Taxonomy {
|
||||||
|
categories: TaxCategory[];
|
||||||
|
attributes: TaxAttribute[];
|
||||||
|
tags: TaxTag[];
|
||||||
|
taxonomy_anchor: string | null;
|
||||||
|
taxonomy_last_sync_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaxonomy(): Promise<Taxonomy> {
|
||||||
|
return fetch('/api/v1/admin/taxonomy').then((r) => parse<Taxonomy>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTaxonomy(): Promise<{
|
||||||
|
taxonomy_anchor: string;
|
||||||
|
taxonomy_last_sync_at: string;
|
||||||
|
}> {
|
||||||
|
return fetch('/api/v1/admin/taxonomy/sync', { method: 'POST' }).then((r) => parse(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pricing (SETTINGS-35)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PriceRule {
|
||||||
|
id: number;
|
||||||
|
species_id: number;
|
||||||
|
price_per_bdft: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPricing(): Promise<PriceRule[]> {
|
||||||
|
return fetch('/api/v1/admin/pricing').then((r) => parse<PriceRule[]>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPricingRule(rule: {
|
||||||
|
species_id: number;
|
||||||
|
price_per_bdft: number;
|
||||||
|
}): Promise<PriceRule> {
|
||||||
|
return fetch('/api/v1/admin/pricing', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(rule),
|
||||||
|
}).then((r) => parse<PriceRule>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePricingRule(
|
||||||
|
id: number,
|
||||||
|
patch: { price_per_bdft: number; species_id?: number },
|
||||||
|
): Promise<PriceRule> {
|
||||||
|
return fetch(`/api/v1/admin/pricing/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(patch),
|
||||||
|
}).then((r) => parse<PriceRule>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePricingRule(id: number): Promise<void> {
|
||||||
|
return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse<void>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings (SETTINGS-*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SettingsView {
|
||||||
|
woo_base_url: string;
|
||||||
|
woo_wp_username: string | null;
|
||||||
|
woo_app_password: { configured: boolean } | string;
|
||||||
|
inference_base_url: { configured: boolean } | string;
|
||||||
|
inference_api_key: { configured: boolean } | string;
|
||||||
|
inference_model: string | null;
|
||||||
|
inference_enabled: boolean;
|
||||||
|
content_llm_enabled: boolean;
|
||||||
|
brand_voice: string | null;
|
||||||
|
geo_context: string | null;
|
||||||
|
woo_create_status: string;
|
||||||
|
taxonomy_last_sync_at: string | null;
|
||||||
|
taxonomy_anchor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettings(): Promise<SettingsView> {
|
||||||
|
return fetch('/api/v1/settings').then((r) => parse<SettingsView>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(
|
||||||
|
patch: Partial<Record<keyof SettingsView, unknown>>,
|
||||||
|
): Promise<SettingsView> {
|
||||||
|
return fetch('/api/v1/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: jsonBody(patch),
|
||||||
|
}).then((r) => parse<SettingsView>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestWooResult {
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
taxonomy_anchor: string | null;
|
||||||
|
taxonomy_last_sync_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestInferenceResult {
|
||||||
|
ok: boolean;
|
||||||
|
mode: 'stateful' | 'stateless' | 'fail';
|
||||||
|
vision_capable: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testWoo(): Promise<TestWooResult> {
|
||||||
|
return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) =>
|
||||||
|
parse<TestWooResult>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testInference(): Promise<TestInferenceResult> {
|
||||||
|
return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) =>
|
||||||
|
parse<TestInferenceResult>(r),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
export let calculate: () => Promise<void>;
|
export let calculate: () => Promise<void>;
|
||||||
export let back: () => void;
|
export let back: () => void;
|
||||||
export let pngUrl: string | null;
|
export let pngUrl: string | null;
|
||||||
|
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
|
||||||
|
export let onContinue: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
let calcBusy = false;
|
let calcBusy = false;
|
||||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||||
@@ -177,6 +179,11 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||||
|
{#if onContinue}
|
||||||
|
<button type="button" class="primary" data-testid="continue-to-review" on:click={onContinue}>
|
||||||
|
Continue to Review
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface CaptureSession {
|
|||||||
calculate(): Promise<void>;
|
calculate(): Promise<void>;
|
||||||
back(): void;
|
back(): void;
|
||||||
getInventoryUrl(): string | null;
|
getInventoryUrl(): string | null;
|
||||||
|
/** The current `measure` branch, or null while not on it (C2 handoff). */
|
||||||
|
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +634,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
return pngUrl;
|
return pngUrl;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
currentMeasure() {
|
||||||
|
return state.k === 'measure' ? state : null;
|
||||||
|
},
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
teardownWorker();
|
teardownWorker();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Shared state for the one-column Create flow (UX.md §1): capture → review →
|
||||||
|
// publish. App.svelte owns it; MeasurePanel hands off the calibrated capture,
|
||||||
|
// Review hands to Publish, Publish clears the device draft on success.
|
||||||
|
//
|
||||||
|
// The flow always concerns a SINGLE slab — there is no list/picker in the
|
||||||
|
// owner path (CAPTURE-25/30).
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { PhotosReadyLineupItem } from '../capture/photosReadyLineup';
|
||||||
|
|
||||||
|
export interface CalibratedCapture {
|
||||||
|
lengthIn: number;
|
||||||
|
thicknessIn: number;
|
||||||
|
sqft: number;
|
||||||
|
bdft: number;
|
||||||
|
widthMinIn: number;
|
||||||
|
widthMaxIn: number;
|
||||||
|
widthAvgIn: number;
|
||||||
|
sourceBelowFloor: boolean;
|
||||||
|
lineup: PhotosReadyLineupItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Flow =
|
||||||
|
| { phase: 'capture' }
|
||||||
|
| { phase: 'saving' }
|
||||||
|
| { phase: 'review'; slabId: string; capture: CalibratedCapture }
|
||||||
|
| { phase: 'publish'; slabId: string; capture: CalibratedCapture }
|
||||||
|
| { phase: 'done'; slabId: string; wooProductId: number; draft: boolean };
|
||||||
|
|
||||||
|
const initial: Flow = { phase: 'capture' };
|
||||||
|
|
||||||
|
export const flow = writable<Flow>(initial);
|
||||||
|
|
||||||
|
export function startSaving(): void {
|
||||||
|
flow.set({ phase: 'saving' });
|
||||||
|
}
|
||||||
|
export function enterReview(slabId: string, capture: CalibratedCapture): void {
|
||||||
|
flow.set({ phase: 'review', slabId, capture });
|
||||||
|
}
|
||||||
|
export function enterPublish(slabId: string, capture: CalibratedCapture): void {
|
||||||
|
flow.set({ phase: 'publish', slabId, capture });
|
||||||
|
}
|
||||||
|
export function markDone(slabId: string, wooProductId: number, draft: boolean): void {
|
||||||
|
flow.set({ phase: 'done', slabId, wooProductId, draft });
|
||||||
|
}
|
||||||
|
export function backToReview(slabId: string, capture: CalibratedCapture): void {
|
||||||
|
flow.set({ phase: 'review', slabId, capture });
|
||||||
|
}
|
||||||
|
export function backToCapture(): void {
|
||||||
|
flow.set({ phase: 'capture' });
|
||||||
|
}
|
||||||
@@ -1,69 +1,137 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let slabId = '';
|
import { onMount, createEventDispatcher } from 'svelte';
|
||||||
let result: {
|
import { getSlab, publishSlab, ApiError, type Slab } from '../../api/slabs';
|
||||||
ok?: boolean;
|
|
||||||
status?: string;
|
|
||||||
woo_product_id?: number;
|
|
||||||
draft?: boolean;
|
|
||||||
error?: { code: string; message: string; detail?: Record<string, unknown> };
|
|
||||||
} | null = null;
|
|
||||||
let busy = false;
|
|
||||||
|
|
||||||
async function publish() {
|
// One-column flow: Review hands to Publish by id (UX.md §1).
|
||||||
if (!slabId) {
|
export let slabId: string;
|
||||||
result = { error: { code: 'input', message: 'Enter a slab id.' } };
|
export let onPublished: (wooProductId: number, draft: boolean) => void;
|
||||||
return;
|
export let onBack: () => void;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let slab: Slab | null = null;
|
||||||
|
let loading = true;
|
||||||
|
let loadError = '';
|
||||||
|
|
||||||
|
interface PublishState {
|
||||||
|
phase: 'idle' | 'working' | 'success' | 'failure';
|
||||||
|
ok?: { wooProductId: number; draft: boolean };
|
||||||
|
fail?: {
|
||||||
|
code: string | null;
|
||||||
|
message: string;
|
||||||
|
recoverable: boolean;
|
||||||
|
partialMedia: boolean;
|
||||||
|
duplicateSku: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
busy = true;
|
let st: PublishState = { phase: 'idle' };
|
||||||
result = null;
|
|
||||||
try {
|
// Map backend codes to the recovery branches (PUBLISH-30/31, QUALITY-120).
|
||||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { method: 'POST' });
|
function classify(code: string | null, message: string, detail: Record<string, unknown> | null): PublishState['fail'] {
|
||||||
const body = await res.json();
|
const d = detail ?? {};
|
||||||
if (res.ok) result = body;
|
return {
|
||||||
else result = { ok: false, error: body.error };
|
code,
|
||||||
} catch {
|
message,
|
||||||
result = { ok: false, error: { code: 'network', message: 'network error' } };
|
recoverable: d.recoverable === true,
|
||||||
|
partialMedia: d.partial_media === true,
|
||||||
|
duplicateSku: code === 'duplicate_sku',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
busy = false;
|
|
||||||
|
function doPublish() {
|
||||||
|
st = { phase: 'working' };
|
||||||
|
publishSlab(slabId)
|
||||||
|
.then((r) => {
|
||||||
|
st = { phase: 'success', ok: { wooProductId: r.woo_product_id, draft: r.draft } };
|
||||||
|
// Mark the flow done (App switches to the Done panel) after a tick so the
|
||||||
|
// success state renders first.
|
||||||
|
onPublished(r.woo_product_id, r.draft);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) };
|
||||||
|
} else {
|
||||||
|
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSku() { dispatch('back'); }
|
||||||
|
function openExisting() { dispatch('open-existing'); }
|
||||||
|
function backToReview() { onBack(); }
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try { slab = await getSlab(slabId); }
|
||||||
|
catch (e) { loadError = e instanceof ApiError ? e.message : 'Could not load the slab.'; }
|
||||||
|
finally { loading = false; }
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="publish">
|
{#if loading}
|
||||||
<h2>Publish to WooCommerce</h2>
|
<section class="pub" aria-busy="true" data-testid="publish-loading"><h1>Publish to WooCommerce</h1><p class="busy">Loading…</p></section>
|
||||||
<div>
|
{:else if loadError}
|
||||||
<label>Slab ID</label>
|
<section class="pub"><h1>Publish to WooCommerce</h1><p class="err" role="alert">{loadError}</p>
|
||||||
<input bind:value={slabId} placeholder="uuid" />
|
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||||
</div>
|
</section>
|
||||||
<button on:click={publish} disabled={busy}>{busy ? 'Publishing…' : 'Publish'}</button>
|
{:else}
|
||||||
|
<section class="pub" data-testid="publish-screen">
|
||||||
|
<h1>Publish to WooCommerce</h1>
|
||||||
|
<p class="meta">SKU {slab?.sku || '—'} · {slab?.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab?.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
|
||||||
|
|
||||||
{#if result}
|
{#if st.phase === 'idle' || st.phase === 'working'}
|
||||||
{#if result.ok}
|
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish} disabled={st.phase === 'working'}>
|
||||||
<p class="ok">
|
{st.phase === 'working' ? 'Publishing…' : 'Publish'}
|
||||||
Published{result.draft ? ' (UAT draft)' : ''} → Woo product #{result.woo_product_id}
|
</button>
|
||||||
</p>
|
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview} disabled={st.phase === 'working'}>Back to Review</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if st.phase === 'success'}
|
||||||
|
<div class="status ok" data-testid="publish-ok">Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}</div>
|
||||||
|
<p class="meta">Photos purged · device draft cleared.</p>
|
||||||
|
<button type="button" class="primary" data-testid="new-slab" on:click={() => {}}>Start a new slab</button>
|
||||||
|
{:else if st.phase === 'failure' && st.fail}
|
||||||
|
{#if st.fail.duplicateSku}
|
||||||
|
<div class="step-x" data-testid="dup-sku">
|
||||||
|
<div class="x n">✕</div>
|
||||||
|
<h2>SKU already exists</h2>
|
||||||
|
<p class="meta">“{slab?.sku}” is already in the store.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
|
||||||
|
<button type="button" class="ghost" data-testid="open-existing" on:click={openExisting}>Open existing product</button>
|
||||||
|
{:else if st.fail.code === 'unpublishable'}
|
||||||
|
<div class="step-x" data-testid="not-ready">
|
||||||
|
<div class="x n">✕</div>
|
||||||
|
<h2>Not ready to publish</h2>
|
||||||
|
<p class="meta">Some fields are missing. Go back and finish Review.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="err">
|
<div class="step-x" data-testid="store-failure">
|
||||||
{result.error?.message}
|
<div class="x n" class:rec={st.fail.recoverable}>{st.fail.partialMedia ? '⚠' : '↻'}</div>
|
||||||
{#if result.error?.detail?.recoverable === false}
|
<h2>{st.fail.message}</h2>
|
||||||
<span class="tag">unrecoverable</span>
|
{#if st.fail.recoverable}<span class="tag">recoverable</span>{/if}
|
||||||
{:else if result.error?.detail?.recoverable === true}
|
{#if st.fail.partialMedia}<span class="tag warn">orphan media — remove manually</span>{/if}
|
||||||
<span class="tag">recoverable — retry</span>
|
{#if !st.fail.recoverable}<span class="tag bad">check Settings</span>{/if}
|
||||||
{/if}
|
</div>
|
||||||
{#if result.error?.detail?.partial_media}
|
<button type="button" class="primary" data-testid="retry-publish" on:click={doPublish}>Retry</button>
|
||||||
<span class="tag warn">orphan media — clean up manually</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.publish { max-width: 520px; color: #eee; }
|
.pub { max-width: 520px; margin: 0 auto; color: #eee; }
|
||||||
label { display: block; margin: 8px 0 4px; }
|
h1 { font-size: 1.3rem; margin: 0 0 6px; } h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||||||
input { width: 100%; padding: 6px; }
|
.meta { color: #888; font-size: .82rem; margin: 4px 0 10px; }
|
||||||
button { margin-top: 12px; }
|
.primary { width: 100%; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; margin-top: 12px; }
|
||||||
.ok { margin-top: 12px; color: #7fd37f; font-weight: 600; }
|
.primary:disabled { background: #333; color: #777; }
|
||||||
.err { margin-top: 12px; color: #e88; }
|
.ghost { width: 100%; padding: 13px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; margin-top: 10px; }
|
||||||
.tag { margin-left: 8px; font-size: 12px; padding: 1px 6px; border-radius: 4px; background: #444; }
|
.status { font-weight: 700; padding: 11px 13px; border-radius: 3px; font-size: .92rem; }
|
||||||
.tag.warn { background: #6a4; color: #000; }
|
.status.ok { background: #122a12; color: #7fd37f; }
|
||||||
|
.step-x { background: #2a1512; border-radius: 3px; padding: 14px; margin: 14px 0; }
|
||||||
|
.x { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.x .n { width: 22px; height: 22px; border-radius: 50%; background: #444; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .8rem; }
|
||||||
|
.x .n.rec { background: #00e5ff; color: #002; }
|
||||||
|
.tag { margin-left: 8px; font-size: .64rem; padding: 1px 6px; border-radius: 2px; background: #444; color: #ccc; }
|
||||||
|
.tag.warn { background: #6a4; color: #000; } .tag.bad { background: #633; color: #ffb4a2; }
|
||||||
|
.err { color: #e88; } .busy { color: #aaa; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Searchable multi-select list (the approved paradigm replacing tap-bubbles).
|
||||||
|
// Squared/slight-radius, type-to-filter, select-all, live count badge, per-row
|
||||||
|
// AI bubble on AI-recommended options. Works for 6 items or 60.
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
interface Opt { id: number; name: string; ai: boolean }
|
||||||
|
export let label: string;
|
||||||
|
export let options: Opt[];
|
||||||
|
export let selected: number[];
|
||||||
|
export let placeholder = 'Choose…';
|
||||||
|
export let testid = '';
|
||||||
|
export let onToggle: (id: number) => void;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let open = false;
|
||||||
|
let filter = '';
|
||||||
|
let focused: number | null = null;
|
||||||
|
|
||||||
|
$: aiSelected = selected.filter((id) => options.find((o) => o.id === id)?.ai);
|
||||||
|
$: filtered = filter.trim()
|
||||||
|
? options.filter((o) => o.name.toLowerCase().includes(filter.trim().toLowerCase()))
|
||||||
|
: options;
|
||||||
|
$: count = selected.length;
|
||||||
|
$: allFilteredSelected = filtered.length > 0 && filtered.every((o) => selected.includes(o.id));
|
||||||
|
|
||||||
|
function toggle(id: number) {
|
||||||
|
onToggle(id);
|
||||||
|
focused = id;
|
||||||
|
}
|
||||||
|
function selectAll() {
|
||||||
|
// Toggle every currently-visible (filtered) option.
|
||||||
|
for (const o of filtered) {
|
||||||
|
if (!selected.includes(o.id)) onToggle(o.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearAll() {
|
||||||
|
for (const o of [...filtered]) {
|
||||||
|
if (selected.includes(o.id)) onToggle(o.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
filter = '';
|
||||||
|
dispatch('commit');
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && open) { open = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
|
||||||
|
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
|
||||||
|
<span class="ms-label">{open ? 'Choose ' + label.toLowerCase() : placeholder}</span>
|
||||||
|
<span class="ms-c">{count === 1 ? '1 selected' : `${count} selected`}</span>
|
||||||
|
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="ms-panel" role="listbox" aria-multiselectable="true">
|
||||||
|
<div class="ms-tools">
|
||||||
|
<input class="ms-search" type="text" placeholder="Filter…" bind:value={filter}
|
||||||
|
data-testid={testid ? `${testid}-filter` : 'ms-filter'} aria-label={`Filter ${label}`} />
|
||||||
|
<button type="button" class="ms-all" data-testid={testid ? `${testid}-all` : 'ms-all'}
|
||||||
|
on:click={allFilteredSelected ? clearAll : selectAll}>
|
||||||
|
{allFilteredSelected ? 'None' : 'All'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul class="ms-list">
|
||||||
|
{#each filtered as o (o.id)}
|
||||||
|
<li
|
||||||
|
class="ms-row"
|
||||||
|
class:on={selected.includes(o.id)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected.includes(o.id)}
|
||||||
|
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||||
|
on:click={() => toggle(o.id)}
|
||||||
|
>
|
||||||
|
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||||
|
<span class="nm">
|
||||||
|
{o.name}
|
||||||
|
{#if o.ai}<span class="ai-tag">AI</span>{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="ms-empty">No matches</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="ms-foot">
|
||||||
|
<span>{options.length} synced{filter.trim() ? ` · ${filtered.length} shown` : ''}</span>
|
||||||
|
<button type="button" on:click={close} data-testid={testid ? `${testid}-done` : 'ms-done'}>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
|
||||||
|
.ms-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||||
|
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
|
||||||
|
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
|
||||||
|
.ms-arrow { color: #777; }
|
||||||
|
.ms-panel { border-top: 1px solid #333; background: #161616; }
|
||||||
|
.ms-tools { display: flex; gap: 8px; padding: 8px; align-items: center; }
|
||||||
|
.ms-search { flex: 1; padding: 7px 9px; background: #0f0f0f; border: 1px solid #3a3a3a; border-radius: 3px; color: #eee; font-size: .85rem; }
|
||||||
|
.ms-all { font-size: .74rem; color: #00e5ff; background: none; border: 0; cursor: pointer; white-space: nowrap; padding: 4px; }
|
||||||
|
.ms-list { list-style: none; margin: 0; padding: 0; max-height: 200px; overflow-y: auto; }
|
||||||
|
.ms-row { display: flex; align-items: center; gap: 9px; padding: 9px 12px; font-size: .9rem; border-top: 1px solid #222; cursor: pointer; }
|
||||||
|
.ms-row:hover { background: #1f1f1f; }
|
||||||
|
.ms-row.on { background: #12262b; }
|
||||||
|
.ms-row.on .nm { color: #fff; }
|
||||||
|
.ms-row .nm { flex: 1; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||||
|
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
|
||||||
|
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
|
||||||
|
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
|
||||||
|
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
|
||||||
|
</style>
|
||||||
@@ -1,86 +1,504 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let slabId = '';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
let primaryCategoryId: number | '' = '';
|
import {
|
||||||
let price = '';
|
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||||
let result: {
|
ApiError,
|
||||||
ready: boolean;
|
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
|
||||||
missing: string[];
|
} from '../../api/slabs';
|
||||||
title: string | null;
|
import type { CalibratedCapture } from '../flow';
|
||||||
short_description: string | null;
|
import MultiSelect from './MultiSelect.svelte';
|
||||||
price: number | null;
|
|
||||||
price_suggestion: number | null;
|
|
||||||
primary_wood_category_id: number | null;
|
|
||||||
} | null = null;
|
|
||||||
let error = '';
|
|
||||||
let busy = false;
|
|
||||||
|
|
||||||
async function review() {
|
// Props handed off by App (one-column flow, UX.md §1).
|
||||||
if (!slabId) {
|
export let slabId: string;
|
||||||
error = 'Enter a slab id.';
|
export let capture: CalibratedCapture | null = null;
|
||||||
return;
|
export let onPublish: () => void;
|
||||||
|
export let onBack: () => void;
|
||||||
|
|
||||||
|
let slab: Slab | null = null;
|
||||||
|
let tax: Taxonomy | null = null;
|
||||||
|
let settings: SettingsView | null = null;
|
||||||
|
let loadError = '';
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
// Picker option types (id + name + whether the AI recommended it).
|
||||||
|
interface Opt { id: number; name: string; ai: boolean }
|
||||||
|
const byId = <T extends { id: number }>(arr: T[]) => {
|
||||||
|
const m = new Map<number, T>();
|
||||||
|
for (const x of arr) m.set(x.id, x);
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
|
||||||
|
let speciesOpts: Opt[] = [];
|
||||||
|
let woodCatOpts: Opt[] = [];
|
||||||
|
let edgeOpts: Opt[] = [];
|
||||||
|
let figureOpts: Opt[] = [];
|
||||||
|
let gradeOpts: Opt[] = [];
|
||||||
|
let thicknessOpts: Opt[] = [];
|
||||||
|
let moistureOpts: Opt[] = [];
|
||||||
|
let figTagOpts: Opt[] = [];
|
||||||
|
let featTagOpts: Opt[] = [];
|
||||||
|
|
||||||
|
function deriveOptions() {
|
||||||
|
if (!tax) return;
|
||||||
|
const cats: TaxCategory[] = tax.categories;
|
||||||
|
// Resolve the two top-level groupers BY SLUG (QUALITY-105 / METADATA-5 — never
|
||||||
|
// hardcode live Woo term ids): `species` → species options; `wcat` → wood
|
||||||
|
// categories. Both are parent_id:null groupers; their children are the leaves.
|
||||||
|
const speciesGrouper = cats.find((c) => c.parent_id === null && c.slug === 'species');
|
||||||
|
const wcatGrouper = cats.find((c) => c.parent_id === null && c.slug === 'wcat');
|
||||||
|
const childrenOf = (g: TaxCategory | undefined): TaxCategory[] =>
|
||||||
|
g ? cats.filter((c) => c.parent_id === g.id) : [];
|
||||||
|
// Attribute names are matched case-insensitively (live Woo uses 'Edge type').
|
||||||
|
const termsOf = (name: string): Opt[] => {
|
||||||
|
const attr = tax!.attributes.find(
|
||||||
|
(a) => a.name.toLowerCase() === name.toLowerCase(),
|
||||||
|
);
|
||||||
|
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||||
|
};
|
||||||
|
speciesOpts = childrenOf(speciesGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
|
||||||
|
woodCatOpts = childrenOf(wcatGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
|
||||||
|
edgeOpts = termsOf('edge type');
|
||||||
|
figureOpts = termsOf('figure');
|
||||||
|
gradeOpts = termsOf('grade');
|
||||||
|
thicknessOpts = termsOf('thickness');
|
||||||
|
moistureOpts = termsOf('moisture');
|
||||||
|
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
|
||||||
|
// filtered by the family the backend keeps. Never invent a prefix — if the
|
||||||
|
// store has none, the picker is legitimately empty.
|
||||||
|
const tags: TaxTag[] = tax.tags ?? [];
|
||||||
|
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||||
|
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||||
}
|
}
|
||||||
busy = true;
|
|
||||||
error = '';
|
// --- Editable model (mirrors the live slab) ---
|
||||||
result = null;
|
let speciesId: number | '' = '';
|
||||||
const payload: Record<string, unknown> = {};
|
let woodCatIds: number[] = [];
|
||||||
if (primaryCategoryId !== '') payload.primary_wood_category_id = primaryCategoryId;
|
let edgeTypeId: number | '' = '';
|
||||||
if (price !== '') payload.price = Number(price);
|
let figureIds: number[] = [];
|
||||||
|
let gradeId: number | '' = '';
|
||||||
|
let thicknessId: number | '' = '';
|
||||||
|
let moistureId: number | '' = '';
|
||||||
|
let figTagIds: number[] = [];
|
||||||
|
let featTagIds: number[] = [];
|
||||||
|
let primaryCatId: number | '' = '';
|
||||||
|
let title = '';
|
||||||
|
let shortDescription = '';
|
||||||
|
let description = '';
|
||||||
|
let contentSource = '';
|
||||||
|
let price = '';
|
||||||
|
let priceSource = 'recommendation';
|
||||||
|
let sku = '';
|
||||||
|
|
||||||
|
// --- Review (server-derived ready/missing/title/price) ---
|
||||||
|
interface ReviewOut {
|
||||||
|
ready: boolean; missing: string[]; title: string | null;
|
||||||
|
short_description: string | null; price: number | null;
|
||||||
|
price_suggestion: number | null; primary_wood_category_id: number | null;
|
||||||
|
}
|
||||||
|
let review: ReviewOut | null = null;
|
||||||
|
let reviewBusy = false;
|
||||||
|
let reviewError = '';
|
||||||
|
// Monotonic token: each commit captures the current value and ignores its
|
||||||
|
// result if a newer commit has since started. Without this, two concurrent
|
||||||
|
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
||||||
|
// a stale "not ready" review clobbers the final ready one — the
|
||||||
|
// continue-to-Publish button stays disabled even though the status reads ready.
|
||||||
|
let commitToken = 0;
|
||||||
|
|
||||||
|
// --- Inference ---
|
||||||
|
let aiBusy = false;
|
||||||
|
let aiNote = '';
|
||||||
|
let genBusy = false;
|
||||||
|
let genError = '';
|
||||||
|
let canGenerateContent = false;
|
||||||
|
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
|
||||||
|
|
||||||
|
const num = (v: unknown) => (typeof v === 'number' ? v : null);
|
||||||
|
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
|
||||||
|
const fmt2 = (v: number) => v.toFixed(2);
|
||||||
|
|
||||||
|
function hydrate(s: Slab, t: Taxonomy | null) {
|
||||||
|
speciesId = s.species_id ?? '';
|
||||||
|
woodCatIds = [...(s.wood_category_ids ?? [])];
|
||||||
|
edgeTypeId = s.edge_type_term_id ?? '';
|
||||||
|
figureIds = [...(s.figure_term_ids ?? [])];
|
||||||
|
gradeId = s.grade_term_id ?? '';
|
||||||
|
thicknessId = s.thickness_term_id ?? '';
|
||||||
|
moistureId = s.moisture_term_id ?? '';
|
||||||
|
figTagIds = [...(s.fig_tag_ids ?? [])];
|
||||||
|
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||||
|
title = s.title ?? '';
|
||||||
|
shortDescription = s.short_description ?? '';
|
||||||
|
description = s.description ?? '';
|
||||||
|
contentSource = s.content_source ?? 'manual';
|
||||||
|
price = s.price != null ? String(s.price) : '';
|
||||||
|
priceSource = s.price_source ?? 'recommendation';
|
||||||
|
sku = s.sku ?? '';
|
||||||
|
primaryCatId = woodCatIds[0] ?? '';
|
||||||
|
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
||||||
|
// its weight behind the value the owner kept).
|
||||||
|
aiRecommendations = [];
|
||||||
|
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
|
||||||
|
aiRecommendations.push(s.species_id);
|
||||||
|
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
|
||||||
|
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
||||||
|
|
||||||
|
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
||||||
|
async function commitReview(): Promise<void> {
|
||||||
|
if (!slab) return;
|
||||||
|
const myToken = ++commitToken;
|
||||||
|
reviewBusy = true;
|
||||||
|
reviewError = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
|
const patch = {
|
||||||
method: 'POST',
|
species_id: speciesId === '' ? null : Number(speciesId),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
wood_category_ids: woodCatIds,
|
||||||
body: JSON.stringify(payload),
|
edge_type_term_id: edgeTypeId === '' ? null : Number(edgeTypeId),
|
||||||
|
figure_term_ids: figureIds,
|
||||||
|
grade_term_id: gradeId === '' ? null : Number(gradeId),
|
||||||
|
thickness_term_id: thicknessId === '' ? null : Number(thicknessId),
|
||||||
|
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||||||
|
fig_tag_ids: figTagIds,
|
||||||
|
feat_tag_ids: featTagIds,
|
||||||
|
title: title || null,
|
||||||
|
short_description: shortDescription || null,
|
||||||
|
description: description || null,
|
||||||
|
content_source: contentSource || null,
|
||||||
|
price: price === '' ? null : Number(price),
|
||||||
|
price_source: priceSource,
|
||||||
|
sku: sku || null,
|
||||||
|
client_rev: slab.server_rev,
|
||||||
|
};
|
||||||
|
const fresh = await updateSlab(slabId, patch);
|
||||||
|
if (myToken !== commitToken) return; // superseded; don't clobber the latest
|
||||||
|
slab = fresh;
|
||||||
|
if (primaryCatId !== '') {
|
||||||
|
const res = await reviewSlab(slabId, {
|
||||||
|
primary_wood_category_id: Number(primaryCatId),
|
||||||
|
title: title || undefined,
|
||||||
|
short_description: shortDescription || undefined,
|
||||||
|
price: price === '' ? undefined : Number(price),
|
||||||
|
sku: sku || undefined,
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
if (myToken !== commitToken) return;
|
||||||
if (res.ok) result = body;
|
review = res;
|
||||||
else error = body.error?.message || `review failed (${res.status})`;
|
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
|
||||||
} catch {
|
} else {
|
||||||
error = 'network error';
|
review = await reviewSlab(slabId);
|
||||||
|
if (myToken !== commitToken) return;
|
||||||
}
|
}
|
||||||
busy = false;
|
} catch (e) {
|
||||||
|
if (myToken !== commitToken) return;
|
||||||
|
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
|
||||||
|
review = null;
|
||||||
|
} finally {
|
||||||
|
if (myToken === commitToken) reviewBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInference() {
|
||||||
|
if (!slab) return;
|
||||||
|
aiBusy = true;
|
||||||
|
aiNote = '';
|
||||||
|
try {
|
||||||
|
const r = await inferTaxon(slabId);
|
||||||
|
const s = r.suggestions;
|
||||||
|
const recs: number[] = [];
|
||||||
|
if (s.species_id) { speciesId = s.species_id; recs.push(s.species_id); }
|
||||||
|
if (s.wood_category_ids) {
|
||||||
|
woodCatIds = [...s.wood_category_ids];
|
||||||
|
if (!primaryCatId) primaryCatId = woodCatIds[0];
|
||||||
|
recs.push(...s.wood_category_ids);
|
||||||
|
}
|
||||||
|
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
|
||||||
|
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
|
||||||
|
if (s.grade_term_id) gradeId = s.grade_term_id;
|
||||||
|
aiRecommendations = recs;
|
||||||
|
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
|
||||||
|
await commitReview();
|
||||||
|
} catch (e) {
|
||||||
|
aiNote = e instanceof ApiError ? e.message : 'Inference unavailable.';
|
||||||
|
} finally {
|
||||||
|
aiBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateContent() {
|
||||||
|
if (!slab) return;
|
||||||
|
genBusy = true;
|
||||||
|
genError = '';
|
||||||
|
try {
|
||||||
|
const r = await inferContent(slabId);
|
||||||
|
description = r.description;
|
||||||
|
contentSource = 'llm';
|
||||||
|
await commitReview();
|
||||||
|
} catch (e) {
|
||||||
|
genError = e instanceof ApiError ? e.message : 'Could not generate text.';
|
||||||
|
} finally {
|
||||||
|
genBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingCount = () => (review ? review.missing.length : null);
|
||||||
|
const ready = () => review?.ready === true;
|
||||||
|
// The button's enabled state must derive from the SAME reactive `review` object
|
||||||
|
// the status text reads. A separate `ready()` function call in the template
|
||||||
|
// evaluates in a different reactive flush, leaving `disabled` stale (false→the
|
||||||
|
// button stays disabled) even after `review.ready` flips true. Binding
|
||||||
|
// `publishDisabled` to `review` (a reactive object) forces the same dependency.
|
||||||
|
let publishDisabled = true;
|
||||||
|
$: publishDisabled = review ? !review.ready : true;
|
||||||
|
|
||||||
|
// --- Multi-select list helpers (searchable, squared) ---
|
||||||
|
function toggleMulti(arr: number[], id: number): number[] {
|
||||||
|
return arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [s, t, set] = await Promise.all([getSlab(slabId), getTaxonomy(), getSettings()]);
|
||||||
|
slab = s; tax = t; settings = set;
|
||||||
|
deriveOptions();
|
||||||
|
hydrate(s, t);
|
||||||
|
canGenerateContent = !!set.content_llm_enabled;
|
||||||
|
await commitReview();
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function goPublish() {
|
||||||
|
onPublish();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish() {
|
||||||
|
goPublish();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="review">
|
{#if loading}
|
||||||
<h2>Review to Ready</h2>
|
<section class="review" aria-busy="true" data-testid="review-loading">
|
||||||
<div>
|
<h1>Review to Ready</h1>
|
||||||
<label>Slab ID</label>
|
<p class="busy">Loading slab…</p>
|
||||||
<input bind:value={slabId} placeholder="uuid" />
|
</section>
|
||||||
</div>
|
{:else if loadError}
|
||||||
<div>
|
<section class="review">
|
||||||
<label>Primary wood category id (optional; defaults to first selected)</label>
|
<h1>Review to Ready</h1>
|
||||||
<input type="number" bind:value={primaryCategoryId} placeholder="auto" />
|
<p class="err" role="alert">{loadError}</p>
|
||||||
</div>
|
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back to capture</button>
|
||||||
<div>
|
</section>
|
||||||
<label>Price override (optional; suggestion = bdft × species rate)</label>
|
{:else}
|
||||||
<input type="number" step="0.01" bind:value={price} placeholder="use suggestion" />
|
<section class="review" data-testid="review-screen">
|
||||||
</div>
|
<h1>Review to Ready</h1>
|
||||||
<button on:click={review} disabled={busy}>{busy ? 'Reviewing…' : 'Review'}</button>
|
|
||||||
{#if error}<p class="err">{error}</p>{/if}
|
|
||||||
|
|
||||||
{#if result}
|
<dl class="math">
|
||||||
<p class="ready" class:ok={result.ready}>
|
<div><dt>Length</dt><dd>{fmt1(capture?.lengthIn ?? slab?.length_in ?? 0)}″</dd></div>
|
||||||
{result.ready ? 'READY for publish' : `Not ready — missing: ${result.missing.join(', ')}`}
|
<div><dt>Thickness</dt><dd>{fmt1(capture?.thicknessIn ?? slab?.thickness_in ?? 0)}″</dd></div>
|
||||||
</p>
|
<div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
|
||||||
<div><strong>Title:</strong> {result.title ?? '(none)'}</div>
|
</dl>
|
||||||
<div><strong>Short description:</strong> {result.short_description ?? '(none)'}</div>
|
|
||||||
<div>
|
{#if settings?.inference_enabled}
|
||||||
<strong>Price:</strong> {result.price != null ? `$${result.price.toFixed(2)}` : '(empty — enter required)'}
|
<div class="aibar">
|
||||||
{#if result.price_suggestion != null && result.price_suggestion !== result.price}
|
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
|
||||||
<span class="sug"> (suggested ${result.price_suggestion.toFixed(2)})</span>
|
{aiBusy ? 'Reading slab…' : 'Suggest with AI'}
|
||||||
{/if}
|
</button>
|
||||||
|
{#if aiNote}<p class="ai-note" data-testid="ai-note">{aiNote}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
<!-- Species (single, AI-assist) -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
|
||||||
|
<select id="species" class="sel" data-testid="species" bind:value={speciesId} on:change={commitReview}>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{#each speciesOpts as o (o.id)}
|
||||||
|
<option value={o.id} data-ai={o.ai ? 'true' : ''}>{o.name}{o.ai ? ' (AI)' : ''}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wood categories (multi, searchable list, AI-assist) -->
|
||||||
|
<MultiSelect
|
||||||
|
label="Wood categories"
|
||||||
|
options={woodCatOpts}
|
||||||
|
selected={woodCatIds}
|
||||||
|
placeholder="Choose wood categories"
|
||||||
|
testid="wood-cat"
|
||||||
|
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Edge type (single, AI-assist) -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
|
||||||
|
<select id="edge" class="sel" data-testid="edge-type" bind:value={edgeTypeId} on:change={commitReview}>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{#each edgeOpts as o (o.id)}
|
||||||
|
<option value={o.id}>{o.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Figure (multi, searchable list, AI-assist) -->
|
||||||
|
<MultiSelect
|
||||||
|
label="Figure"
|
||||||
|
options={figureOpts}
|
||||||
|
selected={figureIds}
|
||||||
|
placeholder="Choose figure"
|
||||||
|
testid="figure"
|
||||||
|
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Grade / Thickness / Moisture (single, AI-assist) -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="grade">Grade <span class="ai">AI assist</span></label>
|
||||||
|
<select id="grade" class="sel" data-testid="grade" bind:value={gradeId} on:change={commitReview}>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{#each gradeOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- fig-* product tags (optional, owner-only, no AI) -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
|
||||||
|
<MultiSelect
|
||||||
|
label="fig-* tags"
|
||||||
|
options={figTagOpts}
|
||||||
|
selected={figTagIds}
|
||||||
|
placeholder="Choose fig-* tags"
|
||||||
|
testid="fig-tag"
|
||||||
|
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- feat-* product tags (optional, AI-assisted by Call 1) -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
||||||
|
<MultiSelect
|
||||||
|
label="feat-* tags"
|
||||||
|
options={featTagOpts}
|
||||||
|
selected={featTagIds}
|
||||||
|
placeholder="Choose feat-* tags"
|
||||||
|
testid="feat-tag"
|
||||||
|
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="thickness">Thickness band</label>
|
||||||
|
<select id="thickness" class="sel" data-testid="thickness-band" bind:value={thicknessId} on:change={commitReview}>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{#each thicknessOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="moisture">Moisture</label>
|
||||||
|
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title / Description -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="title">Title</label>
|
||||||
|
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="short-desc">Short description</label>
|
||||||
|
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="desc">Description</label>
|
||||||
|
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitReview}></textarea>
|
||||||
|
{#if canGenerateContent}
|
||||||
|
<div class="gen">
|
||||||
|
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}>
|
||||||
|
{genBusy ? 'Writing…' : 'Generate text'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Price / SKU (SKU last) -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="price">Price ($)
|
||||||
|
{#if review?.price_suggestion != null}<span class="sug">suggested ${review.price_suggestion.toFixed(2)}</span>{/if}
|
||||||
|
</label>
|
||||||
|
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:change={commitReview} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl" for="sku">SKU <span class="sug">last field, 3–24 chars</span></label>
|
||||||
|
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:blur={commitReview} />
|
||||||
|
{#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)}
|
||||||
|
<p class="nudge">SKU must be 3–24 alphanumerics or hyphens.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ready status + actions -->
|
||||||
|
{#if reviewBusy}
|
||||||
|
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
|
||||||
|
{:else if review}
|
||||||
|
<div class="status" class:ok={ready()} data-testid="ready-status">
|
||||||
|
{ready()
|
||||||
|
? 'Ready to publish'
|
||||||
|
: `Not ready — ${review.missing.length} field${review.missing.length === 1 ? '' : 's'} remaining`}
|
||||||
|
</div>
|
||||||
|
{#if !ready()}
|
||||||
|
<p class="missing" data-testid="missing-list">Missing: {review.missing.join(', ')}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary"
|
||||||
|
data-testid="continue-publish"
|
||||||
|
disabled={publishDisabled}
|
||||||
|
on:click={publish}
|
||||||
|
>
|
||||||
|
Continue to Publish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.review { max-width: 560px; color: #eee; }
|
.review { max-width: 560px; margin: 0 auto; color: #eee; }
|
||||||
label { display: block; margin: 8px 0 4px; }
|
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||||
input { width: 100%; padding: 6px; }
|
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
|
||||||
button { margin-top: 12px; }
|
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||||
.ready { margin-top: 12px; font-weight: 600; }
|
.aibar { margin: 12px 0; }
|
||||||
.ready.ok { color: #7fd37f; }
|
.ai-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .85rem; }
|
||||||
.ready:not(.ok) { color: #e8a; }
|
.ai-btn:disabled { background: #333; color: #777; }
|
||||||
.err { color: #e88; }
|
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
|
||||||
.sug { color: #aaa; font-weight: 400; }
|
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
||||||
|
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
||||||
|
.inp, .sel { width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||||||
|
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
|
||||||
|
.ta { resize: vertical; }
|
||||||
|
.field { margin-bottom: 12px; }
|
||||||
|
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||||||
|
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||||||
|
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
||||||
|
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||||||
|
.status.ok { background: #122a12; color: #7fd37f; }
|
||||||
|
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||||||
|
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
|
.primary { flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
|
||||||
|
.primary:disabled { background: #333; color: #777; }
|
||||||
|
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||||
|
.err { color: #e88; } .busy { color: #aaa; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
|
||||||
|
// the Resume path restores straight to the Photos-ready Measure step — the
|
||||||
|
// deterministic driver for the create→review→publish flow (no live camera/canvas
|
||||||
|
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
|
||||||
|
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
|
||||||
|
// `continueDraft` + `continueToReview` consume it unmodified.
|
||||||
|
//
|
||||||
|
// `source.blob` MUST be a decodable image: restoreFromDraft re-derives the mask
|
||||||
|
// in a Worker that decodes the blob; a failed decode falls back to `mask` (no
|
||||||
|
// results → the Continue button never renders). A 1×1 white PNG decodes fine and
|
||||||
|
// yields slabPx:0 (an empty mask) → rederiveMask resolves → stage reaches measure.
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const KEY = 'slabuploader-draft';
|
||||||
|
|
||||||
|
export interface SeedMeasure {
|
||||||
|
sku: string;
|
||||||
|
lengthIn: number;
|
||||||
|
thicknessIn: number;
|
||||||
|
sqft: number;
|
||||||
|
bdft: number;
|
||||||
|
widthIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEighths = (inches: number) => Math.round(inches * 8);
|
||||||
|
|
||||||
|
export async function seedMeasureDraft(
|
||||||
|
page: Page,
|
||||||
|
d: SeedMeasure,
|
||||||
|
): Promise<void> {
|
||||||
|
const widthEighths = toEighths(d.widthIn);
|
||||||
|
// Build the decodable source PNG in the page via canvas (a fixed base64 1×1
|
||||||
|
// gray+alpha PNG trips Chromium's software decoder in headless), then write
|
||||||
|
// the draft with that blob so restoreFromDraft's worker decode succeeds.
|
||||||
|
await page.evaluate(
|
||||||
|
(payload) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = 1;
|
||||||
|
c.height = 1;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fillRect(0, 0, 1, 1);
|
||||||
|
c.toBlob((png) => {
|
||||||
|
if (!png) { reject(new Error('canvas toBlob returned null')); return; }
|
||||||
|
const draft = {
|
||||||
|
version: 1,
|
||||||
|
stage: 'measure',
|
||||||
|
batch: [],
|
||||||
|
calibrationIndex: null,
|
||||||
|
source: { widthPx: 1, heightPx: 1, blob: png },
|
||||||
|
maskParams: { sheet: 'green', sensitivity: 50 },
|
||||||
|
mask: {
|
||||||
|
slabPx: 0,
|
||||||
|
bbox: { corners: [], longSidePx: 0, angleDeg: 0 },
|
||||||
|
slabBounds: { x: 0, y: 0, w: 0, h: 0 },
|
||||||
|
},
|
||||||
|
axisBbox: null,
|
||||||
|
lengthEighths: payload.lengthEighths,
|
||||||
|
thicknessEighths: payload.thicknessEighths,
|
||||||
|
results: {
|
||||||
|
lengthEighths: payload.lengthEighths,
|
||||||
|
thicknessEighths: payload.thicknessEighths,
|
||||||
|
scale: 8,
|
||||||
|
widths: {
|
||||||
|
min: payload.widthEighths,
|
||||||
|
max: payload.widthEighths,
|
||||||
|
avg: payload.widthEighths,
|
||||||
|
every6In: [],
|
||||||
|
},
|
||||||
|
sqft: payload.sqft,
|
||||||
|
bdft: payload.bdft,
|
||||||
|
sourceBelowFloor: false,
|
||||||
|
},
|
||||||
|
inventoryPng: png,
|
||||||
|
overlayPng: null,
|
||||||
|
batchApply: null,
|
||||||
|
};
|
||||||
|
const req = indexedDB.open('keyval-store', 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains('keyval')) db.createObjectStore('keyval');
|
||||||
|
};
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const db = req.result;
|
||||||
|
const tx = db.transaction('keyval', 'readwrite');
|
||||||
|
tx.objectStore('keyval').put(draft, 'slabuploader-draft');
|
||||||
|
tx.oncomplete = () => { db.close(); resolve(); };
|
||||||
|
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
}, 'image/png');
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
lengthEighths: toEighths(d.lengthIn),
|
||||||
|
thicknessEighths: toEighths(d.thicknessIn),
|
||||||
|
widthEighths,
|
||||||
|
sqft: d.sqft,
|
||||||
|
bdft: d.bdft,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +1,443 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect, type Page, type Route } from '@playwright/test';
|
||||||
|
import { seedMeasureDraft } from './helpers/draft';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M3 Create E2E (QUALITY-50): the Create flow screens render and the publish
|
* M3 Review→Publish E2E (issue #77, QUALITY-50/105): drives the one-column
|
||||||
* control is reachable. Runs against the vite preview (webServer in
|
* create→review→publish flow through the UI against a mocked API
|
||||||
* playwright.config). The backend is the local API; Woo is mocked at the
|
* (`page.route` on the whole /api/v1 namespace; no live backend under e2e).
|
||||||
* host level for the integration path (see backend/tests/test_integration_woo_mock.py).
|
*
|
||||||
* This spec verifies the UI surface, not a live store (that is the UAT walk).
|
* The capture→measure path needs real camera/canvas/Vision workers, so the
|
||||||
|
* deterministic driver is the Resume path: seed an in-progress measure draft
|
||||||
|
* (helpers/draft.ts) → Resume → Continue → mocked createSlab/uploadPhoto →
|
||||||
|
* Review → Publish. All ids are synthetic fixtures (QUALITY-105 / METADATA-5).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
test.describe('M3 Create flow', () => {
|
// --- Fixture taxonomy: fixture ids only, parented like the live Woo tree ---
|
||||||
test('renders all M3 dev screens', async ({ page }) => {
|
const SLAB_ID = '5f3c9a2e-0000-4000-8000-0000000000aa';
|
||||||
await page.goto('/');
|
const SPECIES_GROUper_ID = 10; // "Species" grouper (parent_id null)
|
||||||
// The M3 dev toggles are always present in the toolbar.
|
const SPECIES_ID = 11; // Cedar (parent = species grouper)
|
||||||
const toggles = [
|
const WCAT_ID = 20; // "Wood category" grouper (parent null)
|
||||||
'Settings (dev)',
|
const LIVEEDGE_ID = 21; // Live edge (parent = wcat)
|
||||||
'Slabs (dev)',
|
const BOWL_ID = 22; // Bowl blanks (parent = wcat)
|
||||||
'Photos (dev)',
|
const FIG_TAG_ID = 101; // fig-burl product tag
|
||||||
'Review (dev)',
|
const FEAT_TAG_ID = 201; // feat-mineral-streaks product tag
|
||||||
'Publish (dev)',
|
|
||||||
'Generate (dev)',
|
const TAXONOMY = {
|
||||||
];
|
categories: [
|
||||||
for (const name of toggles) {
|
{ id: SPECIES_GROUper_ID, name: 'Species', slug: 'species', parent_id: null },
|
||||||
await expect(page.getByRole('button', { name })).toBeVisible();
|
{ id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: SPECIES_GROUper_ID },
|
||||||
|
{ id: 12, name: 'Maple', slug: 'maple', parent_id: SPECIES_GROUper_ID },
|
||||||
|
{ id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null },
|
||||||
|
{ id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID },
|
||||||
|
{ id: BOWL_ID, name: 'Bowl blanks', slug: 'bowlblanks', parent_id: WCAT_ID },
|
||||||
|
{ id: 99, name: 'Chairs', slug: 'chairs', parent_id: null },
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{
|
||||||
|
id: 30, name: 'Edge Type', slug: 'edge_type',
|
||||||
|
terms: [
|
||||||
|
{ id: 31, name: 'Live edge', slug: 'live-edge' },
|
||||||
|
{ id: 32, name: 'Square edge', slug: 'square-edge' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 33, name: 'Figure', slug: 'figure',
|
||||||
|
terms: [
|
||||||
|
{ id: 34, name: 'Burl', slug: 'burl' },
|
||||||
|
{ id: 35, name: 'Birdseye', slug: 'birdseye' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 36, name: 'Grade', slug: 'grade',
|
||||||
|
terms: [{ id: 37, name: 'A grade', slug: 'a-grade' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 38, name: 'Thickness', slug: 'thickness',
|
||||||
|
terms: [{ id: 39, name: '2-4 in', slug: '2-4' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 40, name: 'Moisture', slug: 'moisture',
|
||||||
|
terms: [{ id: 41, name: 'Kiln dried', slug: 'kiln-dried' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ id: 100, name: 'fig', slug: 'fig' },
|
||||||
|
{ id: FIG_TAG_ID, name: 'fig-burl', slug: 'fig-burl' },
|
||||||
|
{ id: 102, name: 'fig-birdseye', slug: 'fig-birdseye' },
|
||||||
|
{ id: FEAT_TAG_ID, name: 'feat-mineral-streaks', slug: 'feat-mineral-streaks' },
|
||||||
|
{ id: 202, name: 'feat-knots', slug: 'feat-knots' },
|
||||||
|
],
|
||||||
|
taxonomy_anchor: 'fixture',
|
||||||
|
taxonomy_last_sync_at: '2026-01-01T00:00:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
function settingsView(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
woo_base_url: 'https://example.invalid',
|
||||||
|
woo_wp_username: 'wpuser',
|
||||||
|
woo_app_password: { configured: true },
|
||||||
|
inference_base_url: { configured: false },
|
||||||
|
inference_api_key: { configured: false },
|
||||||
|
inference_model: null,
|
||||||
|
inference_enabled: false,
|
||||||
|
content_llm_enabled: false,
|
||||||
|
brand_voice: null,
|
||||||
|
geo_context: null,
|
||||||
|
woo_create_status: 'draft',
|
||||||
|
taxonomy_last_sync_at: '2026-01-01T00:00:00',
|
||||||
|
taxonomy_anchor: 'fixture',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseSlab(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: SLAB_ID,
|
||||||
|
sku: '',
|
||||||
|
status: 'draft',
|
||||||
|
price: null,
|
||||||
|
price_source: 'recommendation',
|
||||||
|
length_in: 48,
|
||||||
|
thickness_in: 2,
|
||||||
|
sqft: 6.25,
|
||||||
|
bdft: 12.5,
|
||||||
|
width_min_in: 10,
|
||||||
|
width_max_in: 12,
|
||||||
|
width_avg_in: 11,
|
||||||
|
species_id: null,
|
||||||
|
wood_category_ids: [],
|
||||||
|
edge_type_term_id: null,
|
||||||
|
figure_term_ids: [],
|
||||||
|
grade_term_id: null,
|
||||||
|
thickness_term_id: null,
|
||||||
|
moisture_term_id: null,
|
||||||
|
fig_tag_ids: [],
|
||||||
|
feat_tag_ids: [],
|
||||||
|
title: null,
|
||||||
|
short_description: null,
|
||||||
|
description: null,
|
||||||
|
content_source: 'manual',
|
||||||
|
species_confidence: null,
|
||||||
|
figure_confidence: null,
|
||||||
|
grade_confidence: null,
|
||||||
|
inference_status: null,
|
||||||
|
calibration_photo_id: null,
|
||||||
|
woo_product_id: null,
|
||||||
|
server_rev: 0,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiHooks {
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
onPublish?: (route: Route) => Promise<void>;
|
||||||
|
onCall1?: (route: Route) => Promise<void>;
|
||||||
|
onCall2?: (route: Route) => Promise<void>;
|
||||||
|
/** Counts inference endpoints hit (default: record to an array via closure). */
|
||||||
|
calls?: { call1: number; call2: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock the full API for the flow. Slab PUTs are folded into a live row; the
|
||||||
|
* review endpoint recomputes `ready` from the folded row and echoes the primary. */
|
||||||
|
async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
||||||
|
const calls = hooks.calls ?? { call1: 0, call2: 0 };
|
||||||
|
const slab = baseSlab();
|
||||||
|
let primary = null as number | null;
|
||||||
|
|
||||||
|
const merge = (payload: Record<string, unknown>) => {
|
||||||
|
for (const k of [
|
||||||
|
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
|
||||||
|
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
|
||||||
|
'title', 'short_description', 'description', 'content_source', 'price', 'price_source',
|
||||||
|
]) {
|
||||||
|
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
|
||||||
}
|
}
|
||||||
});
|
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
|
||||||
|
};
|
||||||
|
|
||||||
test('Review screen loads the ready-state control', async ({ page }) => {
|
const reviewResult = (payload: Record<string, unknown>) => {
|
||||||
await page.goto('/');
|
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
|
||||||
await page.getByRole('button', { name: 'Review (dev)' }).click();
|
if (typeof payload.price === 'number') slab.price = payload.price;
|
||||||
await expect(page.getByRole('heading', { name: 'Review to Ready' })).toBeVisible();
|
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
|
||||||
await expect(page.getByRole('button', { name: 'Review', exact: true })).toBeVisible();
|
const missing: string[] = [];
|
||||||
});
|
if (!slab.sku) missing.push('sku');
|
||||||
|
if (!slab.species_id) missing.push('species');
|
||||||
|
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
|
||||||
|
if (!slab.edge_type_term_id) missing.push('edge type');
|
||||||
|
if (slab.price == null) missing.push('price');
|
||||||
|
return {
|
||||||
|
ready: missing.length === 0,
|
||||||
|
missing,
|
||||||
|
title: (slab.title as string) ?? null,
|
||||||
|
short_description: (slab.short_description as string) ?? null,
|
||||||
|
price: (slab.price as number) ?? null,
|
||||||
|
price_suggestion: 120,
|
||||||
|
primary_wood_category_id: primary,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
test('Publish screen loads the publish control', async ({ page }) => {
|
await page.route('**/api/v1/**', async (route) => {
|
||||||
await page.goto('/');
|
const req = route.request();
|
||||||
await page.getByRole('button', { name: 'Publish (dev)' }).click();
|
const url = new URL(req.url());
|
||||||
await expect(page.getByRole('heading', { name: 'Publish to WooCommerce' })).toBeVisible();
|
const path = url.pathname;
|
||||||
await expect(page.getByRole('button', { name: 'Publish', exact: true })).toBeVisible();
|
const method = req.method();
|
||||||
});
|
const payload = (() => {
|
||||||
|
try { return req.postDataJSON() as Record<string, unknown>; } catch { return {}; }
|
||||||
|
})();
|
||||||
|
|
||||||
test('Generate control is visible only when enabled + reachable', async ({ page }) => {
|
if (path === '/api/v1/settings' && method === 'GET') {
|
||||||
await page.goto('/');
|
await json(route, settingsView(hooks.settings));
|
||||||
await page.getByRole('button', { name: 'Generate (dev)' }).click();
|
return;
|
||||||
await expect(page.getByRole('heading', { name: 'Generate text' })).toBeVisible();
|
|
||||||
// With no inference configured, the control is disabled with a plain note.
|
|
||||||
const note = page.locator('.note');
|
|
||||||
const state = page.locator('.state');
|
|
||||||
await expect(state).toContainText(/endpoint: (reachable|unreachable)/);
|
|
||||||
const text = (await state.textContent()) ?? '';
|
|
||||||
if (text.includes('unreachable')) {
|
|
||||||
await expect(note).toBeVisible();
|
|
||||||
}
|
}
|
||||||
|
if (path === '/api/v1/admin/taxonomy' && method === 'GET') {
|
||||||
|
await json(route, TAXONOMY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/slabs' && method === 'POST') {
|
||||||
|
// The client generates the slab id (crypto.randomUUID, APP-20); adopt it so
|
||||||
|
// the subsequent photo/review/publish paths (keyed by this id) resolve.
|
||||||
|
slab.id = String(payload.id ?? SLAB_ID);
|
||||||
|
merge(payload); // created with capture math; client fields empty
|
||||||
|
await json(route, slab, 201);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
|
||||||
|
merge(payload);
|
||||||
|
await json(route, slab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') {
|
||||||
|
await json(route, slab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
|
||||||
|
// Photo uploads are multipart; `payload` is {} — the client's photo_id is
|
||||||
|
// in the form body, not JSON, so echo a stable synthetic id.
|
||||||
|
await json(route, {
|
||||||
|
id: 'photo-1',
|
||||||
|
slab_id: slab.id,
|
||||||
|
seq: 1,
|
||||||
|
is_calibration: 1,
|
||||||
|
original_retained: false,
|
||||||
|
processed_retained: true,
|
||||||
|
}, 201);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') {
|
||||||
|
await json(route, reviewResult(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
|
||||||
|
if (hooks.onPublish) { await hooks.onPublish(route); return; }
|
||||||
|
await json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/inference/call1` && method === 'POST') {
|
||||||
|
calls.call1 += 1;
|
||||||
|
if (hooks.onCall1) { await hooks.onCall1(route); return; }
|
||||||
|
await json(route, { ok: false, suggestions: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/inference/call2` && method === 'POST') {
|
||||||
|
calls.call2 += 1;
|
||||||
|
if (hooks.onCall2) { await hooks.onCall2(route); return; }
|
||||||
|
await json(route, { ok: false, description: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, { error: { code: 'not_found', message: path } }, 404);
|
||||||
|
});
|
||||||
|
return { calls, slab };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and
|
||||||
|
// drive Resume → Continue to land in Review.
|
||||||
|
async function reachReview(page: Page) {
|
||||||
|
// Phase 1: boot with ?noload so the app mounts (real origin for IndexedDB)
|
||||||
|
// WITHOUT probing for a draft. Then seed the measure draft into IDB.
|
||||||
|
await page.goto('/?noload');
|
||||||
|
await seedMeasureDraft(page, {
|
||||||
|
sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11,
|
||||||
|
});
|
||||||
|
// Phase 2: reload WITHOUT ?noload so onMount probes IDB and shows Resume.
|
||||||
|
await page.goto('/');
|
||||||
|
// Resume prompt → Continue (restores measure). The mask re-derivation runs in
|
||||||
|
// a worker; wait for the Photos-ready Continue control.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 });
|
||||||
|
// Continue → mocked createSlab + uploadPhoto → Review screen.
|
||||||
|
await page.getByTestId('continue-to-review').click();
|
||||||
|
await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill every ready-gating field to make the slab publishable. */
|
||||||
|
async function completeForReady(page: Page) {
|
||||||
|
await page.getByTestId('species').selectOption(String(SPECIES_ID));
|
||||||
|
// Wood categories: open, select Live edge, Done.
|
||||||
|
await page.getByTestId('wood-cat-btn').click();
|
||||||
|
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
|
||||||
|
await page.getByTestId('wood-cat-done').click();
|
||||||
|
await page.getByTestId('edge-type').selectOption('31');
|
||||||
|
await page.getByTestId('price').fill('120');
|
||||||
|
await page.getByTestId('sku').fill('SLAB-0001');
|
||||||
|
// SKU commits on blur — press Tab to move focus off it (deterministic, single
|
||||||
|
// commit; a body click can re-focus the SKU and re-fire blur).
|
||||||
|
await page.getByTestId('sku').press('Tab');
|
||||||
|
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('M3 Review→Publish flow (#77)', () => {
|
||||||
|
test('golden path: capture→review→ready→publish→done', async ({ page }) => {
|
||||||
|
await mockApi(page);
|
||||||
|
await reachReview(page);
|
||||||
|
// Not ready until fields are set.
|
||||||
|
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
|
||||||
|
await completeForReady(page);
|
||||||
|
// Continue to Publish is enabled and hands off.
|
||||||
|
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||||
|
await page.getByTestId('continue-publish').click();
|
||||||
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||||
|
// Publish → success → Done panel (draft in UAT store).
|
||||||
|
await page.getByTestId('publish-btn').click();
|
||||||
|
await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByText(/Woo product #4242/)).toBeVisible();
|
||||||
|
await expect(page.getByTestId('new-slab')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inference off: no inference calls are made', async ({ page }) => {
|
||||||
|
const { calls } = await mockApi(page, { settings: { inference_enabled: false } });
|
||||||
|
await reachReview(page);
|
||||||
|
await completeForReady(page);
|
||||||
|
// No "Suggest with AI" control when inference is disabled, and zero hits.
|
||||||
|
await expect(page.getByTestId('run-inference')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('generate-text')).toHaveCount(0);
|
||||||
|
expect(calls.call1).toBe(0);
|
||||||
|
expect(calls.call2).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inference on: Suggest with AI prefills + AI badges; Generate text writes', async ({ page }) => {
|
||||||
|
await mockApi(page, {
|
||||||
|
settings: { inference_enabled: true, content_llm_enabled: true },
|
||||||
|
onCall1: async (route) => {
|
||||||
|
await json(route, {
|
||||||
|
ok: true,
|
||||||
|
suggestions: {
|
||||||
|
species_id: SPECIES_ID,
|
||||||
|
species_confidence: 0.9,
|
||||||
|
wood_category_ids: [LIVEEDGE_ID],
|
||||||
|
edge_type_term_id: 31,
|
||||||
|
figure_term_ids: [34, 35],
|
||||||
|
grade_term_id: 37,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCall2: async (route) => {
|
||||||
|
await json(route, { ok: true, description: 'AI-generated slab story.' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await reachReview(page);
|
||||||
|
await page.getByTestId('run-inference').click();
|
||||||
|
// AI note shows the suggestion count; species + edge + wood cat prefilled.
|
||||||
|
await expect(page.getByTestId('ai-note')).toContainText(/AI suggested/i, { timeout: 15000 });
|
||||||
|
await expect(page.getByTestId('species')).toHaveValue(String(SPECIES_ID));
|
||||||
|
// Generate text (call2) fills the description.
|
||||||
|
await page.getByTestId('generate-text').click();
|
||||||
|
await expect(page.getByTestId('desc')).toHaveValue('AI-generated slab story.', { timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fig-*/feat-* tag pickers render (optional) and persist to the slab', async ({ page }) => {
|
||||||
|
const { slab } = await mockApi(page);
|
||||||
|
await reachReview(page);
|
||||||
|
// Both pickers render from the synced tag families (METADATA-30 / METADATA-40).
|
||||||
|
await expect(page.getByTestId('fig-tag-btn')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('feat-tag-btn')).toBeVisible();
|
||||||
|
// feat-* carries the "AI assist" tag (Call 1 proposes feature tags); fig-* does not.
|
||||||
|
// Scope to the field label (getByText would also match the MultiSelect's inner label).
|
||||||
|
await expect(page.locator('label', { hasText: 'feat-* tags' })).toContainText('AI assist');
|
||||||
|
await expect(page.locator('label', { hasText: 'fig-* tags' })).not.toContainText('AI assist');
|
||||||
|
// Select one fig tag and one feat tag.
|
||||||
|
await page.getByTestId('fig-tag-btn').click();
|
||||||
|
await page.getByTestId(`fig-tag-opt-${FIG_TAG_ID}`).click();
|
||||||
|
await page.getByTestId('fig-tag-done').click();
|
||||||
|
await expect(page.getByTestId('fig-tag-btn')).toContainText('1 selected');
|
||||||
|
await page.getByTestId('feat-tag-btn').click();
|
||||||
|
await page.getByTestId(`feat-tag-opt-${FEAT_TAG_ID}`).click();
|
||||||
|
await page.getByTestId('feat-tag-done').click();
|
||||||
|
await expect(page.getByTestId('feat-tag-btn')).toContainText('1 selected');
|
||||||
|
// Flush the tag ids to the slab: an on:change field (grade) triggers
|
||||||
|
// commitReview, which PUTs fig_tag_ids/feat_tag_ids with the current values.
|
||||||
|
await page.getByTestId('grade').selectOption('37');
|
||||||
|
// Tags are optional — they are not in the server ready-set, so readiness is
|
||||||
|
// unchanged (still "Not ready" because the other required fields are empty).
|
||||||
|
await expect(page.getByTestId('ready-status')).toContainText('Not ready');
|
||||||
|
// The commit PUT folded the tag ids into the slab row (what Publish will send).
|
||||||
|
expect(slab.fig_tag_ids).toEqual([FIG_TAG_ID]);
|
||||||
|
expect(slab.feat_tag_ids).toEqual([FEAT_TAG_ID]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publish failure: duplicate SKU offers edit + open existing', async ({ page }) => {
|
||||||
|
await mockApi(page, {
|
||||||
|
onPublish: async (route) => {
|
||||||
|
await json(route, {
|
||||||
|
error: { code: 'duplicate_sku', message: 'SKU already exists', detail: {} },
|
||||||
|
}, 409);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await reachReview(page);
|
||||||
|
await completeForReady(page);
|
||||||
|
await page.getByTestId('continue-publish').click();
|
||||||
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||||
|
await page.getByTestId('publish-btn').click();
|
||||||
|
await expect(page.getByTestId('dup-sku')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByTestId('edit-sku')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('open-existing')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publish failure: recoverable Woo error offers retry', async ({ page }) => {
|
||||||
|
await mockApi(page, {
|
||||||
|
onPublish: async (route) => {
|
||||||
|
await json(route, {
|
||||||
|
error: {
|
||||||
|
code: 'woo_unreachable',
|
||||||
|
message: 'Store is unreachable',
|
||||||
|
detail: { recoverable: true, partial_media: false },
|
||||||
|
},
|
||||||
|
}, 502);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await reachReview(page);
|
||||||
|
await completeForReady(page);
|
||||||
|
await page.getByTestId('continue-publish').click();
|
||||||
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||||
|
await page.getByTestId('publish-btn').click();
|
||||||
|
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByTestId('store-failure')).toContainText('Store is unreachable');
|
||||||
|
await expect(page.getByTestId('retry-publish')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publish failure: partial media is flagged for manual cleanup', async ({ page }) => {
|
||||||
|
await mockApi(page, {
|
||||||
|
onPublish: async (route) => {
|
||||||
|
await json(route, {
|
||||||
|
error: {
|
||||||
|
code: 'woo_partial_media',
|
||||||
|
message: 'Product created, photos failed',
|
||||||
|
detail: { recoverable: true, partial_media: true },
|
||||||
|
},
|
||||||
|
}, 502);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await reachReview(page);
|
||||||
|
await completeForReady(page);
|
||||||
|
await page.getByTestId('continue-publish').click();
|
||||||
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||||
|
await page.getByTestId('publish-btn').click();
|
||||||
|
await expect(page.getByTestId('store-failure')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByTestId('store-failure')).toContainText(/orphan media/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user