Skip to content

Feat/competition bounty flow - #178

Merged
Benjtalkshow merged 9 commits into
boundlessfi:mainfrom
devJaja:feat/competition-bounty-flow
Apr 27, 2026
Merged

Feat/competition bounty flow#178
Benjtalkshow merged 9 commits into
boundlessfi:mainfrom
devJaja:feat/competition-bounty-flow

Conversation

@devJaja

@devJaja devJaja commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

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

  • useJoinCompetition → BountyRegistry.claim_bounty (multiple claimants allowed)
  • useSubmitContestWork → BountyRegistry.submit_work
  • useApproveContestWinner → BountyRegistry.approve_contest_winner (callable multiple times for consolation prizes)
  • useFinalizeContest → BountyRegistry.finalize_contest
  • Typed ContestError with structured error codes; contract client resolved from globalThis.__contestContracts

components/bounty/competition-submission.tsx

  • Shown only to joined participants
  • Live countdown timer to submission deadline
  • Submission form (link / IPFS CID) locked after deadline with a "Submissions closed" message
  • Blind by design — no cross-participant visibility

components/bounty/competition-judging.tsx

  • Creator-only panel, rendered post-deadline or when finalized
  • Lists all revealed submissions with per-entry payout amount + reputation points inputs
  • First entry: "Select as Winner"; subsequent entries: "Award Consolation" — both call approve_contest_winner
  • "Finalize Contest" button appears after at least one approval; calls finalize_contest
  • Locked state shown when already finalized

components/bounty/competition-status.tsx

  • Participant slot count (X / max joined)
  • Submission count shown as ? (hidden) before deadline, revealed count after
  • Phase indicator: Accepting submissions → Judging in progress → Results published

Modified Files

components/bounty-detail/bounty-detail-sidebar-cta.tsx

  • "Join Competition" button replaces generic CTA for COMPETITION type
  • Slot count row (X/max joined) in the meta section
  • CompetitionStatus + CompetitionSubmission panels injected below the main card
  • MobileCTA updated with the same competition-aware logic

components/bounty-detail/bounty-detail-client.tsx

  • CompetitionJudging panel rendered for creator post-deadline / finalized
  • Generic submissions card skipped for competition type

components/bounty/bounty-card.tsx

  • Competition badge with Users icon + slot count (X/max joined) added to card tags

Contract Methods Used

Method Hook
claim_bounty(contributor, bounty_id) useJoinCompetition
submit_work(contributor, bounty_id, work_cid) useSubmitContestWork
approve_contest_winner(creator, bounty_id, winner, payout_amount, points) useApproveContestWinner
finalize_contest(creator, bounty_id) useFinalizeContest

Acceptance Criteria

  • Multiple contributors can join competition bounties
  • Slot count displayed (X/max joined)
  • Submissions hidden until deadline
  • All submissions revealed simultaneously after deadline
  • Creator can select winner(s) and set payout amounts
  • Contest finalization prevents further changes
  • Consolation prizes supported

Summary by CodeRabbit

  • New Features
    • Competition bounties: improved join flow with slot counts, optimistic "Joined ✓" state, spinner while joining, and toast feedback.
    • Creators: in-app judging UI to review submissions, set payouts/points, approve winners, and finalize contests.
    • Participants: submission panel with validation, live countdown, disabled UI after deadline, and success/error toasts.
    • UI: competition status and bounty cards show participant/submission counts and phase (accepting, judging, results).

devJaja added 2 commits April 23, 2026 20:34
- 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
@vercel

vercel Bot commented Apr 24, 2026

Copy link
Copy Markdown

@devJaja is attempting to deploy a commit to the Threadflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements 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

