Skip to content

feat: Add custom reward display configuration for embed#3779

Open
tarai-dl wants to merge 1 commit intodubinc:mainfrom
tarai-dl:rn/custom-reward-display
Open

feat: Add custom reward display configuration for embed#3779
tarai-dl wants to merge 1 commit intodubinc:mainfrom
tarai-dl:rn/custom-reward-display

Conversation

@tarai-dl
Copy link
Copy Markdown

@tarai-dl tarai-dl commented Apr 19, 2026

Summary

This PR adds a rewardDisplay option to the program embed configuration, allowing programs to customize how reward amounts are rendered in the embed UI.

Changes

  • Added rewardDisplay configuration to programEmbedSchema with options:

    • mode: "currency" (default) | "custom"
    • prefix: string (e.g., "")
    • suffix: string (e.g., " credits")
    • divisor: number (default: 100, for cents to units conversion)
    • compact: boolean (e.g., "22k" vs "22000")
  • Created rewardFormatter utility function that respects the display configuration

  • Updated all embed components to use rewardFormatter instead of currencyFormatter:

    • activity.tsx
    • earnings-summary.tsx
    • earnings.tsx
    • leaderboard.tsx
    • links-list.tsx
    • bounties/performance-section.tsx

Usage Example

{
  "rewardDisplay": {
    "mode": "custom",
    "prefix": "",
    "suffix": " credits",
    "divisor": 100,
    "compact": true
  }
}

This would display $220.00 as "2.2k credits" instead.

Closes #3775

Summary by CodeRabbit

  • New Features

    • Added configurable reward display formatting for referral embeds, supporting multiple display modes (currency and custom).
    • Rewards can now be displayed with custom prefixes, suffixes, divisors, and compact formatting across all referral pages.
  • Documentation

    • Extended program embed schema with new rewardDisplay configuration options.

Implement rewardDisplay option in program embed configuration to allow
programs to customize how reward amounts are rendered in the embed.

Closes dubinc#3775
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 19, 2026

@tarai-dl is attempting to deploy a commit to the Dub Team on Vercel.

A member of the Team first needs to authorize it.

@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

The changes introduce a customizable reward display system for referral embeds. A new rewardDisplay configuration option was added to the program embed schema, enabling programs to control how reward amounts are formatted (currency mode or custom units with prefix/suffix/divisor). A new rewardFormatter utility function replaces hardcoded currencyFormatter calls across six referral embed components.

Changes

Cohort / File(s) Summary
Schema & Type Definitions
apps/web/lib/zod/schemas/program-embed.ts, apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx
Added optional rewardDisplay field to programEmbedSchema with mode, prefix, suffix, divisor, and compact options. Extended ReferralsEmbedData type to include programEmbedData property.
Utility Implementation
packages/utils/src/functions/reward-formatter.ts, packages/utils/src/functions/index.ts
Created new rewardFormatter function and RewardDisplayOptions interface. Handles currency and custom display modes with formatting delegation to existing utilities or custom number formatting. Added public re-export.
Embed Component Updates
apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx, apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/performance-section.tsx, apps/web/app/(ee)/app.dub.co/embed/referrals/earnings-summary.tsx, apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx, apps/web/app/(ee)/app.dub.co/embed/referrals/leaderboard.tsx, apps/web/app/(ee)/app.dub.co/embed/referrals/links-list.tsx
Consistently replaced currencyFormatter with rewardFormatter across all reward display instances. Each component now fetches programEmbedData from useReferralsEmbedData() and derives rewardDisplayOptions to pass to the formatter.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Suggested reviewers

  • steven-tey

Poem

🐰 Credits dance, not dollars gleam,
Custom rewards fulfill the dream!
Prefixes, suffixes, divvy the way,
Flexible embeds brighten the day.
No more hard-coded currency's plight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature addition: custom reward display configuration for embeds.
Linked Issues check ✅ Passed All requirements from #3775 are implemented: configurable rewardDisplay with mode, prefix, suffix, divisor, and compact options; rewardFormatter utility created; all currencyFormatter calls in embed components replaced.
Out of Scope Changes check ✅ Passed All changes directly support the custom reward display feature with no unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/performance-section.tsx (1)

