Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Aug 18, 2025

Summary by CodeRabbit

  • New Features

    • Reward type selection via shared options; unified duration picker including lifetime.
    • Editable “Shown as” product labels for conditions.
    • Reward amounts now show ranges when modifiers align (type and duration).
  • UI/UX

    • Redesigned modifiers tooltip with clearer “If/Or” condition lists.
    • Streamlined reward amount formatting across lists, descriptions, FAQs, and previews.
    • Improved max-duration options in forms; one-time and 1-month excluded where not applicable.
    • Clearer error feedback via toasts.
  • Refactor

    • Centralized reward amount formatting with a single helper for consistent output.

@vercel
Copy link
Contributor

vercel bot commented Aug 18, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Aug 20, 2025 5:31am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 18, 2025

Walkthrough

Refactors reward amount construction to accept a reward object and derive ranges from modifiers. Extends schemas to include condition labels, type, and maxDuration. Propagates type/maxDuration through UI, updates selection options, and unifies formatting across tooltips, descriptions, OG image, and FAQs. Adjusts partner reward resolution to rebuild objects on condition matches.

Changes

Cohort / File(s) Summary
Core reward amount computation
apps/web/lib/api/sales/construct-reward-amount.ts
Replaces API to accept a reward object; derives ranges from modifiers when type/maxDuration align; updates currency/percentage formatting; removes legacy amounts[] path.
Schema updates
apps/web/lib/zod/schemas/rewards.ts
Adds ATTRIBUTE_LABELS; adds condition.label; extends rewardConditionsSchema with optional type and maxDuration.
Partner reward resolution
apps/web/lib/partners/determine-partner-reward.ts
Allows reassignment of partnerReward; renames variable; reconstructs partnerReward with amount/type/maxDuration on match; removes debug log; keeps early returns.
Reward editing UI and logic
apps/web/ui/partners/rewards/rewards-logic.tsx, apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
Exports REWARD_TYPES; threads type/maxDuration through modifiers; adds popovers for type and duration; supports product label editing; normalizes Infinity/null for durations; updates constructRewardAmount calls to pass reward objects.
Display components using unified formatter
apps/web/ui/partners/program-reward-description.tsx, apps/web/ui/partners/program-reward-list.tsx, apps/web/ui/partners/program-reward-modifiers-tooltip.tsx, apps/web/ui/partners/format-discount-description.ts
Switches to constructRewardAmount(reward/discount); introduces RewardItem with condition rendering; derives human-readable duration text; uses ATTRIBUTE_LABELS and operator labels.
Public surfaces consuming formatter
apps/web/app/api/og/program/route.tsx, apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx
Replaces inline amount logic with constructRewardAmount(reward); minor spacing tweak in OG route.
Forms: duration options
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx, apps/web/ui/partners/add-edit-discount-sheet.tsx
Filters recurring durations (exclude 0 and 1 in new program form); adds clarifying comment in discount sheet.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant UI as UI (Rewards/Descriptions)
  participant Logic as constructRewardAmount
  participant Schema as Zod (rewardConditionsArraySchema)

  User->>UI: View reward
  UI->>Logic: constructRewardAmount(reward)
  alt reward.modifiers present
    Logic->>Schema: safeParse(reward.modifiers)
    alt Valid and all modifiers match primary type & maxDuration
      Logic-->>UI: "min - max" (currency or %)
    else Invalid or mismatch
      Logic-->>UI: Primary amount (currency or %)
    end
  else No modifiers
    Logic-->>UI: Primary amount (currency or %)
  end
Loading
sequenceDiagram
  autonumber
  participant Engine as Partner Reward Resolver
  participant Cond as Matching Condition
  participant Out as RewardSchema

  Engine->>Engine: Initialize partnerReward
  Engine->>Cond: Evaluate conditions
  alt Match found
    Engine->>Engine: partnerReward = { ...partnerReward, amount/type/maxDuration }
  end
  Engine->>Out: RewardSchema.parse(partnerReward)
  Out-->>Engine: Parsed reward or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

Possibly related PRs

  • Reward Logic #2673 — Similar refactor: reward schemas and constructRewardAmount updated to accept reward objects with modifiers; UI wiring overlaps.
  • Reward modifiers #2645 — Touches determine-partner-reward, schemas, and formatter API for modifier-aware rewards.
  • Reward modifier display #2734 — Modifies reward-modifier flow across ProgramRewardDescription/Tooltip and formatter logic in tandem.

Suggested reviewers

  • steven-tey

Poem

A whisk of code, a hop in time,
I nibbled schemas into line.
Types and durations, neatly chewed,
Modifiers now range-improved.
From flat to percent, I tip my ear—
Rewards made clear; the carrot’s near! 🥕✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch mix-match-reward-structure

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)

51-51: Unnecessary blank line addition.

The extra blank line after the closing </strong> tag doesn't add value and may be inconsistent with the codebase's formatting standards.

            </strong>
