feat(publish): Publish preview before Woo draft POST

After Review → Continue to Publish, show a glanceable confirmation:
ordered photo thumbs, SKU/title/price, taxonomy + dims, short and long
descriptions (plain text). Back to Review edits; Publish posts the draft.
Reuses slab/taxonomy/photos only — no invented fields.
This commit is contained in:
grokbot_ty
2026-09-26 11:47:18 -07:00
parent 8f8e0132e6
commit 73e28180c8
6 changed files with 542 additions and 23 deletions
+74 -23
View File
@@ -1,14 +1,23 @@
<script lang="ts">
import { onMount, createEventDispatcher } from 'svelte';
import { getSlab, publishSlab, ApiError, type Slab } from '../../api/slabs';
import {
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
type Slab, type Taxonomy, type PhotoRow,
} from '../../api/slabs';
import PublishPreview from './PublishPreview.svelte';
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
// One-column flow: Review hands to Publish by id (UX.md §1).
// Idle phase = Publish preview (glanceable confirm) before Woo draft POST.
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 tax: Taxonomy | null = null;
let photos: PhotoRow[] = [];
let preview: PublishPreviewModel | null = null;
let loading = true;
let loadError = '';
@@ -60,30 +69,60 @@
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; }
try {
const [s, t, ph] = await Promise.all([
getSlab(slabId),
getTaxonomy().catch(() => null),
listPhotos(slabId).catch(() => [] as PhotoRow[]),
]);
slab = s;
tax = t;
photos = ph;
preview = buildPublishPreviewModel(
s,
t,
ph,
(id, kind) => photoUrl(slabId, id, kind),
);
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
} finally {
loading = false;
}
});
</script>
{#if loading}
<section class="pub" aria-busy="true" data-testid="publish-loading"><h1>Publish to WooCommerce</h1><p class="busy">Loading…</p></section>
<section class="pub" aria-busy="true" data-testid="publish-loading">
<h1>Publish preview</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>
<section class="pub">
<h1>Publish preview</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>
<h1>Publish preview</h1>
<p class="lede">Confirm what’s about to publish to WooCommerce as a draft.</p>
{#if preview && (st.phase === 'idle' || st.phase === 'working')}
<PublishPreview model={preview} />
{:else if slab && st.phase !== 'idle' && st.phase !== 'working'}
<p class="meta">SKU {slab.sku || '—'} · {slab.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
{/if}
{#if st.phase === 'idle'}
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish}>
Publish
</button>
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
<div class="row">
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish}>
Publish
</button>
</div>
{:else if st.phase === 'working'}
<button type="button" class="primary" data-testid="publish-btn" disabled>
<button type="button" class="primary full" data-testid="publish-btn" disabled>
Publishing…
</button>
{/if}
@@ -91,7 +130,7 @@
{#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>
<button type="button" class="primary full" 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">
@@ -99,15 +138,15 @@
<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>
<button type="button" class="primary full" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
<button type="button" class="ghost full" 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>
<button type="button" class="primary full" 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>
@@ -116,19 +155,31 @@
{#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>
<button type="button" class="primary full" data-testid="retry-publish" on:click={doPublish}>Retry</button>
{/if}
{/if}
</section>
{/if}
<style>
.pub { max-width: 520px; margin: 0 auto; color: #eee; padding: 4px 4px 16px; box-sizing: border-box; }
h1 { font-size: 1.3rem; margin: 0 0 6px; } h2 { font-size: 1.1rem; margin: 0 0 6px; }
/* Match Review margins (UI nits): tight horizontal padding, mobile-first column. */
.pub { max-width: 560px; margin: 0 auto; color: #eee; padding: 4px 4px 20px; box-sizing: border-box; }
h1 { font-size: 1.3rem; margin: 0 0 6px; }
h2 { font-size: 1.1rem; margin: 0 0 6px; }
.lede { color: #9a9a9a; font-size: .82rem; margin: 0 0 14px; }
.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; }
.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; cursor: pointer;
}
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
.primary.full, .ghost.full { width: 100%; margin-top: 12px; }
.ghost {
box-sizing: border-box; padding: 14px 18px; border-radius: 3px;
border: 1px solid #444; background: transparent; color: #ccc;
font-size: .9rem; font-weight: 600; cursor: pointer;
}
.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; }