412-484: ⚠️ Potential issue | 🟠 Major

Missing rewardDisplayOptions in columns memo dependencies (stale-closure risk).

The columns useMemo at lines 412-484 captures rewardDisplayOptions inside the amount cell renderer (line 479) but only lists [isCurrency] as a dependency. If programEmbedData.rewardDisplay changes after mount (e.g., config hot-reload, SWR refresh of embed data, or provider re-render with new options), the cell will keep formatting with the stale options until isCurrency flips.

Same issue in EmbedBountyCommissionsTable at line 659 ([] deps, uses rewardDisplayOptions at line 656).

Proposed fix
-  }, [isCurrency]);
+  }, [isCurrency, rewardDisplayOptions]);
-    [],
+    [rewardDisplayOptions],

Additionally, note the EmbedBountyEventsTable "Amount" column renders row.original.amount which is totalSaleAmount (revenue) — see the cross-file semantic note on earnings.tsx.

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

In
`@apps/web/app/`(ee)/app.dub.co/embed/referrals/bounties/performance-section.tsx
around lines 412 - 484, The useMemo that builds columns (the columns variable in
performance-section.tsx) captures rewardDisplayOptions inside the "amount" cell
via rewardFormatter but only lists [isCurrency] as dependencies, causing stale
closures; update the memo dependency array to include rewardDisplayOptions (or
the specific value like programEmbedData.rewardDisplay) so the columns
regenerate when display options change, and apply the same fix to
EmbedBountyCommissionsTable where its columns memo currently has [] deps but
uses rewardDisplayOptions; ensure any cell renderers that call rewardFormatter
reference the up-to-date prop in the dependency list so formatting updates
correctly.
apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx (1)

159-230: ⚠️ Potential issue | 🟠 Major

programEmbedData identity churns every render, defeating the embedData memo.

programEmbedSchema.parse(program.embedData) at line 159 produces a new object on every render. Since it's now both a member of the memoized embedData value (line 214) and a dependency of the useMemo (line 228), the memo is effectively a no-op — embedData gets a new reference each render, which in turn re-renders every consumer of ReferralsEmbedDataContext (activity, earnings, leaderboard, links-list, performance-section, etc.).

Memoize the parse:

