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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user