Feat/competition bounty flow - #178
Conversation
- Add useJoinCompetition, useSubmitContestWork, useApproveContestWinner, and useFinalizeContest mutations in hooks/use-competition-bounty.ts mapping to BountyRegistry contract methods (claim_bounty, submit_work, approve_contest_winner, finalize_contest) - Add CompetitionSubmission component: blind submission panel with countdown timer; submissions locked after deadline - Add CompetitionJudging component: creator-only panel post-deadline showing all revealed submissions with per-entry payout + reputation point inputs, winner/consolation selection, and finalize button - Add CompetitionStatus component: participant slot count, blind vs revealed submission state, and current phase indicator - Update bounty-detail-sidebar-cta.tsx: replace generic CTA with 'Join Competition' button for COMPETITION type; show slot count (X/max joined); wire CompetitionStatus and CompetitionSubmission panels below the main card; update MobileCTA accordingly - Update bounty-detail-client.tsx: render CompetitionJudging panel for creator after deadline/finalization; skip generic submissions card for competition type - Update bounty-card.tsx: add competition badge with slot count (Users icon + X/max joined) for COMPETITION bounties Closes: Feature — Competition Bounty Flow (#competition-flow) Depends on: boundlessfi#139 (TypeScript contract bindings via __contestContracts)
- hooks/use-competition-bounty.ts: remove unused BountiesQuery import (would fail @typescript-eslint/no-unused-vars); export ContestErrorCode type; remove redundant re-export alias - components/bounty/competition-judging.tsx: extract pendingWinner before map loop to avoid accessing approveMutation.variables inside the render callback (strict null-safety); remove shadowed isPending local variable - components/bounty/competition-status.tsx: add 'use client' directive — component calls Date.now() at render time which causes SSR/client hydration mismatch without it (Next.js build error) - components/bounty-detail/bounty-detail-client.tsx: replace unsafe 'unknown[]' double-cast with a properly typed inline cast matching CompetitionJudging's Submission interface, eliminating the 'Parameters<typeof ...>' hack that breaks under strict mode
|
@devJaja is attempting to deploy a commit to the Threadflow Team on Vercel. A member of the Team first needs to authorize it. |
|
Caution Review failedPull request was closed or merged during review Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughImplements full Competition (best-submission-wins) flow: join slots, blind submissions with deadline handling, creator judging (approve winners, set payouts/points), and finalization. Adds client components, hooks for contract interactions (join/submit/approve/finalize), deadline polling, optimistic updates, and integrates competition UI into bounty detail, sidebar CTA, and bounty card. (50 words) Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant UI as Competition UI
participant Auth as Auth Session
participant Query as React Query
participant Contract as Contest Contract Client
User->>UI: Click "Join Competition"
UI->>Auth: read walletAddress
UI->>Query: joinMutation.mutate({ bountyId, contributorAddress })
Query->>Contract: claim_bounty(contributor, bountyId)
Contract-->>Query: Success / ContestError
Query-->>UI: settled -> update hasJoined, toast
User->>UI: Submit work (before deadline)
UI->>Auth: ensure walletAddress
UI->>Query: submitMutation.mutate({ bountyId, workCid })
Query->>Contract: submit_work(contributor, bountyId, workCid)
Contract-->>Query: Success / Error
Query-->>UI: invalidate bounty detail -> toast
User(creator)->>UI: After deadline, view judging
UI->>Query: fetch submissions
UI->>User: render CompetitionJudging
User(creator)->>UI: Approve winner
UI->>Auth: ensure walletAddress
UI->>Query: approveMutation.mutate({ bountyId, winner, payout, points })
Query->>Contract: approve_contest_winner(...)
Contract-->>Query: Success / Error
Query-->>UI: invalidate -> toast
User(creator)->>UI: Finalize contest
UI->>Query: finalizeMutation.mutate({ bountyId })
Query->>Contract: finalize_contest(...)
Contract-->>Query: Success / Error
Query-->>Query: optimistic cache patch (COMPLETED)
Query-->>UI: invalidate -> "Results published"
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@devJaja Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (6)
components/bounty/bounty-card.tsx (1)
94-95: Drop the unsafe type assertion once the schema exposesmaxParticipants.
(bounty as { maxParticipants?: number | null }).maxParticipantsand the identical pattern inbounty-detail-sidebar-cta.tsx/bounty-detail-client.tsxcircumvent type safety. IfmaxParticipantsis part of the competition bounty contract, add it to theBountyFieldsFragment/ GraphQL schema so the field is surfaced throughBountyFieldsFragmentand the assertion can be removed everywhere.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty/bounty-card.tsx` around lines 94 - 95, The code uses an unsafe type assertion to read maxParticipants ((bounty as { maxParticipants?: number | null }).maxParticipants) which circumvents TypeScript safety; update the GraphQL schema/fragments so maxParticipants is included in BountyFieldsFragment, regenerate the TypeScript types, and then remove the cast and read bounty.maxParticipants directly in components such as bounty-card.tsx, bounty-detail-sidebar-cta.tsx and bounty-detail-client.tsx (ensure the new field is nullable if appropriate and adjust any runtime checks accordingly).hooks/use-competition-bounty.ts (2)
153-156: Missing list invalidation aftersubmit_work.
useSubmitContestWorkonly invalidates the bounty detail, butbounty-card.tsxrenders{slotCount}/{max} joined(currently backed by_count.submissions) from the list query. The card view won't refresh its submission counter until another query triggers invalidation.🔧 Suggested fix
onSettled: (_r, _e, v) => { qc.invalidateQueries({ queryKey: bountyKeys.detail(v.bountyId) }); + qc.invalidateQueries({ queryKey: bountyKeys.lists() }); },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@hooks/use-competition-bounty.ts` around lines 153 - 156, The current onSettled in useSubmitContestWork only invalidates bountyKeys.detail(v.bountyId) so the list-level _count.submissions (used by bounty-card.tsx for "{slotCount}/{max} joined") doesn't refresh; update the onSettled handler in useSubmitContestWork to also invalidate the list query (e.g., call qc.invalidateQueries with bountyKeys.list(...) or the appropriate list key) in addition to bountyKeys.detail so both the detail and the list UI update after submit_work.
77-86:patchDetaillets callers unintentionally overwriteupdatedAt.
...patchspreads afterupdatedAt, so any caller passingupdatedAtinpatchsilently wins over the freshly-generated timestamp. Today no caller does this, but the type isRecord<string, unknown>, so it's an easy footgun. Either spreadupdatedAtlast or narrow the type.🔧 Suggested fix
- bounty: { ...prev.bounty, updatedAt: new Date().toISOString(), ...patch }, + bounty: { ...prev.bounty, ...patch, updatedAt: new Date().toISOString() },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@hooks/use-competition-bounty.ts` around lines 77 - 86, The patchDetail function currently allows callers to override updatedAt because it spreads ...patch after setting updatedAt; change it so updatedAt cannot be overwritten by either (a) move updatedAt to be spread last (i.e., spread patch first then set updatedAt) or (b) tighten the patch parameter type (e.g., replace Record<string, unknown> with a type that excludes updatedAt such as Omit<Partial<BountyQuery['bounty']>, 'updatedAt'>) and ensure patch is applied before you set updatedAt; update function signature and return construction in patchDetail accordingly.components/bounty-detail/bounty-detail-client.tsx (1)
91-99: Type-castsubmissionsvia the GraphQL fragment instead of an inline shape.Casting
bountyto a local{ submissions?: ... }structural type bypasses the generated types and duplicates theSubmissionshape already declared incompetition-judging.tsx. Ifsubmissionsexists on the competition fragment, extend the fragment so it's typed end-to-end; otherwise request it. Same inline-cast pattern repeats inbounty-detail-sidebar-cta.tsxandbounty-card.tsxformaxParticipants.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-client.tsx` around lines 91 - 99, Replace the ad-hoc inline type-cast around `bounty` used to derive `competitionSubmissions` with the generated GraphQL fragment/type that includes `submissions` (and similarly for `maxParticipants` in `bounty-detail-sidebar-cta.tsx` and `bounty-card.tsx`); update the GraphQL fragment used by these components (or request the field from the server) so `Submission` and `maxParticipants` are part of the fragment, then reference the generated fragment type (not a local structural cast) when reading `bounty.submissions` in `bounty-detail-client.tsx` (the `competitionSubmissions` assignment) and the corresponding places in `competition-judging.tsx`, `bounty-detail-sidebar-cta.tsx`, and `bounty-card.tsx` so the code uses the end-to-end generated types.components/bounty-detail/bounty-detail-sidebar-cta.tsx (2)
80-99: Deduplicate the join-competition handler.
handleJoinCompetition(SidebarCTA) andhandleJoin(MobileCTA) are identical apart from one toast string — same wallet resolution, same mutation, sameContestError("already_joined")handling. Extract into a shared hook (e.g.,useJoinCompetitionFlow(bountyId)) so behavior stays consistent between the desktop sidebar and mobile CTA.Also applies to: 391-410
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 80 - 99, Extract the duplicated join logic from handleJoinCompetition and handleJoin into a shared hook named useJoinCompetitionFlow(bountyId) that encapsulates walletAddress checking, calling joinMutation.mutateAsync({ bountyId, contributorAddress: walletAddress }), catching ContestError with code "already_joined" to call setHasJoined(true), and returning a join function plus any state setters (e.g., setHasJoined) or status; update both SidebarCTA and MobileCTA to call the hook and invoke the returned join function (preserve toast messages where component-specific text differs by accepting optional success/failure message params or invoking toasts in the components after the hook resolves).
220-225: Consider surfacing a closed-state message for finalized competitions.The
!canAct && !isCompetitionguard hides the "no longer accepting new submissions" banner for every competition, including finalized/cancelled ones, where the CTA just renders as a static disabled "Completed"/"Not Available" button with no surrounding context.CompetitionStatuscommunicates "Results published" when finalized, but a cancelled competition has no equivalent cue. Consider showing the banner when the competition is cancelled (and keeping it suppressed only forOPEN/COMPLETED).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 220 - 225, The current guard {!canAct && !isCompetition} suppresses the "no longer accepting new submissions" banner for all competitions; update the condition in bounty-detail-sidebar-cta (use the existing isCompetition and the competition status via CompetitionStatus / competition.status) to show the banner for competitions that are not OPEN or COMPLETED (or explicitly when CompetitionStatus.CANCELLED/finalized), e.g., display the message when !canAct && ( !isCompetition ? true : competition.status !== CompetitionStatus.OPEN && competition.status !== CompetitionStatus.COMPLETED ), so cancelled/finalized competitions surface the closed-state banner while preserving suppression for OPEN/COMPLETED.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@components/bounty-detail/bounty-detail-client.tsx`:
- Around line 116-124: The call to CompetitionJudging passes bounty.rewardAmount
and bounty.rewardCurrency which can be null/undefined but the component expects
totalReward: number and currency: string; update the render to guard and only
render <CompetitionJudging ... /> when bounty.rewardAmount != null &&
bounty.rewardCurrency != null (or alternatively change CompetitionJudging props
to accept nullable totalReward?: number and currency?: string and handle
placeholders/limits inside CompetitionJudging such as guarding Input max and
String(totalReward)); locate the usage of CompetitionJudging and the
bounty.rewardAmount / bounty.rewardCurrency references and apply the chosen fix
so no null is forwarded to numeric/string props.
- Around line 100-102: The render currently computes pastDeadline impurely using
Date.now(); extract this into state by creating a pastDeadline state (useState)
and a useEffect that initializes pastDeadline from bounty.bountyWindow?.endDate
and sets a setInterval (every 1000ms) to update setPastDeadline(Date.now() > new
Date(bounty.bountyWindow.endDate).getTime()); ensure you clear the interval on
cleanup and guard for null endDate, then replace the inline pastDeadline
expression with the new state; follow the same pattern as useCountdown in
competition-submission.tsx so the CompetitionJudging panel updates in real time.
In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx`:
- Line 45: SidebarCTA and MobileCTA currently use local useState(false) for
hasJoined which is reset on reload; extend BountyFieldsFragment to add a
per-user flag (e.g., viewerHasClaimed or isParticipant) and initialize/derive
hasJoined from that server value (keep local state only for optimistic UI during
the join request). Extract duplicate join logic from
handleJoinCompetition/handleJoin into a shared hook or utility (e.g.,
useJoinCompetition) that reads the fragment's viewerHasClaimed/isParticipant,
performs the contract call with optimistic state, and updates both local
optimistic state and server cache on success/failure. Also review the
CompetitionStatus usage: replace participantCount={submissionCount} with the
correct unique participant count field (or add it to the fragment) so
participantCount reflects unique participants rather than submissions.
In `@components/bounty/bounty-card.tsx`:
- Around line 92-95: The UI is using submissions for participant count
(slotCount) which conflates submits with claims; update the data model and usage
so participant counts reflect claims: add a claims (or claimants) count field to
the GraphQL BountyCount type and return it from resolvers, then replace usages
of bounty._count?.submissions with bounty._count?.claims (or .claimants) in
components such as bounty-card.tsx (update slotCount and any display labeled
"joined") and bounty-detail-sidebar-cta.tsx (pass
participantCount={bounty._count?.claims}), and ensure CompetitionStatus consumes
the new prop; alternatively, if schema change isn’t possible, change the UI to
derive participants from a proper field (e.g., bounty.claims.length) and ensure
submissions remain hidden before deadline.
In `@components/bounty/competition-judging.tsx`:
- Around line 152-157: The badge and button currently infer ranking from the
array index (using idx === 0 and the button label logic in the map over
submissions), which is incorrect for blind submissions; change the logic to rely
on an explicit winner/priority field or selection state instead of array
position: add or use a property like submission.isWinner (or maintain a
selectedWinnerId state) in the component and render the Badge (Badge / Award)
and the button label based on that field/state rather than idx, and update the
selection handler (the method that sets the winner) to toggle that
property/selected id so the UI reflects an explicit choice not array order.
- Around line 60-65: The input handling currently lets empty points strings
parse to NaN and allows payouts exceeding what's left in escrow; fix by
normalizing and validating before calling the mutation: when computing pts from
points[sub.id] (and payout from payouts[sub.id]) treat empty string or
non-numeric input as a default integer (e.g., 10) by checking for falsy/"" and
using Number.isFinite/Number.isNaN to fall back, ensure pts is an integer >= 0
(use Math.floor or parseInt after the fallback), and for payout clamp/validate
the parsed payout against the remaining reward (compute remainingReward =
totalReward - sumOfApprovedPayouts or use existing remaining variable) so you
show toast.error if payout <= 0 or payout > remainingReward and prevent the
mutation; update the variables payout and pts (from payouts[sub.id] and
points[sub.id]) used by the submission/award function accordingly.
- Line 46: approved is currently only held in component state (approved /
setApproved) so approvals vanish on remount; change the logic to derive initial
approval state from the server-side submission records (e.g., each submission
object's sub.status) and persist updates to the backend before mutating local
state. Specifically, initialize approved from submissions.map/filter where
sub.status indicates approved, disable/hide the "Select as Winner" action for
items whose sub.status === 'approved' and on approve call first hit the server
update endpoint and only on successful response add the id to approved via
setApproved (and keep the Set semantics). Ensure UI checks both the
server-derived sub.status and the local approved Set to prevent duplicate
approvals and to survive page reloads.
In `@components/bounty/competition-status.tsx`:
- Around line 15-27: The component computes pastDeadline once via
isAfterDeadline(deadline) so the UI won't auto-advance when the deadline passes;
update CompetitionStatus to derive pastDeadline from component state and keep it
live (or accept a live pastDeadline prop). Specifically, add a useState for
pastDeadline initialized with isAfterDeadline(deadline) and a useEffect that
sets up a short interval (e.g., 1s) to recompute isAfterDeadline(deadline) and
call setPastDeadline, and clear the interval on unmount; alternatively, change
the component API to accept a boolean pastDeadline prop (matching
CompetitionSubmission's live countdown) and use that instead of calling
isAfterDeadline internally. Ensure you reference the isAfterDeadline helper and
the CompetitionStatus function when making the change.
- Around line 1-5: The file contains a duplicate "use client" directive at the
top; remove the redundant one so only a single "use client" appears before the
imports (keep a single directive immediately before the import list that
includes Users, Clock, Eye, EyeOff, CheckCircle2).
In `@components/bounty/competition-submission.tsx`:
- Around line 18-32: The server/client hydration mismatch is caused by calling
Date.now() inside the useState initializer in useCountdown; change useCountdown
to initialize remaining to a static sentinel (e.g., -1 or null) without calling
Date.now(), then inside the useEffect compute the initial difference once
(setRemaining(new Date(deadline).getTime() - Date.now())) before starting the
setInterval that updates remaining every second, keep the existing cleanup
clearInterval, and update the hook's return type or callers to handle the
sentinel value; reference the useCountdown function, the remaining state,
setRemaining call, and the effect that sets/clears the interval.
In `@hooks/use-competition-bounty.ts`:
- Around line 109-117: The optimistic onMutate in use-competition-bounty.ts is
incorrectly setting bounty.status to "IN_PROGRESS" (via qc.setQueryData and
patchDetail), which breaks multi-participant COMPETITION flows; update the
onMutate to stop changing status — either remove the status patch entirely and
only return { prev, bountyId } or, if you must optimistically reflect the
change, only update a participant/claims counter or participants list (not
bounty.status) using patchDetail, and continue to rely on onSettled invalidation
to refresh the true state; modify the onMutate handler that calls
qc.cancelQueries, qc.getQueryData, qc.setQueryData and patchDetail accordingly.
---
Nitpick comments:
In `@components/bounty-detail/bounty-detail-client.tsx`:
- Around line 91-99: Replace the ad-hoc inline type-cast around `bounty` used to
derive `competitionSubmissions` with the generated GraphQL fragment/type that
includes `submissions` (and similarly for `maxParticipants` in
`bounty-detail-sidebar-cta.tsx` and `bounty-card.tsx`); update the GraphQL
fragment used by these components (or request the field from the server) so
`Submission` and `maxParticipants` are part of the fragment, then reference the
generated fragment type (not a local structural cast) when reading
`bounty.submissions` in `bounty-detail-client.tsx` (the `competitionSubmissions`
assignment) and the corresponding places in `competition-judging.tsx`,
`bounty-detail-sidebar-cta.tsx`, and `bounty-card.tsx` so the code uses the
end-to-end generated types.
In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx`:
- Around line 80-99: Extract the duplicated join logic from
handleJoinCompetition and handleJoin into a shared hook named
useJoinCompetitionFlow(bountyId) that encapsulates walletAddress checking,
calling joinMutation.mutateAsync({ bountyId, contributorAddress: walletAddress
}), catching ContestError with code "already_joined" to call setHasJoined(true),
and returning a join function plus any state setters (e.g., setHasJoined) or
status; update both SidebarCTA and MobileCTA to call the hook and invoke the
returned join function (preserve toast messages where component-specific text
differs by accepting optional success/failure message params or invoking toasts
in the components after the hook resolves).
- Around line 220-225: The current guard {!canAct && !isCompetition} suppresses
the "no longer accepting new submissions" banner for all competitions; update
the condition in bounty-detail-sidebar-cta (use the existing isCompetition and
the competition status via CompetitionStatus / competition.status) to show the
banner for competitions that are not OPEN or COMPLETED (or explicitly when
CompetitionStatus.CANCELLED/finalized), e.g., display the message when !canAct
&& ( !isCompetition ? true : competition.status !== CompetitionStatus.OPEN &&
competition.status !== CompetitionStatus.COMPLETED ), so cancelled/finalized
competitions surface the closed-state banner while preserving suppression for
OPEN/COMPLETED.
In `@components/bounty/bounty-card.tsx`:
- Around line 94-95: The code uses an unsafe type assertion to read
maxParticipants ((bounty as { maxParticipants?: number | null
}).maxParticipants) which circumvents TypeScript safety; update the GraphQL
schema/fragments so maxParticipants is included in BountyFieldsFragment,
regenerate the TypeScript types, and then remove the cast and read
bounty.maxParticipants directly in components such as bounty-card.tsx,
bounty-detail-sidebar-cta.tsx and bounty-detail-client.tsx (ensure the new field
is nullable if appropriate and adjust any runtime checks accordingly).
In `@hooks/use-competition-bounty.ts`:
- Around line 153-156: The current onSettled in useSubmitContestWork only
invalidates bountyKeys.detail(v.bountyId) so the list-level _count.submissions
(used by bounty-card.tsx for "{slotCount}/{max} joined") doesn't refresh; update
the onSettled handler in useSubmitContestWork to also invalidate the list query
(e.g., call qc.invalidateQueries with bountyKeys.list(...) or the appropriate
list key) in addition to bountyKeys.detail so both the detail and the list UI
update after submit_work.
- Around line 77-86: The patchDetail function currently allows callers to
override updatedAt because it spreads ...patch after setting updatedAt; change
it so updatedAt cannot be overwritten by either (a) move updatedAt to be spread
last (i.e., spread patch first then set updatedAt) or (b) tighten the patch
parameter type (e.g., replace Record<string, unknown> with a type that excludes
updatedAt such as Omit<Partial<BountyQuery['bounty']>, 'updatedAt'>) and ensure
patch is applied before you set updatedAt; update function signature and return
construction in patchDetail accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e52acda9-2f9e-42c2-a543-b08506da5593
📒 Files selected for processing (7)
components/bounty-detail/bounty-detail-client.tsxcomponents/bounty-detail/bounty-detail-sidebar-cta.tsxcomponents/bounty/bounty-card.tsxcomponents/bounty/competition-judging.tsxcomponents/bounty/competition-status.tsxcomponents/bounty/competition-submission.tsxhooks/use-competition-bounty.ts
| {isCompetition && isCreator && (pastDeadline || isFinalized) && ( | ||
| <CompetitionJudging | ||
| bountyId={bountyId} | ||
| submissions={competitionSubmissions} | ||
| isFinalized={isFinalized} | ||
| totalReward={bounty.rewardAmount} | ||
| currency={bounty.rewardCurrency} | ||
| /> | ||
| )} |
There was a problem hiding this comment.
totalReward / currency may be nullable at this call site.
CompetitionJudging types totalReward: number and currency: string, but bounty.rewardAmount can be missing (the sidebar renders "TBD" when bounty.rewardAmount != null is false) and bounty.rewardCurrency is similarly optional in the fragment. When null is forwarded, the Input's max={totalReward} becomes max={null} and BigInt(Math.round(payout * 1e7)) is fine but the payout placeholder String(totalReward) renders as "null". Guard the render (or make the props nullable + handle them inside CompetitionJudging).
🔧 Example guard
- {isCompetition && isCreator && (pastDeadline || isFinalized) && (
+ {isCompetition &&
+ isCreator &&
+ (pastDeadline || isFinalized) &&
+ bounty.rewardAmount != null &&
+ bounty.rewardCurrency && (
<CompetitionJudging
bountyId={bountyId}
submissions={competitionSubmissions}
isFinalized={isFinalized}
totalReward={bounty.rewardAmount}
currency={bounty.rewardCurrency}
/>
)}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {isCompetition && isCreator && (pastDeadline || isFinalized) && ( | |
| <CompetitionJudging | |
| bountyId={bountyId} | |
| submissions={competitionSubmissions} | |
| isFinalized={isFinalized} | |
| totalReward={bounty.rewardAmount} | |
| currency={bounty.rewardCurrency} | |
| /> | |
| )} | |
| {isCompetition && | |
| isCreator && | |
| (pastDeadline || isFinalized) && | |
| bounty.rewardAmount != null && | |
| bounty.rewardCurrency && ( | |
| <CompetitionJudging | |
| bountyId={bountyId} | |
| submissions={competitionSubmissions} | |
| isFinalized={isFinalized} | |
| totalReward={bounty.rewardAmount} | |
| currency={bounty.rewardCurrency} | |
| /> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/bounty-detail/bounty-detail-client.tsx` around lines 116 - 124,
The call to CompetitionJudging passes bounty.rewardAmount and
bounty.rewardCurrency which can be null/undefined but the component expects
totalReward: number and currency: string; update the render to guard and only
render <CompetitionJudging ... /> when bounty.rewardAmount != null &&
bounty.rewardCurrency != null (or alternatively change CompetitionJudging props
to accept nullable totalReward?: number and currency?: string and handle
placeholders/limits inside CompetitionJudging such as guarding Input max and
String(totalReward)); locate the usage of CompetitionJudging and the
bounty.rewardAmount / bounty.rewardCurrency references and apply the chosen fix
so no null is forwarded to numeric/string props.
|
|
||
| export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { | ||
| const [copied, setCopied] = useState(false); | ||
| const [hasJoined, setHasJoined] = useState(false); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# See whether the bounty payload already exposes per-user claim state.
rg -nP --type=ts -C3 'hasClaimed|isParticipant|claimants|viewerHasJoined|myClaim'
fd -e graphql -e gql --exec rg -nP -C3 'claim(ant|ed)|participants?\s*:'Repository: boundlessfi/bounties
Length of output: 1466
🏁 Script executed:
# Find and examine the bounty detail query structure
fd -e graphql -e gql | head -20Repository: boundlessfi/bounties
Length of output: 328
🏁 Script executed:
# Search for BountyQuery or Bounty type definition in GraphQL
rg -nP '(type Bounty|BountyQuery|query.*[Bb]ounty)' --type=graphql -A 30Repository: boundlessfi/bounties
Length of output: 95
🏁 Script executed:
# Examine the sidebar CTA component to see what data it receives and uses
rg -nP 'bounty-detail-sidebar-cta' --type=ts -lRepository: boundlessfi/bounties
Length of output: 113
🏁 Script executed:
# Examine the bounty query to see what fields are available
cat lib/graphql/operations/bounty-queries.graphqlRepository: boundlessfi/bounties
Length of output: 652
🏁 Script executed:
# Check the bounty type definition in schema
cat lib/graphql/schema.graphql | head -200Repository: boundlessfi/bounties
Length of output: 3628
🏁 Script executed:
# Look at the bounty detail client and sidebar CTA component
cat -n components/bounty-detail/bounty-detail-client.tsx | head -100Repository: boundlessfi/bounties
Length of output: 4733
🏁 Script executed:
# Examine bounty-detail-sidebar-cta.tsx to understand hasJoined usage
cat -n components/bounty-detail/bounty-detail-sidebar-cta.tsx | head -80Repository: boundlessfi/bounties
Length of output: 3344
🏁 Script executed:
# Check the BountyFieldsFragment definition
cat lib/graphql/operations/fragments.graphqlRepository: boundlessfi/bounties
Length of output: 973
🏁 Script executed:
# Continue reading the SidebarCTA component to see handleJoinCompetition logic
cat -n components/bounty-detail/bounty-detail-sidebar-cta.tsx | sed -n '80,150p'Repository: boundlessfi/bounties
Length of output: 2676
🏁 Script executed:
# See how hasJoined is used in the render/conditional logic
cat -n components/bounty-detail/bounty-detail-sidebar-cta.tsx | sed -n '150,250p'Repository: boundlessfi/bounties
Length of output: 4252
🏁 Script executed:
# Check MobileCTA component (should be in same file) around line 363
cat -n components/bounty-detail/bounty-detail-sidebar-cta.tsx | sed -n '350,420p'Repository: boundlessfi/bounties
Length of output: 2585
🏁 Script executed:
# Check if CompetitionStatus or submissions reveal participant state
rg -nP 'participantCount|claimants|participants' components/bounty-detail/bounty-detail-sidebar-cta.tsxRepository: boundlessfi/bounties
Length of output: 114
🏁 Script executed:
# Look at CompetitionStatus component to see if it tracks per-user state
cat -n components/bounty/competition-status.tsx | head -100Repository: boundlessfi/bounties
Length of output: 3374
🏁 Script executed:
# Check if there are any recent GraphQL query changes or participant tracking
rg -nP 'viewer|currentUser|me.*{' lib/graphql/operations/ --type=tsRepository: boundlessfi/bounties
Length of output: 46
hasJoined doesn't survive page reload — extend the GraphQL schema to expose per-user claim state.
Both SidebarCTA (line 45) and MobileCTA (line 363) use local useState(false) to track join status. After a navigation or reload, a user who has already claimed the bounty sees the "Join Competition" button active again; clicking it then round-trips to the contract and relies on the contract returning a ContestError("already_joined") to set hasJoined = true as a fallback (lines 93–95, 404–406). This is fragile: the contract may not return that exact error code, and the pattern forces unnecessary contract interactions.
Extend BountyFieldsFragment in the GraphQL schema to include a per-user claim indicator (e.g., viewerHasClaimed: Boolean! or isParticipant: Boolean!), and use that authoritative server state to initialize and derive hasJoined. Keep local state only as an optimistic overlay for immediate UI feedback during the join request.
Additionally:
- Lines 80–99 and 391–410: Duplicate
handleJoinCompetition/handleJoinlogic between SidebarCTA and MobileCTA; extract to a shared hook or utility. - Line 277:
participantCount={submissionCount}passed toCompetitionStatusconflates participant count with submission count; confirm whether this should track unique participants instead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` at line 45,
SidebarCTA and MobileCTA currently use local useState(false) for hasJoined which
is reset on reload; extend BountyFieldsFragment to add a per-user flag (e.g.,
viewerHasClaimed or isParticipant) and initialize/derive hasJoined from that
server value (keep local state only for optimistic UI during the join request).
Extract duplicate join logic from handleJoinCompetition/handleJoin into a shared
hook or utility (e.g., useJoinCompetition) that reads the fragment's
viewerHasClaimed/isParticipant, performs the contract call with optimistic
state, and updates both local optimistic state and server cache on
success/failure. Also review the CompetitionStatus usage: replace
participantCount={submissionCount} with the correct unique participant count
field (or add it to the fragment) so participantCount reflects unique
participants rather than submissions.
| const isCompetition = bounty.type === "COMPETITION"; | ||
| const slotCount = bounty._count?.submissions ?? 0; | ||
| const maxParticipants = (bounty as { maxParticipants?: number | null }) | ||
| .maxParticipants ?? null; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Look for a participants/claimants count field on the bounty type and any other usages.
rg -nP --type=ts -C2 '_count\s*\.\s*submissions'
rg -nP --type=ts -C2 '(participantCount|claimantCount|participants|claimCount)\b'
fd -e graphql -e gql --exec rg -nP -C3 'type\s+Bounty\b|BountyCount|maxParticipants'Repository: boundlessfi/bounties
Length of output: 3455
🏁 Script executed:
# Check bounty-card.tsx context around lines 92-95 and how slotCount is used
cat -n components/bounty/bounty-card.tsx | sed -n '85,200p'Repository: boundlessfi/bounties
Length of output: 5216
🏁 Script executed:
# Search for any claimants, claims, or participants count fields in schema
rg -nP --type=ts '(claimants?|claims?|_count\.)' lib/graphql/generated.ts | head -50Repository: boundlessfi/bounties
Length of output: 46
🏁 Script executed:
# Check the full GraphQL schema definition for Bounty
fd -e graphql -e gql --exec grep -A 30 'type Bounty ' {} \;Repository: boundlessfi/bounties
Length of output: 665
slotCount displays submissions as participant count, conflating distinct actions.
Line 93 sources slotCount from bounty._count?.submissions, but line 178 labels it as "joined". In the competition flow, joining (claiming) and submitting work are distinct actions — submissions are intentionally hidden pre-deadline. Using submission count here misrepresents the number of participants who have claimed the bounty.
The GraphQL schema currently provides no dedicated claimants or participants count field in BountyCount (only submissions). The same issue exists in bounty-detail-sidebar-cta.tsx where participantCount={submissionCount} is passed to CompetitionStatus.
Either extend the schema to include a claimants (or claims) count field in BountyCount, or adjust the UI to source participant counts differently. Update all references: bounty-card.tsx (line 93) and bounty-detail-sidebar-cta.tsx (line 277).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/bounty/bounty-card.tsx` around lines 92 - 95, The UI is using
submissions for participant count (slotCount) which conflates submits with
claims; update the data model and usage so participant counts reflect claims:
add a claims (or claimants) count field to the GraphQL BountyCount type and
return it from resolvers, then replace usages of bounty._count?.submissions with
bounty._count?.claims (or .claimants) in components such as bounty-card.tsx
(update slotCount and any display labeled "joined") and
bounty-detail-sidebar-cta.tsx (pass participantCount={bounty._count?.claims}),
and ensure CompetitionStatus consumes the new prop; alternatively, if schema
change isn’t possible, change the UI to derive participants from a proper field
(e.g., bounty.claims.length) and ensure submissions remain hidden before
deadline.
| {idx === 0 && !isApproved && ( | ||
| <Badge className="bg-amber-500/10 text-amber-400 border-amber-500/20 text-[10px]"> | ||
| <Award className="size-3 mr-1" /> | ||
| Top | ||
| </Badge> | ||
| )} |
There was a problem hiding this comment.
"Top" / "Select as Winner" labels assume the array is pre-ranked.
Both the amber "Top" badge and the idx === 0 ? "Select as Winner" : "Award Consolation" button label derive from array index. Since submissions are delivered blind and revealed after the deadline, there's no inherent ranking — whichever submission happens to be first in the submissions prop gets tagged "Top", which can mislead the creator. Suggest either (a) letting the creator choose the primary winner explicitly, or (b) driving the label from a deterministic ranking/score, not array position.
Also applies to: 221-221
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/bounty/competition-judging.tsx` around lines 152 - 157, The badge
and button currently infer ranking from the array index (using idx === 0 and the
button label logic in the map over submissions), which is incorrect for blind
submissions; change the logic to rely on an explicit winner/priority field or
selection state instead of array position: add or use a property like
submission.isWinner (or maintain a selectedWinnerId state) in the component and
render the Badge (Badge / Award) and the button label based on that field/state
rather than idx, and update the selection handler (the method that sets the
winner) to toggle that property/selected id so the UI reflects an explicit
choice not array order.
Benjtalkshow
left a comment
There was a problem hiding this comment.
Hey @devJaja, solid implementation. A few things I'd like you to address beyond what CodeRabbit already flagged.
In bounty-detail-sidebar-cta.tsx, submissionCount = bounty._count?.submissions ?? 0 is being used as the participant count (participantCount={submissionCount} on CompetitionStatus, and the slot row shows {submissionCount}/{max} joined). Submissions and participants are not the same thing. A contributor who joined but hasn't submitted yet won't be counted, so the "X/max joined" figure will under-report until every participant submits. You need the participant/claim count from the backend, not the submission count.
hasJoined is purely local state in SidebarCTA and MobileCTA. CodeRabbit made the same point about approved in competition-judging.tsx, and it applies here too. On reload, a user who already joined sees the "Join Competition" button again and a second click will attempt another claim_bounty call. Derive hasJoined from the bounty payload (a viewerHasClaimed field or walking claimants for the current wallet), and only augment locally for optimism.
canAct = bounty.status === "OPEN" is the only gate on the Join button, which means the button stays enabled after the deadline passes (status typically stays OPEN until finalize). Your hook has a deadline_passed error code but nothing pre-checks it, so users get the toast error only after a failing contract call. Please factor the deadline into the disabled condition.
payoutAmount: BigInt(Math.round(payout * 1e7)) in competition-judging.tsx hardcodes the Stellar 7-decimal asset precision. That's fine for XLM/USDC-on-Stellar today, but any asset with a different precision will silently send the wrong amount. Worth at least a constant with a comment, or ideally deriving the scale from the currency.
In bounty-detail-client.tsx, the submissions field is pulled off bounty via a (bounty as { submissions?: ... }).submissions ?? [] cast. If submissions isn't in BountyFieldsFragment, this cast hides the fact that the data will always be empty at runtime. Either add submissions to the fragment or document why the cast is safe.
CI/CD fails because pnpm-lock.yaml is out of sync with package.json after the main merge. Run pnpm install and commit the updated lockfile.
Please also address all of CodeRabbit's corrections (the Date.now() purity issue, the nullable totalReward/currency, the local-only approved set, the points/payout validation, the array-index ranking for the "Top" badge, the duplicate "use client" in competition-status.tsx, the hydration mismatch in useCountdown, and the optimistic IN_PROGRESS that breaks the multi-participant flow).
Nice work on the hook split. Ping me once these are in.
Also fix conflict and Sync with main branch |
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
components/bounty/bounty-card.tsx (1)
93-96:⚠️ Potential issue | 🟠 Major
slotCountstill derives participant count from_count.submissions.
slotCountis sourced frombounty._count?.submissionsand rendered as the "joined" tally on line 209. Joining (claim_bounty) and submitting (submit_work) are distinct actions in the competition flow, and submissions are intentionally hidden pre-deadline — so this badge will read0/N joineduntil people start submitting, even when contributors have already claimed slots.Source participants from a dedicated claims/claimants count (extending
BountyCountin the schema if needed) rather than reusing submissions.Also applies to: 205-211
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty/bounty-card.tsx` around lines 93 - 96, slotCount is incorrectly derived from bounty._count?.submissions causing the "joined" badge to reflect submissions instead of claims; change it to read the claims/claimants count (e.g., bounty._count?.claims or bounty._count?.claimants) and render that value as joined, and if the BountyCount type/schema lacks a claims/claimants field, extend the BountyCount in the backend/schema and the TS types so bounty._count.claims is available; update any usages in bounty-card.tsx (reference symbols: slotCount, bounty._count?.submissions, maxParticipants) to use the new claims count so joined shows claimants not submissions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@components/bounty/bounty-card.tsx`:
- Around line 205-211: The build fails because the Users icon from lucide-react
is used in the JSX (inside the isCompetition conditional rendering) but not
imported; update the import list that currently brings in Clock and Zap to also
import Users from "lucide-react" so the Users symbol is defined for the Badge
rendering that references isCompetition, slotCount, and maxParticipants.
---
Duplicate comments:
In `@components/bounty/bounty-card.tsx`:
- Around line 93-96: slotCount is incorrectly derived from
bounty._count?.submissions causing the "joined" badge to reflect submissions
instead of claims; change it to read the claims/claimants count (e.g.,
bounty._count?.claims or bounty._count?.claimants) and render that value as
joined, and if the BountyCount type/schema lacks a claims/claimants field,
extend the BountyCount in the backend/schema and the TS types so
bounty._count.claims is available; update any usages in bounty-card.tsx
(reference symbols: slotCount, bounty._count?.submissions, maxParticipants) to
use the new claims count so joined shows claimants not submissions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9aba5966-d91a-4aea-9a20-697775377c5e
📒 Files selected for processing (1)
components/bounty/bounty-card.tsx
Benjtalkshow
left a comment
There was a problem hiding this comment.
@devJaja the only commit since my last review is a merge from main. Nothing else has been addressed.
The merge brought in the JSR install fix from #177, which is good, but it also unblocked the lint step that was previously masked by the failing install. CI is now red on two errors:
bounty-detail-client.tsx:102—Date.now()called directly during render, which CodeRabbit flagged in the original review.bounty-card.tsx:207—'Users' is not defined. The competition badge uses theUsersicon but it's not imported fromlucide-react.
Beyond those two, please go through the original review again, the four Major findings from CodeRabbit, plus my notes on the participant-vs-submission count, the local-only hasJoined state, the canAct deadline gate, and the hardcoded 1e7 Stellar precision. None of those have been touched.
Ping me once the work is in, not before.
- hooks/use-competition-bounty.ts: remove incorrect IN_PROGRESS optimistic update from useJoinCompetition — competition bounties stay OPEN while multiple participants join; only finalize_contest sets COMPLETED - competition-submission.tsx: fix SSR hydration mismatch in useCountdown — initialize remaining as null, set on mount via useEffect - competition-judging.tsx: derive approved from backend submission.status === 'APPROVED' augmented by local optimism (localApproved set); add STELLAR_ASSET_SCALE constant with comment instead of bare 1e7; handle nullable totalReward/currency; validate points >= 0; rename 'Top' badge to 'First' (array index is not a ranking signal) - competition-status.tsx: fix SSR hydration mismatch — initialize pastDeadline as null, compute via useEffect; consolidate interval into single check() call to avoid setState-in-effect lint error - bounty-detail-sidebar-cta.tsx: derive hasJoined from bounty.submissions (server truth) + localJoined optimism — survives page reload; add isPastDeadline gate to Join button disabled condition (pre-checks deadline before contract call); move isPastDeadline to useEffect to avoid impure Date.now() during render; apply same fixes to MobileCTA - bounty-detail-client.tsx: move pastDeadline useState/useEffect before early returns to satisfy rules-of-hooks; derive endDate from bounty before guard clauses - pnpm-lock.yaml: sync lockfile after main branch merge (was causing CI install failure)
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
components/bounty-detail/bounty-detail-sidebar-cta.tsx (1)
252-257:⚠️ Potential issue | 🟡 MinorCompetition users get no "no longer accepting" message when locked out.
The
!canAct && !isCompetitionguard suppresses the explanatory message specifically for competitions, but the competition CTA also gets disabled for!canAct,isPastDeadline, orjoinMutation.isPending. When that happens (e.g., user lands on a competition past its deadline), the button just shows a fallback label like "Completed"/"Not Available" with no contextual explanation. Consider rendering a competition-specific helper line (e.g., "This competition is no longer accepting participants") in those locked-out states.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 252 - 257, The current guard {!canAct && !isCompetition} prevents showing the explanatory "no longer accepting" message for competitions; update the rendering logic in bounty-detail-sidebar-cta (component using canAct, isCompetition, isPastDeadline, and joinMutation.isPending) to show a competition-specific helper line when a competition is locked out — e.g., when isCompetition is true AND ( !canAct || isPastDeadline || joinMutation.isPending ) — by adding a conditional block that renders a message like "This competition is no longer accepting participants" (replacing the generic message) so users see contextual explanation whenever the competition CTA is disabled.
♻️ Duplicate comments (4)
components/bounty-detail/bounty-detail-sidebar-cta.tsx (3)
60-127: 🛠️ Refactor suggestion | 🟠 MajorSidebar/Mobile CTA logic is duplicated end-to-end — extract a shared hook.
walletAddressderivation (60-65 ↔ 415-420),isPastDeadlinepolling (84-92 ↔ 423-431),serverHasJoined/localJoined/hasJoined(97-106 ↔ 433-440), andhandleJoinCompetition/handleJoin(108-127 ↔ 442-461) are byte-for-byte equivalent acrossSidebarCTAandMobileCTA. Any fix (e.g., theviewerHasClaimedchange above) has to be applied in two places, which is a real bug-magnet.Suggest extracting
useCompetitionJoinState(bounty)returning{ walletAddress, isPastDeadline, hasJoined, isJoining, join }and using it in both components.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 60 - 127, Extract the duplicated competition/join logic into a new hook useCompetitionJoinState(bounty) that returns { walletAddress, isPastDeadline, hasJoined, isJoining, join } and replace the repeated code in both SidebarCTA and MobileCTA with calls to this hook; specifically move walletAddress derivation, the isPastDeadline useState/useEffect polling (deadline logic), serverHasJoined/localJoined state and hasJoined computation, and the handleJoinCompetition implementation (including joinMutation.mutateAsync, setLocalJoined, ContestError handling and toast calls) into the hook, ensure join exposes the same behavior and error handling, and update both components to use the returned values and call join instead of duplicating walletAddress, setLocalJoined, serverHasJoined, hasJoined, isPastDeadline, and handleJoinCompetition logic.
308-314:⚠️ Potential issue | 🟠 Major
participantCount={submissionCount}conflates two distinct counts.Per the comment in
competition-status.tsx(lines 7-11), the prop semantically means "joined" whilesubmissionCountis "submitted entries". With the same value passed to bothparticipantCountandsubmissionCount(line 311), the UI's "joined" badge and the post-deadline "revealed submissions" count will always be identical, defeating the purpose of distinguishing them and making the slot-fill UX (X/maxParticipants joined) misleading whenever joiners haven't all submitted.Until the backend exposes a true claim count, either pass
0/undefinedfor one of the two and adjust the UI copy, or hideparticipantCountwhen the field is unavailable. Don't double-bindsubmissionCount.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 308 - 314, The CompetitionStatus prop participantCount is meant to represent "joined" users but the code currently passes submissionCount to both participantCount and submissionCount (CompetitionStatus usage), conflating joined vs submitted; change the call to stop double-binding by either omitting participantCount or passing undefined/0 (e.g., remove participantCount={submissionCount} or set participantCount={undefined}) so the component hides the joined badge when the joined count is unavailable, or explicitly pass a separate joinedCount variable when/if you have it; update any UI copy in CompetitionStatus that assumed participantCount exists so it handles the missing value gracefully.
94-106:⚠️ Potential issue | 🟠 Major
serverHasJoinedderives "joined" fromsubmissions, but joining and submitting are separate steps.A contributor who calls
claim_bounty(joining) but hasn't yet calledsubmit_workwill not appear inbountySubmissions, soserverHasJoinedisfalseand the CTA reverts to "Join Competition" on reload. Clicking it then triggers a redundant on-chainclaim_bountycall that relies on the contract returningContestError("already_joined")to recover (lines 121-124). This is exactly the persistence concern raised previously and remains unresolved.The proper fix is a per-viewer flag exposed by the GraphQL schema (e.g.,
viewerHasClaimed: Boolean!or aclaimants/participantsarray onBountyFieldsFragment). Local optimism (localJoined) should remain only as an immediate-feedback overlay on top of that authoritative server state.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 94 - 106, Replace the current join-state derivation (serverHasJoined computed from bountySubmissions) with an authoritative per-viewer server flag and keep localJoined only for immediate optimism: have the GraphQL BountyFieldsFragment expose viewerHasClaimed (Boolean) or participants/claimants array, then compute serverHasJoined = Boolean(bounty.viewerHasClaimed) || (bountySubmissions?.some(s => s.submittedBy === walletAddress) ?? false) to preserve backwards compatibility; keep hasJoined = serverHasJoined || localJoined, ensure the on-click claim path (the code that calls claim_bounty and setLocalJoined) checks hasJoined first to avoid firing redundant claim_bounty if serverHasClaimed is true, and remove reliance on contract ContestError("already_joined") for correctness.components/bounty-detail/bounty-detail-client.tsx (1)
133-141:⚠️ Potential issue | 🟠 Major
totalReward/currencymay still be nullable at this call site.
bounty.rewardAmountandbounty.rewardCurrencycan benull/missing per the GraphQL fragment (the sidebar shows"TBD"when missing), butCompetitionJudgingdeclares them asnumber/string. Forwardingnullwill yield<Input max={null}>,BigInt(Math.round(null * 1e7))(NaN → throws), and"null"placeholders. Guard the render or accept nullable props inCompetitionJudging.🔧 Proposed guard
- {isCompetition && isCreator && (pastDeadline || isFinalized) && ( + {isCompetition && + isCreator && + (pastDeadline || isFinalized) && + bounty.rewardAmount != null && + bounty.rewardCurrency && ( <CompetitionJudging bountyId={bountyId} submissions={competitionSubmissions} isFinalized={isFinalized} totalReward={bounty.rewardAmount} currency={bounty.rewardCurrency} /> )}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-client.tsx` around lines 133 - 141, The call to <CompetitionJudging> passes bounty.rewardAmount and bounty.rewardCurrency which can be null per the GraphQL fragment; update the render to guard those props or make CompetitionJudging accept nullable values. Specifically, in the JSX conditional that uses isCompetition, isCreator, pastDeadline, and isFinalized, ensure you only pass totalReward and currency when bounty.rewardAmount and bounty.rewardCurrency are non-null (e.g., skip rendering or provide safe defaults), or change CompetitionJudging’s prop types/handlers to accept number | null and string | null and defensively handle null (avoid computations like BigInt(Math.round(null * ...)) and Input max={null}).
🧹 Nitpick comments (3)
components/bounty/competition-submission.tsx (1)
62-82: Consider validating submission format before on-chain call.
handleSubmitonly checks for non-emptyworkCidafter trim. A typo, internal note, or non-URL/CID string will still consume gas and fail at the contract or be permanently recorded as a bad submission with no recovery. Adding a lightweight format check (URL oripfs:///Qm…CID) on the client gives a much better UX than a failed transaction.🔧 Suggested guard
if (!workCid.trim()) { toast.error("Please enter your submission link or CID."); return; } + const cid = workCid.trim(); + const isUrl = /^https?:\/\//i.test(cid); + const isIpfs = /^ipfs:\/\//i.test(cid) || /^(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]{58})$/.test(cid); + if (!isUrl && !isIpfs) { + toast.error("Enter a valid URL or IPFS CID."); + return; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty/competition-submission.tsx` around lines 62 - 82, In handleSubmit, add a lightweight client-side validation for workCid (after trimming) before calling submitMutation.mutateAsync: ensure walletAddress exists, then check workCid matches an allowed pattern (http(s) URL, ipfs:// URI, or common CID patterns like Qm.../CIDv1) using a small regex or helper (e.g., isValidSubmissionFormat(workCid.trim())), and if invalid call toast.error with a clear message and return; keep the existing submitMutation and error handling unchanged so only valid-looking submissions consume gas or are recorded on-chain.components/bounty-detail/bounty-detail-client.tsx (1)
105-119: Inline structural cast duplicates thesubmissionsshape — extract a typed helper.This local type literal will drift from the real
BountyQuery["bounty"].submissionsshape if the GraphQL schema changes, and it's also re-implemented inbounty-detail-sidebar-cta.tsx(lines 97-99) with a different (subset) field set. Extract a typed selector (e.g., inlib/graphql/selectors.ts) that returnsBountyQuery["bounty"]["submissions"]from the merged type, and reuse it across both files.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-client.tsx` around lines 105 - 119, Extract a typed selector function (e.g., selectBountySubmissions) that returns BountyQuery["bounty"]["submissions"] from the merged GraphQL result and place it in a shared module (e.g., lib/graphql/selectors.ts); then replace the inline structural cast in bounty-detail-client.tsx (the code building competitionSubmissions) with a call to selectBountySubmissions(bounty) and update bounty-detail-sidebar-cta.tsx (the other duplicate submission access) to use the same selector so both files share the canonical type and shape instead of duplicating the local type literal.components/bounty/competition-status.tsx (1)
27-37: DuplicatepastDeadlinepolling across the page.
bounty-detail-client.tsx(lines 29-43) and bothSidebarCTA/MobileCTAinbounty-detail-sidebar-cta.tsx(lines 84-92, 423-431) already run their own 10s interval computing the exact samepastDeadlinefrombounty.bountyWindow.endDate. Adding a fourth one here means four timers and four independent state machines for one boolean on the same page.Consider lifting this into a small shared hook (e.g.,
useDeadlinePassed(deadline)) that all consumers use, or acceptpastDeadlineas a prop from the already-computed parent state.🔧 Suggested approach
interface CompetitionStatusProps { participantCount: number; maxParticipants?: number | null; submissionCount: number; - deadline: string | null | undefined; + pastDeadline: boolean | null; isFinalized: boolean; }…and have the sidebar/mobile/client compute
pastDeadlineonce via a shared hook.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty/competition-status.tsx` around lines 27 - 37, The polling logic that sets the pastDeadline state (useState pastDeadline, the useEffect that defines check -> setPastDeadline, and the 10_000ms setInterval) is duplicated across components; extract this into a shared hook (e.g., useDeadlinePassed(deadline)) that encapsulates the Date.now() > new Date(deadline).getTime() check, interval creation/cleanup, and returns a boolean, or instead accept a pastDeadline boolean prop from a parent that already computes it; replace the local useState/useEffect in this component (the pastDeadline state, check function, and interval) with a call to the new useDeadlinePassed(deadline) hook (or use the passed-in prop) so all consumers share a single implementation and timer.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx`:
- Around line 192-205: The "Slots" row is showing submissions instead of
join/claim counts; replace the use of submissionCount in the isCompetition block
with the authoritative join/claim count from the bounty schema (e.g.,
bounty._count?.claims or the prop carrying claim/join count) and display that
value as "{joinCount}{maxParticipants != null ? `/${maxParticipants}` : ''}
joined"; update any prop usage (similar to the earlier CompetitionStatus fix) so
the component receives the correct join/claim count and ensure the displayed
number is clamped/validated against maxParticipants if needed.
- Around line 481-500: MobileCTA currently omits the creator-only Cancel CTA for
competition bounties (unlike SidebarCTA), so update MobileCTA's isCompetition
branch to include the same isCreator-gated cancel affordance. Specifically, add
the conditional render used in SidebarCTA (the isCreator && status in [OPEN,
IN_PROGRESS] cancel button logic) into the isCompetition JSX path next to the
Join button, using the same handlers/state (e.g., handleCancel or
cancelMutation, isCreator, isPastDeadline) so creators on competition bounties
see the Cancel CTA on small viewports.
In `@hooks/use-competition-bounty.ts`:
- Around line 57-62: The function toBountyIdBigInt currently throws ContestError
with code "tx_failed" for parse/validation failures; change it to throw a new
validation-specific code (e.g., "invalid_bounty_id" or "validation_error") so
callers can distinguish parse errors from transaction failures. Update the throw
in toBountyIdBigInt to use the new code and ensure callers that check error.code
(for example the join flow in bounty-detail-sidebar-cta.tsx) treat the new code
appropriately (e.g., don't show generic tx failure toasts or add specific
handling for "invalid_bounty_id").
---
Outside diff comments:
In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx`:
- Around line 252-257: The current guard {!canAct && !isCompetition} prevents
showing the explanatory "no longer accepting" message for competitions; update
the rendering logic in bounty-detail-sidebar-cta (component using canAct,
isCompetition, isPastDeadline, and joinMutation.isPending) to show a
competition-specific helper line when a competition is locked out — e.g., when
isCompetition is true AND ( !canAct || isPastDeadline || joinMutation.isPending
) — by adding a conditional block that renders a message like "This competition
is no longer accepting participants" (replacing the generic message) so users
see contextual explanation whenever the competition CTA is disabled.
---
Duplicate comments:
In `@components/bounty-detail/bounty-detail-client.tsx`:
- Around line 133-141: The call to <CompetitionJudging> passes
bounty.rewardAmount and bounty.rewardCurrency which can be null per the GraphQL
fragment; update the render to guard those props or make CompetitionJudging
accept nullable values. Specifically, in the JSX conditional that uses
isCompetition, isCreator, pastDeadline, and isFinalized, ensure you only pass
totalReward and currency when bounty.rewardAmount and bounty.rewardCurrency are
non-null (e.g., skip rendering or provide safe defaults), or change
CompetitionJudging’s prop types/handlers to accept number | null and string |
null and defensively handle null (avoid computations like BigInt(Math.round(null
* ...)) and Input max={null}).
In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx`:
- Around line 60-127: Extract the duplicated competition/join logic into a new
hook useCompetitionJoinState(bounty) that returns { walletAddress,
isPastDeadline, hasJoined, isJoining, join } and replace the repeated code in
both SidebarCTA and MobileCTA with calls to this hook; specifically move
walletAddress derivation, the isPastDeadline useState/useEffect polling
(deadline logic), serverHasJoined/localJoined state and hasJoined computation,
and the handleJoinCompetition implementation (including
joinMutation.mutateAsync, setLocalJoined, ContestError handling and toast calls)
into the hook, ensure join exposes the same behavior and error handling, and
update both components to use the returned values and call join instead of
duplicating walletAddress, setLocalJoined, serverHasJoined, hasJoined,
isPastDeadline, and handleJoinCompetition logic.
- Around line 308-314: The CompetitionStatus prop participantCount is meant to
represent "joined" users but the code currently passes submissionCount to both
participantCount and submissionCount (CompetitionStatus usage), conflating
joined vs submitted; change the call to stop double-binding by either omitting
participantCount or passing undefined/0 (e.g., remove
participantCount={submissionCount} or set participantCount={undefined}) so the
component hides the joined badge when the joined count is unavailable, or
explicitly pass a separate joinedCount variable when/if you have it; update any
UI copy in CompetitionStatus that assumed participantCount exists so it handles
the missing value gracefully.
- Around line 94-106: Replace the current join-state derivation (serverHasJoined
computed from bountySubmissions) with an authoritative per-viewer server flag
and keep localJoined only for immediate optimism: have the GraphQL
BountyFieldsFragment expose viewerHasClaimed (Boolean) or participants/claimants
array, then compute serverHasJoined = Boolean(bounty.viewerHasClaimed) ||
(bountySubmissions?.some(s => s.submittedBy === walletAddress) ?? false) to
preserve backwards compatibility; keep hasJoined = serverHasJoined ||
localJoined, ensure the on-click claim path (the code that calls claim_bounty
and setLocalJoined) checks hasJoined first to avoid firing redundant
claim_bounty if serverHasClaimed is true, and remove reliance on contract
ContestError("already_joined") for correctness.
---
Nitpick comments:
In `@components/bounty-detail/bounty-detail-client.tsx`:
- Around line 105-119: Extract a typed selector function (e.g.,
selectBountySubmissions) that returns BountyQuery["bounty"]["submissions"] from
the merged GraphQL result and place it in a shared module (e.g.,
lib/graphql/selectors.ts); then replace the inline structural cast in
bounty-detail-client.tsx (the code building competitionSubmissions) with a call
to selectBountySubmissions(bounty) and update bounty-detail-sidebar-cta.tsx (the
other duplicate submission access) to use the same selector so both files share
the canonical type and shape instead of duplicating the local type literal.
In `@components/bounty/competition-status.tsx`:
- Around line 27-37: The polling logic that sets the pastDeadline state
(useState pastDeadline, the useEffect that defines check -> setPastDeadline, and
the 10_000ms setInterval) is duplicated across components; extract this into a
shared hook (e.g., useDeadlinePassed(deadline)) that encapsulates the Date.now()
> new Date(deadline).getTime() check, interval creation/cleanup, and returns a
boolean, or instead accept a pastDeadline boolean prop from a parent that
already computes it; replace the local useState/useEffect in this component (the
pastDeadline state, check function, and interval) with a call to the new
useDeadlinePassed(deadline) hook (or use the passed-in prop) so all consumers
share a single implementation and timer.
In `@components/bounty/competition-submission.tsx`:
- Around line 62-82: In handleSubmit, add a lightweight client-side validation
for workCid (after trimming) before calling submitMutation.mutateAsync: ensure
walletAddress exists, then check workCid matches an allowed pattern (http(s)
URL, ipfs:// URI, or common CID patterns like Qm.../CIDv1) using a small regex
or helper (e.g., isValidSubmissionFormat(workCid.trim())), and if invalid call
toast.error with a clear message and return; keep the existing submitMutation
and error handling unchanged so only valid-looking submissions consume gas or
are recorded on-chain.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 47e3f905-85a1-4526-a130-f2930e158c14
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (7)
components/bounty-detail/bounty-detail-client.tsxcomponents/bounty-detail/bounty-detail-sidebar-cta.tsxcomponents/bounty/bounty-card.tsxcomponents/bounty/competition-judging.tsxcomponents/bounty/competition-status.tsxcomponents/bounty/competition-submission.tsxhooks/use-competition-bounty.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- components/bounty/bounty-card.tsx
- components/bounty/competition-judging.tsx
|
@devJaja
UI matches the codebase except the competition badge in Please address all CodeRabbit findings as well. |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
components/bounty-detail/bounty-detail-sidebar-cta.tsx (1)
274-279:⚠️ Potential issue | 🟡 MinorPast-deadline competition users now get no helper text.
The guard
!canAct && !isCompetitionsuppresses the "no longer accepting new submissions" line for competitions, but a competition wherecanActis true andisPastDeadlineis true (or where the bounty moved toIN_PROGRESS/COMPLETED) renders the disabled CTA with no contextual explanation. Render a competition-specific helper line for the locked-out case so the user understands why "Join Competition" is disabled.🔧 Example
- {!canAct && !isCompetition && ( + {!canAct && !isCompetition && ( <p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center"> <AlertCircle className="size-3 shrink-0" /> This bounty is no longer accepting new submissions. </p> )} + {isCompetition && (isPastDeadline || !canAct) && !hasJoined && ( + <p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center"> + <AlertCircle className="size-3 shrink-0" /> + {isPastDeadline + ? "Submission deadline has passed — judging is in progress." + : "This competition is no longer accepting new participants."} + </p> + )}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 274 - 279, The current JSX conditional (!canAct && !isCompetition) hides the helper text for competitions when the CTA is disabled; update the render logic in the bounty-detail-sidebar-cta component so when !canAct && isCompetition you render a competition-specific helper line (use the existing JSX paragraph block that currently shows "This bounty is no longer accepting new submissions") and make the message conditional on the reason (e.g., when isPastDeadline show "Competition has passed its deadline and is not accepting new submissions" or when bounty.status is IN_PROGRESS or COMPLETED show "This competition is no longer accepting new submissions"); locate the JSX that uses canAct and isCompetition and add a branch for !canAct && isCompetition that displays the explanatory text next to the AlertCircle and aligns with the existing styles so the disabled "Join Competition" CTA has clear context.
🧹 Nitpick comments (3)
components/bounty/bounty-card.tsx (1)
96-97: LiftmaxParticipantsontoBountyFieldsFragmentinstead of casting per call site.The same
(bounty as { maxParticipants?: number | null })cast appears here and twice inbounty-detail-sidebar-cta.tsx(lines 88 and similar). AddingmaxParticipantsto the fragment removes three unsafe casts and lets TypeScript catch downstream null handling. Once exposed on the fragment,slotCount/maxParticipantsderivation can also move into a small helper (e.g., auseCompetitionDisplay(bounty)hook) shared with the sidebar.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty/bounty-card.tsx` around lines 96 - 97, Add maxParticipants to the GraphQL BountyFieldsFragment (so BountyFieldsFragment includes maxParticipants?: number | null) and remove the ad-hoc casts like (bounty as { maxParticipants?: number | null }) in bounty-card.tsx and bounty-detail-sidebar-cta.tsx; then extract the slotCount/maxParticipants derivation into a small shared helper (e.g., useCompetitionDisplay(bounty)) and update bounty-card.tsx and bounty-detail-sidebar-cta.tsx to call useCompetitionDisplay(bounty) and consume the typed maxParticipants/slotCount values from the fragment/helper instead of casting at the call sites.components/bounty-detail/bounty-detail-client.tsx (2)
141-162: Inline structural cast forcompetitionSubmissionsis hard to read and easy to drift.The 14-line
(bounty as { submissions?: ... }).submissions ?? []cast duplicates theSubmissionshape thatCompetitionJudgingalready declares incomponents/bounty/competition-judging.tsx. Two options that are cleaner:
- Export
Submission/SubmissionFragmentfrom the GraphQL layer (or fromcompetition-judging.tsx) and reuse it here, narrowing the cast to one line.- Have
useBountyDetailreturn a typedsubmissions?: Submission[]so no cast is needed at the call site.Either keeps the contract between this file and
CompetitionJudgingchecked by the type system if the submission shape changes later.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-client.tsx` around lines 141 - 162, The inline structural cast creating competitionSubmissions duplicates the Submission shape and is brittle; instead export or reuse a single Submission type and narrow the cast or surface it via the hook: update competition-judging.tsx (or the GraphQL types) to export a Submission/SubmissionFragment type, then change competitionSubmissions to use that type (or modify useBountyDetail to return submissions?: Submission[]) so you can simply access bounty.submissions ?? [] without re-declaring the shape; reference competitionSubmissions, CompetitionJudging, useBountyDetail and the exported Submission type when making the change.
78-86: Deadline-passed polling is duplicated across three components — extract auseDeadlinePassedhook.This same
endDate → setInterval(check, 10_000)pattern is reproduced inSidebarCTA(lines 89–99) andMobileCTA(lines 444–453) ofbounty-detail-sidebar-cta.tsx, and conceptually overlaps with the countdown logic insidecompetition-submission.tsx. Consolidating into a single hook (e.g.,useDeadlinePassed(deadline: string | null | undefined): boolean) removes drift between the three pollers (different intervals, slightly different null-handling) and gives one place to fix issues like SSR initialization or visibility-aware polling.♻️ Suggested hook
// hooks/use-deadline-passed.ts import { useEffect, useState } from "react"; export function useDeadlinePassed( deadline: string | null | undefined, intervalMs = 10_000, ): boolean { const [passed, setPassed] = useState(false); useEffect(() => { if (!deadline) { setPassed(false); return; } const end = new Date(deadline).getTime(); const tick = () => setPassed(Date.now() > end); tick(); const id = setInterval(tick, intervalMs); return () => clearInterval(id); }, [deadline, intervalMs]); return passed; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty-detail/bounty-detail-client.tsx` around lines 78 - 86, Extract the duplicated polling logic into a shared hook named useDeadlinePassed(deadline: string | null | undefined, intervalMs = 10_000): boolean that initializes to false for null deadlines, computes end = new Date(deadline).getTime(), ticks immediately and on a setInterval to set passed = Date.now() > end, and clears the interval on unmount; then replace the inline useEffect/state in bounty-detail-client.tsx (endDate → setPastDeadline), SidebarCTA and MobileCTA in bounty-detail-sidebar-cta.tsx, and any similar polling in competition-submission.tsx by importing useDeadlinePassed and using useDeadlinePassed(bounty?.bountyWindow?.endDate) (or the component’s deadline prop) instead of duplicating the setInterval logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@components/bounty-detail/bounty-detail-client.tsx`:
- Around line 67-72: Remove the duplicate destructuring of session from
authClient.useSession(): keep the first declaration "const { data: session } =
authClient.useSession();" that appears before the useState(...) calls and delete
the second identical line that re-declares session; ensure any subsequent uses
(e.g., isCreator checks) reference the retained session variable and that no
other duplicate declarations remain in bounty-detail-client.tsx.
In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx`:
- Around line 67-134: Duplicate competition join logic in SidebarCTA and
MobileCTA should be extracted into a shared hook; implement export function
useCompetitionJoinState(bounty: SidebarBounty) that encapsulates walletAddress
derivation, deadline polling (isPastDeadline), serverHasJoined/localJoined
merging (hasJoined), and the joinMutation + handleJoinCompetition logic (ensure
it still handles ContestError already_joined). Replace the duplicated blocks in
SidebarCTA and MobileCTA with calls to useCompetitionJoinState(bounty) and use
the returned values (walletAddress, isPastDeadline, hasJoined, joinMutation or
handleJoin) so both components share the same behavior and toast messages;
optionally reuse a smaller useDeadlinePassed(deadline) inside the hook for the
polling logic.
In `@components/bounty/bounty-card.tsx`:
- Around line 219-225: The competition badge uses an off-brand color class
"rose-500" which is inconsistent with other badges; update the Badge component's
className in the isCompetition rendering (the block that includes Badge, Users,
slotCount and maxParticipants) to use an in-palette accent (for example replace
rose-500/10 and rose-400 and rose-500/20 with an amber-* or violet-* variant or
a project token) so the competition badge matches the file's existing badge
palette and visual consistency.
---
Outside diff comments:
In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx`:
- Around line 274-279: The current JSX conditional (!canAct && !isCompetition)
hides the helper text for competitions when the CTA is disabled; update the
render logic in the bounty-detail-sidebar-cta component so when !canAct &&
isCompetition you render a competition-specific helper line (use the existing
JSX paragraph block that currently shows "This bounty is no longer accepting new
submissions") and make the message conditional on the reason (e.g., when
isPastDeadline show "Competition has passed its deadline and is not accepting
new submissions" or when bounty.status is IN_PROGRESS or COMPLETED show "This
competition is no longer accepting new submissions"); locate the JSX that uses
canAct and isCompetition and add a branch for !canAct && isCompetition that
displays the explanatory text next to the AlertCircle and aligns with the
existing styles so the disabled "Join Competition" CTA has clear context.
---
Nitpick comments:
In `@components/bounty-detail/bounty-detail-client.tsx`:
- Around line 141-162: The inline structural cast creating
competitionSubmissions duplicates the Submission shape and is brittle; instead
export or reuse a single Submission type and narrow the cast or surface it via
the hook: update competition-judging.tsx (or the GraphQL types) to export a
Submission/SubmissionFragment type, then change competitionSubmissions to use
that type (or modify useBountyDetail to return submissions?: Submission[]) so
you can simply access bounty.submissions ?? [] without re-declaring the shape;
reference competitionSubmissions, CompetitionJudging, useBountyDetail and the
exported Submission type when making the change.
- Around line 78-86: Extract the duplicated polling logic into a shared hook
named useDeadlinePassed(deadline: string | null | undefined, intervalMs =
10_000): boolean that initializes to false for null deadlines, computes end =
new Date(deadline).getTime(), ticks immediately and on a setInterval to set
passed = Date.now() > end, and clears the interval on unmount; then replace the
inline useEffect/state in bounty-detail-client.tsx (endDate → setPastDeadline),
SidebarCTA and MobileCTA in bounty-detail-sidebar-cta.tsx, and any similar
polling in competition-submission.tsx by importing useDeadlinePassed and using
useDeadlinePassed(bounty?.bountyWindow?.endDate) (or the component’s deadline
prop) instead of duplicating the setInterval logic.
In `@components/bounty/bounty-card.tsx`:
- Around line 96-97: Add maxParticipants to the GraphQL BountyFieldsFragment (so
BountyFieldsFragment includes maxParticipants?: number | null) and remove the
ad-hoc casts like (bounty as { maxParticipants?: number | null }) in
bounty-card.tsx and bounty-detail-sidebar-cta.tsx; then extract the
slotCount/maxParticipants derivation into a small shared helper (e.g.,
useCompetitionDisplay(bounty)) and update bounty-card.tsx and
bounty-detail-sidebar-cta.tsx to call useCompetitionDisplay(bounty) and consume
the typed maxParticipants/slotCount values from the fragment/helper instead of
casting at the call sites.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: bcaf2ec8-c512-426c-86f6-2b57add88a08
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (3)
components/bounty-detail/bounty-detail-client.tsxcomponents/bounty-detail/bounty-detail-sidebar-cta.tsxcomponents/bounty/bounty-card.tsx
hooks/use-deadline-passed.ts (new)
- Single shared hook replacing four independent deadline timers across
competition-status, competition-submission, SidebarCTA, MobileCTA,
and bounty-detail-client; initializes false to avoid SSR hydration
mismatch; polls every 10 s via setInterval
hooks/use-competition-join-state.ts (new)
- Extracts duplicated join logic (walletAddress derivation, deadline
polling, serverHasJoined/localJoined merge, joinMutation, handleJoin
with ContestError already_joined handling) into one hook; SidebarCTA
and MobileCTA now share identical behavior from a single source
hooks/use-competition-bounty.ts
- useSubmitContestWork: add bountyKeys.lists() invalidation so bounty
cards refresh after a submission
components/bounty/competition-judging.tsx
- Export CompetitionSubmissionEntry type so bounty-detail-client.tsx
can reference it without re-declaring the shape inline
components/bounty/competition-submission.tsx
- Validate workCid against URL/IPFS-CID regex before firing on-chain tx
- Show inline validation error on invalid input
- Replace local interval with useDeadlinePassed
components/bounty/competition-status.tsx
- Replace local useState/useEffect deadline polling with useDeadlinePassed
- Rename participantCount prop to claimCount (matches backend field name)
components/bounty/bounty-card.tsx
- Use bounty.claimCount ?? _count.submissions (no unsafe cast)
- Use bounty.maxParticipants directly (no unsafe cast)
- Swap off-brand rose-500 badge to amber-500 to match existing palette
components/bounty-detail/bounty-detail-sidebar-cta.tsx
- Replace duplicated join blocks in SidebarCTA and MobileCTA with
useCompetitionJoinState(bounty)
- Use bounty.claimCount and bounty.maxParticipants (no casts)
- Add competition-specific helper text when deadline has passed and
user has not joined (fixes suppressed !canAct && !isCompetition guard)
components/bounty-detail/bounty-detail-client.tsx
- Remove duplicate session declaration (keep single useSession call)
- Replace inline useEffect deadline polling with useDeadlinePassed
- Replace brittle inline structural cast with CompetitionSubmissionEntry
lib/graphql/generated.ts
- Add maxParticipants?: number | null and claimCount?: number | null to
BountyFieldsFragment; eliminates all (bounty as {...}) casts; fields
are undefined until backend schema adds them
Leftover from before join logic was extracted into useCompetitionJoinState. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Benjtalkshow
left a comment
There was a problem hiding this comment.
All seven items from the last round are addressed cleanly. claimCount/maxParticipants are real fragment fields, the join logic is consolidated into useCompetitionJoinState, useDeadlinePassed collapses the duplicate timers, the submission input is properly URL/CID-validated, and the badge now uses amber to fit the existing palette.
Pushed a small cleanup commit (5af545b) on your branch removing the unused isApplying state in bounty-detail-sidebar-cta.tsx left over from the join-state extraction. Lint is clean now.
Merging this in. Thanks for the thorough follow-up.
- Restore upstream/main version of bounty-detail-sidebar-cta.tsx in full (CompetitionStatus, CompetitionSubmission, useCompetitionJoinState, Join Competition button, past-deadline helper text all preserved from boundlessfi#178) - Add data-testid='apply-to-bounty-btn' only to the Join Competition buttons (SidebarCTA + MobileCTA) — no other sidebar changes - Rewrite e2e tests to test the actual join-competition flow: - Navigation (bounty list -> detail) - Join Competition button visible and enabled for OPEN bounty - Successful join transitions button to Joined state - Contract failure keeps button and shows toast error - Disabled state for non-OPEN bounty Contract client mocked via page.addInitScript(globalThis.__contestContracts) - Revert providers/query-provider.tsx — no global retry change - Keep retry:false scoped to useSubmitToBountyMutation only
feat: Implement Competition (Best Submission Wins) Bounty Flow
Closes #174
Depends on: #139 (TypeScript contract bindings)
Overview
Implements the full frontend flow for Competition (Model 3) bounties — multiple contributors join, submit work
blindly before a deadline, and the creator selects winner(s) after reveal. Maps directly to
approve_contest_winner() and finalize_contest() on the Bounty Registry contract.
Changes
New Files
hooks/use-competition-bounty.ts
components/bounty/competition-submission.tsx
components/bounty/competition-judging.tsx
components/bounty/competition-status.tsx
Modified Files
components/bounty-detail/bounty-detail-sidebar-cta.tsx
components/bounty-detail/bounty-detail-client.tsx
components/bounty/bounty-card.tsx
Contract Methods Used
Acceptance Criteria
Summary by CodeRabbit