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>
202 lines
6.9 KiB
TypeScript
202 lines
6.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import Typography from 'antd/es/typography';
|
|
import Card from 'antd/es/card';
|
|
import Upload from 'antd/es/upload';
|
|
import Button from 'antd/es/button';
|
|
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';
|
|
import type { LinkState } from './intakeApi';
|
|
|
|
const { Title, Paragraph } = Typography;
|
|
const { TextArea } = Input;
|
|
|
|
/**
|
|
* Where someone with no account sends in photos of one item (#222).
|
|
*
|
|
* The three types the server will accept, and the same per-request cap. Listed
|
|
* here so the file picker offers exactly what will be taken and the count is
|
|
* bounded before anything is uploaded — but the server checks both again,
|
|
* because everything on this page is under the sender's control.
|
|
*/
|
|
const ACCEPT = 'image/jpeg,image/png,image/webp';
|
|
const MAX_IMAGES = 6;
|
|
|
|
export default function Submit() {
|
|
const { token = '' } = useParams();
|
|
const [state, setState] = useState<LinkState>({ kind: 'unusable' });
|
|
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);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void fetchIntakeLink(token).then((result) => {
|
|
// The token can change if the URL does, and a late response from the
|
|
// previous one would otherwise overwrite the current answer.
|
|
if (cancelled) return;
|
|
setState(result);
|
|
setChecking(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [token]);
|
|
|
|
async function send() {
|
|
setSending(true);
|
|
setError(null);
|
|
|
|
const result = await submitItem(
|
|
token,
|
|
// originFileObj is what antd hands back for a file it did not upload
|
|
// itself; beforeUpload returning false is what keeps them here. flatMap
|
|
// 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,
|
|
removeBackground
|
|
);
|
|
|
|
setSending(false);
|
|
if (result.ok) {
|
|
setSent(true);
|
|
return;
|
|
}
|
|
setError(result.error);
|
|
}
|
|
|
|
if (checking) {
|
|
return (
|
|
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px', textAlign: 'center' }}>
|
|
<Spin />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Kept apart from the state below on purpose. The two need opposite
|
|
// reactions — wait a moment, versus go and ask for a different link — so
|
|
// telling a throttled sender their link was dead would send them to fetch a
|
|
// replacement that could not have helped.
|
|
if (state.kind === 'throttled') {
|
|
return (
|
|
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
|
|
<Card>
|
|
<Title level={3}>Too many requests just now</Title>
|
|
<Paragraph>
|
|
Your link is fine — this page has just been asked for too many times from your
|
|
connection. Wait a minute and reload.
|
|
</Paragraph>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// One state for unknown, revoked and used-up alike, matching the server's
|
|
// single 404. Saying which it was would tell a stranger whether a link they
|
|
// guessed at exists.
|
|
if (state.kind === 'unusable') {
|
|
return (
|
|
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
|
|
<Card>
|
|
<Title level={3}>This link is not active</Title>
|
|
<Paragraph>
|
|
It may have been turned off, or already used as many times as it was meant for. Ask
|
|
whoever sent it to you for a new one.
|
|
</Paragraph>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sent) {
|
|
return (
|
|
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
|
|
<Card>
|
|
<Title level={3}>Thank you — it arrived</Title>
|
|
<Paragraph>
|
|
Somebody will look at your photos and write it up. Nothing is listed for sale until they
|
|
have.
|
|
</Paragraph>
|
|
<Button
|
|
onClick={() => {
|
|
setFiles([]);
|
|
setNote('');
|
|
setRemoveBackground(true);
|
|
setSent(false);
|
|
}}
|
|
>
|
|
Send another item
|
|
</Button>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px' }}>
|
|
<Card>
|
|
<Title level={3}>Send in an item</Title>
|
|
<Paragraph>
|
|
Photos of one item, and anything you know about it. Send each item separately.
|
|
</Paragraph>
|
|
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Upload
|
|
accept={ACCEPT}
|
|
multiple
|
|
listType="picture"
|
|
maxCount={MAX_IMAGES}
|
|
fileList={files}
|
|
// Returning false stops antd uploading each file the moment it is
|
|
// picked; they are sent together by `send` instead, which is what
|
|
// makes this one request the server can accept or refuse as a unit.
|
|
beforeUpload={() => false}
|
|
onChange={({ fileList }) => setFiles(fileList)}
|
|
>
|
|
<Button icon={<UploadOutlined />}>Choose photos</Button>
|
|
</Upload>
|
|
|
|
<TextArea
|
|
rows={4}
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
aria-label="Anything you know about this item"
|
|
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}>
|
|
Send
|
|
</Button>
|
|
</Space>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|