Adding e2e specs for the submission page found a defect in the page they were written for, which is what they were for. One limiter counted page loads and submissions against the same twenty-per-quarter-hour allowance, so a sender working through a box of stock ran out after ten items — the exact person the feature exists for, and the exact case the limiter's own comment said must not be refused. The comment said refusing them costs a consignment while the number quietly did it. Worse, the page could not tell a 429 from a 404. `fetchIntakeLink` treated any non-OK response as "no link", so a throttled sender was told "This link is not active" and sent to ask for a replacement — which could not have helped, because the problem was their address and a minute of patience. Two conditions needing opposite reactions were sharing a message. Now two limiters, because the two requests cost different things. Reading a link hits one indexed row and writes nothing, so that allowance is generous at 120: someone re-reading the form or losing their signal should never be told to wait. Submitting writes up to six files, so that is the one worth bounding, at 30 — more than anyone photographing items can manage and far less than a script would want. The page gains a third state. Unknown, revoked and used-up still collapse into one "not active" card, because whether a link exists is not something a stranger needs to learn. Throttled is deliberately kept apart from them, since "wait a moment" and "go and ask for another link" are opposite instructions. Measured rather than assumed, on a freshly started process both times: before, 25 page loads produced 14 rejections; after, 40 produce none. The first attempt at that measurement was wrong and worth recording — the restart had failed with EADDRINUSE, so it read 30 of 30 against the old process's already-exhausted store. The two specs now pass in a full parallel run alongside everything else. They are scoped the way #241 asks: unique run ids, assertions naming only this run's rows, nothing asserted about the table as a whole. Backend: 284 integration, 309 unit. Frontend: build clean, lint unchanged at 2 pre-existing warnings. Ref #222, #241
184 lines
6.1 KiB
TypeScript
184 lines
6.1 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 { 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('');
|
|
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
|
|
);
|
|
|
|
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('');
|
|
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."
|
|
/>
|
|
|
|
{error && <Alert type="error" message={error} showIcon />}
|
|
|
|
<Button type="primary" onClick={send} loading={sending} disabled={files.length === 0}>
|
|
Send
|
|
</Button>
|
|
</Space>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|