Proposed fix
-  const programEmbedData = programEmbedSchema.parse(program.embedData);
+  const programEmbedData = useMemo(
+    () => programEmbedSchema.parse(program.embedData),
+    [program.embedData],
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/`(ee)/app.dub.co/embed/referrals/page-client.tsx around lines
159 - 230, programEmbedSchema.parse(program.embedData) is creating a new object
each render and busting the embedData memo; replace the direct parse with a
memoized value (e.g. const programEmbedData = useMemo(() =>
programEmbedSchema.parse(program.embedData), [program.embedData])) so
programEmbedData identity is stable across renders and embedData useMemo
actually caches; update imports if needed and keep programEmbedData in the
embedData dependency list.
♻️ Duplicate comments (2)
apps/web/app/(ee)/app.dub.co/embed/referrals/links-list.tsx (1)

124-129: ⚠️ Potential issue | 🟡 Minor

Sales column uses saleAmount (revenue), not rewards — see cross-file note in earnings.tsx.

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

In `@apps/web/app/`(ee)/app.dub.co/embed/referrals/links-list.tsx around lines 124
- 129, The "sales" column is formatting revenue (row.original.saleAmount) as a
reward; update the column to use the actual reward value and label: replace
row.original.saleAmount with the reward field (e.g., row.original.rewardAmount
or row.original.reward) passed into rewardFormatter, and adjust the column
id/header from "sales"/"Sales" to "rewards"/"Rewards" if the column should
represent rewards rather than revenue (see rewardFormatter usage and
earnings.tsx for the correct reward property name).
apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx (1)

81-85: ⚠️ Potential issue | 🟡 Minor

subValue is saleAmount (revenue), not a reward — see cross-file note in earnings.tsx.

Flagging for consistency; root-cause comment is on earnings.tsx line 69-82.

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

In `@apps/web/app/`(ee)/app.dub.co/embed/referrals/activity.tsx around lines 81 -
85, subValue in activity.tsx is actually saleAmount (revenue) and should not be
formatted with rewardFormatter/rewardDisplayOptions; replace the call to
rewardFormatter(subValue, rewardDisplayOptions) with the revenue/currency
formatter used in earnings.tsx (or import the shared saleAmount/currency
formatter) and update the variable/prop name to saleAmount where possible to
avoid confusion (ensure you import the correct formatter function from the same
module/utility used in earnings.tsx and remove rewardDisplayOptions usage).
🧹 Nitpick comments (2)
packages/utils/src/functions/reward-formatter.ts (2)

23-26: Currency mode silently drops prefix/suffix/compact/divisor.

If a caller sets mode: "currency" together with any of prefix, suffix, compact, or divisor, those fields are silently ignored. This is a reasonable design choice (currency formatting owns its own symbol/locale), but it can surprise users configuring rewardDisplay in the embed admin UI (e.g. setting a prefix then switching mode back to currency). Consider either:

  • documenting in the RewardDisplayOptions JSDoc that prefix/suffix/compact/divisor only apply in "custom" mode, or
  • honoring compact in currency mode too via currencyFormatter(valueInCents, { notation: "compact" }) if that's desired.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/utils/src/functions/reward-formatter.ts` around lines 23 - 26,
Update the API docs to make the behavior explicit: in RewardDisplayOptions JSDoc
(in packages/utils/src/functions/reward-formatter.ts) add a sentence that
prefix, suffix, compact, and divisor only apply when mode === "custom" and are
ignored when options.mode === "currency"; leave the existing early-return that
calls currencyFormatter(valueInCents) unchanged (i.e., keep the check of
options.mode === "currency" and the call to currencyFormatter) so callers know
the fields are intentionally ignored in currency mode.

29-31: Minor: bigint → Number coercion can silently lose precision, and unvalidated divisor can blow up.

Two small robustness points for the custom-mode path:

  1. Number(valueInCents) for a bigint larger than Number.MAX_SAFE_INTEGER will silently lose precision. In practice reward amounts in cents won't hit 2^53, so this is low risk, but worth a comment.
  2. This utility is exported and typed as divisor?: number on the public interface — it's only the zod schema (.positive()) that guarantees non-zero/positive. A TS caller passing { divisor: 0 } or a negative value will produce Infinity/NaN/negative output. A cheap guard keeps the utility safe independently of the schema:
🛡️ Proposed guard
-  const cents = typeof valueInCents === "bigint" ? Number(valueInCents) : valueInCents;
-  const divisor = options.divisor ?? 100;
-  const value = cents / divisor;
+  const cents =
+    typeof valueInCents === "bigint" ? Number(valueInCents) : valueInCents;
+  const divisor =
+    options.divisor && options.divisor > 0 ? options.divisor : 100;
+  const value = cents / divisor;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/utils/src/functions/reward-formatter.ts` around lines 29 - 31, The
custom-mode path currently coerces bigint with Number(valueInCents) (risking
silent precision loss) and trusts options.divisor (which can be 0 or negative).
In reward-formatter.ts around the valueInCents/divisor logic, add a small guard:
if valueInCents is a bigint, check against Number.MAX_SAFE_INTEGER and either
handle via BigInt arithmetic or throw/assert with a clear message (or at least
add a comment explaining the safe-assumption), and validate options.divisor to
ensure it's a finite positive non-zero number (fallback to 100 or throw) before
dividing; update the variables named valueInCents, cents, divisor, and value
accordingly so the function is robust regardless of zod validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@apps/web/app/`(ee)/app.dub.co/embed/referrals/bounties/performance-section.tsx:
- Around line 412-484: The useMemo that builds columns (the columns variable in
performance-section.tsx) captures rewardDisplayOptions inside the "amount" cell
via rewardFormatter but only lists [isCurrency] as dependencies, causing stale
closures; update the memo dependency array to include rewardDisplayOptions (or
the specific value like programEmbedData.rewardDisplay) so the columns
regenerate when display options change, and apply the same fix to
EmbedBountyCommissionsTable where its columns memo currently has [] deps but
uses rewardDisplayOptions; ensure any cell renderers that call rewardFormatter
reference the up-to-date prop in the dependency list so formatting updates
correctly.

In `@apps/web/app/`(ee)/app.dub.co/embed/referrals/page-client.tsx:
- Around line 159-230: programEmbedSchema.parse(program.embedData) is creating a
new object each render and busting the embedData memo; replace the direct parse
with a memoized value (e.g. const programEmbedData = useMemo(() =>
programEmbedSchema.parse(program.embedData), [program.embedData])) so
programEmbedData identity is stable across renders and embedData useMemo
actually caches; update imports if needed and keep programEmbedData in the
embedData dependency list.

---

Duplicate comments:
In `@apps/web/app/`(ee)/app.dub.co/embed/referrals/activity.tsx:
- Around line 81-85: subValue in activity.tsx is actually saleAmount (revenue)
and should not be formatted with rewardFormatter/rewardDisplayOptions; replace
the call to rewardFormatter(subValue, rewardDisplayOptions) with the
revenue/currency formatter used in earnings.tsx (or import the shared
saleAmount/currency formatter) and update the variable/prop name to saleAmount
where possible to avoid confusion (ensure you import the correct formatter
function from the same module/utility used in earnings.tsx and remove
rewardDisplayOptions usage).

In `@apps/web/app/`(ee)/app.dub.co/embed/referrals/links-list.tsx:
- Around line 124-129: The "sales" column is formatting revenue
(row.original.saleAmount) as a reward; update the column to use the actual
reward value and label: replace row.original.saleAmount with the reward field
(e.g., row.original.rewardAmount or row.original.reward) passed into
rewardFormatter, and adjust the column id/header from "sales"/"Sales" to
"rewards"/"Rewards" if the column should represent rewards rather than revenue
(see rewardFormatter usage and earnings.tsx for the correct reward property
name).

---

Nitpick comments:
In `@packages/utils/src/functions/reward-formatter.ts`:
- Around line 23-26: Update the API docs to make the behavior explicit: in
RewardDisplayOptions JSDoc (in packages/utils/src/functions/reward-formatter.ts)
add a sentence that prefix, suffix, compact, and divisor only apply when mode
=== "custom" and are ignored when options.mode === "currency"; leave the
existing early-return that calls currencyFormatter(valueInCents) unchanged
(i.e., keep the check of options.mode === "currency" and the call to
currencyFormatter) so callers know the fields are intentionally ignored in
currency mode.
- Around line 29-31: The custom-mode path currently coerces bigint with
Number(valueInCents) (risking silent precision loss) and trusts options.divisor
(which can be 0 or negative). In reward-formatter.ts around the
valueInCents/divisor logic, add a small guard: if valueInCents is a bigint,
check against Number.MAX_SAFE_INTEGER and either handle via BigInt arithmetic or
throw/assert with a clear message (or at least add a comment explaining the
safe-assumption), and validate options.divisor to ensure it's a finite positive
non-zero number (fallback to 100 or throw) before dividing; update the variables
named valueInCents, cents, divisor, and value accordingly so the function is
robust regardless of zod validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0da72d2a-b440-4a09-8215-06584339128c

📥 Commits

Reviewing files that changed from the base of the PR and between c95323b and df94eb0.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx
  • apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/performance-section.tsx
  • apps/web/app/(ee)/app.dub.co/embed/referrals/earnings-summary.tsx
  • apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx
  • apps/web/app/(ee)/app.dub.co/embed/referrals/leaderboard.tsx
  • apps/web/app/(ee)/app.dub.co/embed/referrals/links-list.tsx
  • apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx
  • apps/web/lib/zod/schemas/program-embed.ts
  • packages/utils/src/functions/index.ts
  • packages/utils/src/functions/reward-formatter.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.

Embed: custom reward unit / currency display

2 participants