e36979617b
- frontend: uuid() helper (crypto.randomUUID -> getRandomValues) for plain-HTTP LAN UAT - slabs.py: empty SKU at create -> NULL (no-SKU-yet is transient, filled in Review) - review.py: ready-gate enforces REVIEW-45 (SKU 3-24 alnum+hyphen) in the middle tier - DB CHECK stays as backstop only (referential/structural), per layering principle
270 lines
9.1 KiB
Svelte
270 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { supported, uuid } from './browser';
|
|
import { createCaptureSession, type CaptureSession } from './capture/session';
|
|
import type { Capture, Measures } from './capture/capture.types';
|
|
import { eighthsToIn } from './capture/capture.types';
|
|
import { buildPhotosReadyLineup } from './capture/photosReadyLineup';
|
|
import Resume from './capture/Resume.svelte';
|
|
import BatchPhotos from './capture/BatchPhotos.svelte';
|
|
import PickCalibration from './capture/PickCalibration.svelte';
|
|
import MaskPanel from './capture/MaskPanel.svelte';
|
|
import AxisPanel from './capture/AxisPanel.svelte';
|
|
import MeasurePanel from './capture/MeasurePanel.svelte';
|
|
import Settings from './create/settings/Settings.svelte';
|
|
import SlabList from './create/slabs/SlabList.svelte';
|
|
import Review from './create/review/Review.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 session: CaptureSession = createCaptureSession();
|
|
|
|
let cap: Capture = { k: 'batch', photos: [] };
|
|
let busy = false;
|
|
let pngUrl: string | null = null;
|
|
let showSettings = false;
|
|
let showSlabs = false;
|
|
|
|
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
|
$: curFlow = $flow;
|
|
let saving = false;
|
|
let saveError = '';
|
|
|
|
const unsubCap = session.capture.subscribe((c) => {
|
|
cap = c;
|
|
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
|
});
|
|
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
|
|
|
onMount(() => {
|
|
// 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(() => {
|
|
unsubCap();
|
|
unsubBusy();
|
|
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 = uuid();
|
|
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: uuid(),
|
|
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>
|
|
|
|
{#if gate}
|
|
<main class="hardstop">
|
|
<h1>Unsupported browser</h1>
|
|
<p>
|
|
SlabUploader needs a modern browser with canvas, workers, and IndexedDB support:
|
|
iOS Safari, Android Chrome, or desktop Chrome, Edge, or Firefox.
|
|
</p>
|
|
<p class="detail">Missing: {gate}</p>
|
|
</main>
|
|
{:else}
|
|
<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'}
|
|
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
|
|
{:else if cap.k === 'batch'}
|
|
<BatchPhotos
|
|
photos={cap.photos}
|
|
onAdd={(f) => void session.addToBatch(f)}
|
|
onRemove={session.removeFromBatch}
|
|
onContinue={session.continueBatch}
|
|
/>
|
|
{:else if cap.k === 'pickCalibration'}
|
|
<PickCalibration
|
|
photos={cap.photos}
|
|
onPick={session.pickCalibration}
|
|
onBack={session.back}
|
|
/>
|
|
{:else if cap.k === 'mask'}
|
|
<MaskPanel
|
|
state={cap}
|
|
{busy}
|
|
setSensitivity={session.setSensitivity}
|
|
setSheet={session.setSheet}
|
|
approveMask={session.approveMask}
|
|
retakes={session.retakes}
|
|
back={session.back}
|
|
/>
|
|
{:else if cap.k === 'axis'}
|
|
<AxisPanel
|
|
state={cap}
|
|
setAxisAngle={session.setAxisAngle}
|
|
approveAxis={session.approveAxis}
|
|
back={session.back}
|
|
/>
|
|
{:else}
|
|
<MeasurePanel
|
|
state={cap}
|
|
setLength={session.setLength}
|
|
setThickness={session.setThickness}
|
|
calculate={session.calculate}
|
|
back={session.back}
|
|
{pngUrl}
|
|
onContinue={saving ? undefined : () => void continueToReview()}
|
|
/>
|
|
{/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">
|
|
<button on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
|
|
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
|
|
</div>
|
|
{#if showSettings}
|
|
<Settings />
|
|
{/if}
|
|
{#if showSlabs}
|
|
<SlabList />
|
|
{/if}
|
|
</main>
|
|
{/if}
|
|
|
|
<style>
|
|
:global(body) {
|
|
margin: 0;
|
|
background: #121212;
|
|
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
|
}
|
|
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
|
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
|
.hardstop h1 { font-size: 1.6rem; }
|
|
.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>
|