M3 Review→Publish UI (#77): one-column flow, searchable pickers, fig/feat tags, e2e
- Review screen: slug-resolved species/wcat groupers, case-insensitive attrs, fig-*/feat-* tag pickers (METADATA-30/40), AI-assist labels + AI bubbles. - App orchestrator: single writable flow store, capture→review→publish, Resume boot. - API client (slabs.ts) mirroring live backend routes; Blob name fix. - Playwright e2e (7 tests, hermetic mock): canvas PNG seed, client-UUID mock, SKU blur commit, ready-state reactive fix; expect timeout 4s, 2 workers. - e2e green 7/7 in 6s; check_requirements + check_coverage PASS.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
// Searchable multi-select list (the approved paradigm replacing tap-bubbles).
|
||||
// Squared/slight-radius, type-to-filter, select-all, live count badge, per-row
|
||||
// AI bubble on AI-recommended options. Works for 6 items or 60.
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
interface Opt { id: number; name: string; ai: boolean }
|
||||
export let label: string;
|
||||
export let options: Opt[];
|
||||
export let selected: number[];
|
||||
export let placeholder = 'Choose…';
|
||||
export let testid = '';
|
||||
export let onToggle: (id: number) => void;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let open = false;
|
||||
let filter = '';
|
||||
let focused: number | null = null;
|
||||
|
||||
$: aiSelected = selected.filter((id) => options.find((o) => o.id === id)?.ai);
|
||||
$: filtered = filter.trim()
|
||||
? options.filter((o) => o.name.toLowerCase().includes(filter.trim().toLowerCase()))
|
||||
: options;
|
||||
$: count = selected.length;
|
||||
$: allFilteredSelected = filtered.length > 0 && filtered.every((o) => selected.includes(o.id));
|
||||
|
||||
function toggle(id: number) {
|
||||
onToggle(id);
|
||||
focused = id;
|
||||
}
|
||||
function selectAll() {
|
||||
// Toggle every currently-visible (filtered) option.
|
||||
for (const o of filtered) {
|
||||
if (!selected.includes(o.id)) onToggle(o.id);
|
||||
}
|
||||
}
|
||||
function clearAll() {
|
||||
for (const o of [...filtered]) {
|
||||
if (selected.includes(o.id)) onToggle(o.id);
|
||||
}
|
||||
}
|
||||
function close() {
|
||||
open = false;
|
||||
filter = '';
|
||||
dispatch('commit');
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) { open = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
|
||||
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
|
||||
<span class="ms-label">{open ? 'Choose ' + label.toLowerCase() : placeholder}</span>
|
||||
<span class="ms-c">{count === 1 ? '1 selected' : `${count} selected`}</span>
|
||||
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="ms-panel" role="listbox" aria-multiselectable="true">
|
||||
<div class="ms-tools">
|
||||
<input class="ms-search" type="text" placeholder="Filter…" bind:value={filter}
|
||||
data-testid={testid ? `${testid}-filter` : 'ms-filter'} aria-label={`Filter ${label}`} />
|
||||
<button type="button" class="ms-all" data-testid={testid ? `${testid}-all` : 'ms-all'}
|
||||
on:click={allFilteredSelected ? clearAll : selectAll}>
|
||||
{allFilteredSelected ? 'None' : 'All'}
|
||||
</button>
|
||||
</div>
|
||||
<ul class="ms-list">
|
||||
{#each filtered as o (o.id)}
|
||||
<li
|
||||
class="ms-row"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">
|
||||
{o.name}
|
||||
{#if o.ai}<span class="ai-tag">AI</span>{/if}
|
||||
</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="ms-empty">No matches</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="ms-foot">
|
||||
<span>{options.length} synced{filter.trim() ? ` · ${filtered.length} shown` : ''}</span>
|
||||
<button type="button" on:click={close} data-testid={testid ? `${testid}-done` : 'ms-done'}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
|
||||
.ms-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
|
||||
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
|
||||
.ms-arrow { color: #777; }
|
||||
.ms-panel { border-top: 1px solid #333; background: #161616; }
|
||||
.ms-tools { display: flex; gap: 8px; padding: 8px; align-items: center; }
|
||||
.ms-search { flex: 1; padding: 7px 9px; background: #0f0f0f; border: 1px solid #3a3a3a; border-radius: 3px; color: #eee; font-size: .85rem; }
|
||||
.ms-all { font-size: .74rem; color: #00e5ff; background: none; border: 0; cursor: pointer; white-space: nowrap; padding: 4px; }
|
||||
.ms-list { list-style: none; margin: 0; padding: 0; max-height: 200px; overflow-y: auto; }
|
||||
.ms-row { display: flex; align-items: center; gap: 9px; padding: 9px 12px; font-size: .9rem; border-top: 1px solid #222; cursor: pointer; }
|
||||
.ms-row:hover { background: #1f1f1f; }
|
||||
.ms-row.on { background: #12262b; }
|
||||
.ms-row.on .nm { color: #fff; }
|
||||
.ms-row .nm { flex: 1; display: flex; align-items: center; gap: 6px; }
|
||||
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
|
||||
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
|
||||
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
|
||||
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
|
||||
</style>
|
||||
@@ -1,86 +1,504 @@
|
||||
<script lang="ts">
|
||||
let slabId = '';
|
||||
let primaryCategoryId: number | '' = '';
|
||||
let price = '';
|
||||
let result: {
|
||||
ready: boolean;
|
||||
missing: string[];
|
||||
title: string | null;
|
||||
short_description: string | null;
|
||||
price: number | null;
|
||||
price_suggestion: number | null;
|
||||
primary_wood_category_id: number | null;
|
||||
} | null = null;
|
||||
let error = '';
|
||||
let busy = false;
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
|
||||
} from '../../api/slabs';
|
||||
import type { CalibratedCapture } from '../flow';
|
||||
import MultiSelect from './MultiSelect.svelte';
|
||||
|
||||
async function review() {
|
||||
if (!slabId) {
|
||||
error = 'Enter a slab id.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
result = null;
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (primaryCategoryId !== '') payload.primary_wood_category_id = primaryCategoryId;
|
||||
if (price !== '') payload.price = Number(price);
|
||||
// Props handed off by App (one-column flow, UX.md §1).
|
||||
export let slabId: string;
|
||||
export let capture: CalibratedCapture | null = null;
|
||||
export let onPublish: () => void;
|
||||
export let onBack: () => void;
|
||||
|
||||
let slab: Slab | null = null;
|
||||
let tax: Taxonomy | null = null;
|
||||
let settings: SettingsView | null = null;
|
||||
let loadError = '';
|
||||
let loading = true;
|
||||
|
||||
// Picker option types (id + name + whether the AI recommended it).
|
||||
interface Opt { id: number; name: string; ai: boolean }
|
||||
const byId = <T extends { id: number }>(arr: T[]) => {
|
||||
const m = new Map<number, T>();
|
||||
for (const x of arr) m.set(x.id, x);
|
||||
return m;
|
||||
};
|
||||
|
||||
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
|
||||
let speciesOpts: Opt[] = [];
|
||||
let woodCatOpts: Opt[] = [];
|
||||
let edgeOpts: Opt[] = [];
|
||||
let figureOpts: Opt[] = [];
|
||||
let gradeOpts: Opt[] = [];
|
||||
let thicknessOpts: Opt[] = [];
|
||||
let moistureOpts: Opt[] = [];
|
||||
let figTagOpts: Opt[] = [];
|
||||
let featTagOpts: Opt[] = [];
|
||||
|
||||
function deriveOptions() {
|
||||
if (!tax) return;
|
||||
const cats: TaxCategory[] = tax.categories;
|
||||
// Resolve the two top-level groupers BY SLUG (QUALITY-105 / METADATA-5 — never
|
||||
// hardcode live Woo term ids): `species` → species options; `wcat` → wood
|
||||
// categories. Both are parent_id:null groupers; their children are the leaves.
|
||||
const speciesGrouper = cats.find((c) => c.parent_id === null && c.slug === 'species');
|
||||
const wcatGrouper = cats.find((c) => c.parent_id === null && c.slug === 'wcat');
|
||||
const childrenOf = (g: TaxCategory | undefined): TaxCategory[] =>
|
||||
g ? cats.filter((c) => c.parent_id === g.id) : [];
|
||||
// Attribute names are matched case-insensitively (live Woo uses 'Edge type').
|
||||
const termsOf = (name: string): Opt[] => {
|
||||
const attr = tax!.attributes.find(
|
||||
(a) => a.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||
};
|
||||
speciesOpts = childrenOf(speciesGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
|
||||
woodCatOpts = childrenOf(wcatGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
|
||||
edgeOpts = termsOf('edge type');
|
||||
figureOpts = termsOf('figure');
|
||||
gradeOpts = termsOf('grade');
|
||||
thicknessOpts = termsOf('thickness');
|
||||
moistureOpts = termsOf('moisture');
|
||||
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
|
||||
// filtered by the family the backend keeps. Never invent a prefix — if the
|
||||
// store has none, the picker is legitimately empty.
|
||||
const tags: TaxTag[] = tax.tags ?? [];
|
||||
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
||||
}
|
||||
|
||||
// --- Editable model (mirrors the live slab) ---
|
||||
let speciesId: number | '' = '';
|
||||
let woodCatIds: number[] = [];
|
||||
let edgeTypeId: number | '' = '';
|
||||
let figureIds: number[] = [];
|
||||
let gradeId: number | '' = '';
|
||||
let thicknessId: number | '' = '';
|
||||
let moistureId: number | '' = '';
|
||||
let figTagIds: number[] = [];
|
||||
let featTagIds: number[] = [];
|
||||
let primaryCatId: number | '' = '';
|
||||
let title = '';
|
||||
let shortDescription = '';
|
||||
let description = '';
|
||||
let contentSource = '';
|
||||
let price = '';
|
||||
let priceSource = 'recommendation';
|
||||
let sku = '';
|
||||
|
||||
// --- Review (server-derived ready/missing/title/price) ---
|
||||
interface ReviewOut {
|
||||
ready: boolean; missing: string[]; title: string | null;
|
||||
short_description: string | null; price: number | null;
|
||||
price_suggestion: number | null; primary_wood_category_id: number | null;
|
||||
}
|
||||
let review: ReviewOut | null = null;
|
||||
let reviewBusy = false;
|
||||
let reviewError = '';
|
||||
// Monotonic token: each commit captures the current value and ignores its
|
||||
// result if a newer commit has since started. Without this, two concurrent
|
||||
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
||||
// a stale "not ready" review clobbers the final ready one — the
|
||||
// continue-to-Publish button stays disabled even though the status reads ready.
|
||||
let commitToken = 0;
|
||||
|
||||
// --- Inference ---
|
||||
let aiBusy = false;
|
||||
let aiNote = '';
|
||||
let genBusy = false;
|
||||
let genError = '';
|
||||
let canGenerateContent = false;
|
||||
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
|
||||
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : null);
|
||||
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 hydrate(s: Slab, t: Taxonomy | null) {
|
||||
speciesId = s.species_id ?? '';
|
||||
woodCatIds = [...(s.wood_category_ids ?? [])];
|
||||
edgeTypeId = s.edge_type_term_id ?? '';
|
||||
figureIds = [...(s.figure_term_ids ?? [])];
|
||||
gradeId = s.grade_term_id ?? '';
|
||||
thicknessId = s.thickness_term_id ?? '';
|
||||
moistureId = s.moisture_term_id ?? '';
|
||||
figTagIds = [...(s.fig_tag_ids ?? [])];
|
||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||
title = s.title ?? '';
|
||||
shortDescription = s.short_description ?? '';
|
||||
description = s.description ?? '';
|
||||
contentSource = s.content_source ?? 'manual';
|
||||
price = s.price != null ? String(s.price) : '';
|
||||
priceSource = s.price_source ?? 'recommendation';
|
||||
sku = s.sku ?? '';
|
||||
primaryCatId = woodCatIds[0] ?? '';
|
||||
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
||||
// its weight behind the value the owner kept).
|
||||
aiRecommendations = [];
|
||||
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
|
||||
aiRecommendations.push(s.species_id);
|
||||
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
|
||||
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
||||
}
|
||||
|
||||
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
||||
|
||||
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
||||
async function commitReview(): Promise<void> {
|
||||
if (!slab) return;
|
||||
const myToken = ++commitToken;
|
||||
reviewBusy = true;
|
||||
reviewError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (res.ok) result = body;
|
||||
else error = body.error?.message || `review failed (${res.status})`;
|
||||
} catch {
|
||||
error = 'network error';
|
||||
const patch = {
|
||||
species_id: speciesId === '' ? null : Number(speciesId),
|
||||
wood_category_ids: woodCatIds,
|
||||
edge_type_term_id: edgeTypeId === '' ? null : Number(edgeTypeId),
|
||||
figure_term_ids: figureIds,
|
||||
grade_term_id: gradeId === '' ? null : Number(gradeId),
|
||||
thickness_term_id: thicknessId === '' ? null : Number(thicknessId),
|
||||
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||||
fig_tag_ids: figTagIds,
|
||||
feat_tag_ids: featTagIds,
|
||||
title: title || null,
|
||||
short_description: shortDescription || null,
|
||||
description: description || null,
|
||||
content_source: contentSource || null,
|
||||
price: price === '' ? null : Number(price),
|
||||
price_source: priceSource,
|
||||
sku: sku || null,
|
||||
client_rev: slab.server_rev,
|
||||
};
|
||||
const fresh = await updateSlab(slabId, patch);
|
||||
if (myToken !== commitToken) return; // superseded; don't clobber the latest
|
||||
slab = fresh;
|
||||
if (primaryCatId !== '') {
|
||||
const res = await reviewSlab(slabId, {
|
||||
primary_wood_category_id: Number(primaryCatId),
|
||||
title: title || undefined,
|
||||
short_description: shortDescription || undefined,
|
||||
price: price === '' ? undefined : Number(price),
|
||||
sku: sku || undefined,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
review = res;
|
||||
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
|
||||
} else {
|
||||
review = await reviewSlab(slabId);
|
||||
if (myToken !== commitToken) return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (myToken !== commitToken) return;
|
||||
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
|
||||
review = null;
|
||||
} finally {
|
||||
if (myToken === commitToken) reviewBusy = false;
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function runInference() {
|
||||
if (!slab) return;
|
||||
aiBusy = true;
|
||||
aiNote = '';
|
||||
try {
|
||||
const r = await inferTaxon(slabId);
|
||||
const s = r.suggestions;
|
||||
const recs: number[] = [];
|
||||
if (s.species_id) { speciesId = s.species_id; recs.push(s.species_id); }
|
||||
if (s.wood_category_ids) {
|
||||
woodCatIds = [...s.wood_category_ids];
|
||||
if (!primaryCatId) primaryCatId = woodCatIds[0];
|
||||
recs.push(...s.wood_category_ids);
|
||||
}
|
||||
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
|
||||
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
|
||||
if (s.grade_term_id) gradeId = s.grade_term_id;
|
||||
aiRecommendations = recs;
|
||||
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
|
||||
await commitReview();
|
||||
} catch (e) {
|
||||
aiNote = e instanceof ApiError ? e.message : 'Inference unavailable.';
|
||||
} finally {
|
||||
aiBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateContent() {
|
||||
if (!slab) return;
|
||||
genBusy = true;
|
||||
genError = '';
|
||||
try {
|
||||
const r = await inferContent(slabId);
|
||||
description = r.description;
|
||||
contentSource = 'llm';
|
||||
await commitReview();
|
||||
} catch (e) {
|
||||
genError = e instanceof ApiError ? e.message : 'Could not generate text.';
|
||||
} finally {
|
||||
genBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
const missingCount = () => (review ? review.missing.length : null);
|
||||
const ready = () => review?.ready === true;
|
||||
// The button's enabled state must derive from the SAME reactive `review` object
|
||||
// the status text reads. A separate `ready()` function call in the template
|
||||
// evaluates in a different reactive flush, leaving `disabled` stale (false→the
|
||||
// button stays disabled) even after `review.ready` flips true. Binding
|
||||
// `publishDisabled` to `review` (a reactive object) forces the same dependency.
|
||||
let publishDisabled = true;
|
||||
$: publishDisabled = review ? !review.ready : true;
|
||||
|
||||
// --- Multi-select list helpers (searchable, squared) ---
|
||||
function toggleMulti(arr: number[], id: number): number[] {
|
||||
return arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id];
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [s, t, set] = await Promise.all([getSlab(slabId), getTaxonomy(), getSettings()]);
|
||||
slab = s; tax = t; settings = set;
|
||||
deriveOptions();
|
||||
hydrate(s, t);
|
||||
canGenerateContent = !!set.content_llm_enabled;
|
||||
await commitReview();
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function goPublish() {
|
||||
onPublish();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
onBack();
|
||||
}
|
||||
|
||||
function publish() {
|
||||
goPublish();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="review">
|
||||
<h2>Review to Ready</h2>
|
||||
<div>
|
||||
<label>Slab ID</label>
|
||||
<input bind:value={slabId} placeholder="uuid" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Primary wood category id (optional; defaults to first selected)</label>
|
||||
<input type="number" bind:value={primaryCategoryId} placeholder="auto" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Price override (optional; suggestion = bdft × species rate)</label>
|
||||
<input type="number" step="0.01" bind:value={price} placeholder="use suggestion" />
|
||||
</div>
|
||||
<button on:click={review} disabled={busy}>{busy ? 'Reviewing…' : 'Review'}</button>
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
{#if loading}
|
||||
<section class="review" aria-busy="true" data-testid="review-loading">
|
||||
<h1>Review to Ready</h1>
|
||||
<p class="busy">Loading slab…</p>
|
||||
</section>
|
||||
{:else if loadError}
|
||||
<section class="review">
|
||||
<h1>Review to Ready</h1>
|
||||
<p class="err" role="alert">{loadError}</p>
|
||||
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back to capture</button>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="review" data-testid="review-screen">
|
||||
<h1>Review to Ready</h1>
|
||||
|
||||
{#if result}
|
||||
<p class="ready" class:ok={result.ready}>
|
||||
{result.ready ? 'READY for publish' : `Not ready — missing: ${result.missing.join(', ')}`}
|
||||
</p>
|
||||
<div><strong>Title:</strong> {result.title ?? '(none)'}</div>
|
||||
<div><strong>Short description:</strong> {result.short_description ?? '(none)'}</div>
|
||||
<div>
|
||||
<strong>Price:</strong> {result.price != null ? `$${result.price.toFixed(2)}` : '(empty — enter required)'}
|
||||
{#if result.price_suggestion != null && result.price_suggestion !== result.price}
|
||||
<span class="sug"> (suggested ${result.price_suggestion.toFixed(2)})</span>
|
||||
<dl class="math">
|
||||
<div><dt>Length</dt><dd>{fmt1(capture?.lengthIn ?? slab?.length_in ?? 0)}″</dd></div>
|
||||
<div><dt>Thickness</dt><dd>{fmt1(capture?.thicknessIn ?? slab?.thickness_in ?? 0)}″</dd></div>
|
||||
<div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
|
||||
</dl>
|
||||
|
||||
{#if settings?.inference_enabled}
|
||||
<div class="aibar">
|
||||
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
|
||||
{aiBusy ? 'Reading slab…' : 'Suggest with AI'}
|
||||
</button>
|
||||
{#if aiNote}<p class="ai-note" data-testid="ai-note">{aiNote}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Species (single, AI-assist) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
|
||||
<select id="species" class="sel" data-testid="species" bind:value={speciesId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each speciesOpts as o (o.id)}
|
||||
<option value={o.id} data-ai={o.ai ? 'true' : ''}>{o.name}{o.ai ? ' (AI)' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Wood categories (multi, searchable list, AI-assist) -->
|
||||
<MultiSelect
|
||||
label="Wood categories"
|
||||
options={woodCatOpts}
|
||||
selected={woodCatIds}
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))}
|
||||
/>
|
||||
|
||||
<!-- Edge type (single, AI-assist) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
|
||||
<select id="edge" class="sel" data-testid="edge-type" bind:value={edgeTypeId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each edgeOpts as o (o.id)}
|
||||
<option value={o.id}>{o.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Figure (multi, searchable list, AI-assist) -->
|
||||
<MultiSelect
|
||||
label="Figure"
|
||||
options={figureOpts}
|
||||
selected={figureIds}
|
||||
placeholder="Choose figure"
|
||||
testid="figure"
|
||||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||
/>
|
||||
|
||||
<!-- Grade / Thickness / Moisture (single, AI-assist) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="grade">Grade <span class="ai">AI assist</span></label>
|
||||
<select id="grade" class="sel" data-testid="grade" bind:value={gradeId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each gradeOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- fig-* product tags (optional, owner-only, no AI) -->
|
||||
<div class="field">
|
||||
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
|
||||
<MultiSelect
|
||||
label="fig-* tags"
|
||||
options={figTagOpts}
|
||||
selected={figTagIds}
|
||||
placeholder="Choose fig-* tags"
|
||||
testid="fig-tag"
|
||||
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- feat-* product tags (optional, AI-assisted by Call 1) -->
|
||||
<div class="field">
|
||||
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="feat-* tags"
|
||||
options={featTagOpts}
|
||||
selected={featTagIds}
|
||||
placeholder="Choose feat-* tags"
|
||||
testid="feat-tag"
|
||||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="lbl" for="thickness">Thickness band</label>
|
||||
<select id="thickness" class="sel" data-testid="thickness-band" bind:value={thicknessId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each thicknessOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="moisture">Moisture</label>
|
||||
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Title / Description -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="title">Title</label>
|
||||
<input id="title" class="inp" data-testid="title" bind:value={title} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="short-desc">Short description</label>
|
||||
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="desc">Description</label>
|
||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitReview}></textarea>
|
||||
{#if canGenerateContent}
|
||||
<div class="gen">
|
||||
<button type="button" class="gen-btn" data-testid="generate-text" on:click={generateContent} disabled={genBusy}>
|
||||
{genBusy ? 'Writing…' : 'Generate text'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Price / SKU (SKU last) -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="price">Price ($)
|
||||
{#if review?.price_suggestion != null}<span class="sug">suggested ${review.price_suggestion.toFixed(2)}</span>{/if}
|
||||
</label>
|
||||
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:change={commitReview} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="sku">SKU <span class="sug">last field, 3–24 chars</span></label>
|
||||
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:blur={commitReview} />
|
||||
{#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)}
|
||||
<p class="nudge">SKU must be 3–24 alphanumerics or hyphens.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ready status + actions -->
|
||||
{#if reviewBusy}
|
||||
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
|
||||
{:else if review}
|
||||
<div class="status" class:ok={ready()} data-testid="ready-status">
|
||||
{ready()
|
||||
? 'Ready to publish'
|
||||
: `Not ready — ${review.missing.length} field${review.missing.length === 1 ? '' : 's'} remaining`}
|
||||
</div>
|
||||
{#if !ready()}
|
||||
<p class="missing" data-testid="missing-list">Missing: {review.missing.join(', ')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
data-testid="continue-publish"
|
||||
disabled={publishDisabled}
|
||||
on:click={publish}
|
||||
>
|
||||
Continue to Publish
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.review { max-width: 560px; color: #eee; }
|
||||
label { display: block; margin: 8px 0 4px; }
|
||||
input { width: 100%; padding: 6px; }
|
||||
button { margin-top: 12px; }
|
||||
.ready { margin-top: 12px; font-weight: 600; }
|
||||
.ready.ok { color: #7fd37f; }
|
||||
.ready:not(.ok) { color: #e8a; }
|
||||
.err { color: #e88; }
|
||||
.sug { color: #aaa; font-weight: 400; }
|
||||
.review { max-width: 560px; margin: 0 auto; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
|
||||
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||
.aibar { margin: 12px 0; }
|
||||
.ai-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .85rem; }
|
||||
.ai-btn:disabled { background: #333; color: #777; }
|
||||
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
|
||||
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
||||
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
||||
.inp, .sel { width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||||
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
|
||||
.ta { resize: vertical; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||||
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||||
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
||||
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||||
.status.ok { background: #122a12; color: #7fd37f; }
|
||||
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||||
.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; }
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.err { color: #e88; } .busy { color: #aaa; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user