-
            {(
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 649fdf7 and a2f04fe.

📒 Files selected for processing (1)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/program-reward-modifiers-tooltip.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (64-66)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (4-26)
⏰ 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 (2)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)

54-54: LGTM! Correctly destructures modifier-specific type.

The destructuring now extracts the type field from each modifier, enabling per-modifier reward type handling. This aligns with the schema changes that added type: z.nativeEnum(RewardStructure) to reward conditions.


60-63: LGTM! Uses modifier-specific type for amount formatting.

The constructRewardAmount function now receives the modifier's own type instead of the parent reward's type, which correctly supports the mix & match reward structure feature where each modifier can have its own type (flat fee vs percentage).

Copy link
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.

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/ui/partners/rewards/rewards-logic.tsx (1)

603-642: Amount input should respect parent type when modifier type is unset

Amount input currently watches only modifiers.{i}.type, so suffix/validation defaults to “USD” even if the parent type is “percentage”. Use a fallback to parent type for consistent UX and validation.

Apply this diff:

-function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) {
-  const { watch, register } = useAddEditRewardForm();
-  const type = watch(`${modifierKey}.type`);
+function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) {
+  const { watch, register } = useAddEditRewardForm();
+  const localType = watch(`${modifierKey}.type`);
+  const parentType = watch("type");
+  const type = localType || parentType;
@@
-        className={cn(
+        className={cn(
           "block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:w-32 sm:text-sm",
           type === "flat" ? "pl-4 pr-12" : "pr-7",
         )}
         {...register(`${modifierKey}.amount`, {
           required: true,
           setValueAs: (value: string) => (value === "" ? undefined : +value),
           min: 0,
           max: type === "percentage" ? 100 : undefined,
           onChange: handleMoneyInputChange,
         })}
@@
-      <span className="absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400">
-        {type === "flat" ? "USD" : "%"}
-      </span>
+      <span className="absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400">
+        {type === "percentage" ? "%" : "USD"}
+      </span>

Additionally, ensure the display at lines 544-546 already uses displayType (with parent fallback), which is good.

🧹 Nitpick comments (3)
apps/web/lib/partners/determine-partner-reward.ts (1)

77-78: Remove debug log of partnerReward

Stray server-side console.log can leak data and clutter logs.

Apply this diff:

-  console.log(partnerReward);
-
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

270-285: Reset dependent fields when entity changes (avoid invalid state)

When switching entity, keep the form state valid by resetting attribute, operator, and value (per prior UX guidance in this codebase).

Apply this diff:

-            onSelect={(value) =>
-              setValue(
-                conditionKey,
-                { entity: value as keyof typeof ENTITIES },
-                {
-                  shouldDirty: true,
-                },
-              )
-            }
+            onSelect={(value) =>
+              setValue(
+                conditionKey,
+                {
+                  entity: value as keyof typeof ENTITIES,
+                  attribute: undefined,
+                  operator: undefined,
+                  value: undefined,
+                },
+                { shouldDirty: true },
+              )
+            }

557-565: Handle undefined maxDuration in badge text

When neither modifier nor parent provides maxDuration, the current string interpolation yields “for undefined month(s)”. Add a neutral fallback.

Apply this diff:

-          <InlineBadgePopover
-            text={
-              displayMaxDuration === 0
-                ? "one time"
-                : displayMaxDuration === Infinity
-                  ? "for the customer's lifetime"
-                  : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}`
-            }
-          >
+          <InlineBadgePopover
+            text={
+              displayMaxDuration === undefined
+                ? "duration"
+                : displayMaxDuration === 0
+                  ? "one time"
+                  : displayMaxDuration === Infinity
+                    ? "for the customer's lifetime"
+                    : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}`
+            }
+          >
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a2f04fe and 4550829.

📒 Files selected for processing (5)
  • apps/web/lib/partners/determine-partner-reward.ts (2 hunks)
  • apps/web/lib/partners/evaluate-reward-conditions.ts (1 hunks)
  • apps/web/lib/zod/schemas/rewards.ts (1 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (5 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (7 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/web/lib/partners/evaluate-reward-conditions.ts
  • apps/web/lib/zod/schemas/rewards.ts
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Learnt from: TWilson023
PR: dubinc/dub#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.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/lib/partners/determine-partner-reward.ts
  • apps/web/ui/partners/rewards/rewards-logic.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/lib/partners/determine-partner-reward.ts
🧬 Code Graph Analysis (2)
apps/web/lib/partners/determine-partner-reward.ts (1)
apps/web/lib/partners/evaluate-reward-conditions.ts (1)
  • evaluateRewardConditions (7-58)
apps/web/ui/partners/rewards/rewards-logic.tsx (5)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (75-75)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (2)
  • InlineBadgePopover (35-72)
  • InlineBadgePopoverMenu (81-175)
packages/prisma/client.ts (1)
  • RewardStructure (19-19)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (4-26)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
⏰ 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 (5)
apps/web/lib/partners/determine-partner-reward.ts (2)

45-45: Good pivot to a mutable binding for reward reassignment

Switching const to let enables clean, immutable-style reassignment when a condition matches.


58-73: Correctly rebuild reward from matched condition (no in-place mutation)

Reconstructing partnerReward from the matched condition (amount/type/maxDuration) aligns with the updated condition evaluator and keeps the object shape consistent for downstream parsing via RewardSchema.

apps/web/ui/partners/rewards/rewards-logic.tsx (3)

41-50: Nice centralized reward type options

Exporting REWARD_TYPES here makes reuse easy across the UI.


103-109: Good: seed per-condition type/maxDuration from parent values

Pre-populating modifier entries with parent type and maxDuration simplifies UX and keeps derived displays consistent.


544-546: Display conversion for flat amounts looks correct

Converting flat amounts to cents before passing to constructRewardAmount matches how formatCurrency expects values. Passing type: displayType || "flat" is sensible given the fallback logic.

Copy link
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.

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)

627-666: AmountInput should fall back to parent type for prefix/suffix and validation

Currently it only watches the modifier’s type, so the input can display “%” while the result uses the parent’s flat type. Watch the parent type and derive an effective type.

-function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) {
-  const { watch, register } = useAddEditRewardForm();
-  const type = watch(`${modifierKey}.type`);
+function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) {
+  const { watch, register } = useAddEditRewardForm();
+  const parentType = watch("type");
+  const type = watch(`${modifierKey}.type`) ?? parentType;

No further changes are needed since the existing uses of type (prefix, suffix, max) will now reflect the effective type.

♻️ Duplicate comments (2)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

549-552: Guard against undefined type when rendering the type badge

capitalize(displayType) can throw when displayType is undefined. The diff above addresses this by providing a fallback label.


271-275: Guard capitalize against undefined entity

capitalize(condition.entity) will throw when entity is unset. Use a conditional fallback.

-          <InlineBadgePopover
-            text={capitalize(condition.entity) || "Select item"}
-            invalid={!condition.entity}
-          >
+          <InlineBadgePopover
+            text={condition.entity ? capitalize(condition.entity) : "Select item"}
+            invalid={!condition.entity}
+          >
🧹 Nitpick comments (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)

42-51: Type the REWARD_TYPES constant to RewardStructure for safety

Ensure menu item values stay narrowed to RewardStructure and not widened to string.

-export const REWARD_TYPES = [
+export const REWARD_TYPES = [
   {
     text: "Flat",
     value: "flat",
   },
   {
     text: "Percentage",
     value: "percentage",
   },
-];
+] as const satisfies { text: string; value: RewardStructure }[];
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 4550829 and b9e7cf9.

📒 Files selected for processing (2)
  • apps/web/lib/zod/schemas/rewards.ts (1 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (9 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/lib/zod/schemas/rewards.ts
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/lib/zod/schemas/rewards.ts
  • apps/web/ui/partners/rewards/rewards-logic.tsx
🧬 Code Graph Analysis (2)
apps/web/lib/zod/schemas/rewards.ts (2)
packages/prisma/client.ts (1)
  • RewardStructure (19-19)
apps/web/lib/zod/schemas/misc.ts (1)
  • maxDurationSchema (56-61)
apps/web/ui/partners/rewards/rewards-logic.tsx (7)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
  • RewardIconSquare (3-7)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
  • InlineBadgePopover (35-72)
  • InlineBadgePopoverMenu (81-175)
  • InlineBadgePopoverInputs (202-276)
  • InlineBadgePopoverInput (177-200)
apps/web/lib/zod/schemas/rewards.ts (3)
  • CONDITION_ATTRIBUTES (22-25)
  • CONDITION_OPERATOR_LABELS (36-43)
  • CONDITION_OPERATORS (27-34)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (75-75)
packages/prisma/client.ts (1)
  • RewardStructure (19-19)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (4-26)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
⏰ 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 (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)

104-110: Good: Propagate parent type/maxDuration into newly-added modifiers

Seeding new modifiers with the current form’s type and maxDuration keeps UI state coherent.

… reward calculations across various components.
Copy link
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.

Actionable comments posted: 2

♻️ Duplicate comments (4)
apps/web/ui/partners/rewards/rewards-logic.tsx (3)

585-604: Avoid “for NaN months” when maxDuration is unset

When displayMaxDuration is undefined, the label renders NaN. Use the resolvedMaxDuration computed earlier and a safe selectedValue.

-          <InlineBadgePopover
-            text={
-              displayMaxDuration === 0
-                ? "one time"
-                : displayMaxDuration === Infinity
-                  ? "for the customer's lifetime"
-                  : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}`
-            }
-          >
+          <InlineBadgePopover
+            text={
+              resolvedMaxDuration === 0
+                ? "one time"
+                : resolvedMaxDuration === Infinity
+                  ? "for the customer's lifetime"
+                  : `for ${resolvedMaxDuration} ${pluralize("month", Number(resolvedMaxDuration))}`
+            }
+          >
             <InlineBadgePopoverMenu
-              selectedValue={
-                displayMaxDuration === Infinity
-                  ? "Infinity"
-                  : displayMaxDuration?.toString()
-              }
+              selectedValue={
+                resolvedMaxDuration === Infinity
+                  ? "Infinity"
+                  : resolvedMaxDuration.toString()
+              }
               onSelect={(value) =>
                 setValue(
                   `${modifierKey}.maxDuration`,
                   value === "Infinity" ? Infinity : Number(value),
                   {
                     shouldDirty: true,
                   },
                 )
               }

547-563: Guard badge text and “of” copy by using effectiveType

Avoid capitalize(undefined) and ensure the “of” prefix aligns with the resolved type.

-      {event === "sale" && (
+      {event === "sale" && (
         <>
           a{" "}
-          <InlineBadgePopover text={capitalize(displayType)}>
+          <InlineBadgePopover text={displayType ? capitalize(displayType) : "Type"}>
             <InlineBadgePopoverMenu
               selectedValue={type}
               onSelect={(value) =>
                 setValue(`${modifierKey}.type`, value as RewardStructure, {
                   shouldDirty: true,
                 })
               }
               items={REWARD_TYPES}
             />
           </InlineBadgePopover>{" "}
-          {displayType === "percentage" && "of "}
+          {effectiveType === "percentage" && "of "}
         </>
       )}

539-543: Compute an effective type and resolved duration to avoid undefined/NaN display paths

Both type and maxDuration can be unset on a modifier; compute a single effectiveType (default to "flat") and a resolvedMaxDuration to keep downstream rendering and formatting safe.

-  // Use parent values as fallbacks if modifier doesn't have type or maxDuration
-  const displayType = type || parentType;
-  const displayMaxDuration =
-    maxDuration !== undefined ? maxDuration : parentMaxDuration;
+  // Use parent values as fallbacks if modifier doesn't have type or maxDuration
+  const displayType = type || parentType;
+  const displayMaxDuration =
+    maxDuration !== undefined ? maxDuration : parentMaxDuration;
+  const effectiveType = (displayType ?? "flat") as RewardStructure;
+  const resolvedMaxDuration =
+    displayMaxDuration === undefined ? 0 : displayMaxDuration;
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)

52-56: Database backfill or default for new modifier.type remains required

This tooltip now destructures type from modifiers. Ensure existing records have type populated or a schema default in place; otherwise rendering can fail.

If not already done in this PR, add a migration to backfill modifier.type and, if applicable, modifier.maxDuration. Also consider a .default(...) in the Zod schema for resilience.

🧹 Nitpick comments (10)
apps/web/lib/api/sales/construct-reward-amount.ts (1)

44-46: Nit: comment wording (“timelines”)

The comment says “type AND timelines doesn't match”. Consider “type AND maxDuration don't match” for precision and grammar.

-  // 2. type AND timelines doesn't match the primary reward
+  // 2. type AND maxDuration don't match the primary reward
apps/web/ui/partners/rewards/rewards-logic.tsx (1)

287-295: Optional: fallback entities if event is unset

If event is temporarily undefined in form state, this filter returns an empty list, blocking selection. Consider defaulting to all entities until event is chosen.

-              items={Object.keys(ENTITIES)
-                .filter((e) =>
-                  EVENT_ENTITIES[event]?.includes(e as keyof typeof ENTITIES),
-                )
+              items={(event
+                ? Object.keys(ENTITIES).filter((e) =>
+                    EVENT_ENTITIES[event]?.includes(e as keyof typeof ENTITIES),
+                  )
+                : Object.keys(ENTITIES))
                 .map((entity) => ({
                   text: capitalize(entity) || entity,
                   value: entity,
                 }))}
apps/web/ui/partners/format-reward-description.ts (1)

16-16: Updated call site is correct; consider passing modifiers to enable ranges

The wrapper call is correct. If you want the description to reflect ranges when modifiers exist (as in ProgramRewardList), ensure the reward object includes modifiers (it will at runtime if you pass the original object).

apps/web/ui/partners/program-reward-list.tsx (1)

20-21: Nit: variable name implies sorting but only filters

sortedFilteredRewards isn’t sorted. Either sort or rename to filteredRewards for clarity.

-  const sortedFilteredRewards = rewards.filter((r) => r.amount >= 0);
+  const filteredRewards = rewards.filter((r) => r.amount >= 0);
...
-      {sortedFilteredRewards.map((reward) => (
+      {filteredRewards.map((reward) => (
apps/web/app/api/og/program/route.tsx (1)

153-159: Unify duration copy: handle one-time, single-month, and Infinity consistently

This block handles only null and multi-month cases. Elsewhere (e.g., ProgramRewardModifiersTooltip) Infinity is treated as lifetime, and one-time (0) is also surfaced. Suggest harmonizing here and gating the recurrence phrasing to sale events.

Apply this diff:

-                {rewards[0].maxDuration === null ? (
-                  "for the customer's lifetime"
-                ) : rewards[0].maxDuration && rewards[0].maxDuration > 1 ? (
-                  <>
-                    , and again every month for {rewards[0].maxDuration} months
-                  </>
-                ) : null}
+                {rewards[0].event === "sale" && (
+                  <>
+                    {rewards[0].maxDuration === 0
+                      ? ", one time"
+                      : rewards[0].maxDuration === 1
+                        ? ", and again for the first month"
+                        : rewards[0].maxDuration === Infinity || rewards[0].maxDuration === null
+                          ? ", for the customer's lifetime"
+                          : rewards[0].maxDuration && rewards[0].maxDuration > 1
+                            ? `, and again every month for ${rewards[0].maxDuration} months`
+                            : null}
+                  </>
+                )}
apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx (1)

24-29: Cover one-time, single-month, and Infinity cases in copy

Add handling for 0 (one-time), 1 (first month), and Infinity (lifetime) to mirror other components.

Apply this diff:

-    ? `For each new customer you refer, you'll earn a ${constructRewardAmount({ reward })} commission on their subscription${
-        reward.maxDuration === null
-          ? " for their lifetime"
-          : reward.maxDuration && reward.maxDuration > 1
-            ? ` for up to ${reward.maxDuration} months`
-            : ""
-      }. There are no limits to how much you can earn.`
+    ? `For each new customer you refer, you'll earn a ${constructRewardAmount({ reward })} commission on their subscription${
+        reward.maxDuration === 0
+          ? " one time"
+          : reward.maxDuration === 1
+            ? " for the first month"
+            : reward.maxDuration === Infinity || reward.maxDuration === null
+              ? " for their lifetime"
+              : reward.maxDuration && reward.maxDuration > 1
+                ? ` for up to ${reward.maxDuration} months`
+                : ""
+      }. There are no limits to how much you can earn.`
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)

52-93: Parse and validate modifiers before rendering; add label fallback

Directly mapping over reward.modifiers assumes schema compliance. Given the new required fields (type, maxDuration), parse via rewardConditionsArraySchema to avoid runtime issues on legacy data, and provide a safe fallback for unknown operator labels.

Apply this diff:

-            {(
-              reward.modifiers as z.infer<typeof rewardConditionsArraySchema>
-            ).map(
-              ({ amount, type, operator, conditions, maxDuration }, idx) => (
-                <Fragment key={idx}>
+            {(() => {
+              const parsed = rewardConditionsArraySchema.safeParse(
+                reward.modifiers,
+              );
+              const modifiers = parsed.success ? parsed.data : [];
+              return modifiers.map(
+                ({ amount, type, operator, conditions, maxDuration }, idx) => (
+                  <Fragment key={idx}>
                   <div className="mt-1 flex items-start gap-1.5">
                     <ArrowTurnRight2 className="mt-0.5 size-3 shrink-0" />
                     <div className="min-w-0">
                       <strong className="text-content-default font-semibold">
                         {constructRewardAmount({
                           reward: {
                             amount,
                             type: type as RewardStructure,
                             maxDuration,
                             modifiers: undefined,
                           },
                         })}
                       </strong>
                       <ul className="overflow-hidden pl-1 text-xs text-neutral-600">
                         {conditions.map((condition, idx) => (
                           <li key={idx} className="flex items-center gap-1">
                             <span className="shrink-0 text-lg leading-none">
                               &bull;
                             </span>
                             <span className="min-w-0 truncate">
                               {idx === 0
                                 ? "If"
                                 : capitalize(operator.toLowerCase())}
                               {` ${condition.entity}`}
                               {` ${condition.attribute}`}
-                              {` ${CONDITION_OPERATOR_LABELS[condition.operator]}`}
+                              {` ${
+                                CONDITION_OPERATOR_LABELS[condition.operator] ??
+                                String(condition.operator).replaceAll("_", " ")
+                              }`}
                               {` ${
                                 condition.value &&
                                 truncate(
                                   Array.isArray(condition.value)
                                     ? condition.value.join(", ")
                                     : condition.value.toString(),
                                   16,
                                 )
                               }`}
                             </span>
                           </li>
                         ))}
                       </ul>
                     </div>
                   </div>
-                </Fragment>
-              ),
-            )}
+                  </Fragment>
+                ),
+              );
+            })()}

63-68: Minor: avoid unnecessary cast if possible

If type is already of the same union as RewardStructure, the as RewardStructure cast can be removed. Not blocking.

apps/web/ui/partners/program-reward-description.tsx (2)

38-56: Handle single-month and Infinity; align copy across components

Currently 1-month rewards show no period text, and Infinity isn’t treated as lifetime. Recommend aligning with tooltip/OG route semantics.

Apply this diff:

-              {reward.maxDuration === null ? (
+              {reward.maxDuration === Infinity || reward.maxDuration === null ? (
                 <>
                   {" "}
                   for the{" "}
                   <strong className={cn("font-semibold", periodClassName)}>
                     customer's lifetime
                   </strong>
                 </>
-              ) : reward.maxDuration && reward.maxDuration > 1 ? (
+              ) : reward.maxDuration === 1 ? (
+                <>
+                  {" "}
+                  for the{" "}
+                  <strong className={cn("font-semibold", periodClassName)}>
+                    first month
+                  </strong>
+                </>
+              ) : reward.maxDuration && reward.maxDuration > 1 ? (
                 <>
                   {" "}
                   for{" "}
                   <strong className={cn("font-semibold", periodClassName)}>
                     {reward.maxDuration % 12 === 0
                       ? `${reward.maxDuration / 12} year${reward.maxDuration / 12 > 1 ? "s" : ""}`
                       : `${reward.maxDuration} months`}
                   </strong>
                 </>
               ) : null}

78-94: Discount duration: include Infinity handling to mirror lifetime semantics

Add Infinity alongside null for lifetime to keep behavior consistent with other components.

Apply this diff:

-          {discount.maxDuration === null ? (
+          {discount.maxDuration === Infinity || discount.maxDuration === null ? (
             <strong className={cn("font-semibold", periodClassName)}>
               for their lifetime
             </strong>
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b9e7cf9 and 638e7ac.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx (1 hunks)
  • apps/web/app/api/og/program/route.tsx (1 hunks)
  • apps/web/lib/api/sales/construct-reward-amount.ts (1 hunks)
  • apps/web/ui/partners/format-discount-description.ts (1 hunks)
  • apps/web/ui/partners/format-reward-description.ts (1 hunks)
  • apps/web/ui/partners/program-reward-description.tsx (2 hunks)
  • apps/web/ui/partners/program-reward-list.tsx (2 hunks)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (3 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (6 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (9 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/program-reward-list.tsx
  • apps/web/ui/partners/program-reward-description.tsx
  • apps/web/app/api/og/program/route.tsx
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
  • apps/web/ui/partners/rewards/rewards-logic.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code Graph Analysis (7)
apps/web/ui/partners/format-discount-description.ts (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
apps/web/ui/partners/format-reward-description.ts (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
apps/web/app/api/og/program/route.tsx (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
apps/web/lib/api/sales/construct-reward-amount.ts (2)
apps/web/lib/types.ts (1)
  • RewardProps (474-474)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (69-71)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (2)
  • rewardConditionsArraySchema (69-71)
  • CONDITION_OPERATOR_LABELS (36-43)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
packages/prisma/client.ts (1)
  • RewardStructure (19-19)
apps/web/ui/partners/rewards/rewards-logic.tsx (6)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
  • RewardIconSquare (3-7)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
  • InlineBadgePopover (35-72)
  • InlineBadgePopoverMenu (81-175)
  • InlineBadgePopoverInputs (202-276)
  • InlineBadgePopoverInput (177-200)
apps/web/lib/zod/schemas/rewards.ts (3)
  • CONDITION_ATTRIBUTES (22-25)
  • CONDITION_OPERATOR_LABELS (36-43)
  • CONDITION_OPERATORS (27-34)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (75-75)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
⏰ 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 (12)
apps/web/lib/api/sales/construct-reward-amount.ts (2)

16-40: Range logic via modifiers is solid and easy to follow

The safeParse + “all modifiers share type and maxDuration” gate before rendering a range is a clear and correct approach. Using Math.min/Math.max across primary and modifiers reads well.


21-25: Infinity ↔ null normalization is already handled in the UI and schema
The Zod maxDurationSchema only accepts numbers in RECURRING_MAX_DURATIONS or null/undefined, and the UI’s add-edit-reward and add-edit-discount sheets convert between Infinity and null on load and on submit. As a result, by the time modifiers and reward come through the schema, both lifetime values are null and modifier.maxDuration === reward.maxDuration still holds. No changes needed here.

apps/web/ui/partners/rewards/rewards-logic.tsx (2)

42-51: REWARD_TYPES looks good and is reusable

Explicit export and simple structure is perfect for menus and badges. Good centralization.


631-667: Modifier amount input UX and constraints look good

Suffix/Prefix adapt to type; min/max validation for percentage; money input handlers wired. Once the dollars→cents conversion above is in place, this stays coherent end-to-end.

apps/web/ui/partners/format-discount-description.ts (1)

17-19: Wrapper call aligns with constructRewardAmount’s new signature

This change keeps the function concise and leverages the shared formatter. Looks good.

apps/web/ui/partners/program-reward-list.tsx (1)

33-35: Good: unified wrapper usage for both rewards and discounts

constructRewardAmount({ reward }) adoption is consistent and enables range display for rewards with modifiers.

apps/web/app/api/og/program/route.tsx (1)

150-152: Good API migration to constructRewardAmount({ reward })

Passing the full reward object aligns with the new helper signature and reduces local formatting logic.

apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx (1)

23-23: Nice consolidation around constructRewardAmount({ reward })

Centralizing amount formatting improves consistency with modifier-aware ranges.

apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)

9-9: Import of RewardStructure is appropriate

Used to type the per-modifier reward object passed into constructRewardAmount.


34-39: Header amount switch to constructRewardAmount({ reward }) looks good

This aligns with the helper’s new API and ensures range rendering when modifiers align with type/maxDuration.

apps/web/ui/partners/program-reward-description.tsx (2)

29-31: Good adoption of constructRewardAmount({ reward })

Removes local branching and centralizes amount/range formatting.


73-76: Discount amount call updated correctly

Passing the discount object into constructRewardAmount keeps formatting consistent across reward/discount.

Copy link
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.

Actionable comments posted: 0

Caution

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

⚠️ Outside diff range comments (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)

54-63: Fix currency fraction logic: using % 100 on dollar amounts is incorrect

formatCurrency receives dollar amounts (already divided by 100), but the check uses amount % 100 === 0, which only hides decimals for multiples of $100 and incorrectly forces cents for $1, $2, etc. Use an integer check on dollars.

Apply this diff:

-const formatCurrency = (amount: number) =>
-  currencyFormatter(
-    amount,
-    amount % 100 === 0
-      ? undefined
-      : {
-          minimumFractionDigits: 2,
-          maximumFractionDigits: 2,
-        },
-  );
+const formatCurrency = (amount: number) =>
+  currencyFormatter(
+    amount,
+    Number.isInteger(amount)
+      ? undefined
+      : {
+          minimumFractionDigits: 2,
+          maximumFractionDigits: 2,
+        },
+  );
♻️ Duplicate comments (5)
apps/web/ui/partners/rewards/rewards-logic.tsx (5)

271-275: Guard capitalize() when entity/operator may be unset

Calling capitalize(condition.entity) and operator.toLowerCase() can throw when unset. Use safe fallbacks.

Apply this diff:

-          {conditionIndex === 0 ? "If" : capitalize(operator.toLowerCase())}{" "}
+          {conditionIndex === 0 ? "If" : capitalize((operator ?? "AND").toLowerCase())}{" "}
           <InlineBadgePopover
-            text={capitalize(condition.entity) || "Select item"}
+            text={condition.entity ? capitalize(condition.entity) : "Select item"}
             invalid={!condition.entity}
           >

539-543: Compute an effective type and resolved maxDuration once; use them everywhere

Unify the fallback logic to avoid undefined type/NaN month edge cases and keep formatting consistent with dollars→cents conversions.

Apply this diff:

-  // Use parent values as fallbacks if modifier doesn't have type or maxDuration
-  const displayType = type || parentType;
-  const displayMaxDuration =
-    maxDuration !== undefined ? maxDuration : parentMaxDuration;
+  // Use parent values as fallbacks if modifier doesn't have type or maxDuration
+  const displayType = type || parentType;
+  const displayMaxDuration =
+    maxDuration !== undefined ? maxDuration : parentMaxDuration;
+  const effectiveType = (displayType ?? "flat") as RewardStructure;
+  const resolvedMaxDuration =
+    displayMaxDuration === undefined ? 0 : displayMaxDuration;

547-563: Guard badge text and rely on effectiveType for label

Prevents capitalize on undefined and ensures “of” renders only for percentage.

Apply this diff:

-      {event === "sale" && (
+      {event === "sale" && (
         <>
           a{" "}
-          <InlineBadgePopover text={capitalize(displayType)}>
+          <InlineBadgePopover text={displayType ? capitalize(displayType) : "Type"}>
             <InlineBadgePopoverMenu
               selectedValue={type}
               onSelect={(value) =>
                 setValue(`${modifierKey}.type`, value as RewardStructure, {
                   shouldDirty: true,
                 })
               }
               items={REWARD_TYPES}
             />
           </InlineBadgePopover>{" "}
-          {displayType === "percentage" && "of "}
+          {effectiveType === "percentage" && "of "}
         </>
       )}

566-575: Ensure correct unit conversion using effectiveType

Flat amounts should be converted dollars→cents for constructRewardAmount; also pass a non-undefined type.

Apply this diff:

-            ? constructRewardAmount({
-                reward: {
-                  amount: displayType === "flat" ? amount * 100 : amount,
-                  type: displayType,
-                  maxDuration: displayMaxDuration,
-                },
-              })
+            ? constructRewardAmount({
+                reward: {
+                  amount: effectiveType === "flat" ? Math.round((amount ?? 0) * 100) : amount,
+                  type: effectiveType,
+                  maxDuration: resolvedMaxDuration,
+                },
+              })

585-623: Resolve undefined maxDuration to avoid “NaN months” and stabilize selection

Use resolvedMaxDuration for label/selection so text and values are always valid.

Apply this diff:

-          <InlineBadgePopover
-            text={
-              displayMaxDuration === 0
-                ? "one time"
-                : displayMaxDuration === Infinity
-                  ? "for the customer's lifetime"
-                  : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}`
-            }
-          >
+          <InlineBadgePopover
+            text={
+              resolvedMaxDuration === 0
+                ? "one time"
+                : resolvedMaxDuration === Infinity
+                  ? "for the customer's lifetime"
+                  : `for ${resolvedMaxDuration} ${pluralize("month", Number(resolvedMaxDuration))}`
+            }
+          >
             <InlineBadgePopoverMenu
-              selectedValue={
-                displayMaxDuration === Infinity
-                  ? "Infinity"
-                  : displayMaxDuration?.toString()
-              }
+              selectedValue={
+                resolvedMaxDuration === Infinity
+                  ? "Infinity"
+                  : resolvedMaxDuration.toString()
+              }
               onSelect={(value) =>
                 setValue(
                   `${modifierKey}.maxDuration`,
                   value === "Infinity" ? Infinity : Number(value),
                   {
                     shouldDirty: true,
                   },
                 )
               }
🧹 Nitpick comments (2)
apps/web/lib/api/sales/construct-reward-amount.ts (1)

27-41: Collapse identical min/max into a single value and avoid duplicate maps

If min equals max, render a single amount (e.g., “10%” or “$5”) instead of a redundant range. Also precompute the amounts array to avoid mapping twice.

Apply this diff:

-      // If the type AND maxDuration matches the primary, show a range
+      // If the type AND maxDuration matches the primary, show a range
       if (matchPrimary) {
-        const min = Math.min(
-          reward.amount,
-          ...modifiers.map((modifier) => modifier.amount),
-        );
-
-        const max = Math.max(
-          reward.amount,
-          ...modifiers.map((modifier) => modifier.amount),
-        );
-
-        return reward.type === "percentage"
-          ? `${min}% - ${max}%`
-          : `${formatCurrency(min / 100)} - ${formatCurrency(max / 100)}`;
+        const amounts = [reward.amount, ...modifiers.map((m) => m.amount)];
+        const min = Math.min(...amounts);
+        const max = Math.max(...amounts);
+
+        if (min === max) {
+          return reward.type === "percentage"
+            ? `${min}%`
+            : formatCurrency(min / 100);
+        }
+
+        return reward.type === "percentage"
+          ? `${min}% - ${max}%`
+          : `${formatCurrency(min / 100)} - ${formatCurrency(max / 100)}`;
       }
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)

312-344: Max-duration UI: consider handling Infinity explicitly in setter for clarity

Number(value) works for "Infinity" today, but being explicit improves readability and guards future regressions.

Apply this diff:

-                          onSelect={(value) =>
-                            setValue("maxDuration", Number(value), {
+                          onSelect={(value) =>
+                            setValue("maxDuration", value === "Infinity" ? Infinity : Number(value), {
                               shouldDirty: true,
                             })
                           }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 638e7ac and b2640bf.

📒 Files selected for processing (3)
  • apps/web/lib/api/sales/construct-reward-amount.ts (1 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (6 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (9 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/rewards/rewards-logic.tsx
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧬 Code Graph Analysis (3)
apps/web/ui/partners/rewards/rewards-logic.tsx (5)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
  • RewardIconSquare (3-7)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
  • InlineBadgePopover (35-72)
  • InlineBadgePopoverMenu (81-175)
  • InlineBadgePopoverInputs (202-276)
  • InlineBadgePopoverInput (177-200)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (75-75)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-52)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
apps/web/lib/api/sales/construct-reward-amount.ts (3)
apps/web/lib/types.ts (1)
  • RewardProps (474-474)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (69-71)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (69-71)
packages/prisma/client.ts (1)
  • RewardStructure (19-19)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
  • REWARD_TYPES (42-51)
⏰ 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/ui/partners/rewards/rewards-logic.tsx (2)

103-110: Good: new modifiers inherit parent type and maxDuration

Seeding a new modifier with the current parent type/maxDuration improves UX and keeps units consistent.


444-462: Nice UX addition: “Shown as” product label inline editor

Good conditional display for productId conditions with a compact input UX.

apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (6)

21-21: Type import looks correct

Using RewardStructure from Prisma ensures stronger typing across the form.


53-54: Centralizing reward-type options via REWARD_TYPES is a good move

Prevents drift between screens and keeps option text/values consistent.


106-108: Correct: normalize modifier amounts and lifetime sentinel in defaults

Dividing flat amounts by 100 for display and mapping nullInfinity improves UI consistency.


190-192: Correct: normalize modifiers before schema parse on submit

Cents conversion for flat and Infinitynull avoids backend validation issues.


282-287: Type selection is safely cast and options centralized

Using RewardStructure cast and REWARD_TYPES menu avoids stray literals.


296-301: Amount display correctly converts flat dollars → cents for formatter

Passing the new reward object shape to constructRewardAmount aligns with the updated API.

Copy link
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.

Actionable comments posted: 1

🧹 Nitpick comments (4)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)

24-49: Prefer schema validation over type assertion when mapping modifiers

Currently, modifiers are asserted as the schema type. Validating with zod avoids runtime failures if upstream data is malformed and brings this component in line with constructRewardAmount’s safeParse pattern.

Apply this diff to validate once and map only valid modifiers:

-            {(
-              reward.modifiers as z.infer<typeof rewardConditionsArraySchema>
-            ).map((modifier, idx) => (
-              <div key={idx} className="space-y-2">
-                <span className="flex w-full items-center justify-center rounded bg-neutral-100 px-2 py-1 text-xs font-semibold text-neutral-600">
-                  OR
-                </span>
-
-                <RewardItem
-                  reward={{
-                    event: reward.event,
-                    amount: modifier.amount,
-                    type:
-                      modifier.type === undefined ? reward.type : modifier.type, // fallback to primary
-                    maxDuration:
-                      modifier.maxDuration === undefined
-                        ? reward.maxDuration
-                        : modifier.maxDuration, // fallback to primary
-                  }}
-                  conditions={modifier.conditions}
-                />
-              </div>
-            ))}
+            {(() => {
+              const parsed = rewardConditionsArraySchema.safeParse(
+                reward.modifiers,
+              );
+              const modifiers = parsed.success ? parsed.data : [];
+              return modifiers.map((modifier, idx) => (
+                <div key={idx} className="space-y-2">
+                  <span className="flex w-full items-center justify-center rounded bg-neutral-100 px-2 py-1 text-xs font-semibold text-neutral-600">
+                    OR
+                  </span>
+
+                  <RewardItem
+                    reward={{
+                      event: reward.event,
+                      amount: modifier.amount,
+                      type:
+                        modifier.type === undefined ? reward.type : modifier.type, // fallback to primary
+                      maxDuration:
+                        modifier.maxDuration === undefined
+                          ? reward.maxDuration
+                          : modifier.maxDuration, // fallback to primary
+                    }}
+                    conditions={modifier.conditions}
+                  />
+                </div>
+              ));
+            })()}

29-29: Prefer stable keys over array index

If a stable identifier exists on modifier (e.g., id), prefer it over idx to avoid potential reconciliation issues if the array order changes.


39-40: Use nullish coalescing for type fallback

Slightly clearer and idiomatic for optional type fields. Keep maxDuration logic as-is to preserve null semantics.

Apply this diff:

-                    type:
-                      modifier.type === undefined ? reward.type : modifier.type, // fallback to primary
+                    type: modifier.type ?? reward.type, // fallback to primary

72-85: Extract duration text logic into a helper for readability and reuse

The nested ternaries are compact but hard to scan. A small helper improves readability and enables reuse in other reward views.

Apply this diff:

-  const durationText =
-    reward.maxDuration === null
-      ? "for the customer's lifetime"
-      : reward.maxDuration === 0
-        ? "one time"
-        : reward.maxDuration && reward.maxDuration % 12 === 0
-          ? `for ${reward.maxDuration / 12} ${pluralize(
-              "year",
-              reward.maxDuration / 12,
-            )}`
-          : reward.maxDuration
-            ? `for ${reward.maxDuration} months`
-            : "";
+  const durationText = formatRewardDuration(reward.maxDuration);

Add this helper (can live in this file or a shared util):

function formatRewardDuration(maxDuration: number | null | undefined) {
  if (maxDuration === null) return "for the customer's lifetime";
  if (maxDuration === 0) return "one time";
  if (typeof maxDuration === "number") {
    if (maxDuration % 12 === 0) {
      const years = maxDuration / 12;
      return `for ${years} ${pluralize("year", years)}`;
    }
    return `for ${maxDuration} months`;
  }
  return "";
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b2640bf and 86b5a42.

📒 Files selected for processing (1)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)
apps/web/lib/types.ts (1)
  • RewardProps (474-474)
packages/ui/src/tooltip.tsx (1)
  • InfoTooltip (193-199)
apps/web/lib/zod/schemas/rewards.ts (2)
  • rewardConditionsArraySchema (69-71)
  • CONDITION_OPERATOR_LABELS (36-43)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-52)
⏰ 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 (4)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)

7-8: InfoTooltip/pluralize imports: consistent UI utilities usage

Good choice leaning on shared UI/utils packages to keep visuals and pluralization consistent across the app.


14-15: Prop widened to RewardProps | null — aligns with constructRewardAmount({ reward }) flow

Accepting the full RewardProps simplifies consumers and keeps this component future-proof as reward fields evolve.


16-18: Null/empty modifiers guard prevents empty tooltip rendering

The early return avoids rendering an empty tooltip and keeps UI clean.


65-70: Correctly isolates primary amount by omitting modifiers

Passing modifiers: undefined ensures header displays the base reward amount and not the range. Matches constructRewardAmount’s updated API.

Copy link
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.

Actionable comments posted: 0

♻️ Duplicate comments (2)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)

36-45: Fallbacks for modifier type/maxDuration are good; ensure upstream data is migrated

The UI fallbacks are sensible. However, per schema updates requiring type/maxDuration on modifiers, missing fields should be backfilled/migrated to avoid validation failures elsewhere.

Would you confirm that a migration/default is in place for modifiers missing type and/or maxDuration? If needed, I can draft a migration or schema defaults.


99-107: Don’t suppress falsy values; add label fallbacks to avoid “undefined”

  • Falsy but valid values (0/false/empty string) are currently dropped by the truthiness check.
  • ATTRIBUTE/OPERATOR label lookups can render as “undefined” if new keys are introduced; add safe fallbacks.

Apply this diff:

-                {idx === 0 ? "If" : "Or"} {condition.entity}{" "}
-                {ATTRIBUTE_LABELS[condition.attribute]}{" "}
-                {CONDITION_OPERATOR_LABELS[condition.operator]}{" "}
-                {condition.value &&
-                  (Array.isArray(condition.value)
-                    ? condition.value.join(", ")
-                    : condition.attribute === "productId" && condition.label
-                      ? condition.label
-                      : condition.value.toString())}
+                {idx === 0 ? "If" : "Or"} {condition.entity}{" "}
+                {(ATTRIBUTE_LABELS as Record<string, string>)[condition.attribute] ??
+                  condition.attribute}{" "}
+                {(CONDITION_OPERATOR_LABELS as Record<string, string>)[condition.operator] ??
+                  condition.operator}{" "}
+                {condition.value != null &&
+                  (Array.isArray(condition.value)
+                    ? condition.value.map((v) => String(v)).join(", ")
+                    : condition.attribute === "productId" && condition.label
+                      ? condition.label
+                      : String(condition.value))}
🧹 Nitpick comments (4)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)

27-49: Validate modifiers before mapping to avoid runtime errors

The current cast trusts reward.modifiers to match rewardConditionsArraySchema. Prefer safe parsing to avoid crashes if legacy/invalid data slips through.

Apply this diff to parse and short-circuit on invalid data:

-            {(
-              reward.modifiers as z.infer<typeof rewardConditionsArraySchema>
-            ).map((modifier, idx) => (
+            {(() => {
+              const parsed = rewardConditionsArraySchema.safeParse(
+                reward.modifiers,
+              );
+              if (!parsed.success) return null;
+              return parsed.data.map((modifier, idx) => (
               <div key={idx} className="space-y-2">
                 <span className="flex w-full items-center justify-center rounded bg-neutral-100 px-2 py-1 text-xs font-semibold text-neutral-600">
                   OR
                 </span>
 
                 <RewardItem
                   reward={{
                     event: reward.event,
                     amount: modifier.amount,
                     type:
                       modifier.type === undefined ? reward.type : modifier.type, // fallback to primary
                     maxDuration:
                       modifier.maxDuration === undefined
                         ? reward.maxDuration
                         : modifier.maxDuration, // fallback to primary
                   }}
                   conditions={modifier.conditions}
                 />
               </div>
-            ))}
+              ));
+            })()}

83-85: Pluralize “month” for correct grammar

Currently renders “1 months”. Use the existing pluralize util for months as well.

Apply this diff:

-          : reward.maxDuration
-            ? `for ${reward.maxDuration} months`
-            : "";
+          : reward.maxDuration
+            ? `for ${reward.maxDuration} ${pluralize("month", reward.maxDuration)}`
+            : "";

96-96: Use a stable key for condition items

Index keys can cause reconciliation issues on edits. Construct a stable key from condition fields.

Apply this diff:

-            <li key={idx} className="flex items-start gap-1">
+            <li
+              key={`${condition.entity}-${condition.attribute}-${condition.operator}-${Array.isArray(condition.value) ? condition.value.join("|") : String(condition.value ?? "")}`}
+              className="flex items-start gap-1"
+            >

29-33: Optional: prefer a stable key for modifier blocks

If modifiers have an id or deterministic composite key, use that instead of idx to reduce reconciliation churn.

Example:

<div
  key={`${modifier.amount}-${modifier.type ?? reward.type}-${modifier.maxDuration ?? reward.maxDuration}-${idx}`}
/>
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 86b5a42 and 289bc98.

📒 Files selected for processing (2)
  • apps/web/lib/zod/schemas/rewards.ts (2 hunks)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/zod/schemas/rewards.ts
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Learnt from: TWilson023
PR: dubinc/dub#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.
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (3)
apps/web/lib/types.ts (1)
  • RewardProps (474-474)
apps/web/lib/zod/schemas/rewards.ts (3)
  • rewardConditionsArraySchema (74-76)
  • ATTRIBUTE_LABELS (36-39)
  • CONDITION_OPERATOR_LABELS (41-48)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-52)
⏰ 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 (3)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (3)

15-19: Prop shape update and early-return guard look solid

Accepting RewardProps | null and guarding on reward?.modifiers?.length is correct and prevents unnecessary renders.


66-71: Confirm intent: header amount ignores modifiers/ranges

Passing modifiers: undefined to constructRewardAmount ensures the header never shows a range, even when all modifiers align with the primary. If the product requirement is to display a range in this tooltip header when appropriate, remove that override.

If the header should show ranges, change to:

-  const rewardAmount = constructRewardAmount({
-    reward: {
-      ...reward,
-      modifiers: undefined,
-    },
-  });
+  const rewardAmount = constructRewardAmount({ reward });

25-27: Nice extraction: RewardItem improves readability and reuse

Pulling header/conditions formatting into RewardItem simplifies the tooltip and centralizes reward amount computation and duration text.

Also applies to: 87-91

Copy link
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.

Actionable comments posted: 1

♻️ Duplicate comments (4)
apps/web/ui/partners/rewards/rewards-logic.tsx (4)

636-649: Resolve undefined maxDuration to avoid “undefined/NaN months”

When both modifier and parent maxDuration are unset, the label currently renders “for undefined months”. Use the resolvedMaxDuration (0 = one time) for label and selection.

Apply this diff (assuming resolvedMaxDuration is defined as suggested above):

-          <InlineBadgePopover
-            text={
-              displayMaxDuration === 0
-                ? "one time"
-                : displayMaxDuration === Infinity
-                  ? "for the customer's lifetime"
-                  : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}`
-            }
-          >
+          <InlineBadgePopover
+            text={
+              resolvedMaxDuration === 0
+                ? "one time"
+                : resolvedMaxDuration === Infinity
+                  ? "for the customer's lifetime"
+                  : `for ${resolvedMaxDuration} ${pluralize(
+                      "month",
+                      Number(resolvedMaxDuration),
+                    )}`
+            }
+          >
-              selectedValue={
-                displayMaxDuration === Infinity
-                  ? "Infinity"
-                  : displayMaxDuration?.toString()
-              }
+              selectedValue={
+                resolvedMaxDuration === Infinity
+                  ? "Infinity"
+                  : resolvedMaxDuration.toString()
+              }

Also applies to: 651-658


660-672: Verify backend/schema support for Infinity used in the UI

The menu includes an “Infinity” option, but validation/persistence must accept and map it. Ensure zod schemas allow Infinity and the server maps it to the intended DB sentinel (e.g., null or -1).

Run this script to verify and find mapping gaps:

#!/bin/bash
set -euo pipefail

echo "Check zod schemas for maxDuration allowed values (Infinity support):"
rg -n -C2 'RECURRING_MAX_DURATIONS|maxDurationSchema|maxDuration|Infinity' apps/web/lib/zod

echo
echo "Search usages/mapping for Infinity to DB sentinel across app/server:"
rg -n -C3 '\bmaxDuration\b|Infinity|\b(one[- ]?time|lifetime)\b' apps | sed -n '1,400p'

270-273: Guard capitalize calls to prevent runtime errors when values are unset

Both operator and condition.entity can be temporarily undefined; calling toLowerCase() or capitalize() on undefined will throw.

Apply this diff:

-            {conditionIndex === 0 ? "If" : capitalize(operator.toLowerCase())}{" "}
+            {conditionIndex === 0
+              ? "If"
+              : operator
+                ? capitalize(operator.toLowerCase())
+                : "And"}{" "}
             <InlineBadgePopover
-              text={capitalize(condition.entity) || "Select item"}
+              text={
+                condition.entity ? capitalize(condition.entity) : "Select item"
+              }
               invalid={!condition.entity}
             >

590-593: Fix: unify effectiveType, guard badge text, and format flat amounts in cents

  • displayType can be undefined (no modifier or parent selection), causing:
    • capitalize(displayType) to throw
    • Incorrect amount formatting (passing dollars to constructRewardAmount’s cents path)
    • Passing undefined type to constructRewardAmount (type narrowing expects RewardStructure)
  • Also add a resolvedMaxDuration to avoid undefined/NaN text later.

Apply this diff:

   const displayType = type || parentType;
   const displayMaxDuration =
     maxDuration !== undefined ? maxDuration : parentMaxDuration;
+  const effectiveType = (displayType ?? "flat") as RewardStructure;
+  const resolvedMaxDuration =
+    displayMaxDuration === undefined ? 0 : displayMaxDuration;
-          <InlineBadgePopover text={capitalize(displayType)}>
+          <InlineBadgePopover
+            text={displayType ? capitalize(displayType) : "Type"}
+          >
-          {displayType === "percentage" && "of "}
+          {effectiveType === "percentage" && "of "}
-            ? constructRewardAmount({
-                reward: {
-                  amount: displayType === "flat" ? amount * 100 : amount,
-                  type: displayType,
-                  maxDuration: displayMaxDuration,
-                },
-              })
+            ? constructRewardAmount({
+                reward: {
+                  amount:
+                    effectiveType === "flat"
+                      ? Math.round(amount * 100)
+                      : amount,
+                  type: effectiveType,
+                  maxDuration: resolvedMaxDuration,
+                  modifiers: undefined,
+                },
+              })

Also applies to: 602-613, 619-624

🧹 Nitpick comments (2)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

683-684: Use parent fallback for AmountInput suffix/validation

AmountInput currently reads only the modifier’s type; when unset, the suffix and max validation can diverge from the parent type. Consider falling back to the parent type for consistency.

Apply this diff:

-  const type = watch(`${modifierKey}.type`);
+  const [localType, parentType] = watch([`${modifierKey}.type`, "type"]);
+  const type = (localType || parentType) as RewardStructure | undefined;

618-626: Zero amount handling: treat 0 as defined if allowed

The invalid flag and display fallback use truthiness, so 0 shows “amount” and marks invalid. If $0 or 0% is a valid reward in your domain, compare against null/undefined instead of falsy.

-        text={
-          amount
+        text={
+          amount !== undefined
             ? constructRewardAmount({
               ...
             })
             : "amount"
         }
-        invalid={!amount}
+        invalid={amount === undefined}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between febe018 and 56b12e0.

📒 Files selected for processing (1)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (11 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Learnt from: TWilson023
PR: dubinc/dub#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.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/rewards/rewards-logic.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (7)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
  • RewardIconSquare (3-7)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
  • InlineBadgePopover (35-78)
  • InlineBadgePopoverMenu (87-181)
  • InlineBadgePopoverInputs (208-282)
  • InlineBadgePopoverInput (183-206)
apps/web/lib/zod/schemas/rewards.ts (3)
  • ATTRIBUTE_LABELS (36-39)
  • CONDITION_OPERATOR_LABELS (41-48)
  • CONDITION_OPERATORS (27-34)
packages/ui/src/icons/nucleo/chevron-right.tsx (1)
  • ChevronRight (3-24)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (75-75)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-52)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
⏰ 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 (2)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

43-52: Good addition: centralized REWARD_TYPES for consistent menus

Exporting REWARD_TYPES here keeps type choices consistent across call sites and avoids duplication. Looks good.


109-111: Pre-fill new modifier with parent type and duration

Seeding modifiers with the parent’s type and maxDuration improves UX and keeps display/validation coherent with parent defaults.

Copy link
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.

Actionable comments posted: 0

♻️ Duplicate comments (5)
apps/web/ui/partners/rewards/rewards-logic.tsx (4)

276-285: Reset dependent fields when entity changes to avoid invalid state carryover

Explicitly clearing attribute/operator/value/label on entity change avoids stale combinations and is more maintainable than overwriting the whole object.

Apply this diff:

-                onSelect={(value) =>
-                  setValue(
-                    conditionKey,
-                    { entity: value as keyof typeof ENTITIES },
-                    {
-                      shouldDirty: true,
-                    },
-                  )
-                }
+                onSelect={(value) => {
+                  const next = value as keyof typeof ENTITIES;
+                  setValue(`${conditionKey}.entity`, next, { shouldDirty: true });
+                  setValue(`${conditionKey}.attribute`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.operator`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.value`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.label`, undefined, { shouldDirty: true });
+                }}

