Skip to content

feat/Implement Spark Credits UI#156

Open
Oluwatos94 wants to merge 2 commits intoboundlessfi:mainfrom
Oluwatos94:Spark-Credits-UI
Open

feat/Implement Spark Credits UI#156
Oluwatos94 wants to merge 2 commits intoboundlessfi:mainfrom
Oluwatos94:Spark-Credits-UI

Conversation

@Oluwatos94
Copy link
Copy Markdown
Contributor

@Oluwatos94 Oluwatos94 commented Mar 27, 2026

feat: implement Spark Credits UI

Add credit balance display, history, and explainer components for the Spark Credits system.
Changes:

  • hooks/use-spark-credits.ts — React Query hooks for balance and history
    • components/reputation/credit-balance.tsx — Navbar widget (⚡ N) wish low-credit warning
    • components/reputation/credit-history.tsx — Earn/spend event list with running balrnce
    • components/reputation/credit-explainer.tsx — How credits work, costs, and tips
    • lib/api/spark-credits.ts + lib/services/spark-credits.ts — API and service layer
    • app/api/spark-credits/[userId]/ — Route handlers for balance and history
    • components/global-navbar.tsx — Add credit balance to navbar
    • app/profile/[userId]/page.tsx — Add Spark Credits tab to profile

Note: Credit gate in hooks/use-claim-bounty.ts is deferred pending #140.

Screenshot (1735)

closes #144

Summary by CodeRabbit

  • New Features
    • Added Spark Credits to user profiles: a new "Spark Credits" tab showing current balance and transaction history.
    • Transaction history view with paginated event list, relative timestamps, and clear Earned/Spent labels.
    • Credit balance indicator added to the global navigation for quick reference.
    • Added an in-page explainer describing how credits are earned, costs, and tips.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

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

A member of the Team first needs to authorize it.

@drips-wave
Copy link
Copy Markdown

drips-wave bot commented Mar 27, 2026

@Oluwatos94 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
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 27774b7e-b87a-4084-8890-cc18ed08fa15

📥 Commits

Reviewing files that changed from the base of the PR and between 6801278 and c824dfa.

📒 Files selected for processing (4)
  • app/api/spark-credits/[userId]/history/route.ts
  • app/profile/[userId]/page.tsx
  • components/reputation/credit-balance.tsx
  • lib/services/spark-credits.ts
✅ Files skipped from review due to trivial changes (1)
  • lib/services/spark-credits.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • components/reputation/credit-balance.tsx
  • app/api/spark-credits/[userId]/history/route.ts
  • app/profile/[userId]/page.tsx

📝 Walkthrough

Walkthrough

Adds Spark Credits end-to-end: types, mock service, two Next.js API routes, client wrappers and React Query hooks, UI components (balance, history, explainer), and profile/navbar integration to display balance and history.

Changes

Cohort / File(s) Summary
Type Definitions
types/spark-credits.ts
New TS types for balances, events, event type union, and paginated history response.
API Routes
app/api/spark-credits/[userId]/route.ts, app/api/spark-credits/[userId]/history/route.ts
Added GET handlers: /api/spark-credits/{userId} returns balance; /api/spark-credits/{userId}/history parses limit/offset (clamped) and returns paginated history via SparkCreditsService.
Service Layer
lib/services/spark-credits.ts
In-memory mock data and SparkCreditsService with getBalance and getCreditHistory (simulated latency, slicing, totalCount, hasMore).
API Client
lib/api/spark-credits.ts
sparkCreditsApi.fetchBalance and fetchHistory wrapping GET calls to the new routes, building query params when provided.
React Hooks / Cache keys
hooks/use-spark-credits.ts
SPARK_CREDITS_KEYS and React Query hooks useSparkCreditsBalance and useSparkCreditsHistory with enabled gating and 2-minute staleTime.
UI Components
components/reputation/credit-balance.tsx, components/reputation/credit-history.tsx, components/reputation/credit-explainer.tsx
New client components: CreditBalance (tooltip, low-balance UI), CreditHistory (scrollable event list, earned/spent visuals), and CreditExplainer (informational card).
Integration / Pages
app/profile/[userId]/page.tsx, components/global-navbar.tsx
Profile page: adds "Spark Credits" tab showing balance, history, and explainer. Navbar: inserts CreditBalance widget (hidden on small screens).

