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">
|
||||
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 & 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);
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user