Files
SlabUploader/frontend/src/create/publish/Publish.svelte
T
grokbot_ty 73e28180c8 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.
2026-09-26 11:47:18 -07:00

193 lines
8.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, createEventDispatcher } from 'svelte';
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 = '';
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 {
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 preview</h1>
<p class="busy">Loading…</p>
</section>
{:else if loadError}
<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 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'}
<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 full" data-testid="publish-btn" disabled>
Publishing…
</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 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">
<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 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 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>
<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 full" data-testid="retry-publish" on:click={doPublish}>Retry</button>
{/if}
{/if}
</section>
{/if}
<style>
/* 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; }
.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; }
.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>