Sequence Diagram(s)

sequenceDiagram
    participant Browser as User Browser
    participant Component as CreditBalance Component
    participant Hook as useSparkCreditsBalance Hook
    participant Client as sparkCreditsApi.fetchBalance
    participant Route as API Route<br/>/api/spark-credits/{userId}
    participant Service as SparkCreditsService

    Browser->>Component: mount with userId
    Component->>Hook: call useSparkCreditsBalance(userId)
    Hook->>Client: fetchBalance(userId)
    Client->>Route: GET /api/spark-credits/{userId}
    Route->>Service: SparkCreditsService.getBalance(userId)
    Service-->>Route: { userId, balance }
    Route-->>Client: JSON response
    Client-->>Hook: SparkCreditsBalance
    Hook-->>Component: data -> render balance
Loading
sequenceDiagram
    participant Browser as User Browser
    participant Page as Profile Credits Tab
    participant Hook as useSparkCreditsHistory Hook
    participant Client as sparkCreditsApi.fetchHistory
    participant Route as API Route<br/>/api/spark-credits/{userId}/history
    participant Service as SparkCreditsService

    Browser->>Page: open "Spark Credits" tab
    Page->>Hook: useSparkCreditsHistory(userId, limit)
    Hook->>Client: fetchHistory(userId, {limit, offset})
    Client->>Route: GET /api/spark-credits/{userId}/history?limit=20&offset=0
    Route->>Service: getCreditHistory(userId, limit, offset)
    Service-->>Route: { events[], totalCount, hasMore }
    Route-->>Client: JSON response
    Client-->>Hook: SparkCreditsHistoryResponse
    Hook-->>Page: render CreditHistory(events)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • Benjtalkshow

Poem

🐰 I hopped through code with nimble feet,
New sparks to count, both small and sweet;
A balance shines upon the bar,
A history scrolls from near and far,
Credits bloom — go claim, compete!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat/Implement Spark Credits UI' clearly and concisely summarizes the primary change: adding UI components and services for the Spark Credits system.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #144: credit balance widget in navbar, credit history display, credit explainer component, API routes, and React Query hooks for data fetching.
Out of Scope Changes check ✅ Passed Minor whitespace adjustment in global-navbar.tsx className is negligible; all changes align with issue #144 scope. Credit gate in claim flow (issue #140) is appropriately deferred.

✏️ 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.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (5)
lib/services/spark-credits.ts (1)

83-96: Harden service pagination inputs (defense in depth).

Even if routes validate inputs, sanitizing limit/offset here prevents bad behavior from any direct callers.

