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; }
@@ -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 &amp; 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() || '—',
};
}
+20
View File
@@ -243,6 +243,20 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
await json(route, slab);
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') {
// Photo uploads are multipart; `payload` is {} — the client's photo_id is
// 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,
seq: 1,
is_calibration: 1,
role: 'calibration',
original_retained: false,
processed_retained: true,
}, 201);
@@ -351,6 +366,11 @@ test.describe('M3 Review→Publish flow (#77)', () => {
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
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).
await page.getByTestId('publish-btn').click();
await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 });