SPB Git

Repository Public

RequestPage.tsx 37.2 KB 499 lines tsx

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
Files main
Menu

Theme