🛡️ Suggested hardening
   static async getCreditHistory(
     userId: string,
     limit = 20,
     offset = 0,
   ): Promise<SparkCreditsHistoryResponse> {
     await new Promise((resolve) => setTimeout(resolve, 100));
+    const safeLimit =
+      Number.isInteger(limit) && limit > 0 ? Math.min(limit, 100) : 20;
+    const safeOffset =
+      Number.isInteger(offset) && offset >= 0 ? offset : 0;
     const events = MOCK_CREDIT_EVENTS[userId] ?? [];
     const totalCount = events.length;
-    const sliced = events.slice(offset, offset + limit);
+    const sliced = events.slice(safeOffset, safeOffset + safeLimit);
     return {
       events: sliced,
       totalCount,
-      hasMore: offset + limit < totalCount,
+      hasMore: safeOffset + safeLimit < totalCount,
     };
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/spark-credits.ts` around lines 83 - 96, The getCreditHistory
method accepts unvalidated limit/offset which can cause unexpected slices or
DoS; inside SparkCreditsService.getCreditHistory (function getCreditHistory)
sanitize inputs by coercing limit and offset to integers, enforce offset >= 0
and limit >= 1, and cap limit to a sensible maximum (e.g., 100) before using
them to slice MOCK_CREDIT_EVENTS[userId]; ensure non-numeric values default to
the provided defaults and that hasMore is computed from the sanitized values.
app/api/spark-credits/[userId]/route.ts (1)

4-10: Add explicit error handling for service failures.

Wrap the service call so this route consistently returns JSON error payloads on failure.

🛠️ Suggested refactor
 export async function GET(
   _request: NextRequest,
   context: { params: Promise<{ userId: string }> },
 ) {
-  const { userId } = await context.params;
-  const balance = await SparkCreditsService.getBalance(userId);
-  return NextResponse.json(balance);
+  try {
+    const { userId } = await context.params;
+    const balance = await SparkCreditsService.getBalance(userId);
+    return NextResponse.json(balance);
+  } catch {
+    return NextResponse.json(
+      { error: "Failed to fetch spark credit balance" },
+      { status: 500 },
+    );
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/spark-credits/`[userId]/route.ts around lines 4 - 10, The GET route
calls SparkCreditsService.getBalance without error handling; wrap the await
SparkCreditsService.getBalance(userId) call in a try/catch inside the exported
GET function and on failure return a consistent JSON error response using
NextResponse.json (include a descriptive message and the error details where
safe) with an appropriate HTTP status (e.g., 500), and optionally map known
service errors to specific statuses (e.g., 404 for not found); keep the
successful path returning NextResponse.json(balance) unchanged.
hooks/use-spark-credits.ts (1)

8-9: Query key doesn't include offset for future pagination.

The API (lib/api/spark-credits.ts) and server route (app/api/spark-credits/[userId]/history/route.ts) both support offset for pagination, but the query key only includes limit. If offset-based pagination is added later, the key should include offset to avoid cache collisions.

Since hasMore in the response suggests pagination may be implemented, consider future-proofing:

🔮 Future-proof query key
-  history: (userId: string, limit?: number) =>
-    [...SPARK_CREDITS_KEYS.all, "history", userId, limit] as const,
+  history: (userId: string, limit?: number, offset?: number) =>
+    [...SPARK_CREDITS_KEYS.all, "history", userId, { limit, offset }] as const,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/use-spark-credits.ts` around lines 8 - 9, The query key for spark
credits history is missing offset which can cause cache collisions when
paginating; update the history function (SPARK_CREDITS_KEYS.history) signature
in hooks/use-spark-credits.ts to accept an optional offset (e.g., history:
(userId: string, limit?: number, offset?: number)) and include offset in the
returned tuple (append offset after limit) so the key becomes
[...SPARK_CREDITS_KEYS.all, "history", userId, limit, offset] as const; keep the
order and as const typing so React Query can distinguish different pages.
components/reputation/credit-history.tsx (2)

71-75: Consider defensive date parsing for createdAt.

If event.createdAt contains an invalid or malformed date string, new Date(event.createdAt) will produce an Invalid Date object, causing formatDistanceToNow to throw or display unexpected output. Consider adding validation or a try-catch with a fallback.

🛡️ Suggested defensive approach
-                        <span>
-                          {formatDistanceToNow(new Date(event.createdAt), {
-                            addSuffix: true,
-                          })}
-                        </span>
+                        <span>
+                          {(() => {
+                            const date = new Date(event.createdAt);
+                            return isNaN(date.getTime())
+                              ? "Unknown date"
+                              : formatDistanceToNow(date, { addSuffix: true });
+                          })()}
+                        </span>

Alternatively, extract this to a helper function for cleaner JSX.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/reputation/credit-history.tsx` around lines 71 - 75, Wrap the date
construction and formatting used in the JSX (where formatDistanceToNow(new
Date(event.createdAt), { addSuffix: true }) is called) with defensive parsing:
validate event.createdAt before calling new Date (e.g., try parsing and check
isValid or that resulting Date is not "Invalid Date"), and when invalid use a
fallback (e.g., Date.now() or a placeholder like "unknown") so
formatDistanceToNow never receives an invalid Date; extract this logic into a
small helper function (e.g., formatEventAge(createdAt)) and call that from the
JSX to keep the component clean.

89-95: Consider formatting numeric values for readability.

event.amount and event.balance are displayed as raw numbers. For larger values, consider using toLocaleString() or a number formatting utility to improve readability (e.g., 1,234 instead of 1234).

✨ Example formatting
-                      <span>{event.amount}</span>
+                      <span>{event.amount.toLocaleString()}</span>
-                      <span>{event.balance} balance</span>
+                      <span>{event.balance.toLocaleString()} balance</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/reputation/credit-history.tsx` around lines 89 - 95, Format the
numeric displays for readability by converting event.amount and event.balance to
locale-formatted strings before rendering: replace raw usage of event.amount and
event.balance with a formatted value (e.g., use
Number(event.amount).toLocaleString() or Intl.NumberFormat) and ensure the sign
rendering around event.amount (the existing isEarned ? "+" : "-") still applies
to the formatted absolute value so you don't double-sign; update both spans that
reference event.amount and event.balance accordingly (the span showing amount
next to the first Zap icon and the span showing balance next to the second Zap
icon).
🤖 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/api/spark-credits/`[userId]/history/route.ts:
- Around line 10-20: Parse and validate the search params used for pagination
before calling SparkCreditsService.getCreditHistory: ensure the parsed limit and
offset are finite integers (use Math.floor or similar), fall back to defaults
when parsing fails (limit default 20, offset default 0), clamp limit to a
reasonable max (e.g., 100) and enforce non-negative offset; replace the current
direct Number(...) usage with this validated/clamped values so
getCreditHistory(userId, limit, offset) always receives safe integers.

In `@app/profile/`[userId]/page.tsx:
- Around line 5-8: The import of useSparkCreditsBalance is unused; edit the
import statement in page.tsx to remove useSparkCreditsBalance and only import
useSparkCreditsHistory from "@/hooks/use-spark-credits" (i.e., change the
imported symbols to avoid the unused binding).

In `@components/global-navbar.tsx`:
- Around line 95-98: The NavRankBadge and CreditBalance components are using a
hardcoded userId="user-1"; replace that with the real authenticated user ID
source (e.g., the session/user object or auth hook) so each signed-in user sees
their own data: locate NavRankBadge and CreditBalance usages in
global-navbar.tsx and pass the actual id (for example user.id or currentUser.id)
obtained from your auth context/prop (e.g., useAuth(), useSession(), or a parent
prop like currentUser) and ensure you handle the unauthenticated case (hide
widgets or return null) before rendering.

In `@components/reputation/credit-balance.tsx`:
- Around line 20-27: The component collapses fetch errors into a zero-balance UX
by using data?.balance ?? 0; update the render logic in the block that reads
useSparkCreditsBalance (and the similar block at the 52-61 region) to check the
hook's error state (e.g., isError or error) before defaulting: if isLoading show
the Skeleton, if isError show an explicit error/placeholder state (or return
null) instead of treating balance as 0, and only compute balance = data?.balance
?? 0 and isLow = balance <= 1 when there is no error; reference
useSparkCreditsBalance, data, isLoading, isError/error, balance, and isLow to
locate and change the logic.

In `@lib/services/spark-credits.ts`:
- Around line 8-12: The mock credits data is inconsistent: MOCK_CREDITS_DB has
"user-1": 5 while the latest event (evt-1) records a running balance of 6,
causing contradictory UI state; update either MOCK_CREDITS_DB or the event data
so both agree (e.g., set MOCK_CREDITS_DB["user-1"] to 6 or adjust evt-1 to 5)
and ensure any other mock balances/events in the same block (the entries around
evt-1) are consistent as well; locate symbols MOCK_CREDITS_DB and evt-1 to make
the change.

---

Nitpick comments:
In `@app/api/spark-credits/`[userId]/route.ts:
- Around line 4-10: The GET route calls SparkCreditsService.getBalance without
error handling; wrap the await SparkCreditsService.getBalance(userId) call in a
try/catch inside the exported GET function and on failure return a consistent
JSON error response using NextResponse.json (include a descriptive message and
the error details where safe) with an appropriate HTTP status (e.g., 500), and
optionally map known service errors to specific statuses (e.g., 404 for not
found); keep the successful path returning NextResponse.json(balance) unchanged.

In `@components/reputation/credit-history.tsx`:
- Around line 71-75: Wrap the date construction and formatting used in the JSX
(where formatDistanceToNow(new Date(event.createdAt), { addSuffix: true }) is
called) with defensive parsing: validate event.createdAt before calling new Date
(e.g., try parsing and check isValid or that resulting Date is not "Invalid
Date"), and when invalid use a fallback (e.g., Date.now() or a placeholder like
"unknown") so formatDistanceToNow never receives an invalid Date; extract this
logic into a small helper function (e.g., formatEventAge(createdAt)) and call
that from the JSX to keep the component clean.
- Around line 89-95: Format the numeric displays for readability by converting
event.amount and event.balance to locale-formatted strings before rendering:
replace raw usage of event.amount and event.balance with a formatted value
(e.g., use Number(event.amount).toLocaleString() or Intl.NumberFormat) and
ensure the sign rendering around event.amount (the existing isEarned ? "+" :
"-") still applies to the formatted absolute value so you don't double-sign;
update both spans that reference event.amount and event.balance accordingly (the
span showing amount next to the first Zap icon and the span showing balance next
to the second Zap icon).

In `@hooks/use-spark-credits.ts`:
- Around line 8-9: The query key for spark credits history is missing offset
which can cause cache collisions when paginating; update the history function
(SPARK_CREDITS_KEYS.history) signature in hooks/use-spark-credits.ts to accept
an optional offset (e.g., history: (userId: string, limit?: number, offset?:
number)) and include offset in the returned tuple (append offset after limit) so
the key becomes [...SPARK_CREDITS_KEYS.all, "history", userId, limit, offset] as
const; keep the order and as const typing so React Query can distinguish
different pages.

In `@lib/services/spark-credits.ts`:
- Around line 83-96: The getCreditHistory method accepts unvalidated
limit/offset which can cause unexpected slices or DoS; inside
SparkCreditsService.getCreditHistory (function getCreditHistory) sanitize inputs
by coercing limit and offset to integers, enforce offset >= 0 and limit >= 1,
and cap limit to a sensible maximum (e.g., 100) before using them to slice
MOCK_CREDIT_EVENTS[userId]; ensure non-numeric values default to the provided
defaults and that hasMore is computed from the sanitized values.
🪄 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: 66e5444d-048d-499c-aad6-18e670882fbc

📥 Commits

Reviewing files that changed from the base of the PR and between 3aad250 and 6801278.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (11)
  • app/api/spark-credits/[userId]/history/route.ts
  • app/api/spark-credits/[userId]/route.ts
  • app/profile/[userId]/page.tsx
  • components/global-navbar.tsx
  • components/reputation/credit-balance.tsx
  • components/reputation/credit-explainer.tsx
  • components/reputation/credit-history.tsx
  • hooks/use-spark-credits.ts
  • lib/api/spark-credits.ts
  • lib/services/spark-credits.ts
  • types/spark-credits.ts

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 Spark Credits UI

1 participant