feat(products-ui): import upload screen (§5.3, PUC-2/5a)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:30:51 -07:00
parent 26f84cb916
commit 188494272a
2 changed files with 64 additions and 2 deletions
+56 -2
View File
@@ -1,4 +1,58 @@
// Import upload (SD-0002 §5.3) — stub; Task 12 replaces this with the dropzone.
// Import upload (SD-0002 §5.3). Selecting a file starts upload + validation
// immediately (PUC-2); file-level rejections (PUC-5a) render in place with the
// picker live for retry. No notifications — errors render here.
import { useRef, useState } from "react";
import { uploadImport } from "../../productsApi";
import { Banner } from "../../ui/kit";
export default function ImportUpload() {
return null;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onPick(files: FileList | null) {
const file = files?.[0];
if (!file) return;
setBusy(true);
setError(null);
const resp = await uploadImport(file);
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
if (!resp.ok) {
setError(resp.error.message);
return;
}
window.location.hash = `#/products/imports/drafts/${resp.value.id}`;
}
return (
<div className="products products--narrow">
<p className="note">
<a href="#/products"> Products</a>
</p>
<h1>Import products</h1>
{error && (
<Banner tone="attn" title="That file can't be imported">
{error}
</Banner>
)}
<label className={`dropzone${busy ? " dropzone--busy" : ""}`}>
<input
ref={inputRef}
type="file"
accept=".csv,text/csv"
disabled={busy}
onChange={(e) => void onPick(e.target.files)}
/>
<span className="dropzone__title">{busy ? "Validating…" : "Choose a CSV file"}</span>
<span className="note">CSV, up to 5,000 rows</span>
</label>
<p className="note">
Works with the canonical format.{" "}
<a href="/api/products/sample.csv" download>
Download sample CSV
</a>
</p>
</div>
);
}
+8
View File
@@ -208,6 +208,14 @@
clip: rect(0 0 0 0);
white-space: nowrap;
}
.dropzone__title {
display: block;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 17px;
color: var(--text-on-dark-soft);
margin-bottom: 2px;
}
/* ── small screens ──────────────────────────────────────────────────────────── */
@media (max-width: 720px) {