634-671: Prevent “NaN months” when maxDuration is unset; resolve a fallback once

When both modifier and parent maxDuration are undefined, labels render “NaN months”. Resolve a single fallback (e.g., 0 = one time) and use it for the badge text and selectedValue.

Apply this diff:

-  <InlineBadgePopover
-    text={
-      displayMaxDuration === 0
-        ? "one time"
-        : displayMaxDuration === Infinity
-          ? "for the customer's lifetime"
-          : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}`
-    }
-  >
+  {(() => {
+    const resolvedMaxDuration =
+      displayMaxDuration === undefined ? 0 : displayMaxDuration;
+    return (
+      <InlineBadgePopover
+        text={
+          resolvedMaxDuration === 0
+            ? "one time"
+            : resolvedMaxDuration === Infinity
+              ? "for the customer's lifetime"
+              : `for ${resolvedMaxDuration} ${pluralize("month", Number(resolvedMaxDuration))}`
+        }
+      >
         <InlineBadgePopoverMenu
-          selectedValue={
-            displayMaxDuration === Infinity
-              ? "Infinity"
-              : displayMaxDuration?.toString()
-          }
+          selectedValue={
+            resolvedMaxDuration === Infinity
+              ? "Infinity"
+              : resolvedMaxDuration.toString()
+          }
           onSelect={(value) =>
             setValue(
               `${modifierKey}.maxDuration`,
               value === "Infinity" ? Infinity : Number(value),
               {
                 shouldDirty: true,
               },
             )
           }
           items={[
             {
               text: "one time",
               value: "0",
             },
             ...RECURRING_MAX_DURATIONS.filter((v) => v !== 0).map((v) => ({
               text: `for ${v} ${pluralize("month", Number(v))}`,
               value: v.toString(),
             })),
             {
               text: "for the customer's lifetime",
               value: "Infinity",
             },
           ]}
         />
-      </InlineBadgePopover>
+      </InlineBadgePopover>
+    );
+  })()}

270-274: Fix crash: capitalize(condition.entity) when entity is unset

capitalize(undefined) throws. This will crash when the user hasn’t selected an entity yet.

Apply this diff:

-            <InlineBadgePopover
-              text={capitalize(condition.entity) || "Select item"}
+            <InlineBadgePopover
+              text={condition.entity ? capitalize(condition.entity) : "Select item"}
               invalid={!condition.entity}
             >

590-594: Guard type badge and unify an effectiveType for formatting/semantics

displayType can be undefined (e.g., modifier not set yet), which crashes on capitalize(displayType) and misformats the amount. Compute an effectiveType once with a safe fallback and use it consistently.

Apply this diff:

   // Use parent values as fallbacks if modifier doesn't have type or maxDuration
-  const displayType = type || parentType;
+  const displayType = type || parentType;
+  const effectiveType = (displayType ?? "flat") as RewardStructure;
   const displayMaxDuration =
     maxDuration !== undefined ? maxDuration : parentMaxDuration;
@@
-      {event === "sale" && (
+      {event === "sale" && (
         <>
           a{" "}
-          <InlineBadgePopover text={capitalize(displayType)}>
+          <InlineBadgePopover text={displayType ? capitalize(displayType) : "Type"}>
             <InlineBadgePopoverMenu
               selectedValue={type}
               onSelect={(value) =>
                 setValue(`${modifierKey}.type`, value as RewardStructure, {
                   shouldDirty: true,
                 })
               }
               items={REWARD_TYPES}
             />
           </InlineBadgePopover>{" "}
-          {displayType === "percentage" && "of "}
+          {effectiveType === "percentage" && "of "}
         </>
       )}
@@
           amount
             ? constructRewardAmount({
-                amount: displayType === "flat" ? amount * 100 : amount,
-                type: displayType,
+                amount: effectiveType === "flat" ? Math.round(amount * 100) : amount,
+                type: effectiveType,
                 maxDuration: displayMaxDuration,
               })
             : "amount"

Note: Using Math.round avoids floating-point precision artifacts when converting dollars → cents.

Also applies to: 601-614, 618-624

apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)

104-109: Don’t suppress valid falsy values (e.g., 0/false) in condition value

The truthy check hides valid values. Switch to a nullish check and safely stringify.

Apply this diff:

-                {condition.value &&
-                  (Array.isArray(condition.value)
-                    ? condition.value.join(", ")
-                    : condition.attribute === "productId" && condition.label
-                      ? condition.label
-                      : condition.value.toString())}
+                {condition.value !== undefined && condition.value !== null &&
+                  (Array.isArray(condition.value)
+                    ? condition.value.map((v) => String(v)).join(", ")
+                    : condition.attribute === "productId" && condition.label
+                      ? condition.label
+                      : String(condition.value))}
🧹 Nitpick comments (7)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

212-214: Avoid duplicating attribute labels; import from schema for consistency

This file defines a local ATTRIBUTE_LABELS that diverges from the shared schema. Use the centralized constant to keep labels consistent across UI.

Apply this diff:

--- a/apps/web/ui/partners/rewards/rewards-logic.tsx
+++ b/apps/web/ui/partners/rewards/rewards-logic.tsx
@@
-import {
-  CONDITION_ATTRIBUTES,
-  CONDITION_CUSTOMER_ATTRIBUTES,
-  CONDITION_OPERATOR_LABELS,
-  CONDITION_OPERATORS,
-  CONDITION_SALE_ATTRIBUTES,
-} from "@/lib/zod/schemas/rewards";
+import {
+  ATTRIBUTE_LABELS,
+  CONDITION_ATTRIBUTES,
+  CONDITION_CUSTOMER_ATTRIBUTES,
+  CONDITION_OPERATOR_LABELS,
+  CONDITION_OPERATORS,
+  CONDITION_SALE_ATTRIBUTES,
+} from "@/lib/zod/schemas/rewards";
@@
-const ATTRIBUTE_LABELS = {
-  productId: "Product ID",
-};
+// Use shared ATTRIBUTE_LABELS from schema

Optionally, if you want “Product ID” title-casing here, consider updating the shared constant to match the product copy guidelines.

Also applies to: 325-329


680-715: AmountInput: derive effective type from parent for correct $/USD adorners

If a modifier doesn’t set its type, the input won’t show the dollar prefix/suffix even when the parent type is “flat”. Watch the parent type and derive an effective type.

Apply this diff:

 function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) {
   const { watch, register } = useAddEditRewardForm();
-  const type = watch(`${modifierKey}.type`);
+  const type = watch(`${modifierKey}.type`);
+  const parentType = watch("type");
+  const effectiveType = (type ?? parentType) as RewardStructure;
@@
-      {type === "flat" && (
+      {effectiveType === "flat" && (
         <span className="absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400">
           $
         </span>
       )}
@@
-          type === "flat" ? "pl-4 pr-12" : "pr-7",
+          effectiveType === "flat" ? "pl-4 pr-12" : "pr-7",
         )}
@@
-        {type === "flat" ? "USD" : "%"}
+        {effectiveType === "flat" ? "USD" : "%"}
apps/web/lib/api/sales/construct-reward-amount.ts (1)

5-12: Range logic and formatting look solid; minor nit on cents precision

The modifiers-match logic is correct, and currency formatting via formatCurrency(min/100) is consistent. One minor nit: ensure upstream callers always pass integer cents for flat amounts to avoid floating precision issues (your UI changes mostly do this; consider Math.round there).

Also applies to: 25-41, 44-49

apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)

74-86: Duration text: prefer explicit handling for undefined to avoid blank states

If maxDuration is undefined, durationText becomes empty even for sale events. Consider aligning with the rest of the UI by treating undefined like 0 (“one time”) or omitting the badge intentionally.

Proposed tweak:

  • Decide a consistent fallback (e.g., undefined => no suffix) or reuse the same “resolvedMaxDuration” pattern as in rewards-logic.tsx for parity across surfaces.

Also applies to: 90-93

apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)

282-287: Type badge safety: guard capitalize(type) in case type is momentarily unset

While defaults usually set type, guarding the badge avoids rare crashes during initial form hydration.

Apply this diff:

-                      <InlineBadgePopover text={capitalize(type)}>
+                      <InlineBadgePopover text={type ? capitalize(type) : "Type"}>

295-300: Minor: round cents to avoid floating artifacts

When converting dollars → cents, use Math.round to prevent values like 10.009999... from leaking through.

Apply this diff:

-                            amount: type === "flat" ? amount * 100 : amount,
+                            amount: type === "flat" ? Math.round(amount * 100) : amount,

320-341: Ensure Infinity selection is round-trippable and typed

Converting "Infinity" via Number(value) works, but selectedValue should also handle Infinity explicitly for clarity.

Apply this diff:

-                          selectedValue={maxDuration?.toString()}
-                          onSelect={(value) =>
-                            setValue("maxDuration", Number(value), {
+                          selectedValue={
+                            maxDuration === Infinity ? "Infinity" : maxDuration?.toString()
+                          }
+                          onSelect={(value) =>
+                            setValue("maxDuration", value === "Infinity" ? Infinity : Number(value), {
                               shouldDirty: true,
                             })
                           }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 56b12e0 and c654176.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx (1 hunks)
  • apps/web/app/api/og/program/route.tsx (1 hunks)
  • apps/web/lib/api/sales/construct-reward-amount.ts (1 hunks)
  • apps/web/lib/partners/determine-partner-reward.ts (2 hunks)
  • apps/web/ui/partners/format-discount-description.ts (1 hunks)
  • apps/web/ui/partners/program-reward-description.tsx (2 hunks)
  • apps/web/ui/partners/program-reward-list.tsx (2 hunks)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (6 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (11 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/web/ui/partners/program-reward-description.tsx
  • apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx
  • apps/web/lib/partners/determine-partner-reward.ts
  • apps/web/ui/partners/format-discount-description.ts
  • apps/web/ui/partners/program-reward-list.tsx
  • apps/web/app/api/og/program/route.tsx
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/program-reward-modifiers-tooltip.tsx
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
  • apps/web/ui/partners/rewards/rewards-logic.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧬 Code Graph Analysis (4)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)
packages/ui/src/tooltip.tsx (1)
  • InfoTooltip (193-199)
apps/web/lib/zod/schemas/rewards.ts (3)
  • rewardConditionsArraySchema (74-76)
  • ATTRIBUTE_LABELS (36-39)
  • CONDITION_OPERATOR_LABELS (41-48)
apps/web/lib/types.ts (1)
  • RewardProps (474-474)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
apps/web/lib/api/sales/construct-reward-amount.ts (3)
apps/web/lib/types.ts (1)
  • RewardProps (474-474)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (74-76)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (74-76)
packages/prisma/client.ts (1)
  • RewardStructure (19-19)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
  • REWARD_TYPES (43-52)
apps/web/ui/partners/rewards/rewards-logic.tsx (6)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
  • RewardIconSquare (3-7)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
  • InlineBadgePopover (35-78)
  • InlineBadgePopoverMenu (87-181)
  • InlineBadgePopoverInputs (208-282)
  • InlineBadgePopoverInput (183-206)
apps/web/lib/zod/schemas/rewards.ts (4)
  • ATTRIBUTE_LABELS (36-39)
  • CONDITION_ATTRIBUTES (22-25)
  • CONDITION_OPERATOR_LABELS (41-48)
  • CONDITION_OPERATORS (27-34)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (75-75)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
⏰ 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

@devkiran devkiran requested a review from steven-tey August 20, 2025 02:17
Copy link
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.

Actionable comments posted: 2

♻️ Duplicate comments (2)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

269-274: Guard capitalize() when entity is unset to avoid runtime error
Calling capitalize(condition.entity) throws when entity is undefined for a new/blank condition.

Apply this diff:

-              text={capitalize(condition.entity) || "Select item"}
+              text={condition.entity ? capitalize(condition.entity) : "Select item"}

277-285: Reset dependent fields when entity changes to avoid invalid state combos
When changing entity, clear attribute/operator/value/label; different entities have different attributes. This aligns with prior learnings.

Apply this diff:

-                onSelect={(value) =>
-                  setValue(
-                    conditionKey,
-                    { entity: value as keyof typeof ENTITIES },
-                    {
-                      shouldDirty: true,
-                    },
-                  )
-                }
+                onSelect={(value) => {
+                  const next = value as keyof typeof ENTITIES;
+                  setValue(`${conditionKey}.entity`, next, { shouldDirty: true });
+                  setValue(`${conditionKey}.attribute`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.operator`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.value`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.label`, undefined, { shouldDirty: true });
+                }}
🧹 Nitpick comments (4)
apps/web/ui/partners/rewards/rewards-logic.tsx (4)

