73e28180c8
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.
193 lines
8.1 KiB
Svelte
193 lines
8.1 KiB
Svelte
<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>
|