feat(intake): offer background removal on the submission page, ticked (#281)

Adds a checkbox to the public submission page that lets a sender opt out of background removal, ticked by default because most items look better cut out and the reverse default would mean almost nobody got it. It only renders when the server reports the sidecar is configured, matching the intake link's new backgroundRemoval flag from Task 5 — an unconfigured environment gets no checkbox rather than one that would do nothing.

submitItem now takes removeBackground as a required fourth parameter, sent as the multipart string 'true' or 'false' to match the backend's exact-string opt-out contract. Making the parameter required rather than optional was deliberate, so the compiler would catch any call site left unupdated; the frontend build (which also type-checks tests/ via tsconfig.test.json) confirmed the only call site, in Submit.tsx, was updated.

scripts/start-local.ps1 now sets REMBG_URL for the local backend so the checkbox is visible during local and e2e runs; the value need not resolve, since no e2e submission reaches the sidecar without a configured drafting step.

Adds two e2e cases to intake-submit.spec.ts: the checkbox appears ticked by default, and a sender can uncheck it and still submit successfully. Both are written per the task-7 brief but not run in this session, since running Playwright requires the full local stack (database, backend, frontend dev server) which was not started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 13:07:17 -05:00
co-authored by Claude Opus 5
parent 984e329c47
commit 1902db6d04
4 changed files with 64 additions and 2 deletions
+19 -1
View File
@@ -8,6 +8,7 @@ import Input from 'antd/es/input';
import Alert from 'antd/es/alert';
import Spin from 'antd/es/spin';
import Space from 'antd/es/space';
import Checkbox from 'antd/es/checkbox';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { fetchIntakeLink, submitItem } from './intakeApi';
@@ -33,6 +34,10 @@ export default function Submit() {
const [checking, setChecking] = useState(true);
const [files, setFiles] = useState<UploadFile[]>([]);
const [note, setNote] = useState('');
// Ticked by default. Most items look better cut out, and a submitter who
// wants their kitchen table in the photograph can say so — the reverse
// default would mean almost nobody got it.
const [removeBackground, setRemoveBackground] = useState(true);
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -62,7 +67,8 @@ export default function Submit() {
// rather than map-then-filter because a type predicate cannot narrow to
// File here — antd's RcFile extends it, so the predicate would widen.
files.flatMap((f) => (f.originFileObj ? [f.originFileObj] : [])),
note
note,
removeBackground
);
setSending(false);
@@ -129,6 +135,7 @@ export default function Submit() {
onClick={() => {
setFiles([]);
setNote('');
setRemoveBackground(true);
setSent(false);
}}
>
@@ -171,6 +178,17 @@ export default function Submit() {
placeholder="What is it, what is it made of, how big, what condition, where did it come from? Anything you know helps — a photo cannot show any of it."
/>
{state.kind === 'usable' && state.link.backgroundRemoval && (
<Checkbox
checked={removeBackground}
onChange={(e) => setRemoveBackground(e.target.checked)}
>
{/* Described by what it does, not by how. Nobody sending in a
vase knows what a cut-out or an alpha channel is. */}
Remove the background from my photos
</Checkbox>
)}
{error && <Alert type="error" message={error} showIcon />}
<Button type="primary" onClick={send} loading={sending} disabled={files.length === 0}>
+12 -1
View File
@@ -1,5 +1,11 @@
export interface IntakeLink {
label: string;
/**
* Whether there is a background-removal sidecar behind the checkbox. False
* hides it entirely rather than showing a control that would do nothing —
* an unconfigured environment is a working one, not a broken one.
*/
backgroundRemoval: boolean;
}
export type LinkState =
@@ -36,13 +42,18 @@ export type SubmitResult = { ok: true } | { ok: false; error: string };
export async function submitItem(
token: string,
files: File[],
note: string
note: string,
removeBackground: boolean
): Promise<SubmitResult> {
const body = new FormData();
// The field name the server's multer instance listens on. Sending several
// under one name is what makes req.files an array.
for (const file of files) body.append('images', file);
body.append('note', note);
// A string, because that is all a multipart field can be. The server treats
// only the exact 'false' as an opt-out, so this is the one value that has to
// be got right.
body.append('removeBackground', removeBackground ? 'true' : 'false');
const res = await fetch(`/api/intake/${encodeURIComponent(token)}`, {
method: 'POST',
+28
View File
@@ -73,4 +73,32 @@ test.describe('Sending in an item through a link', () => {
// is not on the site.
await expect(page.getByText(/Nothing is listed for sale until/)).toBeVisible();
});
// Ticked by default, because that is the decision: most items look better
// cut out, and the reverse default would mean almost nobody got it.
test('offers to remove the background, already ticked', async ({ page }) => {
await page.goto(`/submit/${token}`);
const control = page.getByRole('checkbox', { name: /remove the background/i });
await expect(control).toBeVisible();
await expect(control).toBeChecked();
});
test('lets a sender turn it off and still send', async ({ page }) => {
await page.goto(`/submit/${token}`);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
await page.setInputFiles('input[type="file"]', {
name: `${RUN}-nobg.png`,
mimeType: 'image/png',
buffer: png
});
await page.getByRole('checkbox', { name: /remove the background/i }).uncheck();
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByRole('heading', { name: 'Thank you — it arrived' })).toBeVisible();
});
});
+5
View File
@@ -270,6 +270,11 @@ function Start-Backend {
# No PayPal credentials locally. DEMO_MODE lets the whole cart and checkout
# path run without them and with no way to reach live PayPal.
$env:DEMO_MODE = 'true'
# Needs to be set for the submission page's checkbox to appear at all, but
# not to resolve: no e2e submission reaches the sidecar, because the
# worker only cuts out after a draft and drafting is not configured
# locally.
$env:REMBG_URL = 'http://127.0.0.1:7000'
New-Item -ItemType Directory -Force -Path $env:UPLOADS_DIR *>$null
Write-Step 'Running migrations'