383-424: Mark form as dirty when changing country values
setValue calls here don’t pass options; add { shouldDirty: true } so validation/submit states reflect edits.

Apply this diff:

-                          onSelect={(value) => {
-                            setValue(conditionKey, {
-                              ...condition,
-                              value: isArrayValue
-                                ? Array.isArray(condition.value)
-                                  ? (condition.value as string[]).includes(
-                                      value,
-                                    )
-                                    ? (condition.value.filter(
-                                        (v) => v !== value,
-                                      ) as string[])
-                                    : ([...condition.value, value] as string[])
-                                  : [value]
-                                : value,
-                            });
-                          }}
+                          onSelect={(value) => {
+                            setValue(
+                              conditionKey,
+                              {
+                                ...condition,
+                                value: isArrayValue
+                                  ? Array.isArray(condition.value)
+                                    ? (condition.value as string[]).includes(value)
+                                      ? (condition.value.filter((v) => v !== value) as string[])
+                                      : ([...condition.value, value] as string[])
+                                    : [value]
+                                  : value,
+                              },
+                              { shouldDirty: true },
+                            );
+                          }}

426-441: Also mark dirty when editing array values
Same rationale for InlineBadgePopoverInputs.

Apply this diff:

-                          onChange={(values) => {
-                            setValue(conditionKey, {
-                              ...condition,
-                              value: values,
-                            });
-                          }}
+                          onChange={(values) => {
+                            setValue(
+                              conditionKey,
+                              { ...condition, value: values },
+                              { shouldDirty: true },
+                            );
+                          }}