Cohort / File(s) Summary
Competition Components
components/bounty/competition-judging.tsx, components/bounty/competition-submission.tsx, components/bounty/competition-status.tsx
New client components: creator judging UI (approve/finalize with optimistic UI), contestant submission panel (validated CID/URL, countdown, disabled after deadline), and competition status card (participants, submissions hidden/revealed, phase).
Competition Hooks / Mutations
hooks/use-competition-bounty.ts, hooks/use-competition-join-state.ts, hooks/use-deadline-passed.ts
New client hooks: contract-wrapping React Query mutations (join, submit, approve, finalize) with ContestError, ID parsing, optimistic finalize cache patch; join-state hook exposing wallet, hasJoined, handleJoin; deadline hook with interval polling.
Bounty Detail Integration
components/bounty-detail/bounty-detail-client.tsx, components/bounty-detail/bounty-detail-sidebar-cta.tsx
Detects competition bounties, computes join/submission counts and deadlines, hides generic submissions card for competitions, wires competition join state and join button UX, conditionally renders CompetitionSubmission, CompetitionStatus, and CompetitionJudging for creators post-deadline/finalized.
Bounty Card & Types
components/bounty/bounty-card.tsx, lib/graphql/generated.ts
Bounty card shows competition “joined” badge and counts with optional /maxParticipants; GraphQL fragment type extended with maxParticipants? and claimCount? to support new fields.
Small client logic
components/bounty-detail/bounty-detail-client.tsx (other edits)
Session/context usage moved earlier; deadline-awareness added via useDeadlinePassed; competition-specific rendering flags computed.

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"
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • Implement Competition (Best Submission Wins) Bounty Flow #174: Implement Competition (Best Submission Wins) Bounty Flow — This PR implements the competition join/submit/judging/finalize flow described in the issue.
  • boundlessfi/bounties#141 — Matches objectives for competition flow: join slots, blind submissions, approve/finalize contract calls implemented here.

Possibly related PRs

Poem

🐰 I hopped in to build a contest trail,
Slots fill softly like a carrot gale.
Submissions hidden till the deadline's chime,
Judges pick winners — payouts in time.
Burrow bells ring, the meadow cheers, hooray!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses most acceptance criteria but has unresolved issues raised in code review that prevent full compliance with #174 objectives. Fix outstanding items: consolidate join logic into useCompetitionJoinState hook, add maxParticipants to BountyFieldsFragment to avoid casts, implement useDeadlinePassed hook for deadline checks, add URL/IPFS-CID validation in competition-submission, add bountyKeys.lists() invalidation to useSubmitContestWork, render competition-specific locked message post-deadline, and review badge color per branding guidelines.
Docstring Coverage ⚠️ Warning Docstring coverage is 35.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/competition bounty flow' clearly summarizes the main change—implementing the competition bounty flow feature across components and hooks.
Out of Scope Changes check ✅ Passed All changes directly support competition bounty flow implementation: new hooks and components for joining, submitting, judging, and status display, plus modifications to existing components to integrate the flow as specified in issue #174.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@drips-wave

drips-wave Bot commented Apr 24, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (6)
components/bounty/bounty-card.tsx (1)

94-95: Drop the unsafe type assertion once the schema exposes maxParticipants.

(bounty as { maxParticipants?: number | null }).maxParticipants and the identical pattern in bounty-detail-sidebar-cta.tsx / bounty-detail-client.tsx circumvent type safety. If maxParticipants is part of the competition bounty contract, add it to the BountyFieldsFragment / GraphQL schema so the field is surfaced through BountyFieldsFragment and 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 after submit_work.

