From 2eddefd0ac48ddfa0c25139f47fd07e28054944f Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Sat, 4 Jul 2026 23:27:04 +0100 Subject: [PATCH 1/2] feat(bounty): submissions review tab (#632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the management dashboard's Submissions tab: list submitted work from the organizer submissions endpoint (#337) with submitter identity, status, the content link plus documentation/tweet/demo/media, and submitted-at. Respect submissionVisibility — competition work stays sealed (and unfetched) until the deadline. Adds a client-side 'stage for payout' selection that feeds winner selection (#633). --- .../manage/BountyManagementDashboard.tsx | 8 +- .../manage/BountySubmissionsPanel.tsx | 349 ++++++++++++++++++ features/bounties/api/keys.ts | 12 + .../api/organizer-dashboard-client.ts | 26 ++ .../bounties/api/use-organizer-dashboard.ts | 31 ++ features/bounties/index.ts | 14 +- 6 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 components/organization/bounties/manage/BountySubmissionsPanel.tsx diff --git a/components/organization/bounties/manage/BountyManagementDashboard.tsx b/components/organization/bounties/manage/BountyManagementDashboard.tsx index 15396b3c..24db560b 100644 --- a/components/organization/bounties/manage/BountyManagementDashboard.tsx +++ b/components/organization/bounties/manage/BountyManagementDashboard.tsx @@ -23,6 +23,7 @@ import { type BountyOperateOverview, } from '@/features/bounties'; import { ordinal } from '@/lib/utils'; +import BountySubmissionsPanel from './BountySubmissionsPanel'; export default function BountyManagementDashboard() { const params = useParams<{ id: string; bountyId: string }>(); @@ -144,7 +145,12 @@ export default function BountyManagementDashboard() { )} - + diff --git a/components/organization/bounties/manage/BountySubmissionsPanel.tsx b/components/organization/bounties/manage/BountySubmissionsPanel.tsx new file mode 100644 index 00000000..f660232d --- /dev/null +++ b/components/organization/bounties/manage/BountySubmissionsPanel.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { + CheckCircle2, + ExternalLink, + EyeOff, + FileText, + Github, + Loader2, + PlaySquare, + Star, + Trophy, + Twitter, +} from 'lucide-react'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { BoundlessButton } from '@/components/buttons'; +import EmptyState from '@/components/EmptyState'; +import { DueCountdown } from '@/components/bounties/DueCountdown'; +import { + useBountySubmissions, + type OrganizerBountySubmission, +} from '@/features/bounties'; + +const STATUS_CLASS: Record = { + pending: 'border-blue-500/30 bg-blue-500/10 text-blue-400', + accepted: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + rejected: 'border-red-500/30 bg-red-500/10 text-red-400', + disputed: 'border-amber-500/30 bg-amber-500/10 text-amber-400', +}; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export default function BountySubmissionsPanel({ + organizationId, + bountyId, + submissionVisibility, + submissionDeadline, +}: { + organizationId: string; + bountyId: string; + submissionVisibility: string; + submissionDeadline: string | null; +}) { + const [staged, setStaged] = useState>(new Set()); + + const deadlinePassed = submissionDeadline + ? new Date(submissionDeadline).getTime() <= Date.now() + : false; + // Competition submissions stay hidden until the deadline so the organizer + // cannot play favorites mid-flight. + const gated = + submissionVisibility === 'HIDDEN_UNTIL_DEADLINE' && !deadlinePassed; + + // Don't even fetch sealed competition work until the deadline. + const { data, isLoading, error } = useBountySubmissions( + organizationId, + bountyId, + {}, + { enabled: !gated } + ); + + if (gated) { + return ( +
+ +

+ Submissions are hidden until the deadline +

+

+ This is a competition. Work stays sealed so review stays fair. +

+ {submissionDeadline && ( +
+ +
+ )} +
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + const submissions = data?.items ?? []; + + if (submissions.length === 0) { + return ( +
+ +
+ ); + } + + const toggleStage = (id: string) => + setStaged(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + return ( +
+ {staged.size > 0 && ( +
+ + {staged.size} staged for payout + + (winner selection + signing lands in #633) + +
+ )} + + {submissions.map(s => ( + toggleStage(s.id)} + /> + ))} +
+ ); +} + +function SubmissionCard({ + submission: s, + staged, + onToggleStage, +}: { + submission: OrganizerBountySubmission; + staged: boolean; + onToggleStage: () => void; +}) { + const user = s.submittedBy; + const statusClass = + STATUS_CLASS[s.status] ?? 'border-zinc-700 bg-zinc-800/60 text-zinc-300'; + const awarded = s.tierPosition != null; + + return ( +
+
+ {/* Submitter */} + + + + + {user.name.charAt(0).toUpperCase()} + + +
+

+ {user.name} +

+ {user.username && ( +

@{user.username}

+ )} +
+ + +
+ {awarded && ( + + + {ordinal(s.tierPosition as number)} + {s.tierAmount + ? ` · ${Number(s.tierAmount).toLocaleString()}` + : ''} + + )} + + {s.status} + +
+
+ + {/* Work links */} +
+ {s.contentUri && ( + } + label='Submission' + primary + /> + )} + {s.documentationUrl && ( + } + label='Docs' + /> + )} + {s.tweetUrl && ( + } + label='Tweet' + /> + )} + {s.demoVideoUrl && ( + } + label='Demo' + /> + )} +
+ + {/* Media */} + {s.mediaUrls.length > 0 && ( +
+ {s.mediaUrls.map(url => ( + + Submission media + + ))} +
+ )} + + {/* Footer */} +
+ + Submitted {formatDate(s.createdAt)} + {s.escrowAnchorStatus && s.escrowAnchorStatus !== 'active' && ( + + ({s.escrowAnchorStatus.replace(/_/g, ' ')}) + + )} + + + {staged ? ( + <> + + Staged + + ) : ( + <> + + Stage for payout + + )} + +
+
+ ); +} + +function LinkChip({ + href, + icon, + label, + primary, +}: { + href: string; + icon: React.ReactNode; + label: string; + primary?: boolean; +}) { + return ( + + {icon} + {label} + + + ); +} + +const ordinal = (n: number): string => { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`; +}; diff --git a/features/bounties/api/keys.ts b/features/bounties/api/keys.ts index 2ae4765b..a52a63af 100644 --- a/features/bounties/api/keys.ts +++ b/features/bounties/api/keys.ts @@ -23,4 +23,16 @@ export const bountyKeys = { myActivity: () => [...bountyKeys.all, 'my-activity'] as const, overview: (organizationId: string, bountyId: string) => [...bountyKeys.all, 'overview', organizationId, bountyId] as const, + orgSubmissions: ( + organizationId: string, + bountyId: string, + params: Record = {} + ) => + [ + ...bountyKeys.all, + 'org-submissions', + organizationId, + bountyId, + params, + ] as const, }; diff --git a/features/bounties/api/organizer-dashboard-client.ts b/features/bounties/api/organizer-dashboard-client.ts index cc4cb2bc..c2d6bcee 100644 --- a/features/bounties/api/organizer-dashboard-client.ts +++ b/features/bounties/api/organizer-dashboard-client.ts @@ -28,3 +28,29 @@ export const getBountyOverview = async ( { params: { path: { organizationId, bountyId } } } ) ); + +// ── Organizer submissions review (#337 / #632) ──────────────────────────────── + +export type OrganizerBountySubmission = Schemas['OrganizerBountySubmissionDto']; +export type OrganizerBountySubmissionList = + Schemas['OrganizerBountySubmissionListDto']; +export type OrganizerSubmissionUser = Schemas['OrganizerSubmissionUserDto']; + +export interface OrganizerSubmissionsParams { + status?: string; + page?: number; + limit?: number; +} + +/** List the submitted work on a bounty for the reviewing organizer. */ +export const listBountySubmissions = async ( + organizationId: string, + bountyId: string, + params: OrganizerSubmissionsParams = {} +): Promise => + unwrapData( + await apiClient.GET( + '/api/organizations/{organizationId}/bounties/{bountyId}/submissions', + { params: { path: { organizationId, bountyId }, query: params } } + ) + ); diff --git a/features/bounties/api/use-organizer-dashboard.ts b/features/bounties/api/use-organizer-dashboard.ts index a4ce9655..3b33792a 100644 --- a/features/bounties/api/use-organizer-dashboard.ts +++ b/features/bounties/api/use-organizer-dashboard.ts @@ -5,7 +5,10 @@ import { useQuery } from '@tanstack/react-query'; import { bountyKeys } from './keys'; import { getBountyOverview, + listBountySubmissions, type BountyOperateOverview, + type OrganizerBountySubmissionList, + type OrganizerSubmissionsParams, } from './organizer-dashboard-client'; /** @@ -24,3 +27,31 @@ export function useBountyOverview( enabled: !!organizationId && !!bountyId, }); } + +/** + * Submitted work on a bounty, for the reviewing organizer (#337 / #632). + * Pass `enabled: false` to keep sealed competition work unfetched until the + * deadline (the FE gate) so it never reaches the browser early. + */ +export function useBountySubmissions( + organizationId: string | undefined, + bountyId: string | undefined, + params: OrganizerSubmissionsParams = {}, + options: { enabled?: boolean } = {} +) { + return useQuery({ + queryKey: bountyKeys.orgSubmissions( + organizationId ?? '', + bountyId ?? '', + params as Record + ), + queryFn: () => + listBountySubmissions( + organizationId as string, + bountyId as string, + params + ), + enabled: !!organizationId && !!bountyId && (options.enabled ?? true), + retry: false, + }); +} diff --git a/features/bounties/index.ts b/features/bounties/index.ts index a01baec7..6e3bfa0b 100644 --- a/features/bounties/index.ts +++ b/features/bounties/index.ts @@ -184,7 +184,10 @@ export { } from './api/use-participant-dashboard'; // ── Organizer operate dashboard (#338 / #630) ───────────────────────────────── -export { getBountyOverview } from './api/organizer-dashboard-client'; +export { + getBountyOverview, + listBountySubmissions, +} from './api/organizer-dashboard-client'; export type { BountyOperateOverview, BountyOperateIntake, @@ -192,8 +195,15 @@ export type { BountyOperateSubmissionStats, BountyOperateContributionStats, BountyOverviewPrizeTier, + OrganizerBountySubmission, + OrganizerBountySubmissionList, + OrganizerSubmissionUser, + OrganizerSubmissionsParams, } from './api/organizer-dashboard-client'; -export { useBountyOverview } from './api/use-organizer-dashboard'; +export { + useBountyOverview, + useBountySubmissions, +} from './api/use-organizer-dashboard'; // Participant hooks (React Query). export { From 100c1eff78a8f2326aacff3871d0e623ccd9933f Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sun, 5 Jul 2026 12:12:33 +0100 Subject: [PATCH 2/2] fix(bounty): review fixes for submissions tab (#632) - rebase onto the #630 review fixes (ace008f6); port listBountySubmissions to apiClient/unwrapData and drop the hand-rolled query serializer - lift winner staging into the dashboard so it survives tab switches and can feed the Payout tab (#633) - add pagination (backend caps pages at 50, defaults to 20): pager UI plus showing-x-of-y, so large bounties are no longer silently truncated - unlock the hidden-until-deadline gate live via a 30s recheck; do not gate when no deadline exists; label the gate as UX-only in comments (the organizer endpoint returns submissions regardless of visibility) - type submissionVisibility as the generated enum end to end, dropping the fails-open '' fallback - share submissionStatusClass from statusClass.ts; import ordinal from lib/utils; drop redundant retry: false - show tier amounts with full precision and the reward currency - render non-link submitter when no username; optimize Cloudinary media thumbs (96px) instead of full-size unoptimized originals; index media keys - fold hook options into one bag: useBountySubmissions(org, id, { params, enabled }) Co-Authored-By: Claude Fable 5 --- components/bounties/statusClass.ts | 20 ++ .../manage/BountyManagementDashboard.tsx | 20 +- .../manage/BountySubmissionsPanel.tsx | 194 ++++++++++++------ features/bounties/api/keys.ts | 4 +- .../bounties/api/use-organizer-dashboard.ts | 13 +- 5 files changed, 177 insertions(+), 74 deletions(-) diff --git a/components/bounties/statusClass.ts b/components/bounties/statusClass.ts index 335595f6..52fb26aa 100644 --- a/components/bounties/statusClass.ts +++ b/components/bounties/statusClass.ts @@ -17,3 +17,23 @@ const STATUS_CLASS: Record = { export function bountyStatusClass(status: string): string { return STATUS_CLASS[status] ?? 'border-zinc-700 bg-zinc-800/60 text-zinc-300'; } + +/** + * Badge styling for the submission review status (pending/accepted/rejected/ + * disputed). Kept next to bountyStatusClass so the two vocabularies stay + * deliberately aligned; disputed is amber here (not the bounty-level red) so + * a disputed submission reads differently from a rejected one in the list. + */ +const SUBMISSION_STATUS_CLASS: Record = { + pending: 'border-blue-500/30 bg-blue-500/10 text-blue-400', + accepted: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + rejected: 'border-red-500/30 bg-red-500/10 text-red-400', + disputed: 'border-amber-500/30 bg-amber-500/10 text-amber-400', +}; + +export function submissionStatusClass(status: string): string { + return ( + SUBMISSION_STATUS_CLASS[status] ?? + 'border-zinc-700 bg-zinc-800/60 text-zinc-300' + ); +} diff --git a/components/organization/bounties/manage/BountyManagementDashboard.tsx b/components/organization/bounties/manage/BountyManagementDashboard.tsx index 24db560b..2662402e 100644 --- a/components/organization/bounties/manage/BountyManagementDashboard.tsx +++ b/components/organization/bounties/manage/BountyManagementDashboard.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeft, Info, Loader2, Lock } from 'lucide-react'; @@ -30,6 +31,20 @@ export default function BountyManagementDashboard() { const organizationId = params?.id ?? ''; const bountyId = params?.bountyId ?? ''; + // Winner staging lives here (above the tab boundary) so it survives tab + // switches and is reachable by the Payout tab (#633). + const [stagedWinners, setStagedWinners] = useState>(new Set()); + const toggleStagedWinner = (id: string) => + setStagedWinners(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + const { data: overview, isLoading, @@ -148,8 +163,11 @@ export default function BountyManagementDashboard() {
diff --git a/components/organization/bounties/manage/BountySubmissionsPanel.tsx b/components/organization/bounties/manage/BountySubmissionsPanel.tsx index f660232d..63991188 100644 --- a/components/organization/bounties/manage/BountySubmissionsPanel.tsx +++ b/components/organization/bounties/manage/BountySubmissionsPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { @@ -21,17 +21,16 @@ import { Badge } from '@/components/ui/badge'; import { BoundlessButton } from '@/components/buttons'; import EmptyState from '@/components/EmptyState'; import { DueCountdown } from '@/components/bounties/DueCountdown'; +import { submissionStatusClass } from '@/components/bounties/statusClass'; import { useBountySubmissions, + type BountyOperateOverview, type OrganizerBountySubmission, } from '@/features/bounties'; +import { ordinal } from '@/lib/utils'; -const STATUS_CLASS: Record = { - pending: 'border-blue-500/30 bg-blue-500/10 text-blue-400', - accepted: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', - rejected: 'border-red-500/30 bg-red-500/10 text-red-400', - disputed: 'border-amber-500/30 bg-amber-500/10 text-amber-400', -}; +/** Matches the backend's default page size (organizer submissions endpoint). */ +const PAGE_SIZE = 20; function formatDate(iso: string): string { return new Date(iso).toLocaleDateString(undefined, { @@ -41,33 +40,63 @@ function formatDate(iso: string): string { }); } +/** Token amounts arrive as decimal strings; show full Stellar precision. */ +function formatTierAmount(amount: string): string { + const n = Number(amount); + return Number.isFinite(n) + ? n.toLocaleString(undefined, { maximumFractionDigits: 7 }) + : amount; +} + export default function BountySubmissionsPanel({ organizationId, bountyId, submissionVisibility, submissionDeadline, + rewardCurrency, + staged, + onToggleStage, }: { organizationId: string; bountyId: string; - submissionVisibility: string; + submissionVisibility: BountyOperateOverview['submissionVisibility']; submissionDeadline: string | null; + rewardCurrency: string; + staged: Set; + onToggleStage: (id: string) => void; }) { - const [staged, setStaged] = useState>(new Set()); + const [page, setPage] = useState(1); + const [deadlinePassed, setDeadlinePassed] = useState(() => + submissionDeadline + ? new Date(submissionDeadline).getTime() <= Date.now() + : false + ); + + // Re-check on the same cadence as DueCountdown so the gate opens while the + // organizer is sitting on the tab, instead of only on remount. + useEffect(() => { + if (!submissionDeadline || deadlinePassed) return; + const target = new Date(submissionDeadline).getTime(); + const interval = setInterval(() => { + if (Date.now() >= target) setDeadlinePassed(true); + }, 30_000); + return () => clearInterval(interval); + }, [submissionDeadline, deadlinePassed]); - const deadlinePassed = submissionDeadline - ? new Date(submissionDeadline).getTime() <= Date.now() - : false; - // Competition submissions stay hidden until the deadline so the organizer - // cannot play favorites mid-flight. + // Keep competition work out of the UI until the deadline. Publish validation + // guarantees a deadline for live competitions; without one there is nothing + // to count down to, so we do not gate. NOTE: this is a UX gate only, the + // organizer endpoint itself returns submissions regardless of visibility. const gated = - submissionVisibility === 'HIDDEN_UNTIL_DEADLINE' && !deadlinePassed; + submissionVisibility === 'HIDDEN_UNTIL_DEADLINE' && + submissionDeadline != null && + !deadlinePassed; - // Don't even fetch sealed competition work until the deadline. + // Don't fetch sealed competition work until the deadline. const { data, isLoading, error } = useBountySubmissions( organizationId, bountyId, - {}, - { enabled: !gated } + { params: { page, limit: PAGE_SIZE }, enabled: !gated } ); if (gated) { @@ -80,14 +109,12 @@ export default function BountySubmissionsPanel({

This is a competition. Work stays sealed so review stays fair.

- {submissionDeadline && ( -
- -
- )} +
+ +
); } @@ -113,6 +140,8 @@ export default function BountySubmissionsPanel({ } const submissions = data?.items ?? []; + const total = data?.total ?? submissions.length; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); if (submissions.length === 0) { return ( @@ -126,13 +155,8 @@ export default function BountySubmissionsPanel({ ); } - const toggleStage = (id: string) => - setStaged(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + const rangeStart = (page - 1) * PAGE_SIZE + 1; + const rangeEnd = rangeStart + submissions.length - 1; return (
@@ -150,28 +174,77 @@ export default function BountySubmissionsPanel({ toggleStage(s.id)} + onToggleStage={() => onToggleStage(s.id)} /> ))} + + {totalPages > 1 && ( +
+ + Showing {rangeStart}-{rangeEnd} of {total} submissions + +
+ setPage(p => Math.max(1, p - 1))} + > + Previous + + + Page {page} of {totalPages} + + = totalPages} + onClick={() => setPage(p => Math.min(totalPages, p + 1))} + > + Next + +
+
+ )}
); } function SubmissionCard({ submission: s, + rewardCurrency, staged, onToggleStage, }: { submission: OrganizerBountySubmission; + rewardCurrency: string; staged: boolean; onToggleStage: () => void; }) { const user = s.submittedBy; - const statusClass = - STATUS_CLASS[s.status] ?? 'border-zinc-700 bg-zinc-800/60 text-zinc-300'; const awarded = s.tierPosition != null; + const submitter = ( + <> + + + + {user.name.charAt(0).toUpperCase()} + + +
+

+ {user.name} +

+ {user.username && ( +

@{user.username}

+ )} +
+ + ); + return (
{/* Submitter */} - - - - - {user.name.charAt(0).toUpperCase()} - - -
-

- {user.name} -

- {user.username && ( -

@{user.username}

- )} -
- + {user.username ? ( + + {submitter} + + ) : ( +
{submitter}
+ )}
{awarded && ( @@ -211,13 +275,13 @@ function SubmissionCard({ {ordinal(s.tierPosition as number)} {s.tierAmount - ? ` · ${Number(s.tierAmount).toLocaleString()}` + ? ` · ${formatTierAmount(s.tierAmount)} ${rewardCurrency}` : ''} )} {s.status} @@ -260,9 +324,9 @@ function SubmissionCard({ {/* Media */} {s.mediaUrls.length > 0 && (
- {s.mediaUrls.map(url => ( + {s.mediaUrls.map((url, i) => ( @@ -341,9 +409,3 @@ function LinkChip({ ); } - -const ordinal = (n: number): string => { - const s = ['th', 'st', 'nd', 'rd']; - const v = n % 100; - return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`; -}; diff --git a/features/bounties/api/keys.ts b/features/bounties/api/keys.ts index a52a63af..52e2a3e9 100644 --- a/features/bounties/api/keys.ts +++ b/features/bounties/api/keys.ts @@ -1,3 +1,5 @@ +import type { OrganizerSubmissionsParams } from './organizer-dashboard-client'; + /** * React Query key factory for the bounties feature. Co-locating the keys keeps * the hooks and any imperative `queryClient.invalidateQueries` calls in sync. @@ -26,7 +28,7 @@ export const bountyKeys = { orgSubmissions: ( organizationId: string, bountyId: string, - params: Record = {} + params: OrganizerSubmissionsParams = {} ) => [ ...bountyKeys.all, diff --git a/features/bounties/api/use-organizer-dashboard.ts b/features/bounties/api/use-organizer-dashboard.ts index 3b33792a..a14784f2 100644 --- a/features/bounties/api/use-organizer-dashboard.ts +++ b/features/bounties/api/use-organizer-dashboard.ts @@ -30,20 +30,22 @@ export function useBountyOverview( /** * Submitted work on a bounty, for the reviewing organizer (#337 / #632). - * Pass `enabled: false` to keep sealed competition work unfetched until the - * deadline (the FE gate) so it never reaches the browser early. + * Pass `enabled: false` to keep the Submissions tab from fetching sealed + * competition work before the deadline. This is a UX gate only: the API + * returns the organizer's submissions regardless of visibility, so the + * actual seal (if required) must be enforced server-side. */ export function useBountySubmissions( organizationId: string | undefined, bountyId: string | undefined, - params: OrganizerSubmissionsParams = {}, - options: { enabled?: boolean } = {} + options: { params?: OrganizerSubmissionsParams; enabled?: boolean } = {} ) { + const params = options.params ?? {}; return useQuery({ queryKey: bountyKeys.orgSubmissions( organizationId ?? '', bountyId ?? '', - params as Record + params ), queryFn: () => listBountySubmissions( @@ -52,6 +54,5 @@ export function useBountySubmissions( params ), enabled: !!organizationId && !!bountyId && (options.enabled ?? true), - retry: false, }); }