496-510: Don’t flag product label as invalid unless the editor is expanded
Showing invalid style when the “Shown as” editor is collapsed can be noisy.

Apply this diff:

-                <InlineBadgePopover
-                  text={condition.label || "Product name"}
-                  invalid={!condition.label}
-                >
+                <InlineBadgePopover
+                  text={condition.label || "Product name"}
+                  invalid={displayProductLabel && !condition.label}
+                >

681-716: Use parent fallback for AmountInput type to keep suffix/validation accurate
If the modifier’s type is unset, the input suffix and max validation may be wrong. Compute an effective type using the parent reward’s type.

Apply this diff:

-  const type = watch(`${modifierKey}.type`);
+  const modifierType = watch(`${modifierKey}.type`);
+  const parentType = watch("type");
+  const type = (modifierType ?? parentType ?? "flat") as RewardStructure;
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between c654176 and d447afa.

📒 Files selected for processing (1)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (11 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/rewards/rewards-logic.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (8)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
  • RewardIconSquare (3-7)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
  • InlineBadgePopover (35-78)
  • InlineBadgePopoverMenu (87-181)
  • InlineBadgePopoverInputs (208-282)
  • InlineBadgePopoverInput (183-206)
apps/web/lib/zod/schemas/rewards.ts (4)
  • ATTRIBUTE_LABELS (36-39)
  • CONDITION_ATTRIBUTES (22-25)
  • CONDITION_OPERATOR_LABELS (41-48)
  • CONDITION_OPERATORS (27-34)
packages/ui/src/icons/nucleo/chevron-right.tsx (1)
  • ChevronRight (3-24)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (75-75)
packages/prisma/client.ts (1)
  • RewardStructure (19-19)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-50)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
