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)} + > + + + +