useSubmitContestWork only invalidates the bounty detail, but bounty-card.tsx renders {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: patchDetail lets callers unintentionally overwrite updatedAt.

...patch spreads after updatedAt, so any caller passing updatedAt in patch silently wins over the freshly-generated timestamp. Today no caller does this, but the type is Record<string, unknown>, so it's an easy footgun. Either spread updatedAt last 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-cast submissions via the GraphQL fragment instead of an inline shape.

Casting bounty to a local { submissions?: ... } structural type bypasses the generated types and duplicates the Submission shape already declared in competition-judging.tsx. If submissions exists on the competition fragment, extend the fragment so it's typed end-to-end; otherwise request it. Same inline-cast pattern repeats in bounty-detail-sidebar-cta.tsx and bounty-card.tsx for maxParticipants.

🤖 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) and handleJoin (MobileCTA) are identical apart from one toast string — same wallet resolution, same mutation, same ContestError("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 && !isCompetition guard 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. CompetitionStatus communicates "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 for OPEN / 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

📥 Commits

Reviewing files that changed from the base of the PR and between ed167b6 and dc4ea00.

📒 Files selected for processing (7)
  • components/bounty-detail/bounty-detail-client.tsx
  • components/bounty-detail/bounty-detail-sidebar-cta.tsx
  • components/bounty/bounty-card.tsx
  • components/bounty/competition-judging.tsx
  • components/bounty/competition-status.tsx
  • components/bounty/competition-submission.tsx
  • hooks/use-competition-bounty.ts

Comment thread components/bounty-detail/bounty-detail-client.tsx Outdated
Comment on lines +116 to +124
{isCompetition && isCreator && (pastDeadline || isFinalized) && (
<CompetitionJudging
bountyId={bountyId}
submissions={competitionSubmissions}
isFinalized={isFinalized}
totalReward={bounty.rewardAmount}
currency={bounty.rewardCurrency}
/>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
{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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -20

Repository: 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 30

Repository: 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 -l

Repository: boundlessfi/bounties

Length of output: 113


🏁 Script executed:

# Examine the bounty query to see what fields are available
cat lib/graphql/operations/bounty-queries.graphql

Repository: boundlessfi/bounties

Length of output: 652


🏁 Script executed:

# Check the bounty type definition in schema
cat lib/graphql/schema.graphql | head -200

Repository: 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 -100

Repository: 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 -80

Repository: boundlessfi/bounties

Length of output: 3344


🏁 Script executed:

# Check the BountyFieldsFragment definition
cat lib/graphql/operations/fragments.graphql

Repository: 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.tsx

Repository: 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 -100

Repository: 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=ts

Repository: 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/handleJoin logic between SidebarCTA and MobileCTA; extract to a shared hook or utility.
  • Line 277: participantCount={submissionCount} passed to CompetitionStatus conflates 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.

Comment thread components/bounty/bounty-card.tsx Outdated
Comment on lines +92 to +95
const isCompetition = bounty.type === "COMPETITION";
const slotCount = bounty._count?.submissions ?? 0;
const maxParticipants = (bounty as { maxParticipants?: number | null })
.maxParticipants ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -50

Repository: 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.

Comment thread components/bounty/competition-judging.tsx Outdated
Comment on lines +152 to +157
{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>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"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.

Comment thread components/bounty/competition-status.tsx
Comment thread components/bounty/competition-status.tsx Outdated
Comment thread components/bounty/competition-submission.tsx Outdated
Comment thread hooks/use-competition-bounty.ts Outdated

@Benjtalkshow Benjtalkshow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Benjtalkshow

Copy link
Copy Markdown
Contributor

@devJaja

Also fix conflict and Sync with main branch

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
components/bounty/bounty-card.tsx (1)

93-96: ⚠️ Potential issue | 🟠 Major

slotCount still derives participant count from _count.submissions.

slotCount is sourced from bounty._count?.submissions and 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 read 0/N joined until people start submitting, even when contributors have already claimed slots.

Source participants from a dedicated claims/claimants count (extending BountyCount in 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

📥 Commits

Reviewing files that changed from the base of the PR and between dc4ea00 and fc3eb93.

📒 Files selected for processing (1)
  • components/bounty/bounty-card.tsx

@Benjtalkshow Benjtalkshow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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:

  1. bounty-detail-client.tsx:102Date.now() called directly during render, which CodeRabbit flagged in the original review.
  2. bounty-card.tsx:207'Users' is not defined. The competition badge uses the Users icon but it's not imported from lucide-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.

devJaja added 2 commits April 25, 2026 14:24
- 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)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Competition users get no "no longer accepting" message when locked out.

The !canAct && !isCompetition guard suppresses the explanatory message specifically for competitions, but the competition CTA also gets disabled for !canAct, isPastDeadline, or joinMutation.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 | 🟠 Major

Sidebar/Mobile CTA logic is duplicated end-to-end — extract a shared hook.

walletAddress derivation (60-65 ↔ 415-420), isPastDeadline polling (84-92 ↔ 423-431), serverHasJoined/localJoined/hasJoined (97-106 ↔ 433-440), and handleJoinCompetition/handleJoin (108-127 ↔ 442-461) are byte-for-byte equivalent across SidebarCTA and MobileCTA. Any fix (e.g., the viewerHasClaimed change 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" while submissionCount is "submitted entries". With the same value passed to both participantCount and submissionCount (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/undefined for one of the two and adjust the UI copy, or hide participantCount when the field is unavailable. Don't double-bind submissionCount.

🤖 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

serverHasJoined derives "joined" from submissions, but joining and submitting are separate steps.

A contributor who calls claim_bounty (joining) but hasn't yet called submit_work will not appear in bountySubmissions, so serverHasJoined is false and the CTA reverts to "Join Competition" on reload. Clicking it then triggers a redundant on-chain claim_bounty call that relies on the contract returning ContestError("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 a claimants/participants array on BountyFieldsFragment). 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 / currency may still be nullable at this call site.

bounty.rewardAmount and bounty.rewardCurrency can be null/missing per the GraphQL fragment (the sidebar shows "TBD" when missing), but CompetitionJudging declares them as number / string. Forwarding null will yield <Input max={null}>, BigInt(Math.round(null * 1e7)) (NaN → throws), and "null" placeholders. Guard the render or accept nullable props in CompetitionJudging.

🔧 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.

handleSubmit only checks for non-empty workCid after 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 or ipfs:// / 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 the submissions shape — extract a typed helper.

This local type literal will drift from the real BountyQuery["bounty"].submissions shape if the GraphQL schema changes, and it's also re-implemented in bounty-detail-sidebar-cta.tsx (lines 97-99) with a different (subset) field set. Extract a typed selector (e.g., in lib/graphql/selectors.ts) that returns BountyQuery["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: Duplicate pastDeadline polling across the page.

bounty-detail-client.tsx (lines 29-43) and both SidebarCTA/MobileCTA in bounty-detail-sidebar-cta.tsx (lines 84-92, 423-431) already run their own 10s interval computing the exact same pastDeadline from bounty.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 accept pastDeadline as 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 pastDeadline once 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

📥 Commits

Reviewing files that changed from the base of the PR and between fc3eb93 and 8d32c5d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • components/bounty-detail/bounty-detail-client.tsx
  • components/bounty-detail/bounty-detail-sidebar-cta.tsx
  • components/bounty/bounty-card.tsx
  • components/bounty/competition-judging.tsx
  • components/bounty/competition-status.tsx
  • components/bounty/competition-submission.tsx
  • hooks/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

Comment thread components/bounty-detail/bounty-detail-sidebar-cta.tsx
Comment thread components/bounty-detail/bounty-detail-sidebar-cta.tsx
Comment thread hooks/use-competition-bounty.ts
@Benjtalkshow

Copy link
Copy Markdown
Contributor

@devJaja
Good follow-up — the SSR hydration fix, optimistic rollback on finalize, and nullable judging props are clean. A few real things still open before merge:

  • slotCount = _count.submissions in bounty-card.tsx:94 and the same conflation in bounty-detail-sidebar-cta.tsx:308-314 (participantCount and submissionCount both bound to the same value) means the "joined" badge will show 0/N until people start submitting. Needs a real claims count on BountyCount or a viewerHasClaimed flag on the fragment. The added comment acknowledges this but doesn't fix it.
  • SidebarCTA and MobileCTA duplicate the join logic byte-for-byte (wallet derivation, deadline polling, serverHasJoined/localJoined, handleJoinCompetition). Extract useCompetitionJoinState(bounty) so a fix lands in one place.
  • useSubmitContestWork (use-competition-bounty.ts:130) only invalidates bountyKeys.detail. Add bountyKeys.lists() so the card refreshes after submit.
  • Past-deadline competition users see no helper text — the !isCompetition guard at bounty-detail-sidebar-cta.tsx:252 suppresses it. Render a competition-specific line when locked out.
  • (bounty as { maxParticipants?: number | null }) cast still in three files. Add maxParticipants to BountyFieldsFragment so the cast goes away.
  • Four independent deadline timers on the same page (competition-status.tsx, competition-submission.tsx, SidebarCTA, MobileCTA). Lift into useDeadlinePassed(deadline).
  • competition-submission.tsx accepts any non-empty string and fires the on-chain tx. Add a basic URL/IPFS-CID check before mutateAsync.

UI matches the codebase except the competition badge in bounty-card.tsx:206 uses rose-500, which isn't part of the existing status palette (emerald, amber, blue, slate, red, gray, yellow). Swap to amber or violet to stay on-brand.

Please address all CodeRabbit findings as well.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Past-deadline competition users now get no helper text.

The guard !canAct && !isCompetition suppresses the "no longer accepting new submissions" line for competitions, but a competition where canAct is true and isPastDeadline is true (or where the bounty moved to IN_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: Lift maxParticipants onto BountyFieldsFragment instead of casting per call site.

The same (bounty as { maxParticipants?: number | null }) cast appears here and twice in bounty-detail-sidebar-cta.tsx (lines 88 and similar). Adding maxParticipants to the fragment removes three unsafe casts and lets TypeScript catch downstream null handling. Once exposed on the fragment, slotCount/maxParticipants derivation can also move into a small helper (e.g., a useCompetitionDisplay(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 for competitionSubmissions is hard to read and easy to drift.

The 14-line (bounty as { submissions?: ... }).submissions ?? [] cast duplicates the Submission shape that CompetitionJudging already declares in components/bounty/competition-judging.tsx. Two options that are cleaner:

  1. Export Submission/SubmissionFragment from the GraphQL layer (or from competition-judging.tsx) and reuse it here, narrowing the cast to one line.
  2. Have useBountyDetail return a typed submissions?: Submission[] so no cast is needed at the call site.

Either keeps the contract between this file and CompetitionJudging checked 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 a useDeadlinePassed hook.

This same endDate → setInterval(check, 10_000) pattern is reproduced in SidebarCTA (lines 89–99) and MobileCTA (lines 444–453) of bounty-detail-sidebar-cta.tsx, and conceptually overlaps with the countdown logic inside competition-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

📥 Commits

Reviewing files that changed from the base of the PR and between 8d32c5d and f6fe419.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • components/bounty-detail/bounty-detail-client.tsx
  • components/bounty-detail/bounty-detail-sidebar-cta.tsx
  • components/bounty/bounty-card.tsx

Comment thread components/bounty-detail/bounty-detail-client.tsx Outdated
Comment thread components/bounty-detail/bounty-detail-sidebar-cta.tsx Outdated
Comment thread components/bounty/bounty-card.tsx
devJaja and others added 3 commits April 27, 2026 00:39
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 Benjtalkshow left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Benjtalkshow
Benjtalkshow merged commit f73835a into boundlessfi:main Apr 27, 2026
1 of 3 checks passed
0xDeon added a commit to 0xDeon/bounties that referenced this pull request Apr 27, 2026
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Competition (Best Submission Wins) Bounty Flow

2 participants