From 73e28180c84359c5b444fe2031a611abe180ccd8 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 11:47:18 -0700 Subject: [PATCH] feat(publish): Publish preview before Woo draft POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/create/publish/Publish.svelte | 97 ++++++++--- .../src/create/publish/PublishPreview.svelte | 115 ++++++++++++++ .../publish/publishPreview.contract.test.ts | 50 ++++++ .../publish/publishPreviewModel.test.ts | 150 ++++++++++++++++++ .../src/create/publish/publishPreviewModel.ts | 133 ++++++++++++++++ frontend/tests/e2e/m3-create.spec.ts | 20 +++ 6 files changed, 542 insertions(+), 23 deletions(-) create mode 100644 frontend/src/create/publish/PublishPreview.svelte create mode 100644 frontend/src/create/publish/publishPreview.contract.test.ts create mode 100644 frontend/src/create/publish/publishPreviewModel.test.ts create mode 100644 frontend/src/create/publish/publishPreviewModel.ts diff --git a/frontend/src/create/publish/Publish.svelte b/frontend/src/create/publish/Publish.svelte index 2c7d225..f86e65d 100644 --- a/frontend/src/create/publish/Publish.svelte +++ b/frontend/src/create/publish/Publish.svelte @@ -1,14 +1,23 @@ {#if loading} -

Publish to WooCommerce

Loading…

+
+

Publish preview

+

Loading…

+
{:else if loadError} -

Publish to WooCommerce

+
+

Publish preview

+
{:else}
-

Publish to WooCommerce

-

SKU {slab?.sku || '—'} · {slab?.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab?.price != null ? `$${slab.price.toFixed(2)}` : '—'}

+

Publish preview

+

Confirm what’s about to publish to WooCommerce as a draft.

+ + {#if preview && (st.phase === 'idle' || st.phase === 'working')} + + {:else if slab && st.phase !== 'idle' && st.phase !== 'working'} +

SKU {slab.sku || '—'} · {slab.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab.price != null ? `$${slab.price.toFixed(2)}` : '—'}

+ {/if} {#if st.phase === 'idle'} - - +
+ + +
{:else if st.phase === 'working'} - {/if} @@ -91,7 +130,7 @@ {#if st.phase === 'success'}
Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}

Photos purged · device draft cleared.

- + {:else if st.phase === 'failure' && st.fail} {#if st.fail.duplicateSku}
@@ -99,15 +138,15 @@

SKU already exists

“{slab?.sku}” is already in the store.

- - + + {:else if st.fail.code === 'unpublishable'}
✕

Not ready to publish

Some fields are missing. Go back and finish Review.

- + {:else}
{st.fail.partialMedia ? '⚠' : '↻'}
@@ -116,19 +155,31 @@ {#if st.fail.partialMedia}orphan media — remove manually{/if} {#if !st.fail.recoverable}check Settings{/if}
- + {/if} {/if}
{/if} diff --git a/frontend/src/create/publish/publishPreview.contract.test.ts b/frontend/src/create/publish/publishPreview.contract.test.ts new file mode 100644 index 0000000..6ecc40d --- /dev/null +++ b/frontend/src/create/publish/publishPreview.contract.test.ts @@ -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, /
 {
+  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 });