Repository Public
1// Deposit portal, as seen by someone without an account. The page knows the portal's public description and, once a2// deposit is admitted, a submission token that can only send the declared files and read the state of that deposit.3import { formatBytes, type RequestFields } from '@spd/shared';4import { type FormEvent, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';5import { ApiError, get, post } from '../api.ts';6import { Glyph } from '../glyphs.tsx';7import { fmtDate, getLocale, useT } from '../i18n.ts';8import { Button, copyText, cx, ProgressBar, Spinner } from '../ui.tsx';9import { transfer } from '../upload-engine.ts';10import { Notice, PField, PublicShell } from './PublicShell.tsx';11import { accentVars, BLOCKED_EXT, isTouch, serverExt, store, tokenKey } from './util.ts';1213interface PortalInfo {14 title: string; description: string; instructions: string; brandName: string; brandColor: string; needsPassword: boolean; expiresAt: string | null;15 maxFileBytes: number; maxFiles: number; remainingBytes: number; allowedExtensions: string[]; fields: RequestFields; requireConsent: boolean; consentText: string; retentionNote: string; chunkBytes: number;16}17interface Head { title: string; description: string; brandName: string; brandColor: string }18interface Admitted { ref: string; submissionToken: string; items: { uploadId: string; name: string; size: number }[] }19interface StatusOut { ref: string; status: 'open' | 'processing' | 'completed' | 'rejected' | 'expired'; confirmationMessage: string; items: { name: string; status: 'accepted' | 'rejected' | 'pending'; reason: string | null }[] }20/** What survives an accidental reload of the tab (sessionStorage): enough to resume or to show the receipt again. */21interface Pending { v: 1; token: string; ref: string; chunkBytes: number; head: Head; items: { uploadId: string; name: string; original: string; size: number }[]; receivedAt?: string }22type Loaded =23 | { kind: 'loading' } | { kind: 'unavailable' } | { kind: 'closed' } | { kind: 'error'; tooMany?: boolean }24 | { kind: 'ready'; info: PortalInfo | null; pending: Pending | null; status: StatusOut | null };2526const REASONS = ['type_not_allowed', 'incomplete', 'executable', 'type_mismatch', 'infected', 'unscannable', 'target_unavailable', 'quota_exceeded', 'expired'];27const bearer = (token: string) => ({ headers: { Authorization: `Bearer ${token}` } });28const fileKey = (f: File) => `${f.name}|${f.size}|${f.lastModified}`;2930export function RequestPage({ token }: { token: string }) {31 const t = useT();32 const key = tokenKey('spd.deposit', token);33 const [state, setState] = useState<Loaded>({ kind: 'loading' });34 const [round, setRound] = useState(0); // "another deposit" and "retry" load the portal again3536 useEffect(() => {37 let live = true;38 setState({ kind: 'loading' });39 void (async () => {40 // a deposit begun in this tab comes first: it must stay reachable even if the portal has filled up or closed since41 let pending = store.get<Pending>(key), status: StatusOut | null = null;42 if (pending?.v === 1) {43 try { status = await get<StatusOut>('/public/submissions/status', bearer(pending.token)); if (status.status === 'expired') throw new ApiError(410, 'portal_closed', ''); }44 catch (e) { if (e instanceof ApiError && e.status >= 400 && e.status < 500) { store.del(key); pending = null; status = null; } }45 } else pending = null;46 let info: PortalInfo | null = null, fail: Loaded | null = null;47 try { info = await get<PortalInfo>(`/public/requests/${token}`); }48 catch (e) { fail = e instanceof ApiError && e.status === 404 ? { kind: 'unavailable' } : e instanceof ApiError && e.status === 410 ? { kind: 'closed' } : { kind: 'error', tooMany: e instanceof ApiError && e.status === 429 }; }49 if (!live) return;50 if (pending && (status || info)) setState({ kind: 'ready', info, pending, status });51 else if (info) setState({ kind: 'ready', info, pending: null, status: null });52 else setState(fail!);53 })();54 return () => { live = false; };55 }, [token, key, round]);5657 const head: Head | null = state.kind === 'ready' ? state.info ?? state.pending!.head : null;58 const title = head ? [head.title, head.brandName].filter(Boolean).join(' — ') : t('public.portal.title');59 return (60 <PublicShell title={title}>61 {state.kind === 'loading' ? <div className="flex justify-center py-24 text-ink-3"><Spinner size={28} /></div>62 : state.kind === 'unavailable' ? <Notice icon="link" title={t('public.portal.unavailable')} text={t('public.portal.unavailableText')} />63 : state.kind === 'closed' ? <Notice icon="lock" title={t('public.portal.closed')} text={t('public.portal.closedText')} />64 : state.kind === 'error' ? <Notice icon="warning" tone="warn" title={state.tooMany ? t('public.err.tooMany') : t('public.err.network')} action={<Button size="lg" icon="refresh" onClick={() => setRound((n) => n + 1)}>{t('public.retry')}</Button>} />65 : <Deposit key={round} token={token} storeKey={key} info={state.info} pending={state.pending} status={state.status} head={head!} onRestart={() => { store.del(key); setRound((n) => n + 1); }} />}66 </PublicShell>67 );68}6970function PortalHeader({ head, info }: { head: Head; info: PortalInfo | null }) {71 const t = useT();72 const loc = getLocale();73 return (74 <header className="overflow-hidden card">75 <div className="relative overflow-hidden bg-accent px-6 pt-8 pb-7 text-accent-ink after:pointer-events-none after:absolute after:inset-0 after:bg-[radial-gradient(120%_90%_at_100%_0%,rgb(255_255_255/0.28),transparent_60%)] sm:px-8 sm:pt-9 sm:pb-8" style={accentVars(head.brandColor)} data-testid="portal-banner">76 {head.brandName ? <p className="relative z-10 text-[13px] font-bold tracking-[0.08em] uppercase opacity-90">{head.brandName}</p> : null}77 <h1 className="relative z-10 mt-1 text-[30px] leading-[1.1] font-bold tracking-[-0.03em] break-words">{head.title}</h1>78 {head.description ? <p className="relative z-10 mt-2.5 text-[16px] leading-relaxed break-words whitespace-pre-line opacity-95">{head.description}</p> : null}79 </div>80 {info ? (81 <div className="space-y-4 px-5 py-5 sm:px-7">82 {info.instructions ? (83 <section aria-label={t('public.portal.instructions')}>84 <h2 className="text-xs font-semibold tracking-wide text-ink-3 uppercase">{t('public.portal.instructions')}</h2>85 <p className="mt-1.5 text-[15px] leading-relaxed break-words whitespace-pre-line text-ink">{info.instructions}</p>86 </section>87 ) : null}88 <section aria-label={t('public.portal.rules')}>89 <h2 className="text-xs font-semibold tracking-wide text-ink-3 uppercase">{t('public.portal.rules')}</h2>90 <ul className="mt-2 space-y-1.5 text-sm text-ink-2" data-testid="portal-rules">91 <Rule icon="file">{info.allowedExtensions.length ? t('public.portal.types', { list: info.allowedExtensions.map((e) => e.toUpperCase()).join(', ') }) : t('public.portal.typesAny')}</Rule>92 <Rule icon="upload">{t('public.portal.maxSize', { size: formatBytes(info.maxFileBytes, loc) })} · {t('public.portal.maxFiles', { n: info.maxFiles })}</Rule>93 {info.remainingBytes < info.maxFileBytes * info.maxFiles ? <Rule icon="inbox">{t('public.portal.remaining', { size: formatBytes(info.remainingBytes, loc) })}</Rule> : null}94 {info.expiresAt ? <Rule icon="clock">{t('public.portal.deadline', { date: fmtDate(info.expiresAt) })}</Rule> : null}95 </ul>96 </section>97 </div>98 ) : null}99 </header>100 );101}102const Rule = ({ icon, children }: { icon: string; children: React.ReactNode }) => <li className="flex items-start gap-2.5"><Glyph name={icon} size={17} className="mt-0.5 shrink-0 text-ink-3" /><span className="min-w-0 break-words">{children}</span></li>;103104type Phase = 'form' | 'resume' | 'sending' | 'failed' | 'analysing' | 'receipt' | 'closedMid';105interface Row { uploadId: string; name: string; size: number; sent: number; state: 'waiting' | 'sending' | 'done' | 'error' }106107function Deposit({ token, storeKey, info, pending: initial, status: initialStatus, head, onRestart }: { token: string; storeKey: string; info: PortalInfo | null; pending: Pending | null; status: StatusOut | null; head: Head; onRestart: () => void }) {108 const t = useT();109 const [pending, setPending] = useState<Pending | null>(initial);110 const [phase, setPhase] = useState<Phase>(() => !initial || !initialStatus ? (initial ? 'resume' : 'form') : initialStatus.status === 'open' ? 'resume' : initialStatus.status === 'processing' ? 'analysing' : 'receipt');111 const [rows, setRows] = useState<Row[]>([]);112 const [result, setResult] = useState<StatusOut | null>(initialStatus && (initialStatus.status === 'completed' || initialStatus.status === 'rejected') ? initialStatus : null);113 const files = useRef(new Map<string, File>()); // uploadId → the depositor's file (memory only)114 const abort = useRef<AbortController | null>(null);115 const shut = useRef(false); // the server said the deposit is over: nothing may put the page back in a sending state116 const patch = (id: string, p: Partial<Row>) => setRows((rs) => rs.map((r) => (r.uploadId === id ? { ...r, ...p } : r)));117118 /** Send whatever the server does not hold yet, three files at a time, then hand the deposit over for inspection. */119 const run = useCallback(async (p: Pending, resume: boolean) => {120 const ctl = new AbortController(); abort.current = ctl;121 const auth = bearer(p.token);122 setRows((old) => p.items.map((i) => ({ uploadId: i.uploadId, name: i.name, size: i.size, sent: old.find((o) => o.uploadId === i.uploadId)?.sent ?? 0, state: 'waiting' })));123 setPhase('sending');124 const queue = [...p.items];125 let closed = false, failed = false;126 const worker = async () => {127 for (let item = queue.shift(); item && !ctl.signal.aborted; item = queue.shift()) {128 const id = item.uploadId;129 try {130 const startAt = resume ? (await get<{ received: number }>(`/public/uploads/${id}`, { ...auth, signal: ctl.signal })).received : 0;131 patch(id, { state: 'sending', sent: startAt });132 await transfer({ file: files.current.get(id)!, chunkPath: (o) => `/public/uploads/${id}?offset=${o}`, statusPath: `/public/uploads/${id}`, headers: auth.headers, chunkBytes: p.chunkBytes, startAt, signal: ctl.signal, onProgress: (sent) => patch(id, { sent }) });133 patch(id, { state: 'done', sent: item.size });134 } catch (e) {135 if ((e as Error).name === 'AbortError') return;136 // refused for good: the portal expired or was revoked (410), or was deleted with its submissions (401/404)137 if (e instanceof ApiError && [401, 404, 410].includes(e.status)) closed = true; else failed = true;138 patch(id, { state: 'error' });139 ctl.abort();140 }141 }142 };143 await Promise.all([worker(), worker(), worker()]);144 if (shut.current) return;145 if (closed) { store.del(storeKey); setPhase('closedMid'); return; }146 if (failed || ctl.signal.aborted) { setPhase('failed'); return; }147 try { await post('/public/submissions/finalize', undefined, auth); }148 catch (e) {149 if (e instanceof ApiError && e.status >= 400 && e.status < 500 && e.status !== 429) { store.del(storeKey); setPhase('closedMid'); } else setPhase('failed');150 return;151 }152 setPhase('analysing');153 }, [storeKey]);154155 // the transfer must not be lost to a stray swipe or a closed tab156 useEffect(() => {157 if (phase !== 'sending') return;158 const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ''; };159 window.addEventListener('beforeunload', warn);160 return () => window.removeEventListener('beforeunload', warn);161 }, [phase]);162 useEffect(() => () => abort.current?.abort(), []);163 // A portal revoked or expired during a transfer is noticed within seconds, without waiting for the answer to a block164 // that may still be travelling (a server that refuses early does not always get its answer through to a busy upload).165 useEffect(() => {166 if (phase !== 'sending' || !pending) return;167 const timer = window.setInterval(async () => {168 let over = false;169 try { over = (await get<StatusOut>('/public/submissions/status', bearer(pending.token))).status === 'expired'; }170 catch (e) { over = e instanceof ApiError && [401, 404, 410].includes(e.status); }171 if (!over || shut.current) return;172 shut.current = true; abort.current?.abort(); store.del(storeKey); setPhase('closedMid');173 }, 4000);174 return () => clearInterval(timer);175 }, [phase, pending, storeKey]);176177 // inspection: ask every two seconds until every file has its verdict178 useEffect(() => {179 if (phase !== 'analysing' || !pending) return;180 let live = true, timer = 0;181 const tick = async () => {182 try {183 const s = await get<StatusOut>('/public/submissions/status', bearer(pending.token));184 if (!live) return;185 if (s.status === 'completed' || s.status === 'rejected') {186 const done = { ...pending, receivedAt: pending.receivedAt ?? new Date().toISOString() };187 store.set(storeKey, done); setPending(done); setResult(s); setPhase('receipt'); return;188 }189 if (s.status === 'expired') { store.del(storeKey); setPhase('closedMid'); return; }190 } catch (e) { if (live && e instanceof ApiError && [401, 404, 410].includes(e.status)) { store.del(storeKey); setPhase('closedMid'); return; } }191 if (live) timer = window.setTimeout(tick, 2000);192 };193 void tick();194 return () => { live = false; clearTimeout(timer); };195 }, [phase, pending, storeKey]);196197 const admitted = (a: Admitted, picked: File[]) => {198 const p: Pending = { v: 1, token: a.submissionToken, ref: a.ref, chunkBytes: info!.chunkBytes, head: { title: head.title, description: head.description, brandName: head.brandName, brandColor: head.brandColor }, items: a.items.map((i, n) => ({ ...i, original: picked[n]!.name })) };199 a.items.forEach((i, n) => files.current.set(i.uploadId, picked[n]!));200 store.set(storeKey, p); setPending(p);201 void run(p, false);202 };203204 return (205 <div className="space-y-4">206 <PortalHeader head={head} info={phase === 'form' ? info : null} />207 {phase === 'form' && info ? <DepositForm token={token} info={info} onAdmitted={admitted} /> : null}208 {phase === 'resume' && pending ? <Resume pending={pending} color={head.brandColor} onAbandon={onRestart} onContinue={(m) => { files.current = m; void run(pending, true); }} /> : null}209 {(phase === 'sending' || phase === 'failed') && pending ? <Sending rows={rows} failed={phase === 'failed'} color={head.brandColor} onResume={() => void run(pending, true)} /> : null}210 {phase === 'analysing' ? (211 <section className="card px-6 py-12 text-center" role="status" aria-live="polite" data-testid="analysing">212 <div className="flex justify-center text-ink-2"><Spinner size={30} /></div>213 <h2 className="mt-4 text-lg font-semibold">{t('public.portal.analysing')}</h2>214 <p className="mx-auto mt-2 max-w-sm text-sm leading-relaxed text-ink-2">{t('public.portal.analysingText')}</p>215 </section>216 ) : null}217 {phase === 'closedMid' ? <Notice icon="lock" tone="warn" title={t('public.portal.closedMid')} text={t('public.portal.closedMidText')} /> : null}218 {phase === 'receipt' && pending && result ? <Receipt pending={pending} result={result} color={head.brandColor} onAnother={onRestart} /> : null}219 </div>220 );221}222223// ---------------------------------------------------------------- the form ----------------------------------------------------------------224function DepositForm({ token, info, onAdmitted }: { token: string; info: PortalInfo; onAdmitted: (a: Admitted, files: File[]) => void }) {225 const t = useT();226 const loc = getLocale();227 const formId = useId();228 const [values, setValues] = useState<Record<string, string>>({});229 const [errors, setErrors] = useState<Record<string, string>>({});230 const [picked, setPicked] = useState<File[]>([]);231 const [consent, setConsent] = useState(false);232 const [banner, setBanner] = useState<string | null>(null);233 const [busy, setBusy] = useState(false);234 const [over, setOver] = useState(false);235 const fileInput = useRef<HTMLInputElement>(null), photoInput = useRef<HTMLInputElement>(null), formRef = useRef<HTMLFormElement>(null);236 const touch = useMemo(isTouch, []);237 const imagesOk = !info.allowedExtensions.length || ['jpg', 'jpeg', 'heic', 'png'].some((e) => info.allowedExtensions.includes(e));238239 const set = (k: string, v: string) => { setValues((s) => ({ ...s, [k]: v })); if (errors[k]) setErrors(({ [k]: _, ...rest }) => rest); };240 /** Why a file cannot be sent, or null. Checked before anything leaves the device. */241 const problem = (f: File): string | null => {242 const ext = serverExt(f.name);243 if (BLOCKED_EXT.has(ext)) return t('public.portal.fileBlocked', { ext });244 if (info.allowedExtensions.length && !info.allowedExtensions.includes(ext)) return ext ? t('public.portal.fileType', { ext }) : t('public.portal.fileTypeNone');245 if (f.size === 0) return t('public.portal.fileEmpty');246 if (f.size > info.maxFileBytes) return t('public.portal.fileTooLarge', { size: formatBytes(f.size, loc), max: formatBytes(info.maxFileBytes, loc) });247 return null;248 };249 const add = (list: FileList | File[] | null) => {250 if (!list) return;251 const incoming = [...list]; // copied now: the input's FileList is emptied right after this call252 setPicked((cur) => { const seen = new Set(cur.map(fileKey)); return [...cur, ...incoming.filter((f) => !seen.has(fileKey(f)) && seen.add(fileKey(f)))]; });253 setBanner(null); setErrors(({ files: _, ...rest }) => rest);254 };255 const invalid = picked.filter((f) => problem(f));256 const total = picked.reduce((n, f) => n + f.size, 0);257 const listError = picked.length > info.maxFiles ? t('public.portal.tooMany', { max: info.maxFiles, n: picked.length })258 : total > info.remainingBytes ? t('public.portal.overTotal', { size: formatBytes(total, loc), max: formatBytes(info.remainingBytes, loc) })259 : invalid.length ? t('public.portal.fixFiles') : errors.files ?? null;260261 const fieldDefs = [262 ...(['name', 'email', 'reference'] as const).filter((k) => info.fields[k] !== 'off').map((k) => ({ key: k, label: t(`public.portal.${k}`), required: info.fields[k] === 'required', builtin: true })),263 ...info.fields.custom.map((c) => ({ key: `c:${c.key}`, label: c.label, required: c.required, builtin: false })),264 ];265266 const submit = async (e: FormEvent) => {267 e.preventDefault();268 const errs: Record<string, string> = {};269 if (info.needsPassword && !values.password) errs.password = t('public.err.passwordRequired');270 for (const f of fieldDefs) if (f.required && !(values[f.key] ?? '').trim()) errs[f.key] = t('public.portal.required');271 const email = (values.email ?? '').trim();272 if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errs.email = t('public.portal.emailInvalid');273 if (!picked.length) errs.files = t('public.portal.noFiles');274 if (info.requireConsent && !consent) errs.consent = t('public.portal.consentRequired');275 setErrors(errs); setBanner(null);276 if (Object.keys(errs).length || listError) {277 requestAnimationFrame(() => (formRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]') ?? formRef.current?.querySelector<HTMLElement>('[data-list-error]'))?.focus());278 return;279 }280 setBusy(true);281 try {282 const custom: Record<string, string> = {};283 for (const c of info.fields.custom) { const v = (values[`c:${c.key}`] ?? '').trim(); if (v) custom[c.key] = v; }284 const a = await post<Admitted>(`/public/requests/${token}/submissions`, {285 password: info.needsPassword ? values.password : undefined, name: (values.name ?? '').trim(), email, reference: (values.reference ?? '').trim(), custom, consent,286 files: picked.map((f) => ({ name: f.name, size: f.size })),287 });288 onAdmitted(a, picked);289 } catch (err) {290 setBusy(false);291 const x = err instanceof ApiError ? err : new ApiError(0, 'network', '');292 const fe: Record<string, string> = {};293 if (x.code === 'password_invalid') fe.password = t('public.err.passwordInvalid');294 else if (x.code === 'password_required') fe.password = t('public.err.passwordRequired');295 else if (x.code === 'field_required') { const k = String(x.data.field ?? ''); fe[['name', 'email', 'reference'].includes(k) ? k : `c:${k}`] = t('public.portal.required'); }296 else if (x.code === 'consent_required') fe.consent = t('public.portal.consentRequired');297 else if (x.code === 'invalid_request' && !/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test(email) && email) fe.email = t('public.portal.emailInvalid');298 if (Object.keys(fe).length) { setErrors(fe); requestAnimationFrame(() => formRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()); return; }299 setBanner(x.code === 'too_many_files' ? t('public.portal.serverTooMany', { max: x.data.max ?? info.maxFiles }) : x.code === 'type_not_allowed' ? t('public.portal.serverType', { name: x.data.name ?? '' })300 : x.code === 'file_too_large' ? t('public.portal.serverTooLarge', { name: x.data.name ?? '' }) : x.code === 'portal_full' ? t('public.portal.full')301 : x.code === 'too_many_attempts' ? t('public.err.tooMany') : x.status === 429 ? t('public.portal.rate')302 : x.status === 410 ? t('public.portal.closed') : x.status === 404 ? t('public.portal.unavailable') : x.status === 0 ? t('public.err.network') : t('public.err.generic'));303 }304 };305306 return (307 <form ref={formRef} onSubmit={submit} noValidate className="space-y-4" aria-labelledby={`${formId}-h`}>308 <h2 id={`${formId}-h`} className="sr-only">{t('public.portal.title')}</h2>309 {info.needsPassword || fieldDefs.length ? (310 <section className="space-y-4 card px-5 py-5 sm:px-7">311 {info.needsPassword ? <PField label={t('public.password')} type="password" required autoComplete="off" value={values.password ?? ''} onChange={(e) => set('password', e.target.value)} error={errors.password} hint={t('public.portal.passwordHint')} /> : null}312 {fieldDefs.map((f) => (313 <PField key={f.key} label={f.label} required={f.required} optional={t('public.optional')} value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} error={errors[f.key]}314 maxLength={f.builtin ? (f.key === 'email' ? 200 : 120) : 500} hint={f.key === 'reference' ? t('public.portal.referenceHint') : undefined}315 type={f.key === 'email' ? 'email' : 'text'} inputMode={f.key === 'email' ? 'email' : undefined} autoComplete={f.key === 'name' ? 'name' : f.key === 'email' ? 'email' : 'off'} autoCapitalize={f.key === 'email' ? 'none' : undefined} />316 ))}317 {info.needsPassword || fieldDefs.some((f) => f.required) ? <p className="text-xs text-ink-3">{t('public.portal.requiredLegend')}</p> : null}318 </section>319 ) : null}320321 <section className="card px-5 py-5 sm:px-7" aria-label={t('public.portal.files')}>322 <h2 className="text-sm font-medium text-ink">{t('public.portal.files')}<span aria-hidden="true" className="text-danger"> *</span></h2>323 <input ref={fileInput} type="file" multiple className="sr-only" tabIndex={-1} aria-hidden="true" data-testid="file-input" onChange={(e) => { add(e.target.files); e.target.value = ''; }} />324 <input ref={photoInput} type="file" accept="image/*" capture="environment" className="sr-only" tabIndex={-1} aria-hidden="true" data-testid="photo-input" onChange={(e) => { add(e.target.files); e.target.value = ''; }} />325 <div data-testid="dropzone" onClick={(e) => { if (e.target === e.currentTarget) fileInput.current?.click(); }}326 onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={(e) => { e.preventDefault(); setOver(false); add(e.dataTransfer.files); }}327 className={cx('mt-3 flex cursor-pointer flex-col items-center gap-3 rounded-2xl border-2 border-dashed px-4 text-center transition', picked.length ? 'py-5' : 'py-9', over ? 'border-accent bg-accent-soft' : 'border-line-strong bg-bg')}>328 {picked.length ? null : <div className="pointer-events-none flex h-12 w-12 items-center justify-center rounded-2xl bg-surface text-ink-2 shadow-sm"><Glyph name="upload" size={24} /></div>}329 <p className="pointer-events-none text-[15px] font-medium text-ink">{over ? t('public.portal.dropActive') : touch ? t('public.portal.dropTouch') : t('public.portal.drop')}</p>330 <div className="flex w-full flex-col items-stretch justify-center gap-2 sm:w-auto sm:flex-row">331 <Button size="lg" icon={picked.length ? 'plus' : 'file'} onClick={() => fileInput.current?.click()}>{picked.length ? t('public.portal.addMore') : t('public.portal.choose')}</Button>332 {touch && imagesOk ? <Button size="lg" icon="camera" onClick={() => photoInput.current?.click()}>{t('public.portal.photo')}</Button> : null}333 </div>334 </div>335 {picked.length ? (336 <div className="mt-4">337 <div className="flex flex-wrap items-center justify-between gap-2">338 <p className="text-sm text-ink-2" aria-live="polite">{t('public.portal.selected', { n: picked.length, size: formatBytes(total, loc) })}</p>339 {invalid.length ? <Button size="sm" variant="ghost" icon="trash" onClick={() => setPicked((c) => c.filter((f) => !problem(f)))}>{t('public.portal.removeInvalid')}</Button> : null}340 </div>341 <ul className="mt-2 divide-y divide-line rounded-2xl border border-line" data-testid="picked">342 {picked.map((f) => {343 const bad = problem(f);344 return (345 <li key={fileKey(f)} className="flex items-center gap-3 py-2 pr-1 pl-3" data-invalid={bad ? 'true' : undefined}>346 <Glyph name={bad ? 'warning' : 'file'} size={20} className={cx('shrink-0', bad ? 'text-danger' : 'text-ink-3')} />347 <div className="min-w-0 flex-1">348 <p className="truncate text-sm font-medium text-ink">{f.name}</p>349 {bad ? <p className="text-sm text-danger">{bad}</p> : <p className="text-xs text-ink-3">{formatBytes(f.size, loc)}</p>}350 </div>351 <button type="button" aria-label={t('public.portal.remove', { name: f.name })} onClick={() => setPicked((c) => c.filter((x) => x !== f))}352 className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-ink-2 hover:bg-hover hover:text-ink"><Glyph name="x" size={18} /></button>353 </li>354 );355 })}356 </ul>357 </div>358 ) : null}359 {listError ? <p role="alert" tabIndex={-1} data-list-error className="mt-3 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger outline-none">{listError}</p> : null}360 </section>361362 {info.requireConsent || info.retentionNote ? (363 <section className="space-y-3 card px-5 py-5 sm:px-7">364 {info.requireConsent ? (365 <div>366 <label className="flex cursor-pointer items-start gap-3">367 <input type="checkbox" checked={consent} onChange={(e) => { setConsent(e.target.checked); setErrors(({ consent: _, ...rest }) => rest); }} aria-invalid={!!errors.consent || undefined} aria-describedby={errors.consent ? `${formId}-c` : undefined}368 className="mt-0.5 h-5 w-5 shrink-0 accent-[var(--accent)]" />369 <span className="min-w-0 text-[15px] leading-relaxed break-words whitespace-pre-line text-ink">{info.consentText || t('public.portal.consentDefault')}<span aria-hidden="true" className="text-danger"> *</span></span>370 </label>371 {errors.consent ? <p id={`${formId}-c`} role="alert" className="mt-1.5 pl-8 text-sm text-danger">{errors.consent}</p> : null}372 </div>373 ) : null}374 {info.retentionNote ? <p className="text-xs leading-relaxed break-words whitespace-pre-line text-ink-3"><span className="font-semibold">{t('public.portal.retention')} — </span>{info.retentionNote}</p> : null}375 </section>376 ) : null}377378 {banner ? <p role="alert" className="rounded-2xl bg-danger-soft px-4 py-3 text-sm text-danger" data-testid="submit-error">{banner}</p> : null}379 <Button type="submit" variant="primary" size="lg" busy={busy} icon="upload" className="h-14! w-full text-base!" style={accentVars(info.brandColor)}>380 {picked.length ? t('public.portal.sendN', { n: picked.length }) : t('public.portal.send')}381 </Button>382 </form>383 );384}385386// ---------------------------------------------------------------- transfer ----------------------------------------------------------------387function Sending({ rows, failed, color, onResume }: { rows: Row[]; failed: boolean; color: string; onResume: () => void }) {388 const t = useT();389 const loc = getLocale();390 const total = rows.reduce((n, r) => n + r.size, 0), sent = rows.reduce((n, r) => n + Math.min(r.sent, r.size), 0);391 return (392 <section className="card px-5 py-5 sm:px-7" data-testid="sending">393 <h2 className="text-lg font-semibold">{failed ? t('public.portal.failed') : t('public.portal.sending')}</h2>394 {failed ? (395 <div role="alert" className="mt-3 rounded-2xl bg-warn-soft px-4 py-3 text-sm text-warn">396 <p>{t('public.portal.failedText')}</p>397 <Button className="mt-3" variant="primary" icon="refresh" onClick={onResume}>{t('public.portal.resumeBtn')}</Button>398 </div>399 ) : <p className="mt-2 flex items-start gap-2 rounded-2xl bg-warn-soft px-4 py-3 text-sm font-medium text-warn" data-testid="keep-open"><Glyph name="warning" size={18} className="mt-px shrink-0" />{t('public.portal.keepOpen')}</p>}400 <div className="mt-5" style={accentVars(color)}>401 <div className="mb-1.5 flex items-baseline justify-between gap-3 text-sm"><span className="font-medium">{t('public.portal.overall')}</span><span className="text-ink-2 tabular-nums">{t('public.portal.sentOf', { sent: formatBytes(sent, loc), total: formatBytes(total, loc) })} · {total ? Math.floor((sent / total) * 100) : 0} %</span></div>402 <ProgressBar value={total ? sent / total : 0} label={t('public.portal.overall')} />403 </div>404 <ul className="mt-5 space-y-3.5" style={accentVars(color)}>405 {rows.map((r) => (406 <li key={r.uploadId}>407 <div className="mb-1 flex items-center justify-between gap-3 text-sm">408 <span className="min-w-0 truncate">{r.name}</span>409 <span className={cx('flex shrink-0 items-center gap-1 text-xs', r.state === 'done' ? 'text-ok' : r.state === 'error' ? 'text-warn' : 'text-ink-3')}>410 {r.state === 'done' ? <><Glyph name="check" size={14} />{t('public.portal.sent')}</> : r.state === 'error' ? t('public.portal.fileError') : r.state === 'waiting' ? t('public.portal.waiting') : `${Math.floor((r.sent / r.size) * 100)} %`}411 </span>412 </div>413 <ProgressBar value={r.size ? r.sent / r.size : 0} label={t('public.portal.fileProgress', { name: r.name })} />414 </li>415 ))}416 </ul>417 </section>418 );419}420421/** After a reload in the middle of a transfer: the browser has forgotten the files, the server has not forgotten the bytes. */422function Resume({ pending, color, onContinue, onAbandon }: { pending: Pending; color: string; onContinue: (files: Map<string, File>) => void; onAbandon: () => void }) {423 const t = useT();424 const loc = getLocale();425 const [found, setFound] = useState<Map<string, File>>(new Map());426 const [mismatch, setMismatch] = useState<string | null>(null);427 const input = useRef<HTMLInputElement>(null);428 const add = (list: FileList | null) => {429 if (!list) return;430 const next = new Map(found); let miss: string | null = null;431 for (const f of list) { const item = pending.items.find((i) => !next.has(i.uploadId) && i.original === f.name && i.size === f.size); if (item) next.set(item.uploadId, f); else if (![...next.values()].includes(f)) miss = f.name; }432 setFound(next); setMismatch(miss);433 };434 return (435 <section className="card px-5 py-5 sm:px-7" data-testid="resume">436 <h2 className="text-lg font-semibold">{t('public.portal.resume')}</h2>437 <p className="mt-2 text-sm leading-relaxed text-ink-2">{t('public.portal.resumeText', { ref: pending.ref })}</p>438 <ul className="mt-4 divide-y divide-line rounded-2xl border border-line">439 {pending.items.map((i) => (440 <li key={i.uploadId} className="flex items-center gap-3 px-3 py-2.5">441 <Glyph name={found.has(i.uploadId) ? 'check' : 'file'} size={18} className={found.has(i.uploadId) ? 'text-ok' : 'text-ink-3'} />442 <span className="min-w-0 flex-1 truncate text-sm">{i.original}</span>443 <span className="shrink-0 text-xs text-ink-3">{formatBytes(i.size, loc)} · {found.has(i.uploadId) ? t('public.portal.resumeFound') : t('public.portal.resumeMissing')}</span>444 </li>445 ))}446 </ul>447 {mismatch ? <p role="alert" className="mt-3 text-sm text-danger">{t('public.portal.resumeMismatch', { name: mismatch })}</p> : null}448 <input ref={input} type="file" multiple className="sr-only" tabIndex={-1} aria-hidden="true" data-testid="resume-input" onChange={(e) => { add(e.target.files); e.target.value = ''; }} />449 <div className="mt-4 flex flex-col gap-2 sm:flex-row">450 {found.size < pending.items.length ? <Button size="lg" icon="file" onClick={() => input.current?.click()}>{t('public.portal.resumeChoose')}</Button>451 : <Button size="lg" variant="primary" icon="upload" style={accentVars(color)} onClick={() => onContinue(found)}>{t('public.portal.resumeGo')}</Button>}452 <Button size="lg" variant="ghost" onClick={onAbandon}>{t('public.portal.resumeAbandon')}</Button>453 </div>454 </section>455 );456}457458// ---------------------------------------------------------------- receipt ----------------------------------------------------------------459function Receipt({ pending, result, color, onAnother }: { pending: Pending; result: StatusOut; color: string; onAnother: () => void }) {460 const t = useT();461 const heading = useRef<HTMLHeadingElement>(null);462 const [copied, setCopied] = useState(false);463 useEffect(() => { heading.current?.focus(); window.scrollTo({ top: 0 }); }, []);464 const ok = result.items.filter((i) => i.status === 'accepted').length, ko = result.items.filter((i) => i.status === 'rejected').length;465 const none = ok === 0;466 return (467 <section className="card px-5 py-7 sm:px-7" data-testid="receipt">468 <div className="text-center">469 <div className={cx('mx-auto flex h-14 w-14 items-center justify-center rounded-full', none ? 'bg-danger-soft text-danger' : ko ? 'bg-warn-soft text-warn' : 'bg-ok-soft text-ok')}><Glyph name={none ? 'x' : ko ? 'warning' : 'check'} size={30} /></div>470 <h2 ref={heading} tabIndex={-1} className="mt-4 text-xl font-semibold outline-none">{none ? t('public.portal.receiptNone') : ko ? t('public.portal.receiptPartial') : t('public.portal.receipt')}</h2>471 {none ? <p className="mx-auto mt-2 max-w-sm text-sm text-ink-2">{t('public.portal.receiptNoneText')}</p> : null}472 {result.confirmationMessage ? <p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed break-words whitespace-pre-line text-ink" data-testid="confirmation">{result.confirmationMessage}</p> : null}473 </div>474 <div className="mt-6 rounded-2xl bg-bg px-4 py-4 text-center">475 <p className="text-xs font-semibold tracking-wide text-ink-3 uppercase">{t('public.portal.refLabel')}</p>476 <p className="mt-1 font-mono text-3xl font-semibold tracking-wider select-all" data-testid="receipt-ref">{result.ref}</p>477 <div className="mt-3 flex justify-center"><Button size="sm" icon={copied ? 'check' : 'copy'} onClick={async () => { if (await copyText(result.ref)) { setCopied(true); setTimeout(() => setCopied(false), 1800); } }}>{copied ? t('common.copied') : t('public.portal.copyRef')}</Button></div>478 <p className="mt-3 text-xs text-ink-3">{t('public.portal.refHint')}</p>479 {pending.receivedAt ? <p className="mt-1 text-xs text-ink-3">{t('public.portal.date', { date: fmtDate(pending.receivedAt) })}</p> : null}480 </div>481 <p className="mt-6 text-sm font-medium">{t('public.portal.summary', { ok, ko })}</p>482 <ul className="mt-2 divide-y divide-line rounded-2xl border border-line" data-testid="receipt-files">483 {result.items.map((i, n) => (484 <li key={n} className="flex items-start gap-3 px-3 py-3" data-status={i.status}>485 <Glyph name={i.status === 'accepted' ? 'check' : i.status === 'rejected' ? 'x' : 'clock'} size={20} className={cx('mt-0.5 shrink-0', i.status === 'accepted' ? 'text-ok' : i.status === 'rejected' ? 'text-danger' : 'text-ink-3')} />486 <div className="min-w-0 flex-1">487 <p className="text-sm font-medium break-words">{i.name}</p>488 <p className={cx('text-sm', i.status === 'accepted' ? 'text-ok' : i.status === 'rejected' ? 'text-danger' : 'text-ink-3')}>489 {i.status === 'accepted' ? t('public.portal.accepted') : i.status === 'rejected' ? `${t('public.portal.rejected')} — ${t(`public.reason.${REASONS.includes(i.reason ?? '') ? i.reason : 'other'}`)}` : t('public.portal.pending')}490 </p>491 </div>492 </li>493 ))}494 </ul>495 <Button size="lg" className="mt-6 w-full" icon="plus" onClick={onAnother} style={accentVars(color)}>{t('public.portal.another')}</Button>496 </section>497 );498}499