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:
Ty
2026-09-20 14:34:24 -07:00
parent 2e2be48154
commit 1133201f30
12 changed files with 1996 additions and 275 deletions
+127 -59
View File
@@ -1,69 +1,137 @@
<script lang="ts">
let slabId = '';
let result: {
ok?: boolean;
status?: string;
woo_product_id?: number;
draft?: boolean;
error?: { code: string; message: string; detail?: Record<string, unknown> };
} | null = null;
let busy = false;
import { onMount, createEventDispatcher } from 'svelte';
import { getSlab, publishSlab, ApiError, type Slab } from '../../api/slabs';
async function publish() {
if (!slabId) {
result = { error: { code: 'input', message: 'Enter a slab id.' } };
return;
}
busy = true;
result = null;
try {
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { method: 'POST' });
const body = await res.json();
if (res.ok) result = body;
else result = { ok: false, error: body.error };
} catch {
result = { ok: false, error: { code: 'network', message: 'network error' } };
}
busy = false;
// One-column flow: Review hands to Publish by id (UX.md §1).
export let slabId: string;
export let onPublished: (wooProductId: number, draft: boolean) => void;
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;
};
}
let st: PublishState = { phase: 'idle' };
// Map backend codes to the recovery branches (PUBLISH-30/31, QUALITY-120).
function classify(code: string | null, message: string, detail: Record<string, unknown> | null): PublishState['fail'] {
const d = detail ?? {};
return {
code,
message,
recoverable: d.recoverable === true,
partialMedia: d.partial_media === true,
duplicateSku: code === 'duplicate_sku',
};
}
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>
<div class="publish">
<h2>Publish to WooCommerce</h2>
<div>
<label>Slab ID</label>
<input bind:value={slabId} placeholder="uuid" />
</div>
<button on:click={publish} disabled={busy}>{busy ? 'Publishing…' : 'Publish'}</button>
{#if loading}
<section class="pub" aria-busy="true" data-testid="publish-loading"><h1>Publish to WooCommerce</h1><p class="busy">Loading…</p></section>
{:else if loadError}
<section class="pub"><h1>Publish to WooCommerce</h1><p class="err" role="alert">{loadError}</p>
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
</section>
{: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 result.ok}
<p class="ok">
Published{result.draft ? ' (UAT draft)' : ''} → Woo product #{result.woo_product_id}
</p>
{:else}
<p class="err">
{result.error?.message}
{#if result.error?.detail?.recoverable === false}
<span class="tag">unrecoverable</span>
{:else if result.error?.detail?.recoverable === true}
<span class="tag">recoverable — retry</span>
{/if}
{#if result.error?.detail?.partial_media}
<span class="tag warn">orphan media — clean up manually</span>
{/if}
</p>
{#if st.phase === 'idle' || st.phase === 'working'}
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish} disabled={st.phase === 'working'}>
{st.phase === 'working' ? 'Publishing…' : 'Publish'}
</button>
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview} disabled={st.phase === 'working'}>Back to Review</button>
{/if}
{/if}
</div>
{#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}
<div class="step-x" data-testid="store-failure">
<div class="x n" class:rec={st.fail.recoverable}>{st.fail.partialMedia ? '⚠' : '↻'}</div>
<h2>{st.fail.message}</h2>
{#if st.fail.recoverable}<span class="tag">recoverable</span>{/if}
{#if st.fail.partialMedia}<span class="tag warn">orphan media — remove manually</span>{/if}
{#if !st.fail.recoverable}<span class="tag bad">check Settings</span>{/if}
</div>
<button type="button" class="primary" data-testid="retry-publish" on:click={doPublish}>Retry</button>
{/if}
{/if}
</section>
{/if}
<style>
.publish { max-width: 520px; color: #eee; }
label { display: block; margin: 8px 0 4px; }
input { width: 100%; padding: 6px; }
button { margin-top: 12px; }
.ok { margin-top: 12px; color: #7fd37f; font-weight: 600; }
.err { margin-top: 12px; color: #e88; }
.tag { margin-left: 8px; font-size: 12px; padding: 1px 6px; border-radius: 4px; background: #444; }
.tag.warn { background: #6a4; color: #000; }
.pub { max-width: 520px; margin: 0 auto; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 6px; } h2 { font-size: 1.1rem; margin: 0 0 6px; }
.meta { color: #888; font-size: .82rem; margin: 4px 0 10px; }
.primary { width: 100%; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; margin-top: 12px; }
.primary:disabled { background: #333; color: #777; }
.ghost { width: 100%; padding: 13px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; margin-top: 10px; }
.status { font-weight: 700; padding: 11px 13px; border-radius: 3px; font-size: .92rem; }
.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>