⏰ 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/ui/partners/rewards/rewards-logic.tsx (6)

43-52: REWARD_TYPES constant reads well and is reusable

Exporting a shared REWARD_TYPES list keeps UI and logic consistent.


109-111: Seeding new modifiers with parent type/maxDuration is a good call

This ensures nested editors start with sensible fallbacks and keeps formatting/validation stable.


164-174: Delegating per-condition remove via onRemove is a cleaner responsibility split

Nice refactor. It simplifies ConditionLogic.


262-263: Local UI state for product label expansion

The state handling is straightforward and scoped appropriately.


286-293: Entity options filtered by event looks correct

Filtering against EVENT_ENTITIES prevents invalid choices.


322-331: Attribute and operator flows are solid

  • Selecting attribute resets other fields — good for consistency.
  • Operator change toggles value shape (array vs string) — correct and defensive.

Also applies to: 341-358

Copy link
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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)

107-109: Defaulting modifier maxDuration: handle undefined as well as null

When loading older rewards or partially-filled modifier forms, maxDuration may be undefined. Treating both null and undefined as Infinity makes the UI consistent for “lifetime”.

Apply this diff:

-        amount: m.type === "flat" ? m.amount / 100 : m.amount,
-        maxDuration: m.maxDuration === null ? Infinity : m.maxDuration,
+        amount: m.type === "flat" ? m.amount / 100 : m.amount,
+        maxDuration: m.maxDuration == null ? Infinity : m.maxDuration,

