Loading…
Loading…
+{loadError}
+{loadError}
Confirm what’s about to publish to WooCommerce as a draft.
+ + {#if preview && (st.phase === 'idle' || st.phase === 'working')} + {
+ 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);
+});
diff --git a/frontend/src/create/publish/publishPreviewModel.test.ts b/frontend/src/create/publish/publishPreviewModel.test.ts
new file mode 100644
index 0000000..c504b6a
--- /dev/null
+++ b/frontend/src/create/publish/publishPreviewModel.test.ts
@@ -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 {
+ 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, '—');
+});
diff --git a/frontend/src/create/publish/publishPreviewModel.ts b/frontend/src/create/publish/publishPreviewModel.ts
new file mode 100644
index 0000000..f0d944b
--- /dev/null
+++ b/frontend/src/create/publish/publishPreviewModel.ts
@@ -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): 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() || '—',
+ };
+}
diff --git a/frontend/tests/e2e/m3-create.spec.ts b/frontend/tests/e2e/m3-create.spec.ts
index ae3e0fc..c4e2f48 100644
--- a/frontend/tests/e2e/m3-create.spec.ts
+++ b/frontend/tests/e2e/m3-create.spec.ts
@@ -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 });