From e7b01fdb360ccc221a0d2cc0f7c59b94d52c7598 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 31 Aug 2026 15:17:40 -0500 Subject: [PATCH] feat(intake): add the public submission page (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where someone with no account sends in photos of one item. Route /submit/:token, outside the authentik gate by design: the token in the URL is the whole access control, which is what #222 chose deliberately over accounts. One state for every refusal, matching the server's single 404. Unknown, revoked and used-up links all render the same "this link is not active" card, because saying which kind of dead it was would tell a stranger whether a link they guessed at exists — the server is careful about that and the page must not undo it. `beforeUpload` returns false so antd keeps the files rather than uploading each one as it is picked. The submission is then a single request the server can accept or refuse as a unit, which is what makes the transaction on the other side meaningful. The accepted types and the six-file cap are stated here so the picker offers exactly what will be taken, but both are checked again server-side, because everything on this page is under the sender's control. The fetch effect guards against a late response from a previous token overwriting the current answer, which is reachable simply by editing the URL. TypeScript caught a real mistake rather than a stylistic one: `.filter((f): f is File => ...)` on antd's originFileObj does not narrow, because RcFile extends File and the predicate would widen rather than narrow. flatMap avoids the predicate entirely. Verified in a browser rather than by inspection: a throwaway Playwright run against the live stack confirmed the form renders for a good token, the inactive card renders for a bad one, and a photo can actually be sent and acknowledged. The database then showed the item at status pending with the default price, the draft carrying the note and its originating link, the image row written, the link's counter at one — and zero storefront-visible items, which is the property that matters most. Ref #222 --- frontend/src/intake/Submit.tsx | 164 +++++++++++++++++++++++++++++++ frontend/src/intake/intakeApi.ts | 45 +++++++++ frontend/src/main.tsx | 3 + 3 files changed, 212 insertions(+) create mode 100644 frontend/src/intake/Submit.tsx create mode 100644 frontend/src/intake/intakeApi.ts diff --git a/frontend/src/intake/Submit.tsx b/frontend/src/intake/Submit.tsx new file mode 100644 index 0000000..d1db0ec --- /dev/null +++ b/frontend/src/intake/Submit.tsx @@ -0,0 +1,164 @@ +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'; + +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 [label, setLabel] = useState(null); + const [checking, setChecking] = useState(true); + const [files, setFiles] = useState([]); + const [note, setNote] = useState(''); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchIntakeLink(token).then((link) => { + // 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; + setLabel(link?.label ?? null); + 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 ( +
+ +
+ ); + } + + // One state for every refusal, matching the server's single 404. Saying which + // of revoked, unknown or used-up it was would tell a stranger whether a link + // they guessed at exists. + if (label === null) { + return ( +
+ + This link is not active + + 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. + + +
+ ); + } + + if (sent) { + return ( +
+ + Thank you — it arrived + + Somebody will look at your photos and write it up. Nothing is listed for sale until they + have. + + + +
+ ); + } + + return ( +
+ + Send in an item + + Photos of one item, and anything you know about it. Send each item separately. + + + + false} + onChange={({ fileList }) => setFiles(fileList)} + > + + + +