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:
@@ -1,14 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, createEventDispatcher } from 'svelte';
|
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).
|
// 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 slabId: string;
|
||||||
export let onPublished: (wooProductId: number, draft: boolean) => void;
|
export let onPublished: (wooProductId: number, draft: boolean) => void;
|
||||||
export let onBack: () => void;
|
export let onBack: () => void;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
let slab: Slab | null = null;
|
let slab: Slab | null = null;
|
||||||
|
let tax: Taxonomy | null = null;
|
||||||
|
let photos: PhotoRow[] = [];
|
||||||
|
let preview: PublishPreviewModel | null = null;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let loadError = '';
|
let loadError = '';
|
||||||
|
|
||||||
@@ -60,30 +69,60 @@
|
|||||||
function backToReview() { onBack(); }
|
function backToReview() { onBack(); }
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try { slab = await getSlab(slabId); }
|
try {
|
||||||
catch (e) { loadError = e instanceof ApiError ? e.message : 'Could not load the slab.'; }
|
const [s, t, ph] = await Promise.all([
|
||||||
finally { loading = false; }
|
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>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#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}
|
{: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>
|
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="pub" data-testid="publish-screen">
|
<section class="pub" data-testid="publish-screen">
|
||||||
<h1>Publish to WooCommerce</h1>
|
<h1>Publish preview</h1>
|
||||||
<p class="meta">SKU {slab?.sku || '—'} · {slab?.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab?.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
|
<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'}
|
{#if st.phase === 'idle'}
|
||||||
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish}>
|
<div class="row">
|
||||||
Publish
|
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
||||||
</button>
|
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish}>
|
||||||
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
|
Publish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else if st.phase === 'working'}
|
{: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…
|
Publishing…
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -91,7 +130,7 @@
|
|||||||
{#if st.phase === 'success'}
|
{#if st.phase === 'success'}
|
||||||
<div class="status ok" data-testid="publish-ok">Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}</div>
|
<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>
|
<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}
|
{:else if st.phase === 'failure' && st.fail}
|
||||||
{#if st.fail.duplicateSku}
|
{#if st.fail.duplicateSku}
|
||||||
<div class="step-x" data-testid="dup-sku">
|
<div class="step-x" data-testid="dup-sku">
|
||||||
@@ -99,15 +138,15 @@
|
|||||||
<h2>SKU already exists</h2>
|
<h2>SKU already exists</h2>
|
||||||
<p class="meta">“{slab?.sku}” is already in the store.</p>
|
<p class="meta">“{slab?.sku}” is already in the store.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="primary" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
|
<button type="button" class="primary full" 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="ghost full" data-testid="open-existing" on:click={openExisting}>Open existing product</button>
|
||||||
{:else if st.fail.code === 'unpublishable'}
|
{:else if st.fail.code === 'unpublishable'}
|
||||||
<div class="step-x" data-testid="not-ready">
|
<div class="step-x" data-testid="not-ready">
|
||||||
<div class="x n">✕</div>
|
<div class="x n">✕</div>
|
||||||
<h2>Not ready to publish</h2>
|
<h2>Not ready to publish</h2>
|
||||||
<p class="meta">Some fields are missing. Go back and finish Review.</p>
|
<p class="meta">Some fields are missing. Go back and finish Review.</p>
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<div class="step-x" data-testid="store-failure">
|
<div class="step-x" data-testid="store-failure">
|
||||||
<div class="x n" class:rec={st.fail.recoverable}>{st.fail.partialMedia ? '⚠' : '↻'}</div>
|
<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.partialMedia}<span class="tag warn">orphan media — remove manually</span>{/if}
|
||||||
{#if !st.fail.recoverable}<span class="tag bad">check Settings</span>{/if}
|
{#if !st.fail.recoverable}<span class="tag bad">check Settings</span>{/if}
|
||||||
</div>
|
</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}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.pub { max-width: 520px; margin: 0 auto; color: #eee; padding: 4px 4px 16px; box-sizing: border-box; }
|
/* Match Review margins (UI nits): tight horizontal padding, mobile-first column. */
|
||||||
h1 { font-size: 1.3rem; margin: 0 0 6px; } h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
.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; }
|
.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; }
|
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||||
.primary:disabled { background: #333; color: #777; }
|
.primary {
|
||||||
.ghost { width: 100%; padding: 13px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; margin-top: 10px; }
|
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 { font-weight: 700; padding: 11px 13px; border-radius: 3px; font-size: .92rem; }
|
||||||
.status.ok { background: #122a12; color: #7fd37f; }
|
.status.ok { background: #122a12; color: #7fd37f; }
|
||||||
.step-x { background: #2a1512; border-radius: 3px; padding: 14px; margin: 14px 0; }
|
.step-x { background: #2a1512; border-radius: 3px; padding: 14px; margin: 14px 0; }
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PublishPreviewModel } from './publishPreviewModel';
|
||||||
|
|
||||||
|
export let model: PublishPreviewModel;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="preview" data-testid="publish-preview">
|
||||||
|
{#if model.photos.length}
|
||||||
|
<div class="thumbs" data-testid="preview-thumbs" aria-label="Photo lineup">
|
||||||
|
{#each model.photos as p, i (p.id)}
|
||||||
|
<div class="thumb" class:primary={p.primary} data-testid="preview-thumb" data-primary={p.primary ? 'true' : ''}>
|
||||||
|
<img src={p.url} alt="Photo {i + 1}" />
|
||||||
|
{#if p.primary}<span class="pri">PRIMARY</span>{/if}
|
||||||
|
<span class="seq" aria-hidden="true">{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="head" data-testid="preview-head">
|
||||||
|
<div class="sku" data-testid="preview-sku">SKU {model.sku}</div>
|
||||||
|
<h2 class="title" data-testid="preview-title">{model.title}</h2>
|
||||||
|
<div class="price" data-testid="preview-price">{model.price}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="facts" data-testid="preview-facts">
|
||||||
|
{#each model.facts as f (f.label)}
|
||||||
|
<div class="fact" data-testid="preview-fact" data-label={f.label}>
|
||||||
|
<dt>{f.label}</dt>
|
||||||
|
<dd>{f.value}</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="block" data-testid="preview-short">
|
||||||
|
<div class="lbl">Short description</div>
|
||||||
|
<p class="body short">{model.shortDescription}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block" data-testid="preview-long">
|
||||||
|
<div class="lbl">Description</div>
|
||||||
|
<pre class="body long">{model.description}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.preview { color: #eee; }
|
||||||
|
.thumbs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.thumbs { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 2px solid #2a2a2a;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #eee;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg,#ccc 25%,transparent 25%),
|
||||||
|
linear-gradient(-45deg,#ccc 25%,transparent 25%),
|
||||||
|
linear-gradient(45deg,transparent 75%,#ccc 75%),
|
||||||
|
linear-gradient(-45deg,transparent 75%,#ccc 75%);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-position: 0 0,0 8px,8px -8px,-8px 0;
|
||||||
|
}
|
||||||
|
.thumb.primary { border-color: #00e5ff; }
|
||||||
|
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.pri {
|
||||||
|
position: absolute; top: 4px; right: 4px;
|
||||||
|
background: #00e5ff; color: #002;
|
||||||
|
font-size: .52rem; font-weight: 800;
|
||||||
|
padding: 1px 4px; border-radius: 2px; letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
.seq {
|
||||||
|
position: absolute; bottom: 4px; left: 4px;
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: rgba(0,0,0,.7); color: #fff;
|
||||||
|
font-size: .62rem; display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head { margin: 0 0 12px; }
|
||||||
|
.sku { color: #888; font-size: .78rem; margin: 0 0 4px; }
|
||||||
|
.title { font-size: 1.15rem; font-weight: 700; margin: 0 0 6px; line-height: 1.3; }
|
||||||
|
.price { font-size: 1.05rem; font-weight: 700; color: #7fd37f; }
|
||||||
|
|
||||||
|
.facts { margin: 0 0 14px; }
|
||||||
|
.fact {
|
||||||
|
display: flex; justify-content: space-between; gap: 12px;
|
||||||
|
border-bottom: 1px solid #2a2a2a; padding: 6px 0; font-size: .85rem;
|
||||||
|
}
|
||||||
|
.fact dt { color: #9a9a9a; margin: 0; flex: none; }
|
||||||
|
.fact dd {
|
||||||
|
margin: 0; text-align: right; font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block { margin: 0 0 14px; }
|
||||||
|
.lbl { display: block; font-size: .78rem; color: #bbb; margin: 0 0 4px; }
|
||||||
|
.body {
|
||||||
|
margin: 0; padding: 10px 12px;
|
||||||
|
background: #141414; border: 1px solid #333; border-radius: 3px;
|
||||||
|
color: #ddd; font-size: .86rem; line-height: 1.45;
|
||||||
|
}
|
||||||
|
.short { white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.long {
|
||||||
|
white-space: pre-wrap; word-break: break-word;
|
||||||
|
font-family: inherit; max-height: 320px; overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
const dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const publishSrc = readFileSync(join(dir, 'Publish.svelte'), 'utf8');
|
||||||
|
const previewSrc = readFileSync(join(dir, 'PublishPreview.svelte'), 'utf8');
|
||||||
|
const modelSrc = readFileSync(join(dir, 'publishPreviewModel.ts'), 'utf8');
|
||||||
|
|
||||||
|
test('Publish uses dedicated PublishPreview before Woo POST', () => {
|
||||||
|
assert.match(publishSrc, /import PublishPreview from '\.\/PublishPreview\.svelte'/);
|
||||||
|
assert.match(publishSrc, /buildPublishPreviewModel/);
|
||||||
|
assert.match(publishSrc, /data-testid="publish-preview"|PublishPreview/);
|
||||||
|
// Idle confirm still posts only via doPublish / publish-btn
|
||||||
|
assert.match(publishSrc, /data-testid="publish-btn"/);
|
||||||
|
assert.match(publishSrc, /data-testid="back-review"/);
|
||||||
|
assert.match(publishSrc, /publishSlab\(slabId\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PublishPreview layout: thumbs → head → facts → short → long', () => {
|
||||||
|
assert.match(previewSrc, /data-testid="publish-preview"/);
|
||||||
|
assert.match(previewSrc, /data-testid="preview-thumbs"/);
|
||||||
|
assert.match(previewSrc, /data-testid="preview-head"/);
|
||||||
|
assert.match(previewSrc, /data-testid="preview-sku"/);
|
||||||
|
assert.match(previewSrc, /data-testid="preview-title"/);
|
||||||
|
assert.match(previewSrc, /data-testid="preview-price"/);
|
||||||
|
assert.match(previewSrc, /data-testid="preview-facts"/);
|
||||||
|
assert.match(previewSrc, /data-testid="preview-short"/);
|
||||||
|
assert.match(previewSrc, /data-testid="preview-long"/);
|
||||||
|
// Plain-text long description (pre-wrap), not HTML render
|
||||||
|
assert.match(previewSrc, /white-space:\s*pre-wrap/);
|
||||||
|
assert.match(previewSrc, /<pre class="body long"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Preview model resolves taxonomy names; does not invent fields', () => {
|
||||||
|
assert.match(modelSrc, /species_id/);
|
||||||
|
assert.match(modelSrc, /wood_category_ids/);
|
||||||
|
assert.match(modelSrc, /edge_type_term_id/);
|
||||||
|
assert.match(modelSrc, /figure_term_ids/);
|
||||||
|
assert.match(modelSrc, /grade_term_id/);
|
||||||
|
assert.match(modelSrc, /moisture_term_id/);
|
||||||
|
assert.match(modelSrc, /feat_tag_ids/);
|
||||||
|
assert.match(modelSrc, /width_min_in/);
|
||||||
|
assert.match(modelSrc, /bdft/);
|
||||||
|
assert.match(modelSrc, /decodeName/);
|
||||||
|
// No invented brand/voice fields
|
||||||
|
assert.equal(modelSrc.includes('brand_voice'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import {
|
||||||
|
buildPublishPreviewModel,
|
||||||
|
formatWidths,
|
||||||
|
} from './publishPreviewModel';
|
||||||
|
import type { PhotoRow, Slab, Taxonomy } from '../../api/slabs';
|
||||||
|
|
||||||
|
function baseSlab(over: Partial<Slab> = {}): Slab {
|
||||||
|
return {
|
||||||
|
id: 's1',
|
||||||
|
sku: 'SLAB-99',
|
||||||
|
status: 'ready',
|
||||||
|
price: 420.5,
|
||||||
|
price_source: 'recommendation',
|
||||||
|
length_in: 96,
|
||||||
|
thickness_in: 2,
|
||||||
|
sqft: 8,
|
||||||
|
bdft: 16,
|
||||||
|
width_min_in: 12,
|
||||||
|
width_max_in: 18,
|
||||||
|
width_avg_in: 15,
|
||||||
|
species_id: 10,
|
||||||
|
wood_category_ids: [20, 21],
|
||||||
|
edge_type_term_id: 30,
|
||||||
|
figure_term_ids: [40],
|
||||||
|
grade_term_id: 50,
|
||||||
|
thickness_term_id: 60,
|
||||||
|
moisture_term_id: 70,
|
||||||
|
fig_tag_ids: [],
|
||||||
|
feat_tag_ids: [80],
|
||||||
|
title: 'Live Edge Cedar Slab',
|
||||||
|
short_description: '96″ × 12–18″ × 2″ cedar',
|
||||||
|
description: 'A solid cedar slab.\n\n- Kiln dried\n- Live edge',
|
||||||
|
content_source: 'llm',
|
||||||
|
species_confidence: null,
|
||||||
|
figure_confidence: null,
|
||||||
|
grade_confidence: null,
|
||||||
|
inference_status: null,
|
||||||
|
calibration_photo_id: null,
|
||||||
|
woo_product_id: null,
|
||||||
|
client_rev: 1,
|
||||||
|
server_rev: 2,
|
||||||
|
created_at: 't',
|
||||||
|
updated_at: 't',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tax: Taxonomy = {
|
||||||
|
categories: [
|
||||||
|
{ id: 1, name: 'Species', slug: 'species', parent_id: null, woo_id: null },
|
||||||
|
{ id: 10, name: 'Western Red Cedar', slug: 'wrc', parent_id: 1, woo_id: null },
|
||||||
|
{ id: 2, name: 'wcat', slug: 'wcat', parent_id: null, woo_id: null },
|
||||||
|
{ id: 20, name: 'Live edge wood', slug: 'liveedge', parent_id: 2, woo_id: null },
|
||||||
|
{ id: 21, name: 'Slabs & Lumber', slug: 'snl', parent_id: 2, woo_id: null },
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{
|
||||||
|
id: 1, name: 'Edge type', slug: 'edge',
|
||||||
|
terms: [{ id: 30, name: 'Live Edge', slug: 'le' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, name: 'Figure', slug: 'figure',
|
||||||
|
terms: [{ id: 40, name: 'Quilted', slug: 'quilt' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, name: 'Grade', slug: 'grade',
|
||||||
|
terms: [{ id: 50, name: 'Select', slug: 'sel' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4, name: 'Moisture', slug: 'moisture',
|
||||||
|
terms: [{ id: 70, name: 'Kiln dried', slug: 'kd' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ id: 80, name: 'Bookmatched', slug: 'feat-bookmatched' },
|
||||||
|
],
|
||||||
|
taxonomy_anchor: null,
|
||||||
|
taxonomy_last_sync_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const photos: PhotoRow[] = [
|
||||||
|
{
|
||||||
|
id: 'p2', seq: 2, is_calibration: 0, role: 'batch',
|
||||||
|
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p1', seq: 1, is_calibration: 1, role: 'calibration',
|
||||||
|
width_px: 100, height_px: 100, has_original: 1, has_processed: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('formatWidths joins min–max and avg', () => {
|
||||||
|
assert.equal(formatWidths(baseSlab()), '12–18″ (avg 15″)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildPublishPreviewModel: ordered thumbs, decoded names, all facts', () => {
|
||||||
|
const m = buildPublishPreviewModel(baseSlab(), tax, photos, (id, kind) => `/p/${id}/${kind}`);
|
||||||
|
assert.deepEqual(m.photos.map((p) => p.id), ['p1', 'p2']);
|
||||||
|
assert.equal(m.photos[0].primary, true);
|
||||||
|
assert.equal(m.photos[0].url, '/p/p1/original');
|
||||||
|
assert.equal(m.photos[1].url, '/p/p2/processed');
|
||||||
|
assert.equal(m.sku, 'SLAB-99');
|
||||||
|
assert.equal(m.title, 'Live Edge Cedar Slab');
|
||||||
|
assert.equal(m.price, '$420.50');
|
||||||
|
assert.equal(m.shortDescription, '96″ × 12–18″ × 2″ cedar');
|
||||||
|
assert.match(m.description, /Kiln dried/);
|
||||||
|
|
||||||
|
const by = Object.fromEntries(m.facts.map((f) => [f.label, f.value]));
|
||||||
|
assert.equal(by.Species, 'Western Red Cedar');
|
||||||
|
assert.equal(by['Wood categories'], 'Live edge wood, Slabs & Lumber');
|
||||||
|
assert.equal(by.Edge, 'Live Edge');
|
||||||
|
assert.equal(by.Figure, 'Quilted');
|
||||||
|
assert.equal(by.Grade, 'Select');
|
||||||
|
assert.equal(by.Moisture, 'Kiln dried');
|
||||||
|
assert.equal(by.Features, 'Bookmatched');
|
||||||
|
assert.equal(by.Length, '96″');
|
||||||
|
assert.equal(by.Widths, '12–18″ (avg 15″)');
|
||||||
|
assert.equal(by.Thickness, '2″');
|
||||||
|
assert.equal(by.Bdft, '16.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildPublishPreviewModel: empty optional fields show em dash', () => {
|
||||||
|
const m = buildPublishPreviewModel(
|
||||||
|
baseSlab({
|
||||||
|
figure_term_ids: [],
|
||||||
|
feat_tag_ids: [],
|
||||||
|
edge_type_term_id: null,
|
||||||
|
title: null,
|
||||||
|
short_description: null,
|
||||||
|
description: null,
|
||||||
|
price: null,
|
||||||
|
sku: '',
|
||||||
|
}),
|
||||||
|
tax,
|
||||||
|
[],
|
||||||
|
() => '',
|
||||||
|
);
|
||||||
|
assert.equal(m.photos.length, 0);
|
||||||
|
assert.equal(m.title, '—');
|
||||||
|
assert.equal(m.sku, '—');
|
||||||
|
assert.equal(m.price, '—');
|
||||||
|
assert.equal(m.shortDescription, '—');
|
||||||
|
assert.equal(m.description, '—');
|
||||||
|
const by = Object.fromEntries(m.facts.map((f) => [f.label, f.value]));
|
||||||
|
assert.equal(by.Edge, '—');
|
||||||
|
assert.equal(by.Figure, '—');
|
||||||
|
assert.equal(by.Features, '—');
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Pure builders for the Publish preview confirmation screen.
|
||||||
|
* Resolves taxonomy ids → display names; never invents fields beyond the slab.
|
||||||
|
*/
|
||||||
|
import { decodeName } from '../decodeName';
|
||||||
|
import type { PhotoRow, Slab, Taxonomy, TaxAttribute, TaxCategory, TaxTag } from '../../api/slabs';
|
||||||
|
|
||||||
|
export interface PreviewPhoto {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
role: 'calibration' | 'overlay' | 'batch';
|
||||||
|
/** Leftmost (seq order) = Woo cover. */
|
||||||
|
primary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewFact {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishPreviewModel {
|
||||||
|
photos: PreviewPhoto[];
|
||||||
|
sku: string;
|
||||||
|
title: string;
|
||||||
|
price: string;
|
||||||
|
facts: PreviewFact[];
|
||||||
|
shortDescription: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt1 = (v: number) => {
|
||||||
|
const s = v.toFixed(1);
|
||||||
|
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
||||||
|
};
|
||||||
|
const fmt2 = (v: number) => v.toFixed(2);
|
||||||
|
|
||||||
|
function catName(cats: TaxCategory[], id: number | null | undefined): string {
|
||||||
|
if (id == null) return '—';
|
||||||
|
const c = cats.find((x) => x.id === id);
|
||||||
|
return c ? decodeName(c.name) : `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function termName(attrs: TaxAttribute[], id: number | null | undefined): string {
|
||||||
|
if (id == null) return '—';
|
||||||
|
for (const a of attrs) {
|
||||||
|
const t = a.terms.find((x) => x.id === id);
|
||||||
|
if (t) return decodeName(t.name);
|
||||||
|
}
|
||||||
|
return `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrTermsByName(attrs: TaxAttribute[], name: string): { id: number; name: string }[] {
|
||||||
|
const attr = attrs.find((a) => a.name.toLowerCase() === name.toLowerCase());
|
||||||
|
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: decodeName(t.name) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function namesForIds(
|
||||||
|
ids: number[],
|
||||||
|
lookup: (id: number) => string | null,
|
||||||
|
): string {
|
||||||
|
if (!ids.length) return '—';
|
||||||
|
return ids.map((id) => lookup(id) ?? `#${id}`).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagName(tags: TaxTag[], id: number): string | null {
|
||||||
|
const t = tags.find((x) => x.id === id);
|
||||||
|
return t ? decodeName(t.name) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widths as "min–max (avg)" inches, matching Review dims language. */
|
||||||
|
export function formatWidths(slab: Pick<Slab, 'width_min_in' | 'width_max_in' | 'width_avg_in'>): string {
|
||||||
|
return `${fmt1(slab.width_min_in)}–${fmt1(slab.width_max_in)}″ (avg ${fmt1(slab.width_avg_in)}″)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPublishPreviewModel(
|
||||||
|
slab: Slab,
|
||||||
|
tax: Taxonomy | null,
|
||||||
|
photos: PhotoRow[],
|
||||||
|
urlFor: (photoId: string, kind: 'original' | 'processed') => string,
|
||||||
|
): PublishPreviewModel {
|
||||||
|
const cats = tax?.categories ?? [];
|
||||||
|
const attrs = tax?.attributes ?? [];
|
||||||
|
const tags = tax?.tags ?? [];
|
||||||
|
|
||||||
|
const figureTerms = attrTermsByName(attrs, 'figure');
|
||||||
|
const figureLookup = (id: number) => {
|
||||||
|
const t = figureTerms.find((x) => x.id === id);
|
||||||
|
return t ? t.name : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ordered = [...photos].sort((a, b) => a.seq - b.seq);
|
||||||
|
const previewPhotos: PreviewPhoto[] = ordered.map((p, i) => ({
|
||||||
|
id: p.id,
|
||||||
|
url: urlFor(p.id, p.has_processed ? 'processed' : 'original'),
|
||||||
|
role: p.role ?? 'batch',
|
||||||
|
primary: i === 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const woodNames = namesForIds(slab.wood_category_ids ?? [], (id) => {
|
||||||
|
const c = cats.find((x) => x.id === id);
|
||||||
|
return c ? decodeName(c.name) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const facts: PreviewFact[] = [
|
||||||
|
{ label: 'Species', value: catName(cats, slab.species_id) },
|
||||||
|
{ label: 'Wood categories', value: woodNames },
|
||||||
|
{ label: 'Edge', value: termName(attrs, slab.edge_type_term_id) },
|
||||||
|
{
|
||||||
|
label: 'Figure',
|
||||||
|
value: namesForIds(slab.figure_term_ids ?? [], figureLookup),
|
||||||
|
},
|
||||||
|
{ label: 'Grade', value: termName(attrs, slab.grade_term_id) },
|
||||||
|
{ label: 'Moisture', value: termName(attrs, slab.moisture_term_id) },
|
||||||
|
{
|
||||||
|
label: 'Features',
|
||||||
|
value: namesForIds(slab.feat_tag_ids ?? [], (id) => tagName(tags, id)),
|
||||||
|
},
|
||||||
|
{ label: 'Length', value: `${fmt1(slab.length_in)}″` },
|
||||||
|
{ label: 'Widths', value: formatWidths(slab) },
|
||||||
|
{ label: 'Thickness', value: `${fmt1(slab.thickness_in)}″` },
|
||||||
|
{ label: 'Bdft', value: fmt2(slab.bdft) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
photos: previewPhotos,
|
||||||
|
sku: slab.sku || '—',
|
||||||
|
title: slab.title || '—',
|
||||||
|
price: slab.price != null ? `$${slab.price.toFixed(2)}` : '—',
|
||||||
|
facts,
|
||||||
|
shortDescription: (slab.short_description ?? '').trim() || '—',
|
||||||
|
description: (slab.description ?? '').trim() || '—',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -243,6 +243,20 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
|||||||
await json(route, slab);
|
await json(route, slab);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
|
||||||
|
// Publish preview + Review strip: ordered lineup (empty is fine for golden path).
|
||||||
|
await json(route, [
|
||||||
|
{
|
||||||
|
id: 'photo-1', seq: 1, is_calibration: 1, role: 'calibration',
|
||||||
|
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photo-2', seq: 2, is_calibration: 0, role: 'overlay',
|
||||||
|
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
|
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
|
||||||
// Photo uploads are multipart; `payload` is {} — the client's photo_id is
|
// Photo uploads are multipart; `payload` is {} — the client's photo_id is
|
||||||
// in the form body, not JSON, so echo a stable synthetic id.
|
// in the form body, not JSON, so echo a stable synthetic id.
|
||||||
@@ -251,6 +265,7 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
|
|||||||
slab_id: slab.id,
|
slab_id: slab.id,
|
||||||
seq: 1,
|
seq: 1,
|
||||||
is_calibration: 1,
|
is_calibration: 1,
|
||||||
|
role: 'calibration',
|
||||||
original_retained: false,
|
original_retained: false,
|
||||||
processed_retained: true,
|
processed_retained: true,
|
||||||
}, 201);
|
}, 201);
|
||||||
@@ -351,6 +366,11 @@ test.describe('M3 Review→Publish flow (#77)', () => {
|
|||||||
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
await expect(page.getByTestId('continue-publish')).toBeEnabled();
|
||||||
await page.getByTestId('continue-publish').click();
|
await page.getByTestId('continue-publish').click();
|
||||||
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
await expect(page.getByTestId('publish-screen')).toBeVisible();
|
||||||
|
// Publish preview: glanceable confirm before Woo draft POST.
|
||||||
|
await expect(page.getByTestId('publish-preview')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('preview-title')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('preview-facts')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('back-review')).toBeVisible();
|
||||||
// Publish → success → Done panel (draft in UAT store).
|
// Publish → success → Done panel (draft in UAT store).
|
||||||
await page.getByTestId('publish-btn').click();
|
await page.getByTestId('publish-btn').click();
|
||||||
await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
Reference in New Issue
Block a user