Note: Using == here is intentional to match null or undefined without affecting 0.


191-193: Normalize modifiers before validation: add rounding and undefined handling

  • Rounding: When converting flat amounts back to cents, prefer Math.round to avoid floating-point drift (e.g., 12.34 * 100 → 1233.999…).
  • Undefined handling: Convert undefined maxDuration to null so the schema doesn’t see undefined for a nullable field.
-            amount: m.type === "flat" ? m.amount * 100 : m.amount,
-            maxDuration: m.maxDuration === Infinity ? null : m.maxDuration,
+            amount: m.type === "flat" ? Math.round(m.amount * 100) : m.amount,
+            maxDuration:
+              m.maxDuration == null
+                ? null
+                : m.maxDuration === Infinity
+                  ? null
+                  : m.maxDuration,

Note: Consider making the same Math.round change for the top-level amount conversion for consistency.


283-288: Prefer casting to the form’s own type rather than the Prisma enum

This avoids coupling the UI form to the Prisma enum and relies solely on the zod-derived type that the form actually uses.

Apply this diff:

-                            setValue("type", value as RewardStructure, {
+                            setValue("type", value as FormData["type"], {
                               shouldDirty: true,
                             })

Additionally, this continues to work after the type-only import in Line 21.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d447afa and 682801e.

📒 Files selected for processing (1)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (6 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/rewards/add-edit-reward-sheet.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (74-76)
packages/prisma/client.ts (1)
  • RewardStructure (19-19)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
  • REWARD_TYPES (43-52)
⏰ 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 (2)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)

53-53: Good move: centralize reward types via REWARD_TYPES

Sourcing options from a single shared constant avoids drift between UI and logic.


296-301: constructRewardAmount signature updated and call sites migrated

The function in apps/web/lib/api/sales/construct-reward-amount.ts now accepts a single object

export const constructRewardAmount = (
  reward: Pick<RewardProps, "amount" | "type" | "maxDuration" | "modifiers">,
) => {  }

All 12+ call sites (e.g. in add-edit-reward-sheet.tsx, format-discount-description.ts, program-reward-list.tsx, etc.) now pass exactly one object with amount, type, maxDuration (and implicitly modifiers), and none use the old multi-arg form. No further changes needed.

@steven-tey
Copy link
Collaborator

/bug0 run

Copy link
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.

Actionable comments posted: 1

♻️ Duplicate comments (3)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

276-285: Reset dependent fields when entity changes to avoid invalid states

Changing entity should clear attribute, operator, value, and label to prevent incompatible combinations.

-                onSelect={(value) =>
-                  setValue(
-                    conditionKey,
-                    { entity: value as keyof typeof ENTITIES },
-                    {
-                      shouldDirty: true,
-                    },
-                  )
-                }
+                onSelect={(value) => {
+                  const next = value as keyof typeof ENTITIES;
+                  setValue(`${conditionKey}.entity`, next, { shouldDirty: true });
+                  setValue(`${conditionKey}.attribute`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.operator`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.value`, undefined, { shouldDirty: true });
+                  setValue(`${conditionKey}.label`, undefined, { shouldDirty: true });
+                }}

590-594: Fix crashes and incorrect amount formatting when type/maxDuration are unset

  • capitalize(displayType) can throw when displayType is undefined.
  • Flat amounts are passed in dollars to constructRewardAmount when displayType is unset, causing $10 to render as $0.10.
  • Duration text can render “NaN months” when unset.

Compute effectiveType and resolvedMaxDuration once and use them consistently.

   // Use parent values as fallbacks if modifier doesn't have type or maxDuration
-  const displayType = type || parentType;
-  const displayMaxDuration =
-    maxDuration !== undefined ? maxDuration : parentMaxDuration;
+  const displayType = type || parentType;
+  const displayMaxDuration =
+    maxDuration !== undefined ? maxDuration : parentMaxDuration;
+  const effectiveType = (displayType ?? "flat") as RewardStructure;
+  const typeBadgeLabel = displayType ? capitalize(displayType) : "Type";
+  const resolvedMaxDuration =
+    displayMaxDuration === undefined ? 0 : displayMaxDuration;
@@
-      {event === "sale" && (
+      {event === "sale" && (
         <>
           a{" "}
-          <InlineBadgePopover text={capitalize(displayType)}>
+          <InlineBadgePopover text={typeBadgeLabel}>
             <InlineBadgePopoverMenu
               selectedValue={type}
               onSelect={(value) =>
                 setValue(`${modifierKey}.type`, value as RewardStructure, {
                   shouldDirty: true,
                 })
               }
               items={REWARD_TYPES}
             />
           </InlineBadgePopover>{" "}
-          {displayType === "percentage" && "of "}
+          {effectiveType === "percentage" && "of "}
         </>
       )}
@@
-          amount
-            ? constructRewardAmount({
-                amount: displayType === "flat" ? amount * 100 : amount,
-                type: displayType,
-                maxDuration: displayMaxDuration,
-              })
+          amount
+            ? constructRewardAmount({
+                amount: effectiveType === "flat" ? Math.round(amount * 100) : amount,
+                type: effectiveType,
+                maxDuration: resolvedMaxDuration,
+              })
             : "amount"
@@
-          <InlineBadgePopover
-            text={
-              displayMaxDuration === 0
-                ? "one time"
-                : displayMaxDuration === Infinity
-                  ? "for the customer's lifetime"
-                  : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}`
-            }
-          >
+          <InlineBadgePopover
+            text={
+              resolvedMaxDuration === 0
+                ? "one time"
+                : resolvedMaxDuration === Infinity
+                  ? "for the customer's lifetime"
+                  : `for ${resolvedMaxDuration} ${pluralize("month", Number(resolvedMaxDuration))}`
+            }
+          >
             <InlineBadgePopoverMenu
-              selectedValue={
-                displayMaxDuration === Infinity
-                  ? "Infinity"
-                  : displayMaxDuration?.toString()
-              }
+              selectedValue={
+                resolvedMaxDuration === Infinity ? "Infinity" : resolvedMaxDuration.toString()
+              }
               onSelect={(value) =>
                 setValue(
                   `${modifierKey}.maxDuration`,
                   value === "Infinity" ? Infinity : Number(value),
                   {
                     shouldDirty: true,
                   },
                 )
               }
               items={[
                 {
                   text: "one time",
                   value: "0",
                 },
                 ...RECURRING_MAX_DURATIONS.filter(
                   (v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts)
                 ).map((v) => ({
                   text: `for ${v} ${pluralize("month", Number(v))}`,
                   value: v.toString(),
                 })),
                 {
                   text: "for the customer's lifetime",
                   value: "Infinity",
                 },
               ]}
             />
           </InlineBadgePopover>

Also applies to: 598-614, 618-623, 634-673

apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)

21-21: Use type-only Prisma imports in client components

This is a client module. Import Prisma types with import type to avoid bundling server code.

-import { EventType, RewardStructure } from "@dub/prisma/client";
+import type { EventType, RewardStructure } from "@dub/prisma/client";
🧹 Nitpick comments (7)
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx (1)

427-437: Avoid per-option selected; control the select via value/defaultValue

Using selected on <option> conflicts with react-hook-form’s control pattern and can lead to unexpected defaults. Set the select’s defaultValue (or a controlled value) and remove the per-option selected.

Apply this diff:

@@
-              <select
+              <select
                 {...register("maxDuration", {
                   setValueAs: (v) => {
                     if (v === "" || v === null) {
                       return null;
                     }
 
                     return parseInt(v);
                   },
                 })}
+                defaultValue={12}
                 className="mt-2 block w-full rounded-md border border-neutral-300 bg-white py-2 pl-3 pr-10 text-sm text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500"
               >
-                {RECURRING_MAX_DURATIONS.filter(
+                {RECURRING_MAX_DURATIONS.filter(
                   (v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts)
                 ).map((duration) => (
                   <option
                     key={duration}
                     value={duration}
-                    selected={duration === 12}
                   >
                     {duration} {duration === 1 ? "month" : "months"}
                   </option>
                 ))}
                 <option value="">Lifetime</option>
               </select>

Also applies to: 415-426

apps/web/ui/partners/rewards/rewards-logic.tsx (2)

16-16: Use type-only Prisma imports in client components

This is a client module. Import Prisma types with import type to avoid pulling runtime code into the bundle.

-import { EventType, RewardStructure } from "@dub/prisma/client";
+import type { EventType, RewardStructure } from "@dub/prisma/client";

688-696: Normalize type in AmountInput to stabilize UI and validation

Before the effect syncs parent type, displayType can be undefined, producing wrong padding/units and missing max validation. Compute effectiveType and use it.

-  const displayType = type || parentType;
+  const displayType = type || parentType;
+  const effectiveType = (displayType ?? "flat") as RewardStructure;
@@
-      {displayType === "flat" && (
+      {effectiveType === "flat" && (
@@
-          displayType === "flat" ? "pl-4 pr-12" : "pr-7",
+          effectiveType === "flat" ? "pl-4 pr-12" : "pr-7",
@@
-          max: displayType === "percentage" ? 100 : undefined,
+          max: effectiveType === "percentage" ? 100 : undefined,
@@
-        {displayType === "flat" ? "USD" : "%"}
+        {effectiveType === "flat" ? "USD" : "%"}

Also applies to: 699-699, 707-707, 714-714, 727-727

apps/web/lib/api/sales/construct-reward-amount.ts (2)

9-13: Avoid parsing empty modifier arrays

reward.modifiers being an empty array is truthy; parsing then failing on .min(1) is unnecessary overhead. Short-circuit on length.

-  if (reward.modifiers) {
+  if (Array.isArray(reward.modifiers) && reward.modifiers.length > 0) {
     const parsedModifiers = rewardConditionsArraySchema.safeParse(
       reward.modifiers,
     );

47-52: Minor: fix comment typo

“timelines” should be “timelines”/“timeline” singular. Also consider clarifying that “maxDuration/type doesn’t match”.

-  // 2. type AND timelines doesn't match the primary reward
+  // 2. type AND timeline don't match the primary reward
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)

221-224: Prefer direct Infinity check for readability

Infinity === Number(data.maxDuration) works but is opaque. A direct comparison is clearer.

-      maxDuration:
-        Infinity === Number(data.maxDuration) ? null : data.maxDuration,
+      maxDuration: data.maxDuration === Infinity ? null : data.maxDuration,

346-348: Deduplicate “exclude 0 and 1 month” logic

This filter appears in multiple components. Consider a shared helper, e.g., getRecurringDurationOptions({ excludeOneTime: true, excludeOneMonth: true }) to keep UI consistent.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 682801e and 1e458c5.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx (1 hunks)
  • apps/web/lib/api/sales/construct-reward-amount.ts (1 hunks)
  • apps/web/ui/partners/add-edit-discount-sheet.tsx (1 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (7 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (11 hunks)
✅ Files skipped from review due to trivial changes (1)
  • apps/web/ui/partners/add-edit-discount-sheet.tsx
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Learnt from: TWilson023
PR: dubinc/dub#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.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#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/ui/partners/rewards/rewards-logic.tsx
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.

Applied to files:

  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧬 Code Graph Analysis (4)
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx (1)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
apps/web/ui/partners/rewards/rewards-logic.tsx (7)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
  • RewardIconSquare (3-7)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (5)
  • InlineBadgePopover (35-78)
  • InlineBadgePopoverMenu (87-176)
  • InlineBadgePopoverInputs (203-277)
  • InlineBadgePopoverInput (178-201)
  • InlineBadgePopoverContext (27-33)
apps/web/lib/zod/schemas/rewards.ts (4)
  • ATTRIBUTE_LABELS (36-39)
  • CONDITION_ATTRIBUTES (22-25)
  • CONDITION_OPERATOR_LABELS (41-48)
  • CONDITION_OPERATORS (27-34)
packages/ui/src/icons/nucleo/chevron-right.tsx (1)
  • ChevronRight (3-24)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (75-75)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-53)
apps/web/lib/zod/schemas/misc.ts (1)
  • RECURRING_MAX_DURATIONS (6-6)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (74-76)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
  • REWARD_TYPES (43-52)
apps/web/lib/api/sales/construct-reward-amount.ts (3)
apps/web/lib/types.ts (1)
  • RewardProps (474-474)
apps/web/lib/zod/schemas/rewards.ts (1)
  • rewardConditionsArraySchema (74-76)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
⏰ 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 (3)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

43-52: LGTM: Centralized reward type menu

Exporting REWARD_TYPES here and reusing across panes keeps the UI consistent and reduces duplication.


109-111: LGTM: Seed new modifier with parent type/duration

Initializing each modifier with parent type and maxDuration aligns downstream formatting and avoids undefined state on first render.

apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)

197-207: LGTM: Normalize modifier amounts and lifetime mapping

Converting flat amounts dollars→cents and mapping Infinitynull for persistence aligns with schema expectations.

@steven-tey steven-tey merged commit ded189d into main Aug 20, 2025
8 checks passed
@steven-tey steven-tey deleted the mix-match-reward-structure branch August 20, 2025 19:05
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.

4 participants