feat: Implement Earnings & Payout Tracking#96
feat: Implement Earnings & Payout Tracking#96Oluwatos94 wants to merge 1 commit intoboundlessfi:mainfrom
Conversation
|
@Oluwatos94 is attempting to deploy a commit to the Threadflow Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThe pull request implements earnings and payout tracking by introducing a new Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (3)
components/reputation/earnings-summary.tsx (2)
6-15: Rename theEarningsSummarytype to avoid a name collision with the component.Both the exported type (Line 6) and the exported function (Line 21) are named
EarningsSummary. While TypeScript allows a value and a type to share the same name, it forces every consumer to alias one of them — as seen inpage.tsxwithtype EarningsSummary as EarningsSummaryType. Rename the type to something likeEarningsSummaryDatato make the public API unambiguous.♻️ Proposed rename
-export type EarningsSummary = { +export type EarningsSummaryData = { totalEarned: number; pendingAmount: number; currency: string; payoutHistory: Array<{ amount: number; date: string; status: "processing" | "completed"; }>; }; interface EarningsSummaryProps { - earnings: EarningsSummary; + earnings: EarningsSummaryData; }And update the import in
app/profile/[userId]/page.tsx:import { EarningsSummary, - type EarningsSummary as EarningsSummaryType, + type EarningsSummaryData, } from "@/components/reputation/earnings-summary";Also applies to: 21-21
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/reputation/earnings-summary.tsx` around lines 6 - 15, The exported type EarningsSummary conflicts with the exported component function of the same name; rename the type to EarningsSummaryData (update the export type declaration currently named EarningsSummary) and update all consumers/imports (e.g., the import in page.tsx that currently aliases the type) to use EarningsSummaryData so the component function can remain EarningsSummary without forcing callers to alias the type.
68-90: Use a stable key for payout history rows instead of array index.
key={index}causes React to mis-identify rows if the list ever reorders or items are prepended. Prefer a composite of stable fields such asdate+amount.♻️ Proposed fix
- <div - key={index} + <div + key={`${payout.date}-${payout.amount}-${index}`}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/reputation/earnings-summary.tsx` around lines 68 - 90, The list rendering of earnings.payoutHistory uses key={index}, which is unstable; update the key on the mapped element (the div inside earnings.payoutHistory.map) to use a stable unique identifier such as payout.id if present, otherwise a composite stable string like `${payout.date}-${payout.amount}`; keep the rest of the render (Badge, formatDate, formatCurrency) unchanged and ensure the key expression references the payout object's stable fields rather than the array index.app/profile/[userId]/page.tsx (1)
86-96: Extract the duplicated status-derivation logic into a shared helper.The logic that maps a bounty's raw
status+claimExpiresAtinto"completed" | "in-review" | "active"is copy-pasted verbatim between theearningsSummarymemo (Lines 86–96) and themyClaimsmemo (Lines 133–146). Any future change (e.g., adding a new status) must be applied in both places.♻️ Proposed extraction
+function deriveBountyStatus( + status: string | undefined, + claimExpiresAt: string | undefined | null, +): string { + if (status === "closed") return "completed"; + if (status === "claimed" && claimExpiresAt) { + const expiry = new Date(claimExpiresAt); + if (!Number.isNaN(expiry.getTime()) && expiry < new Date()) + return "in-review"; + } + return "active"; +}Then replace both duplication sites:
- let status = "active"; - if (bounty.status === "closed") { - status = "completed"; - } else if (bounty.status === "claimed" && bounty.claimExpiresAt) { - const expiry = new Date(bounty.claimExpiresAt); - if (!Number.isNaN(expiry.getTime()) && expiry < new Date()) { - status = "in-review"; - } - } + const status = deriveBountyStatus(bounty.status, bounty.claimExpiresAt);Also applies to: 133-146
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/profile/`[userId]/page.tsx around lines 86 - 96, The status-derivation logic duplicated in the earningsSummary memo (lines around the for-loop over userBounties) and the myClaims memo should be extracted into a single helper function (e.g., deriveBountyStatus or getBountyDisplayStatus) that accepts a bounty object (or its status and claimExpiresAt) and returns the union "completed" | "in-review" | "active"; implement the same parsing/Number.isNaN expiry check and comparison to new Date() inside that helper, then replace both inlined blocks by calling this helper from earningsSummary and myClaims so future changes only update one function.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/profile/`[userId]/page.tsx:
- Around line 121-123: When userBounties is empty the code currently only sets
totalEarned from reputation.stats.totalEarnings but leaves pendingAmount and
payoutHistory at their defaults, causing inconsistent fallback behavior; update
the fallback in the block that checks userBounties and reputation to populate
pendingAmount and payoutHistory from the corresponding reputation.stats fields
(e.g., reputation.stats.pendingAmount and reputation.stats.payoutHistory) if
present, or explicitly set them to consistent safe defaults, and if the omission
was intentional add a clarifying comment near the userBounties/reputation logic
explaining why only totalEarned is used.
- Around line 101-115: The payoutHistory entries incorrectly use
bounty.claimExpiresAt as the payout date; update the two places where
payoutHistory.push is called (the "completed" branch and the "processing" branch
inside app/profile/[userId]/page.tsx) to use a proper timestamp field such as
bounty.completedAt or fallback to bounty.updatedAt, e.g. use bounty.completedAt
?? bounty.updatedAt ?? null, and if neither field exists leave a clear TODO
comment or null so we don't silently show claimExpiresAt as the transaction
date; ensure all references to bounty.claimExpiresAt in these payoutHistory
pushes are replaced accordingly.
- Around line 34-63: mockHistory (built with MAX_MOCK_HISTORY inside the useMemo
that depends on reputation) contains hardcoded/fabricated bounty records and
must not be shipped to production; replace it by fetching real bounty history
from the backend or gate the mock generation behind an explicit development-only
flag (e.g., only create mockHistory when process.env.NODE_ENV === "development"
or a dedicated feature flag), and ensure any UI rendering uses the real API
response (or an empty array) when the flag is off so fabricated projectName,
dates, rewardAmount, feedback, etc. never appear to real users.
- Around line 162-163: The Skeleton components are using an invalid Tailwind
class "h-100" which is ignored and collapses the elements; update the two
Skeleton usages (the JSX lines rendering Skeleton with className="h-100
md:col-span-1" and className="h-100 md:col-span-2") to use a valid height
utility (for example h-24, h-40, or h-96) that matches the desired visual
height, or if you truly need h-100, add that value to your Tailwind config's
theme.extend.height and rebuild so the class exists; change the className
strings accordingly to either a valid built-in h-* or the new configured key.
- Around line 25-26: The code unsafely asserts params.userId as a string; update
the use of useParams() in page.tsx to validate the param type before using it
(e.g., check params.userId !== undefined and typeof params.userId === "string"
or if it's an array take the first element), assign a properly-typed userId
string variable only after that check, and handle the absent/array case by
returning a safe fallback (render an error, call notFound(), or redirect) so
downstream calls that use userId (fetches/filters) never receive undefined or
string[].
In `@components/reputation/earnings-summary.tsx`:
- Around line 22-23: The ternary that defines currencySymbol is a no-op
(currencySymbol and earnings.currency are identical); update the code in the
earnings-summary component by either removing currencySymbol and using
earnings.currency directly, or implement a real mapping so currencySymbol
reflects a symbol (e.g., map "USD" -> "$", "USDC" -> "USDC", "EUR" -> "€")
before rendering; locate the current const currencySymbol and replace it with
the chosen approach, updating any uses of currencySymbol in the component
(render/props) to match.
---
Nitpick comments:
In `@app/profile/`[userId]/page.tsx:
- Around line 86-96: The status-derivation logic duplicated in the
earningsSummary memo (lines around the for-loop over userBounties) and the
myClaims memo should be extracted into a single helper function (e.g.,
deriveBountyStatus or getBountyDisplayStatus) that accepts a bounty object (or
its status and claimExpiresAt) and returns the union "completed" | "in-review" |
"active"; implement the same parsing/Number.isNaN expiry check and comparison to
new Date() inside that helper, then replace both inlined blocks by calling this
helper from earningsSummary and myClaims so future changes only update one
function.
In `@components/reputation/earnings-summary.tsx`:
- Around line 6-15: The exported type EarningsSummary conflicts with the
exported component function of the same name; rename the type to
EarningsSummaryData (update the export type declaration currently named
EarningsSummary) and update all consumers/imports (e.g., the import in page.tsx
that currently aliases the type) to use EarningsSummaryData so the component
function can remain EarningsSummary without forcing callers to alias the type.
- Around line 68-90: The list rendering of earnings.payoutHistory uses
key={index}, which is unstable; update the key on the mapped element (the div
inside earnings.payoutHistory.map) to use a stable unique identifier such as
payout.id if present, otherwise a composite stable string like
`${payout.date}-${payout.amount}`; keep the rest of the render (Badge,
formatDate, formatCurrency) unchanged and ensure the key expression references
the payout object's stable fields rather than the array index.
| const params = useParams(); | ||
| const userId = params.userId as string; |
There was a problem hiding this comment.
params.userId as string is unsafe — Next.js dynamic params can be string | string[].
useParams() returns string | string[] per segment; asserting as string without a check will pass undefined or an array to downstream calls (API fetch, filter) when the segment is absent or caught by a catch-all route.
🛠️ Proposed fix
- const userId = params.userId as string;
+ const rawUserId = params.userId;
+ const userId = Array.isArray(rawUserId) ? rawUserId[0] : rawUserId ?? "";📝 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.
| const params = useParams(); | |
| const userId = params.userId as string; | |
| const params = useParams(); | |
| const rawUserId = params.userId; | |
| const userId = Array.isArray(rawUserId) ? rawUserId[0] : rawUserId ?? ""; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/profile/`[userId]/page.tsx around lines 25 - 26, The code unsafely
asserts params.userId as a string; update the use of useParams() in page.tsx to
validate the param type before using it (e.g., check params.userId !== undefined
and typeof params.userId === "string" or if it's an array take the first
element), assign a properly-typed userId string variable only after that check,
and handle the absent/array case by returning a safe fallback (render an error,
call notFound(), or redirect) so downstream calls that use userId
(fetches/filters) never receive undefined or string[].
| const MAX_MOCK_HISTORY = 50; | ||
|
|
||
| const mockHistory = useMemo(() => { | ||
| if (!reputation) return []; | ||
| const count = Math.min( | ||
| reputation.stats.totalCompleted ?? 0, | ||
| MAX_MOCK_HISTORY, | ||
| ); | ||
| return Array(count) | ||
| .fill(null) | ||
| .map((_, i) => ({ | ||
| id: `bounty-${i}`, | ||
| bountyId: `b-${i}`, | ||
| bountyTitle: `Implemented feature #${100 + i}`, | ||
| projectName: "Drips Protocol", | ||
| projectLogoUrl: null, | ||
| difficulty: ["BEGINNER", "INTERMEDIATE", "ADVANCED"][i % 3] as | ||
| | "BEGINNER" | ||
| | "INTERMEDIATE" | ||
| | "ADVANCED", | ||
| rewardAmount: 500, | ||
| rewardCurrency: "USDC", | ||
| claimedAt: "2023-01-01T00:00:00Z", | ||
| completedAt: "2024-01-15T12:00:00Z", | ||
| completionTimeHours: 48, | ||
| maintainerRating: 5, | ||
| maintainerFeedback: "Great work!", | ||
| pointsEarned: 150, | ||
| })); | ||
| }, [reputation]); |
There was a problem hiding this comment.
Remove (or clearly gate) the hardcoded mock history data before production.
mockHistory is built entirely from fabricated values: a fixed project name ("Drips Protocol"), static dates ("2023-01-01" / "2024-01-15"), a hardcoded reward (500 USDC), and canned feedback. This data is rendered live on the Bounty History tab and will be shown to real users as if it were their actual claim records.
Either drive this from real API data or gate it behind an explicit flag (e.g. process.env.NODE_ENV === "development") so it can never reach production.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/profile/`[userId]/page.tsx around lines 34 - 63, mockHistory (built with
MAX_MOCK_HISTORY inside the useMemo that depends on reputation) contains
hardcoded/fabricated bounty records and must not be shipped to production;
replace it by fetching real bounty history from the backend or gate the mock
generation behind an explicit development-only flag (e.g., only create
mockHistory when process.env.NODE_ENV === "development" or a dedicated feature
flag), and ensure any UI rendering uses the real API response (or an empty
array) when the flag is off so fabricated projectName, dates, rewardAmount,
feedback, etc. never appear to real users.
| payoutHistory.push({ | ||
| amount, | ||
| date: bounty.claimExpiresAt ?? new Date().toISOString(), | ||
| status: "completed", | ||
| }); | ||
| } else if ( | ||
| pendingStatuses.some((s) => normalizeStatus(s) === normalized) | ||
| ) { | ||
| pendingAmount += amount; | ||
| if (normalized === "in-review") { | ||
| payoutHistory.push({ | ||
| amount, | ||
| date: bounty.claimExpiresAt ?? new Date().toISOString(), | ||
| status: "processing", | ||
| }); |
There was a problem hiding this comment.
claimExpiresAt is used as the payout date, which is semantically incorrect.
claimExpiresAt is the deadline by which a claimer must deliver — not the date a payout occurred or was initiated. Displaying it as the transaction date in Payout History will confuse users (e.g., the date shown precedes the bounty being marked complete). A completedAt, updatedAt, or similar field should be used instead. If such a field doesn't yet exist on the bounty shape, this should be a placeholder/TODO rather than silently showing the wrong date.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/profile/`[userId]/page.tsx around lines 101 - 115, The payoutHistory
entries incorrectly use bounty.claimExpiresAt as the payout date; update the two
places where payoutHistory.push is called (the "completed" branch and the
"processing" branch inside app/profile/[userId]/page.tsx) to use a proper
timestamp field such as bounty.completedAt or fallback to bounty.updatedAt, e.g.
use bounty.completedAt ?? bounty.updatedAt ?? null, and if neither field exists
leave a clear TODO comment or null so we don't silently show claimExpiresAt as
the transaction date; ensure all references to bounty.claimExpiresAt in these
payoutHistory pushes are replaced accordingly.
| if (userBounties.length === 0 && reputation) { | ||
| totalEarned = reputation.stats.totalEarnings; | ||
| } |
There was a problem hiding this comment.
Reputation-stats fallback is inconsistent — only totalEarned is populated.
When userBounties is empty and reputation data is present, the fallback sets totalEarned from reputation.stats.totalEarnings but leaves pendingAmount at 0 and payoutHistory as []. If the reputation object also carries a pending amount (or if the intent is purely a display fallback), the three fields should either all be sourced from reputation stats or the comment should make the intentional incompleteness explicit.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/profile/`[userId]/page.tsx around lines 121 - 123, When userBounties is
empty the code currently only sets totalEarned from
reputation.stats.totalEarnings but leaves pendingAmount and payoutHistory at
their defaults, causing inconsistent fallback behavior; update the fallback in
the block that checks userBounties and reputation to populate pendingAmount and
payoutHistory from the corresponding reputation.stats fields (e.g.,
reputation.stats.pendingAmount and reputation.stats.payoutHistory) if present,
or explicitly set them to consistent safe defaults, and if the omission was
intentional add a clarifying comment near the userBounties/reputation logic
explaining why only totalEarned is used.
| <Skeleton className="h-100 md:col-span-1" /> | ||
| <Skeleton className="h-100 md:col-span-2" /> |
There was a problem hiding this comment.
h-100 is not a default Tailwind spacing utility — skeletons will render with no height.
Tailwind's height scale is a combination of the default spacing scale as well as some additional values specific to heights, and the default numeric scale tops out at h-96 (24 rem). h-100 will be silently ignored unless it is explicitly extended in tailwind.config, leaving the skeleton <div>s collapsed and invisible during the loading state.
🛠️ Proposed fix
- <Skeleton className="h-100 md:col-span-1" />
- <Skeleton className="h-100 md:col-span-2" />
+ <Skeleton className="h-[400px] md:col-span-1" />
+ <Skeleton className="h-[400px] md:col-span-2" />📝 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.
| <Skeleton className="h-100 md:col-span-1" /> | |
| <Skeleton className="h-100 md:col-span-2" /> | |
| <Skeleton className="h-[400px] md:col-span-1" /> | |
| <Skeleton className="h-[400px] md:col-span-2" /> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/profile/`[userId]/page.tsx around lines 162 - 163, The Skeleton
components are using an invalid Tailwind class "h-100" which is ignored and
collapses the elements; update the two Skeleton usages (the JSX lines rendering
Skeleton with className="h-100 md:col-span-1" and className="h-100
md:col-span-2") to use a valid height utility (for example h-24, h-40, or h-96)
that matches the desired visual height, or if you truly need h-100, add that
value to your Tailwind config's theme.extend.height and rebuild so the class
exists; change the className strings accordingly to either a valid built-in h-*
or the new configured key.
| const currencySymbol = | ||
| earnings.currency === "USDC" ? "USDC" : earnings.currency; |
There was a problem hiding this comment.
currencySymbol ternary is a no-op — both branches return earnings.currency.
const currencySymbol = earnings.currency === "USDC" ? "USDC" : earnings.currency;Whether the condition is true or false, currencySymbol === earnings.currency. The variable is indistinguishable from earnings.currency directly. If the intent was to map other currency codes to symbols (e.g., "USD" → "$"), that logic is missing. Remove the dead ternary or implement the actual mapping.
🛠️ Proposed fix (if no symbol mapping is needed)
- const currencySymbol =
- earnings.currency === "USDC" ? "USDC" : earnings.currency;
+ const currencySymbol = earnings.currency;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/reputation/earnings-summary.tsx` around lines 22 - 23, The ternary
that defines currencySymbol is a no-op (currencySymbol and earnings.currency are
identical); update the code in the earnings-summary component by either removing
currencySymbol and using earnings.currency directly, or implement a real mapping
so currencySymbol reflects a symbol (e.g., map "USD" -> "$", "USDC" -> "USDC",
"EUR" -> "€") before rendering; locate the current const currencySymbol and
replace it with the chosen approach, updating any uses of currencySymbol in the
component (render/props) to match.
feat: implement earnings & payout tracking in My Claims
Adds an EarningsSummary component to the My Claims tab on the profile page, showing total earned, pending amount (by currency), and a payout history list with processing/completed statuses. Earnings data is derived from the user's bounty claims, with a fallback to reputation stats when no claim data is available.
closes #84
Summary by CodeRabbit
New Features
Improvements