feat(intake): add the public submission page (#222)

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
This commit is contained in:
2026-08-31 15:42:26 -05:00
parent 1fc632598a
commit e7b01fdb36
3 changed files with 212 additions and 0 deletions
+164
View File
@@ -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<string | null>(null);
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((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 (
<div style={{ maxWidth: 640, margin: '48px auto', padding: '0 16px', textAlign: 'center' }}>
<Spin />
</div>
);
}
// 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 (
<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>
);
}
+45
View File
@@ -0,0 +1,45 @@
export interface IntakeLink {
label: string;
}
/**
* Whether a link works, and what it is called.
*
* Every refusal from the server is a 404 by design — unknown, revoked and
* exhausted links are indistinguishable, because whether a link exists is not
* something a stranger needs to learn. So there is one "this link does not
* work" state here rather than several the page would have to explain, and
* null is the whole of it.
*/
export async function fetchIntakeLink(token: string): Promise<IntakeLink | null> {
const res = await fetch(`/api/intake/${encodeURIComponent(token)}`);
if (!res.ok) return null;
return res.json();
}
export type SubmitResult = { ok: true } | { ok: false; error: string };
export async function submitItem(
token: string,
files: File[],
note: string
): 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);
const res = await fetch(`/api/intake/${encodeURIComponent(token)}`, {
method: 'POST',
body
});
if (res.ok) return { ok: true };
// The server's message is the useful one — it names the offending file for a
// type or content refusal. The fallback covers a response that is not JSON
// at all, which is what a proxy error looks like.
const payload = await res.json().catch(() => ({}));
return { ok: false, error: payload.error ?? 'Something went wrong. Please try again.' };
}
+3
View File
@@ -15,6 +15,7 @@ import Admin from './admin/Admin';
import AuthRouteModal from './customer/AuthRouteModal';
import Account from './customer/Account';
import PrivacyPolicy from './customer/PrivacyPolicy';
import Submit from './intake/Submit';
import VerifyEmail from './customer/VerifyEmail';
import ForgotPassword from './customer/ForgotPassword';
import ResetPassword from './customer/ResetPassword';
@@ -106,6 +107,8 @@ function AppRoutes() {
MODAL_ROUTES would put it back in the 700px box it just left. */}
<Route path="/orders" element={<Orders />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
{/* Public and unauthenticated: the token is the whole access control. */}
<Route path="/submit/:token" element={<Submit />} />
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
{/* Rendered outside the Routes above, which are showing the backdrop. */}