Add participant documents

This commit is contained in:
Tanuja Pansare 2026-07-22 10:31:31 +05:30
parent 569ba6e97c
commit c803b9d253
5 changed files with 631 additions and 9 deletions

View File

@ -0,0 +1,394 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, Download, Trash2, FileText } from 'lucide-react';
import { participantDocumentsApi } from '../../../services/api';
import type { ParticipantDocumentRow } from '../../../services/api/participantDocuments';
type Props = {
open: boolean;
participantId: string;
participantName: string;
eventId: string;
onClose: () => void;
};
function formatWhen(iso?: string) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return '—';
}
}
export function ParticipantDocumentsPanel({ open, participantId, participantName, eventId, onClose }: Props) {
const [busy, setBusy] = useState(false);
const [loadingDocs, setLoadingDocs] = useState(false);
const [docs, setDocs] = useState<ParticipantDocumentRow[]>([]);
const [err, setErr] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [deletedMsg, setDeletedMsg] = useState<string | null>(null);
const loadDocs = async () => {
setLoadingDocs(true);
setErr(null);
try {
const r = await participantDocumentsApi.getParticipantDocuments(participantId, eventId);
setDocs(r.data?.documents ?? []);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed to load documents');
setDocs([]);
} finally {
setLoadingDocs(false);
}
};
useEffect(() => {
if (!open) return;
void loadDocs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, participantId, eventId]);
const onUpload = async () => {
if (!file) return;
setUploading(true);
setErr(null);
try {
const res = await participantDocumentsApi.uploadParticipantDocument(participantId, { eventId, file });
if (!res.ok) {
let msg = res.statusText;
try {
const j = await res.json();
msg = j?.error?.message || msg;
} catch {}
throw new Error(msg);
}
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
await loadDocs();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploading(false);
}
};
const onDelete = async (doc: ParticipantDocumentRow) => {
// Backend enforces authz. Frontend can only optionally hide.
const ok = window.confirm(`Delete "${doc.originalFileName}"?`);
if (!ok) return;
setErr(null);
setBusy(true);
try {
const res = await participantDocumentsApi.deleteParticipantDocument(participantId, doc._id, eventId);
if (!res.ok) {
let msg = res.statusText;
try {
const j = await res.json();
msg = j?.error?.message || msg;
} catch {}
throw new Error(msg);
}
setDeletedMsg('Document deleted.');
await loadDocs();
setTimeout(() => setDeletedMsg(null), 1800);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Delete failed');
} finally {
setBusy(false);
}
};
if (!open) return null;
return (
<>
<div
onClick={onClose}
role="presentation"
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="participant-docs-title"
style={{
position: 'fixed',
top: 0,
right: 0,
height: '100%',
width: 'min(720px, 100vw)',
background: 'var(--card)',
zIndex: 201,
display: 'flex',
flexDirection: 'column',
boxShadow: '-4px 0 40px rgba(0,0,0,0.2)',
animation: 'participantDocsSlideIn 0.2s ease',
}}
>
<style>{`@keyframes participantDocsSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
padding: '16px 18px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}
>
<div style={{ minWidth: 0 }}>
<p style={{ fontSize: 'var(--text-xs)', fontFamily: 'var(--font-noto-sans)', color: 'var(--muted)', margin: '0 0 4px' }}>
Documents
</p>
<h2
id="participant-docs-title"
style={{
fontFamily: 'var(--font-inter)',
fontSize: 'var(--text-lg)',
fontWeight: 'var(--font-weight-medium)',
color: 'var(--foreground)',
margin: 0,
lineHeight: 1.3,
wordBreak: 'break-word',
}}
>
{participantName}
</h2>
<p style={{ margin: '6px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)', fontFamily: 'var(--font-noto-sans)' }}>
Event-scoped upload (current event only)
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
flexShrink: 0,
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--card)',
color: 'var(--muted)',
cursor: 'pointer',
}}
>
<X size={18} />
</button>
</div>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start',marginTop: 22, gap: 10 }}>
<div style={{ flex: '1 1 280px' }}>
<label style={{ display: 'block', fontSize: 10, fontFamily: 'var(--font-noto-sans)', fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 6 }}>
Upload Document
</label>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0] || null;
setFile(f);
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: "var(--card)",
}}
>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
style={{
padding: "8px 14px",
border: "1px solid var(--primary)",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
cursor: "pointer",
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
📁 Choose File
</button>
<span
style={{
color: file ? "var(--foreground)" : "var(--muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
fontSize: "var(--text-sm)",
}}
>
{file ? file.name : "No file selected"}
</span>
</div>
<p
style={{
marginTop: 8,
fontSize: "var(--text-xs)",
color: "var(--muted)",
}}
>
Supported: PDF, JPG, PNG, DOC, DOCX
</p>
</div>
<button
type="button"
disabled={!file || uploading}
onClick={() => void onUpload()}
style={{
minWidth: 180,
height: 44,
padding: "0 20px",
alignSelf: "flex-start",
marginTop: 30, // aligns with Choose File row
border: "none",
borderRadius: "var(--radius-button)",
background:
!file || uploading
? "#cbd5e1"
: "var(--primary)",
color: "#fff",
fontWeight: 600,
cursor: !file || uploading ? "not-allowed" : "pointer",
transition: "all .2s",
}}
>
{uploading ? "Uploading..." : "Upload Document"}
</button>
</div>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 0 16px' }}>
{err ? (
<p style={{ padding: '16px 18px', margin: 0, color: 'var(--destructive)', fontSize: 'var(--text-sm)' }}>{err}</p>
) : null}
{deletedMsg ? (
<p style={{ padding: '16px 18px', margin: 0, color: 'var(--success)', fontSize: 'var(--text-sm)' }}>{deletedMsg}</p>
) : null}
{loadingDocs ? (
<p style={{ padding: '24px 18px', margin: 0, color: 'var(--muted)' }}>Loading…</p>
) : docs.length === 0 ? (
<div style={{ padding: '26px 18px' }}>
<div style={{ border: '2px dashed var(--border)', borderRadius: 'var(--radius-card)', padding: '22px 16px', textAlign: 'center' }}>
<FileText size={32} style={{ margin: '0 auto 10px', color: 'var(--muted)' }} />
<p style={{ margin: 0, fontFamily: 'var(--font-inter)', fontSize: 'var(--text-base)', color: 'var(--foreground)', fontWeight: 700 }}>
No documents
</p>
<p style={{ margin: '8px 0 0', fontFamily: 'var(--font-noto-sans)', fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>
Upload the first document for this participant in the current event.
</p>
</div>
</div>
) : (
<div style={{ padding: '12px 18px' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-noto-sans)' }}>
<thead>
<tr style={{ background: 'var(--secondary)', borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'left', padding: '10px 12px', whiteSpace: 'nowrap' }}>Document</th>
<th style={{ textAlign: 'left', padding: '10px 12px', whiteSpace: 'nowrap' }}>Uploaded By</th>
<th style={{ textAlign: 'left', padding: '10px 12px', whiteSpace: 'nowrap' }}>Uploaded At</th>
<th style={{ textAlign: 'center', padding: '10px 12px', whiteSpace: 'nowrap' }}>Actions</th>
</tr>
</thead>
<tbody>
{docs.map((d) => (
<tr key={d._id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '10px 12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<FileText size={14} style={{ color: 'var(--muted)' }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--foreground)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 260 }}>
{d.originalFileName}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>{d.mimeType || '—'}</div>
</div>
</div>
</td>
<td style={{ padding: '10px 12px', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
{d.uploadedBy ? d.uploadedBy : '—'}
</td>
<td style={{ padding: '10px 12px', color: 'var(--muted)', whiteSpace: 'nowrap' }}>{formatWhen(d.uploadedAt)}</td>
<td style={{ padding: '10px 12px' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, alignItems: 'center' }}>
<button
type="button"
onClick={() => void participantDocumentsApi.downloadParticipantDocument(participantId, d._id, eventId, d.originalFileName)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--secondary)',
color: 'var(--foreground)',
cursor: 'pointer',
fontSize: 'var(--text-xs)',
}}
>
<Download size={14} />
</button>
<button
type="button"
disabled={busy}
onClick={() => void onDelete(d)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--card)',
color: 'var(--destructive)',
cursor: busy ? 'not-allowed' : 'pointer',
fontSize: 'var(--text-xs)',
}}
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</>
);
}

View File

@ -4,7 +4,7 @@ import { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../contexts/PermissionContext";
import {
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X,
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X, FileText,
Phone, Mail, Calendar, User, Filter, UserCheck,
AlertCircle, List, LayoutGrid, ChevronDown, CheckCircle2, Clock,
Users, ChevronLeft, ChevronRight, QrCode,
@ -15,6 +15,9 @@ import { portfolioListOrganizationId } from "../../../services/api/scopedOrganiz
import { AddParticipantPanel } from "../panels/AddParticipantPanel";
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
import { ParticipantQrModal } from "./ParticipantQrModal";
import { ParticipantDocumentsPanel } from "../panels/ParticipantDocumentsPanel";
import { tagChipStyle } from "./participantTagUtils";
import { useParticipantSelection } from "./useParticipantSelection";
import { useParticipantBulkActions } from "./useParticipantBulkActions";
@ -212,6 +215,7 @@ interface RowProps {
onConfirm: () => void;
onReject: () => void;
onQr: () => void;
onDocs: () => void;
canEdit?: boolean;
canDelete?: boolean;
selectable?: boolean;
@ -219,7 +223,7 @@ interface RowProps {
onToggleSelect?: () => void;
}
function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
function ParticipantRow({ p, onView,onEdit, onDelete, onConfirm, onReject, onQr, onDocs, canEdit, canDelete, selectable, selected, onToggleSelect, }: RowProps) {
const [hovered, setHovered] = useState(false);
const isPending = p.status === "Pending";
@ -322,12 +326,103 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr
</button>
</>
)}
{/* View Button */}
<button
type="button"
onClick={onView}
title="View profile"
aria-label="View profile"
style={{
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 10px",
borderRadius: "var(--radius-button)",
background: "var(--card)",
border: "1px solid var(--border)",
color: "var(--card-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--primary)";
e.currentTarget.style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--card-foreground)";
}}
>
<Eye size={11} /> View
</button>
{/* QR Button */}
<button
type="button"
onClick={onQr}
title="Check-in QR code (EventBuzz Checkins app)"
aria-label="Check-in QR code"
style={iconBtnStyle}
style={{
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 10px",
borderRadius: "var(--radius-button)",
background: "var(--card)",
border: "1px solid var(--border)",
color: "var(--card-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--primary)";
e.currentTarget.style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--card-foreground)";
}}
>
<QrCode size={11} /> QR
</button>
<button
onClick={onDocs}
title="Participant documents"
style={{
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 10px",
borderRadius: "var(--radius-button)",
background: "var(--card)",
border: "1px solid var(--border)",
color: "var(--card-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
cursor: "pointer",
}}
>
<FileText size={11} /> Document
</button>
<button
onClick={onQr}
title="Check-in QR code (EventBuzz Checkins app)"
style={{
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 10px",
borderRadius: "var(--radius-button)",
background: "var(--card)",
border: "1px solid var(--border)",
color: "var(--card-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
cursor: "pointer",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
@ -350,6 +445,7 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr
>
<Eye size={13} />
</button>
{canEdit && (
<button
type="button"
@ -384,7 +480,8 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr
/* ─────────────── Participant Card ─────────────── */
function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, onDocs, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
const [hovered, setHovered] = useState(false);
const isPending = p.status === "Pending";
@ -479,6 +576,30 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQ
<div style={{ display: "flex", gap: "4px" }}>
<button
type="button"
onClick={onDocs}
title="Participant documents"
style={{
width: "26px",
height: "26px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--secondary)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
cursor: "pointer",
color: "var(--muted)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
<FileText size={12} />
</button>
<button
onClick={onQr}
title="Check-in QR code"
aria-label="Check-in QR code"
@ -678,7 +799,9 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
const [viewModal, setViewModal] = useState<ParticipantDisplay | null>(null);
const [qrParticipant, setQrParticipant] = useState<ParticipantDisplay | null>(null);
const [docsParticipant, setDocsParticipant] = useState<ParticipantDisplay | null>(null);
const [showAddPanel, setShowAddPanel] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [confirm, setConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null);
const [exporting, setExporting] = useState(false);
@ -723,10 +846,14 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
});
}, [selectedEventId, listNonce, enableBulk]);
const searchDebounce = useRef<ReturnType<typeof setTimeout>>();
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
searchDebounce.current = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
return () => {
if (searchDebounce.current) {
clearTimeout(searchDebounce.current);
}
};
}, [searchInput]);
useEffect(() => { setPage(1); }, [search, statusFilter, designationFilter, activeTag, limit]);
@ -1020,7 +1147,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
</tr>
</thead>
<tbody>
{list.map((p) => (
{list.map((p) => (
<ParticipantRow
key={p.id}
p={p}
@ -1035,8 +1162,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onQr={() => setQrParticipant(p)}
canEdit={canEditParticipants}
canDelete={canDeleteParticipants}
onDocs={() => setDocsParticipant(p)}
// canEdit={isSuperAdmin}
/>
))}
{/* )) */}
</tbody>
</table>
</div>
@ -1072,6 +1202,8 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onDelete={() => deleteParticipant(p.id)}
onConfirm={() => confirmRegistration(p.id)}
onReject={() => rejectRegistration(p.id)}
// onToggleCheckIn={() => toggleCheckIn(p.id)}
onDocs={() => setDocsParticipant(p)}
onQr={() => setQrParticipant(p)}
canEdit={canEditParticipants}
canDelete={canDeleteParticipants}
@ -1123,6 +1255,15 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onClose={() => setQrParticipant(null)}
/>
)}
{docsParticipant && selectedEventId && (
<ParticipantDocumentsPanel
open={true}
participantId={docsParticipant.id}
participantName={docsParticipant.name}
eventId={selectedEventId}
onClose={() => setDocsParticipant(null)}
/>
)}
{showAddPanel && selectedEventId && (
<AddParticipantPanel
eventId={selectedEventId}
@ -1195,4 +1336,4 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
) : null}
</div>
);
}
}

View File

@ -16,7 +16,7 @@ import {
export type { ApiRequestInit };
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3002/api';
const BASE_URL = API_BASE_URL;
const STORAGE_KEYS = {

View File

@ -11,6 +11,8 @@ export { authApi } from './auth';
export { eventsApi } from './events';
export { rewardPointsApi, type LeaderboardRow, type CreateRewardPointPayload } from './rewardPoints';
export { participantsApi, type ParticipantWithEvent } from './participants';
export { participantDocumentsApi, type ParticipantDocumentRow } from './participantDocuments';
export {
attendancesApi,
type CreateAttendancePayload,

View File

@ -0,0 +1,85 @@
import { api, fetchWithAuth, getAccessToken, API_BASE_URL } from './client';
export interface ParticipantDocumentRow {
_id: string;
participant: string;
organization: string;
event: string;
uploadedBy?: string | null;
fileName: string;
originalFileName: string;
url: string;
mimeType?: string;
size?: number;
uploadedAt?: string;
}
export const participantDocumentsApi = {
getParticipantDocuments(participantId: string, eventId: string) {
return api.get<{ documents: ParticipantDocumentRow[] }>(
`/participants/${participantId}/documents?eventId=${encodeURIComponent(eventId)}`,
);
},
uploadParticipantDocument(participantId: string, payload: { eventId: string; file: File }) {
const fd = new FormData();
fd.append('eventId', payload.eventId);
fd.append('file', payload.file, payload.file.name);
fd.append('name', payload.file.name);
// Send explicit mimeType so backend can use it even if Hapi strips multipart metadata
fd.append('mimeType', payload.file.type || 'application/octet-stream');
return fetchWithAuth(`/participants/${participantId}/documents`, {
method: 'POST',
body: fd,
});
},
async deleteParticipantDocument(participantId: string, documentId: string, eventId: string) {
return fetchWithAuth(
`/participants/${participantId}/documents/${documentId}?eventId=${encodeURIComponent(eventId)}`,
{ method: 'DELETE' },
);
},
async downloadParticipantDocument(participantId: string, documentId: string, eventId: string, fileName?: string) {
// Use backend proxy endpoint that streams the file with proper Content-Disposition headers.
// This avoids cross-origin download issues with CloudFront URLs.
const token = getAccessToken();
const url = `${API_BASE_URL}/participants/${participantId}/documents/${documentId}/download?eventId=${encodeURIComponent(eventId)}`;
try {
const res = await fetch(url, {
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
let msg = `Download failed (${res.status})`;
try {
const j = await res.json();
msg = j?.error?.message || msg;
} catch {}
throw new Error(msg);
}
const blob = await res.blob();
const effectiveFileName = fileName || 'download';
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = effectiveFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke the blob URL after a short delay to allow download to start
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
} catch (err) {
console.error('[downloadParticipantDocument]', err);
throw err;
}
},
};