-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add shareable earnings chart feature #3246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Introduces a shareable earnings chart modal and API route, allowing users to copy or download a chart image of their earnings. Updates UI components for improved styling consistency and integrates the share modal into the program dashboard.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds a share-earnings UI and modal, new OG image API for partner earnings with font loader, a centralized timeseries service, and small routing/middleware updates for rewind and partners paths. No public API breaking changes. Changes
Sequence Diagram(s)sequenceDiagram
participant User as Browser (User)
participant UI as Enrolled Page (React)
participant API as /api/og/partner-earnings (Server)
participant TS as getPartnerEarningsTimeseries (Service/DB)
participant Font as loadGoogleFont (External)
Note over UI,API: Share earnings -> generate OG image
User->>UI: Click "Share" button
UI->>API: GET /api/og/partner-earnings?programId&start&end&interval&background
API->>TS: fetch timeseries(partnerId, programId, filters)
TS-->>API: return timeseries data
API->>Font: loadGoogleFont("Inter:700")
Font-->>API: font ArrayBuffer (optional)
API-->>UI: ImageResponse (PNG/SVG)
UI->>UI: fetch blob, show preview
UI->>User: actions: Download / Copy (ClipboardItem)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
📜 Recent review detailsConfiguration used: defaults Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (2)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (2)
115-130: Consider checking ClipboardItem support for better UX.The
ClipboardItemAPI for images has limited browser support (Safari added it in 13.1, Firefox has partial support). While the catch block handles failures gracefully, you could optionally check support upfront to provide a more informative message or hide the copy button.🔎 Optional: Check for ClipboardItem support
const supportsImageClipboard = typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write !== undefined; // Then in the UI, either disable/hide the Copy button or show a tooltip // when supportsImageClipboard is false
152-158: Consider addingaria-labelfor accessibility.The close button uses only an icon without visible text. Adding an
aria-labelwould improve screen reader support.🔎 Optional: Add aria-label
<button type="button" onClick={() => setShowModal(false)} + aria-label="Close modal" className="group rounded-lg p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200" >
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/share/route.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx(9 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx(1 hunks)
🧰 Additional context used
🧠 Learnings (11)
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-10-08T21:33:23.553Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2936
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/add-hostname-modal.tsx:28-34
Timestamp: 2025-10-08T21:33:23.553Z
Learning: In the dub/ui Button component, when the `disabledTooltip` prop is set to a non-undefined value (e.g., a string), the button is automatically disabled. Therefore, it's not necessary to also add the same condition to the `disabled` prop—setting `disabledTooltip={permissionsError || undefined}` is sufficient to disable the button when there's a permissions error.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically sets type="button" when an onClick prop is passed and defaults to type="submit" otherwise, using the logic: type={props.onClick ? "button" : "submit"}. This prevents accidental form submissions when buttons are used for modal triggers or other non-form actions.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-24T16:10:37.349Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/ui/partners/partner-about.tsx:11-11
Timestamp: 2025-09-24T16:10:37.349Z
Learning: In the Dub codebase, the team prefers to import Icon as a runtime value from "dub/ui" and uses Icon as both a type and variable name in component props, even when this creates shadowing. This is their established pattern and should not be suggested for refactoring.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-17T17:40:35.470Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx:95-121
Timestamp: 2025-09-17T17:40:35.470Z
Learning: In the Dub UI Switch component, providing a truthy `disabledTooltip` prop automatically disables the switch and prevents user interaction, so an explicit `disabled` prop is not needed when using `disabledTooltip`.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-17T17:40:35.470Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx:95-121
Timestamp: 2025-09-17T17:40:35.470Z
Learning: In the Dub UI Switch component, providing a truthy `disabledTooltip` prop automatically disables the switch and prevents user interaction, so an explicit `disabled` prop is not needed when using `disabledTooltip`. The component computes `switchDisabled = disabledTooltip ? true : disabled || loading` and passes this to the underlying Radix Switch primitive.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
🧬 Code graph analysis (3)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (2)
apps/web/lib/analytics/types.ts (1)
IntervalOptions(16-16)packages/ui/src/button.tsx (1)
Button(158-158)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx (3)
apps/web/lib/swr/use-program-enrollment.ts (1)
useProgramEnrollment(11-49)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (1)
ShareEarningsModal(35-64)packages/ui/src/tooltip.tsx (1)
Tooltip(68-119)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/share/route.tsx (1)
packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(7-22)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (9)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1)
39-39: LGTM!The styling update to
rounded-xl border-neutral-200maintains consistency with the other dashboard cards updated in this PR.apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/share/route.tsx (3)
15-36: LGTM!The GET handler setup is well-structured: parallel font loading with
Promise.all, safe JSON parsing with fallback, and proper type handling for query parameters.
55-141: LGTM!The ImageResponse structure is clean with proper font registration. The
currencyFormatterusage correctly expects values in cents, which aligns with howtotalandepcare calculated in the page client.
143-184: LGTM!The Chart component handles edge cases well: empty data returns null, single data points render a centered circle (avoiding division by zero in
scaleX), andmaxEarningshas a minimum of 1 to prevent division by zero inscaleY.apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx (3)
332-342: LGTM!The ShareEarningsModal integration correctly passes all required props. The fallback to empty string for
programIdis safe since the modal's effect guards against empty values before fetching.
346-359: LGTM!The share button is well-implemented with a Tooltip for context and appropriate hover states. The
type="button"prevents accidental form submissions.
238-240: LGTM!Styling updates to
rounded-xl border-neutral-200are consistent across all card components in the dashboard.Also applies to: 455-455, 522-522
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (2)
132-146: LGTM!The download handler correctly manages memory by revoking the blob URL after use, following the standard pattern for programmatic downloads.
35-64: LGTM!The component structure is clean with proper separation between the wrapper (handling modal visibility) and inner component (handling state and rendering). The props interface is well-typed.
| fetch(imageUrl, { signal: abortController.signal }) | ||
| .then((res) => | ||
| res.blob().then((blob) => { | ||
| setBlob(blob); | ||
| setIsLoading(false); | ||
| }), | ||
| ) | ||
| .catch((err) => { | ||
| if (err.name === "AbortError") return; | ||
| toast.error("Failed to prepare chart image for sharing"); | ||
| setIsLoading(false); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check response status before processing the blob.
The fetch doesn't verify res.ok before calling res.blob(). If the server returns a 4xx/5xx error, this could set an invalid blob or fail silently.
🔎 Proposed fix
fetch(imageUrl, { signal: abortController.signal })
- .then((res) =>
- res.blob().then((blob) => {
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(`Failed to fetch chart image: ${res.status}`);
+ }
+ return res.blob().then((blob) => {
setBlob(blob);
setIsLoading(false);
- }),
- )
+ });
+ })
.catch((err) => {🤖 Prompt for AI Agents
In
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx
around lines 99 to 110, the fetch response is passed straight to res.blob()
without checking res.ok; update the promise chain to first check if res.ok and,
if not, throw an error (include status/text) so the catch block handles HTTP
errors, then call res.blob() only on a successful response and
setBlob/setIsLoading accordingly; keep the existing AbortError early return and
ensure toast.error and setIsLoading(false) run for non-abort failures.
There was a problem hiding this comment.
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)
apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/share-rewind-modal.tsx (1)
30-42: Loading state not reset on fetch failure.If the fetch fails,
isLoadingremainstrueindefinitely, keeping the Copy and Download buttons permanently disabled.🔎 Proposed fix
useEffect(() => { setIsLoading(true); fetch(imageUrl) .then((res) => res.blob().then((blob) => { setBlob(blob); setIsLoading(false); }), ) .catch(() => { toast.error("Failed to prepare rewind image for sharing"); + setIsLoading(false); }); }, [imageUrl]);
🧹 Nitpick comments (3)
apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/share-rewind-modal.tsx (1)
25-25: Consider extracting the duplicated URL construction.The image URL is constructed identically on lines 25 and 54. Extract it to a constant or use the existing
imageUrlvariable for theimgsrc.🔎 Proposed fix
<img - src={`/api/og/partner-rewind?${new URLSearchParams({ step }).toString()}`} + src={imageUrl} alt="share rewind image" className="relative size-full" />Also applies to: 54-54
apps/web/app/api/og/partner-rewind/route.tsx (1)
46-47: Font loading failure results in silent degradation.If
loadGoogleFontreturnsundefined, the image renders with an empty fonts array. This is acceptable fallback behavior, but consider logging when font loading fails for observability.Also applies to: 137-146
apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (1)
86-93: Consider adding explicit typing to the accumulator.The
reduceaccumulator lacks type annotation, which can mask type errors.🔎 Proposed fix
- const commissionLookup = earnings.reduce((acc, item) => { + const commissionLookup = earnings.reduce<Record<string, { earnings: number; [key: string]: number }>>((acc, item) => { if (!(item.start in acc)) acc[item.start] = { earnings: 0 }; acc[item.start].earnings += Number(item.earnings); if (groupBy) { - acc[item.start][item[groupBy]] = Number(item.earnings); + acc[item.start][item[groupBy] as string] = Number(item.earnings); } return acc; }, {});
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.tsapps/web/app/(ee)/api/partners/[partnerId]/route.tsapps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/rewind.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/share-rewind-modal.tsxapps/web/app/api/og/load-google-font.tsapps/web/app/api/og/partner-earnings/route.tsxapps/web/app/api/og/partner-rewind/route.tsxapps/web/lib/api/partner-profile/get-partner-earnings-timeseries.tsapps/web/lib/api/partner-profile/get-partner-for-program.tsapps/web/lib/middleware/partners.tsapps/web/lib/middleware/utils/partners-redirect.ts
💤 Files with no reviewable changes (1)
- apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/rewind.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.
Applied to files:
apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/share-rewind-modal.tsx
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/share-rewind-modal.tsx
🧬 Code graph analysis (4)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (2)
apps/web/lib/zod/schemas/partner-profile.ts (1)
getPartnerEarningsTimeseriesSchema(78-81)apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (1)
getPartnerEarningsTimeseries(10-127)
apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (7)
apps/web/lib/zod/schemas/partner-profile.ts (1)
getPartnerEarningsTimeseriesSchema(78-81)apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
getProgramEnrollmentOrThrow(6-67)apps/web/lib/analytics/utils/get-start-end-dates.ts (1)
getStartEndDates(5-51)apps/web/lib/planetscale/granularity.ts (1)
sqlGranularityMap(12-45)packages/prisma/index.ts (1)
prisma(3-9)packages/utils/src/functions/group-by.ts (1)
groupBy(1-13)packages/prisma/client.ts (1)
Prisma(30-30)
apps/web/app/api/og/partner-rewind/route.tsx (5)
apps/web/lib/auth/partner.ts (1)
withPartnerProfile(47-175)apps/web/ui/partners/rewind/constants.ts (1)
REWIND_STEPS(6-46)apps/web/lib/api/partners/get-partner-rewind.ts (1)
getPartnerRewind(6-61)apps/web/lib/api/errors.ts (1)
DubApiError(58-75)apps/web/app/api/og/load-google-font.ts (1)
loadGoogleFont(1-14)
apps/web/app/api/og/partner-earnings/route.tsx (5)
apps/web/lib/auth/partner.ts (1)
withPartnerProfile(47-175)apps/web/lib/zod/schemas/partner-profile.ts (1)
getPartnerEarningsTimeseriesSchema(78-81)apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (1)
getPartnerEarningsTimeseries(10-127)apps/web/app/api/og/load-google-font.ts (1)
loadGoogleFont(1-14)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(7-22)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (8)
apps/web/lib/middleware/utils/partners-redirect.ts (2)
8-8: LGTM! Clean redirect mapping for the rewind feature.The addition of the "/rewind" → "/rewind/2025" redirect is consistent with the existing redirect patterns and properly supports the new rewind feature introduced in this PR.
12-12: Nice refactor! Cleaner and more idiomatic.The simplified return statement maintains identical behavior while improving readability. Using the logical OR operator for the null fallback is more concise than the previous conditional approach.
apps/web/lib/middleware/partners.ts (1)
21-21: LGTM! Properly integrates with the redirect mapping.Adding "/rewind" to the authenticated paths ensures users must log in to access the rewind feature. This works cohesively with the redirect mapping in
partners-redirect.ts, where authenticated users accessing "/rewind" will be redirected to "/rewind/2025".apps/web/app/(ee)/api/partners/[partnerId]/route.ts (1)
1-6: LGTM!The import path update aligns with the broader refactoring toward a centralized
partner-profilenamespace for partner-related data fetches.apps/web/app/api/og/partner-rewind/route.tsx (1)
17-35: LGTM!The refactor to use
withPartnerProfilewrapper improves consistency with other partner routes. The zod validation ensures thestepparameter is valid before use, making the non-null assertion on line 26 safe.apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (1)
6-19: LGTM!Clean refactor that properly separates concerns—the route handles HTTP layer while business logic is delegated to
getPartnerEarningsTimeseries. The zod schema validation ensures type safety for the filters.apps/web/app/api/og/partner-earnings/route.tsx (1)
142-183: LGTM on Chart component.The Chart component handles edge cases well: empty data returns
null, single data point renders centered, and the scaling logic correctly avoids division by zero withMath.max(..., 1).apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (1)
10-126: Well-structured utility function.The function cleanly encapsulates the timeseries data retrieval logic with proper date range handling, granularity selection, and gap-filling for missing periods. The separation from the route handler improves testability and reusability.
There was a problem hiding this comment.
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)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (1)
87-98: Check response status before processing the blob.The fetch doesn't verify
res.okbefore callingres.blob(). If the API returns a 4xx/5xx error, this could set an invalid blob or display a misleading error message to the user.🔎 Proposed fix
fetch(imageUrl, { signal: abortController.signal }) - .then((res) => - res.blob().then((blob) => { + .then((res) => { + if (!res.ok) { + throw new Error(`Failed to fetch chart image: ${res.status}`); + } + return res.blob().then((blob) => { setBlob(blob); setIsLoading(false); - }), - ) + }); + }) .catch((err) => { if (err.name === "AbortError") return; toast.error("Failed to prepare chart image for sharing"); setIsLoading(false); });
🧹 Nitpick comments (1)
apps/web/app/api/og/partner-earnings/route.tsx (1)
157-163: Consider moving scale functions inside the multi-point rendering branch.The
scaleXfunction on line 160 containschartWidth / (data.length - 1), which would divide by zero ifdata.length === 1. While this is prevented by the special case handling on lines 165-176, the scale functions are defined before this check, creating a subtle risk if the code is refactored later.🔎 Proposed refactor
const maxEarnings = Math.max(...data.map((d) => d.earnings), 1); - const scaleX = (index: number) => - padding.left + (chartWidth / (data.length - 1)) * index; - - const scaleY = (value: number) => - padding.top + chartHeight - (value / maxEarnings) * chartHeight; - if (data.length === 1) { + const singleX = padding.left + chartWidth / 2; + const singleY = padding.top + chartHeight - (data[0].earnings / maxEarnings) * chartHeight; - const singleX = padding.left + chartWidth / 2; - const singleY = scaleY(data[0].earnings); return ( <svg viewBox={`0 0 ${width} ${height}`} style={{ width: "100%", height: "100%" }} > <circle cx={singleX} cy={singleY} r={circleRadius} fill="#DA2778" /> </svg> ); } + const scaleX = (index: number) => + padding.left + (chartWidth / (data.length - 1)) * index; + + const scaleY = (value: number) => + padding.top + chartHeight - (value / maxEarnings) * chartHeight; + const linePath = data
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsxapps/web/app/api/og/partner-earnings/route.tsx
🧰 Additional context used
🧠 Learnings (10)
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-10-08T21:33:23.553Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2936
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/add-hostname-modal.tsx:28-34
Timestamp: 2025-10-08T21:33:23.553Z
Learning: In the dub/ui Button component, when the `disabledTooltip` prop is set to a non-undefined value (e.g., a string), the button is automatically disabled. Therefore, it's not necessary to also add the same condition to the `disabled` prop—setting `disabledTooltip={permissionsError || undefined}` is sufficient to disable the button when there's a permissions error.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically sets type="button" when an onClick prop is passed and defaults to type="submit" otherwise, using the logic: type={props.onClick ? "button" : "submit"}. This prevents accidental form submissions when buttons are used for modal triggers or other non-form actions.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-24T16:10:37.349Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/ui/partners/partner-about.tsx:11-11
Timestamp: 2025-09-24T16:10:37.349Z
Learning: In the Dub codebase, the team prefers to import Icon as a runtime value from "dub/ui" and uses Icon as both a type and variable name in component props, even when this creates shadowing. This is their established pattern and should not be suggested for refactoring.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-17T17:40:35.470Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx:95-121
Timestamp: 2025-09-17T17:40:35.470Z
Learning: In the Dub UI Switch component, providing a truthy `disabledTooltip` prop automatically disables the switch and prevents user interaction, so an explicit `disabled` prop is not needed when using `disabledTooltip`.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-09-17T17:40:35.470Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx:95-121
Timestamp: 2025-09-17T17:40:35.470Z
Learning: In the Dub UI Switch component, providing a truthy `disabledTooltip` prop automatically disables the switch and prevents user interaction, so an explicit `disabled` prop is not needed when using `disabledTooltip`. The component computes `switchDisabled = disabledTooltip ? true : disabled || loading` and passes this to the underlying Radix Switch primitive.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
🧬 Code graph analysis (3)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (2)
apps/web/lib/analytics/types.ts (1)
IntervalOptions(16-16)packages/ui/src/button.tsx (1)
Button(158-158)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx (3)
apps/web/lib/swr/use-program-enrollment.ts (1)
useProgramEnrollment(11-49)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (1)
ShareEarningsModal(33-58)packages/ui/src/tooltip.tsx (1)
Tooltip(68-119)
apps/web/app/api/og/partner-earnings/route.tsx (6)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts (1)
GET(7-19)apps/web/lib/auth/partner.ts (1)
withPartnerProfile(47-175)apps/web/lib/zod/schemas/partner-profile.ts (1)
getPartnerEarningsTimeseriesSchema(78-81)apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (1)
getPartnerEarningsTimeseries(10-127)apps/web/app/api/og/load-google-font.ts (1)
loadGoogleFont(1-14)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(7-22)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (6)
apps/web/app/api/og/partner-earnings/route.tsx (1)
17-24: Past review comment has been addressed.The previous concern about
totalandepcparameters not being validated has been resolved. The current implementation correctly validates all query parameters through the schema, andtotalis now computed from the fetched timeseries data rather than being read directly from searchParams.apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx (2)
124-124: LGTM: Consistent styling improvements.The border radius and color updates (rounded-lg → rounded-xl, border-neutral-300 → border-neutral-200) improve visual consistency across the dashboard cards.
Also applies to: 238-238, 455-455, 522-522
346-359: LGTM: Share button implementation is clean and accessible.The Tooltip-wrapped share button follows established patterns in the codebase and includes proper accessibility attributes.
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (3)
120-134: LGTM: Download implementation includes proper cleanup.The download handler correctly manages the blob URL lifecycle by revoking the object URL after use.
156-165: LGTM: Modal UI with proper loading and disabled states.The conditional rendering and button disabled states ensure users can't interact with incomplete data.
Also applies to: 193-206
103-118: No action needed. TheClipboardItemAPI is fully supported in Firefox 127+ (released June 2024) and widely available across modern browsers as of December 2025. The code correctly implements the API with proper error handling that gracefully catches and reports failures to the user. Feature detection is optional but not required.Likely an incorrect or invalid review comment.
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
♻️ Duplicate comments (1)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (1)
91-97: Check response status before processing the blob.The fetch doesn't verify
res.okbefore callingres.blob(). If the server returns a 4xx/5xx error, this could set an invalid blob or fail silently.🔎 Proposed fix
fetch(imageUrl, { signal: abortController.signal }) - .then((res) => - res.blob().then((blob) => { + .then((res) => { + if (!res.ok) { + throw new Error(`Failed to fetch chart image: ${res.status}`); + } + return res.blob().then((blob) => { setBlob(blob); setIsLoading(false); - }), - ) + }); + }) .catch((err) => {
🧹 Nitpick comments (3)
apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (2)
31-38: Consider using object property shorthand.The parameters
partnerIdandprogramIdcan use shorthand syntax for cleaner code.🔎 Proposed refactor
const { program, links } = await getProgramEnrollmentOrThrow({ - partnerId: partnerId, - programId: programId, + partnerId, + programId, include: { program: true, links: true, }, });
146-175: Consider extracting the data initialization logic for better readability.The nested ternary operators and filters on lines 156-168 work correctly but are complex. Extracting this into a helper function would improve maintainability.
🔎 Example refactor
+ const initializeGroupData = ( + groupBy: string | undefined, + type: string | undefined, + linkId: string | undefined, + links: { id: string }[], + ): Record<string, number> => { + if (!groupBy) return {}; + + if (groupBy === "type") { + const types = ["sale", "lead", "click"]; + return Object.fromEntries( + types + .filter((t) => !type || type === t) + .map((t) => [t, 0]) + ); + } + + return Object.fromEntries( + links + .filter((link) => !linkId || link.id === linkId) + .map((link) => [link.id, 0]) + ); + }; while (currentDate < endDate) { const periodKey = format(currentDate, formatString); const { earnings, ...rest } = commissionLookup[periodKey] || {}; timeseries.push({ start: currentDate.toISOString(), earnings: earnings || 0, groupBy: groupBy || undefined, - data: groupBy - ? { - ...(groupBy === "type" - ? Object.fromEntries( - ["sale", "lead", "click"] - // only show filtered type if type filter is provided - .filter((t) => (type ? type === t : true)) - .map((t) => [t, 0]), - ) - : Object.fromEntries( - links - // only show filtered link if linkId filter is provided - .filter((link) => (linkId ? link.id === linkId : true)) - .map((link) => [link.id, 0]), - )), - ...rest, - } - : undefined, + data: groupBy + ? { ...initializeGroupData(groupBy, type, linkId, links), ...rest } + : undefined, }); currentDate = dateIncrement(currentDate); }apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (1)
107-122: Consider adding ClipboardItem browser support detection.The
ClipboardItemAPI is not supported in all browsers (e.g., Firefox doesn't support image copying). Consider adding feature detection to provide a better user experience.🔎 Proposed enhancement
const handleCopy = async () => { if (!blob) return; try { + if (!navigator.clipboard || typeof ClipboardItem === 'undefined') { + toast.error("Clipboard copying is not supported in this browser"); + return; + } + const clipboardItem = new ClipboardItem({ [blob.type]: blob, }); await navigator.clipboard.write([clipboardItem]); toast.success("Copied to clipboard"); } catch (err) { console.error("Failed to copy image: ", err); toast.error("Failed to copy image to clipboard"); } };
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsxapps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (5)
apps/web/lib/zod/schemas/partner-profile.ts (1)
getPartnerEarningsTimeseriesSchema(78-81)apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
getProgramEnrollmentOrThrow(6-67)apps/web/lib/analytics/utils/get-start-end-dates.ts (1)
getStartEndDates(5-51)apps/web/lib/planetscale/granularity.ts (1)
sqlGranularityMap(12-45)packages/utils/src/functions/group-by.ts (1)
groupBy(1-13)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (1)
apps/web/lib/analytics/types.ts (1)
IntervalOptions(16-16)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (6)
apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts (3)
1-17: LGTM!The function signature is well-typed using Zod schema inference, and imports are properly organized.
63-103: LGTM!The WHERE clause construction properly uses parameterized queries with placeholders, preventing SQL injection vulnerabilities.
119-127: Past type issue has been addressed.The
QueryResultinterface now properly includestypeandlinkIdas optional fields, resolving the previous review concern about type definition not including grouped columns.apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx (3)
1-58: LGTM!The component structure, imports, and type definitions are well-organized.
124-138: LGTM!The download handler properly creates, uses, and cleans up the blob URL to prevent memory leaks.
196-210: LGTM!The action buttons properly handle disabled states based on loading and blob availability.
Introduces a shareable earnings chart modal and API route, allowing users to copy or download a chart image of their earnings. Updates UI components for improved styling consistency and integrates the share modal into the program dashboard.
CleanShot.2025-12-18.at.13.32.18.mp4
Summary by CodeRabbit
New Features
Style
Chores
✏️ Tip: You can customize this high-level summary in your review settings.