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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Aug 22, 2025

Summary by CodeRabbit

  • New Features

    • Add "partner" as a condition entity with partner metrics and productId for sales; pick highest‑amount matching group; new numeric comparison operators.
  • UI

    • Numeric-aware inputs/formatting, currency scaling on load/save, updated labels/icons, improved modifier tooltip and inline badge, slightly wider dropdown, lowercase reward amount styling.
  • Chores

    • Invalidate default partner-group pages after reward updates; adjust post-submit refresh/notification flow for group settings.
  • Behavior

    • Reward summaries now display "Up to …" using the maximum value.
  • Tests

    • Expanded tests for numeric operators, edge cases, and multi-group selection.

@vercel
Copy link
Contributor

vercel bot commented Aug 22, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Aug 25, 2025 0:15am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 22, 2025

Walkthrough

Aggregates partner link metrics into reward evaluation, adds "partner" as a condition entity with numeric operators and UI/currency handling, updates reward display to show "Up to" max, extends tests for numeric operators, and conditionally revalidates default partner-group pages after reward updates.

Changes

Cohort / File(s) Summary
Reward schema updates
apps/web/lib/zod/schemas/rewards.ts
Adds partner to CONDITION_ENTITIES, introduces CONDITION_PARTNER_ATTRIBUTES and CONDITION_SALE_ATTRIBUTES, adds ENTITY_ATTRIBUTE_TYPES, new numeric operators (greater_than, greater_than_or_equal, less_than, less_than_or_equal), operator categories, updated labels/operator labels, and extends rewardContextSchema with partner metrics.
Partner reward determination
apps/web/lib/partners/determine-partner-reward.ts
Adds ProgramEnrollmentWithReward type, fetches rewardId and links, aggregates totalClicks/totalLeads/totalConversions/totalSaleAmount and totalCommissions into context.partner, and evaluates modifiers using the augmented context to possibly override reward fields.
Condition evaluation logic & tests
apps/web/lib/partners/evaluate-reward-conditions.ts
apps/web/tests/rewards/reward-conditions.test.ts
Adds partner to evaluation context, accumulates all matching groups, implements numeric comparison operators with numeric coercion (greater/less ± equal), returns highest-amount matching group; tests expanded extensively for numeric operators, coercion, edge cases, AND/OR and multi-group selection.
UI: rewards logic, inputs & sheets
apps/web/ui/partners/rewards/rewards-logic.tsx
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
Uses ENTITY_ATTRIBUTE_TYPES to select attribute types/operators; introduces AmountInput/ResultAmountInput, numeric-aware formatting/validation, scales currency condition values on load/save (divide/multiply by 100), and adjusts form parsing/validation. Also logs parse errors on submit.
Program modifier tooltip UI
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
Adds REWARD_MODIFIER_LABELS, makes modifier rendering operator- and currency-aware, maps country/product labels, adjusts wording/formatting, and propagates modifier.operator into RewardItem.
Inline badge dropdown tweak
apps/web/ui/partners/rewards/inline-badge-popover.tsx
Minor CSS change: Command.List max width increased from max-w-48 to max-w-52.
Reward amount display change
apps/web/lib/api/sales/construct-reward-amount.ts
When modifiers produce a range, display now shows Up to {max} (uses max only) instead of a min–max range.
Program reward description styling
apps/web/ui/partners/program-reward-description.tsx
Adds lowercase CSS class to the reward amount text.
Update reward action cache behavior
apps/web/lib/actions/partners/update-reward.ts
Detects default partner group (slug === "default"), removes debug logging, and conditionally injects revalidatePath calls for /partners.dub.co/${program.slug} and /partners.dub.co/${program.slug}/apply into the Promise.allSettled revalidation array when the group is default.
Misc: exports & error logging
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
Exposes/imports ENTITY_ATTRIBUTE_TYPES and logs parse errors (console.log("parse error", error)) in submit error path.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant UI
  participant Server as PartnerService
  participant DB as Prisma
  participant Eval as EvaluateConditions

  User->>UI: request partner reward
  UI->>Server: getPartnerReward(partnerId, programId)
  Server->>DB: query ProgramEnrollment (+ rewardId, links)
  DB-->>Server: enrollment + links + reward
  Server->>Server: aggregate partner metrics (clicks/leads/conversions/saleAmount, totalCommissions)
  Server->>Eval: evaluate(modifiers, context{partner metrics})
  Eval-->>Server: best matching condition group or null
  alt condition matched
    Server->>Server: override reward (amount/type/maxDuration)
  end
  Server-->>UI: final reward or null
Loading
sequenceDiagram
  autonumber
  actor Admin
  participant Action as updateRewardAction
  participant Cache as RevalidatePath

  Admin->>Action: update reward
  Action->>Action: detect isDefaultGroup (slug == "default")
  par persist changes
    Action-->>Action: Promise.allSettled([...common tasks...])
  and conditional cache invalidation
    alt isDefaultGroup
      Action->>Cache: revalidate /partners.dub.co/{slug}
      Action->>Cache: revalidate /partners.dub.co/{slug}/apply
    else Not default
      Action-->>Action: skip default page revalidation
    end
  end
  Action-->>Admin: done
Loading

Estimated code review effort

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

Possibly related PRs

Poem

I hop through metrics, tally each click,
I count conversions, leads, and sale tick.
Rules now compare numbers true and bright,
Default burrows cleared when changes light.
A carrot reward—up to maximum delight! 🥕🐇

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 reward-partner-attribute

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

Caution

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

⚠️ Outside diff range comments (4)
apps/web/lib/zod/schemas/rewards.ts (1)

38-49: Numeric operators added — add schema-level type guards to prevent invalid combinations

Right now, any attribute can pair with any operator. At runtime, non‑numeric attributes combined with numeric operators just fail the comparison, but we can catch these earlier with Zod refinements for better error messages and data hygiene.

  • Enforce: numeric operators require number (or array of numbers where applicable).
  • Enforce: in/not_in require arrays.
  • Optionally: equals_to/not_equals can allow either strict type match or numeric coercion only for numeric attributes (see UI comment where we filter operators per attribute).

Suggested diff to add a targeted refinement (keeps current union while validating operator/value compatibility):

 export const rewardConditionSchema = z.object({
   entity: z.enum(CONDITION_ENTITIES),
   attribute: z.enum(CONDITION_ATTRIBUTES),
   operator: z.enum(CONDITION_OPERATORS),
   value: z.union([
     z.string(),
     z.number(),
     z.array(z.string()),
     z.array(z.number()),
   ]),
   label: z
     .string()
     .nullish()
     .describe("Product name used for display purposes in the UI."),
-});
+}).superRefine((cond, ctx) => {
+  const numericAttributes = new Set(CONDITION_PARTNER_ATTRIBUTES);
+  const numericOps = new Set([
+    "greater_than",
+    "greater_than_or_equal",
+    "less_than",
+    "less_than_or_equal",
+  ]);
+  const listOps = new Set(["in", "not_in"]);
+
+  if (numericOps.has(cond.operator)) {
+    if (!numericAttributes.has(cond.attribute)) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Numeric operators are only valid for partner numeric attributes.",
+        path: ["operator"],
+      });
+    }
+    if (typeof cond.value !== "number") {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Value must be a number for numeric operators.",
+        path: ["value"],
+      });
+    }
+  }
+
+  if (listOps.has(cond.operator) && !Array.isArray(cond.value)) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Value must be an array for 'in'/'not_in' operators.",
+      path: ["value"],
+    });
+  }
+});
apps/web/ui/partners/rewards/rewards-logic.tsx (3)

286-294: Reset dependent fields when entity changes to avoid invalid state

Per prior learning, switching the entity should reset attribute, operator, and value to prevent stale, incompatible combinations.

-                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,
+                      label: undefined,
+                    },
+                    { shouldDirty: true },
+                  )
+                }

316-339: Also reset operator/value when attribute changes

Changing the attribute should clear the operator and value, since valid operator sets differ between string and numeric attributes.

-                    onSelect={(value) =>
-                      setValue(
-                        conditionKey,
-                        {
-                          entity: condition.entity,
-                          attribute:
-                            value as (typeof CONDITION_ATTRIBUTES)[number],
-                        },
-                        {
-                          shouldDirty: true,
-                        },
-                      )
-                    }
+                    onSelect={(value) =>
+                      setValue(
+                        conditionKey,
+                        {
+                          entity: condition.entity,
+                          attribute: value as (typeof CONDITION_ATTRIBUTES)[number],
+                          operator: undefined,
+                          value: undefined,
+                          label: condition.attribute === "productId" ? condition.label : undefined,
+                        },
+                        { shouldDirty: true },
+                      )
+                    }

349-385: Filter operator list by attribute type; coerce value input accordingly

Right now, all operators are shown regardless of attribute. Restrict operators to reduce invalid combos and friction:

  • country/productId: string operators only (equals_to, not_equals, starts_with, ends_with, in, not_in).
  • partner metrics: numeric operators only (greater_than, greater_than_or_equal, less_than, less_than_or_equal, equals_to, not_equals if you want exact matches).

Also, when a numeric operator is chosen, ensure the input is stored as a number.

                   <InlineBadgePopoverMenu
                     selectedValue={condition.operator}
-                    onSelect={(value) =>
+                    onSelect={(value) =>
                       setValue(
                         conditionKey,
                         {
                           ...condition,
                           operator:
                             value as (typeof CONDITION_OPERATORS)[number],
                           // Update value to array / string / number if needed
                           ...(["in", "not_in"].includes(value)
                             ? !Array.isArray(condition.value)
                               ? { value: [] }
                               : null
                             : [
                                   "greater_than",
                                   "greater_than_or_equal",
                                   "less_than",
                                   "less_than_or_equal",
                                 ].includes(value)
                               ? typeof condition.value !== "number"
                                 ? { value: undefined }
                                 : null
                               : typeof condition.value !== "string"
                                 ? { value: "" }
                                 : null),
                         },
                         {
                           shouldDirty: true,
                         },
                       )
                     }
-                    items={CONDITION_OPERATORS.map((operator) => ({
-                      text: CONDITION_OPERATOR_LABELS[operator],
-                      value: operator,
-                    }))}
+                    items={((
+                      attr: (typeof CONDITION_ATTRIBUTES)[number] | undefined,
+                    ) => {
+                      const isNumericAttr =
+                        !!attr &&
+                        (CONDITION_PARTNER_ATTRIBUTES as readonly string[]).includes(attr);
+                      const NUMERIC_OPS = [
+                        "greater_than",
+                        "greater_than_or_equal",
+                        "less_than",
+                        "less_than_or_equal",
+                        "equals_to",
+                        "not_equals",
+                      ] as const;
+                      const STRING_OPS = [
+                        "equals_to",
+                        "not_equals",
+                        "starts_with",
+                        "ends_with",
+                        "in",
+                        "not_in",
+                      ] as const;
+                      const ops = isNumericAttr ? NUMERIC_OPS : STRING_OPS;
+                      return ops.map((op) => ({
+                        text: CONDITION_OPERATOR_LABELS[op],
+                        value: op,
+                      }));
+                    })(condition.attribute)}
                   />

And below, when rendering the value input (still in this block), add numeric input handling when a numeric operator is selected:

-                      ) : (
+                      ) : (
                         // String input
-                        <InlineBadgePopoverInput
-                          {...register(`${conditionKey}.value`, {
-                            required: true,
-                          })}
-                        />
+                        <InlineBadgePopoverInput
+                          type={
+                            ["greater_than","greater_than_or_equal","less_than","less_than_or_equal"].includes(
+                              condition.operator as string,
+                            )
+                              ? "number"
+                              : "text"
+                          }
+                          {...register(`${conditionKey}.value`, {
+                            required: true,
+                            setValueAs: (v: string) =>
+                              ["greater_than","greater_than_or_equal","less_than","less_than_or_equal"].includes(
+                                condition.operator as string,
+                              )
+                                ? v === "" ? undefined : +v
+                                : v,
+                          })}
+                        />
                       )}
🧹 Nitpick comments (6)
apps/web/lib/zod/schemas/rewards.ts (2)

90-96: Reward modifier amount is constrained to integers — verify product requirements

amount is z.number().int().min(0). If you plan to allow fractional percentages (e.g., 12.5%) or non-integer flat amounts, this will reject valid inputs. The UI currently permits decimals. If decimals should be allowed, consider removing .int():

-  amount: z.number().int().min(0),
+  amount: z.number().min(0),

Would you like me to scan the repo for usages/tests that assume integer amounts before we change this?


159-181: Partner context schema — consider non-negative constraint and defaults

All partner metrics are naturally non-negative. You could add .nonnegative() to each for extra safety. Not required, just a mild guardrail.

-      totalClicks: z.number().nullish(),
+      totalClicks: z.number().nonnegative().nullish(),

Repeat for the other metrics.

apps/web/lib/partners/evaluate-reward-conditions.ts (2)

19-20: Return the best match without sorting; add deterministic tie-breaker

Collecting and then sorting is correct but O(n log n) and leaves tie-breakers to engine sort stability. Prefer a single pass reduce with a deterministic fallback (first occurrence wins on ties).

-  const matchingConditions: RewardConditions[] = [];
+  const matchingConditions: RewardConditions[] = [];
   ...
   if (conditionsMet) {
     matchingConditions.push(conditionGroup);
   }
  }
-
-  if (matchingConditions.length === 0) {
-    return null;
-  }
-
-  // Find the best matching condition (highest amount)
-  return matchingConditions.sort((a, b) => b.amount - a.amount)[0];
+  if (matchingConditions.length === 0) return null;
+  // Find highest amount; on tie, prefer the earliest matched group
+  let best = matchingConditions[0];
+  for (let i = 1; i < matchingConditions.length; i++) {
+    const g = matchingConditions[i];
+    if (g.amount > best.amount) best = g;
+  }
+  return best;

Also applies to: 52-55, 57-63


107-114: Numeric comparisons — guard against non-finite values to avoid NaN/Infinity surprises

Add a quick finite check so malformed inputs short-circuit to false rather than relying on NaN comparison semantics.

-    case "greater_than":
-      return Number(fieldValue) > Number(condition.value);
+    case "greater_than": {
+      const a = Number(fieldValue);
+      const b = Number(condition.value);
+      if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
+      return a > b;
+    }
     case "greater_than_or_equal":
-      return Number(fieldValue) >= Number(condition.value);
+      {
+        const a = Number(fieldValue);
+        const b = Number(condition.value);
+        if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
+        return a >= b;
+      }
     case "less_than":
-      return Number(fieldValue) < Number(condition.value);
+      {
+        const a = Number(fieldValue);
+        const b = Number(condition.value);
+        if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
+        return a < b;
+      }
     case "less_than_or_equal":
-      return Number(fieldValue) <= Number(condition.value);
+      {
+        const a = Number(fieldValue);
+        const b = Number(condition.value);
+        if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
+        return a <= b;
+      }
apps/web/lib/partners/determine-partner-reward.ts (1)

38-57: Consider pushing aggregation into the DB to avoid fetching all links

For partners with many links, summing in the app can be memory/latency heavy. If feasible, use Prisma aggregation or a dedicated SQL view to compute totals:

  • A groupBy on Link with sum of clicks, leads, conversions, saleAmount filtered by programEnrollmentId.
  • Or maintain a denormalized stats table updated by triggers/jobs.

This makes evaluation cheaper and keeps determinePartnerReward lean.

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

219-243: formatValue: consider treating numeric-like strings as numbers for display

Optional: if a numeric operator was chosen but the value is still a string (e.g., pasted), you can render it as-is without truncation.

-  return truncate(value.toString(), 20);
+  return truncate(value.toString(), 20);

No change required; the operator-based numeric coercion above should keep this minimal.

📜 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 87b22f2 and 8627a39.

📒 Files selected for processing (5)
  • apps/web/lib/actions/partners/update-reward.ts (2 hunks)
  • apps/web/lib/partners/determine-partner-reward.ts (5 hunks)
  • apps/web/lib/partners/evaluate-reward-conditions.ts (5 hunks)
  • apps/web/lib/zod/schemas/rewards.ts (4 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (8 hunks)
🧰 Additional context used
🧠 Learnings (4)
📓 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/lib/partners/determine-partner-reward.ts
  • apps/web/lib/actions/partners/update-reward.ts
  • apps/web/lib/partners/evaluate-reward-conditions.ts
  • apps/web/lib/zod/schemas/rewards.ts
  • apps/web/ui/partners/rewards/rewards-logic.tsx
📚 Learning: 2025-08-14T05:17:51.825Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.825Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.

Applied to files:

  • apps/web/lib/actions/partners/update-reward.ts
📚 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
  • apps/web/ui/partners/rewards/rewards-logic.tsx
🧬 Code graph analysis (2)
apps/web/lib/partners/evaluate-reward-conditions.ts (1)
apps/web/lib/types.ts (1)
  • RewardConditions (502-502)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
apps/web/lib/zod/schemas/rewards.ts (1)
  • CONDITION_PARTNER_ATTRIBUTES (24-30)
⏰ 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 (7)
apps/web/lib/zod/schemas/rewards.ts (1)

18-18: Partner entity, attributes, and labels — looks solid and consistent

Adding "partner" to CONDITION_ENTITIES and introducing CONDITION_PARTNER_ATTRIBUTES with matching ATTRIBUTE_LABELS is consistent with the rest of the schema and the UI usage. No issues spotted.

Also applies to: 24-36, 51-59

apps/web/lib/actions/partners/update-reward.ts (2)

61-66: Default group detection — LGTM

Simple and correct detection using slug === "default". This matches how groups are referenced elsewhere in the codebase.


84-91: Revalidation paths usage confirmed

We’ve verified that the domain segment is used consistently within the partner-related actions:

  • In apps/web/lib/actions/partners/update-reward.ts (lines 87–88), you call
    revalidatePath(`/partners.dub.co/${program.slug}`);
    revalidatePath(`/partners.dub.co/${program.slug}/apply`);
  • In apps/web/lib/actions/partners/update-program.ts (lines 127–129), you call
    revalidatePath(`/partners.dub.co/${program.slug}`);
    revalidatePath(`/partners.dub.co/${program.slug}/apply`);
    revalidatePath(`/partners.dub.co/${program.slug}/apply/success`);

Elsewhere (e.g. apps/web/lib/integrations/segment/install.ts), you use a domain-agnostic path (/${workspace.slug}/settings/...). The use of /partners.dub.co/... in partner actions appears intentional and consistent. If your Next.js routing is configured to serve these under the /partners.dub.co segment, no changes are required here.

apps/web/lib/partners/evaluate-reward-conditions.ts (1)

26-33: Partner entity support — LGTM

Reading from context.partner?.[attribute] slots neatly into the existing pattern. The undefined short-circuit above ensures safe evaluation when metrics aren’t present.

apps/web/lib/partners/determine-partner-reward.ts (1)

110-117: Override semantics — LGTM

Clear precedence: condition can override amount, and optionally type and maxDuration (fallback to existing). Matches PR intent to pick highest-amount matching condition and apply overrides.

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

201-217: Partner entity surfaced in ENTITIES and EVENT_ENTITIES — LGTM

Partner attributes are wired for sale and lead events, with sane exclusions for click. Consistent with backend schema and evaluation.


263-266: Icon mapping includes partner → Users — LGTM

Visual consistency looks good.

@devkiran devkiran marked this pull request as ready for review August 22, 2025 17:10
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: 5

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/inline-badge-popover.tsx (1)

107-123: Avoid mutating the incoming items prop when sorting

items.sort(...) mutates the array received via props, which can cause subtle bugs and unnecessary parent re-renders. Sort a shallow copy instead.

Apply this diff:

-    setSortedItems(
-      items.sort((a, b) => {
+    const next = [...items].sort((a, b) => {
         const aSelected = isMultiSelect
           ? selectedValue?.includes(a.value)
           : selectedValue === a.value;
         const bSelected = isMultiSelect
           ? selectedValue?.includes(b.value)
           : selectedValue === b.value;

         // First sort by whether the items are selected
         if (aSelected !== bSelected) return aSelected ? -1 : 1;

         // Then sort as per the original order of the items
         return items.indexOf(a) - items.indexOf(b);
-      }),
-    );
+      });
+    setSortedItems(next);
🧹 Nitpick comments (6)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)

238-242: Also round top-level flat amounts to cents on submit

Same drift risk exists for the primary amount. Consider Math.round for consistency with modifiers.

Proposed change:

-      amount: type === "flat" ? data.amount * 100 : data.amount,
+      amount: type === "flat" ? Math.round(data.amount * 100) : data.amount,
apps/web/ui/partners/rewards/rewards-logic.tsx (2)

488-493: Numeric inputs for conditions: consider integer-only for count attributes

totalClicks/totalLeads/totalConversions are counts. Allowing decimals isn’t harmful but unnecessary; tightening to integers can prevent odd inputs.

If desired, pass an extra hint to AmountInput (e.g., integerOnly) based on attribute (or attributeType === "number" && attribute in count set) and set step=1 plus parseInt in setValueAs.


761-809: Improve mobile UX for numeric inputs with inputMode/step

Adding inputMode and step improves keyboards and precision on mobile; keeps behavior unchanged on desktop.

Apply this diff:

       <input
         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 === "currency" && "pl-4 pr-12",
           type === "percentage" && "pr-7",
         )}
+        inputMode={type === "number" ? "numeric" : "decimal"}
+        step={
+          type === "currency" ? "0.01" : type === "percentage" ? "0.01" : "1"
+        }
         {...register(fieldKey, {
           required: true,
           setValueAs: (value: string) => (value === "" ? undefined : +value),
           min: 0,
           max: type === "percentage" ? 100 : undefined,
           onChange: handleMoneyInputChange,
         })}
apps/web/lib/zod/schemas/rewards.ts (3)

107-121: Consider cross-field refinements to enforce value/operator compatibility

Server-side schema currently permits any value type/operator pair. Optional: use superRefine to assert numeric attributes receive numbers and only numeric operators, and vice versa.

Example sketch:

export const rewardConditionSchema = z
  .object({
    entity: z.enum(CONDITION_ENTITIES),
    attribute: z.enum(CONDITION_ATTRIBUTES),
    operator: z.enum(CONDITION_OPERATORS),
    value: z.union([z.string(), z.number(), z.array(z.string()), z.array(z.number())]),
    label: z.string().nullish().describe("Product name used for display purposes in the UI."),
  })
  .superRefine((c, ctx) => {
    const attrType = ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] ?? "string";
    const isNumeric = attrType === "number" || attrType === "currency";

    if (isNumeric) {
      if (typeof c.value !== "number") {
        ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["value"], message: "Expected a number." });
      }
      if (!NUMBER_CONDITION_OPERATORS.includes(c.operator)) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["operator"], message: "Invalid numeric operator." });
      }
    } else {
      if (typeof c.value === "number" || Array.isArray(c.value) && typeof c.value[0] === "number") {
        ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["value"], message: "Expected a string or string[] value." });
      }
    }
  });

192-215: Document currency units in partner context

Clarify that totalSaleAmount and totalCommissions are cents to match UI conversions.

Apply this diff:

   partner: z
     .object({
       totalClicks: z.number().nullish(),
       totalLeads: z.number().nullish(),
       totalConversions: z.number().nullish(),
-      totalSaleAmount: z.number().nullish(),
-      totalCommissions: z.number().nullish(),
+      totalSaleAmount: z.number().nullish().describe("Amount in cents"),
+      totalCommissions: z.number().nullish().describe("Amount in cents"),
     })
     .optional(),

84-92: Ensure ATTRIBUTE_LABELS.productId is consistent across the codebase

To keep the UX uniform, the productId label should read “Product ID” everywhere. Your schema in apps/web/lib/zod/schemas/rewards.ts is already correct, but the UI tooltip definition is out of sync.

• apps/web/lib/zod/schemas/rewards.ts

export const ATTRIBUTE_LABELS = {
    
  productId: "Product ID",  // ✅ correct} as const;

• apps/web/ui/partners/program-reward-modifiers-tooltip.tsx

export const ATTRIBUTE_LABELS = {
    
  productId: "Product",     // ⚠️ please update to "Product ID"};

— Update the tooltip’s ATTRIBUTE_LABELS in program-reward-modifiers-tooltip.tsx so that productId uses "Product ID" to match the schema.

📜 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 8627a39 and 42e05cf.

📒 Files selected for processing (5)
  • apps/web/lib/zod/schemas/rewards.ts (3 hunks)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3 hunks)
  • apps/web/ui/partners/rewards/inline-badge-popover.tsx (1 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (13 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 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
  • apps/web/lib/zod/schemas/rewards.ts
  • 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/rewards/rewards-logic.tsx
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
  • apps/web/lib/zod/schemas/rewards.ts
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code graph analysis (4)
apps/web/ui/partners/rewards/rewards-logic.tsx (4)
apps/web/lib/zod/schemas/rewards.ts (4)
  • CONDITION_PARTNER_ATTRIBUTES (24-30)
  • ENTITY_ATTRIBUTE_TYPES (38-56)
  • NUMBER_CONDITION_OPERATORS (74-82)
  • STRING_CONDITION_OPERATORS (71-72)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (76-76)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (1)
  • InlineBadgePopoverContext (27-33)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
apps/web/lib/zod/schemas/rewards.ts (1)
  • ENTITY_ATTRIBUTE_TYPES (38-56)
apps/web/lib/zod/schemas/rewards.ts (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)
  • ATTRIBUTE_LABELS (13-21)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (5)
  • ATTRIBUTE_LABELS (84-92)
  • rewardConditionsArraySchema (131-133)
  • rewardConditionsSchema (123-129)
  • CONDITION_OPERATOR_LABELS (94-105)
  • ENTITY_ATTRIBUTE_TYPES (38-56)
apps/web/lib/types.ts (1)
  • RewardProps (475-475)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
🔇 Additional comments (9)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (1)

138-139: Wider menu is fine; improves long-label usability

Bumping max-w from 48 to 52 makes the command list less cramped and better for longer attribute labels. No functional concerns.

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

61-62: Per-modifier operator plumbed through correctly

Forwarding modifier.operator to RewardItem ensures proper “If / And / Or” labeling in the tooltip. Good change.

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

211-227: Partner entity is correctly added and scoped to sale/lead events

Including partner in ENTITIES and EVENT_ENTITIES aligns the UI with schema capabilities. Looks correct.


229-266: formatValue handles numeric vs currency well

Clear separation for number/currency, plus truncation for strings. Nice touch with conditional fraction digits.


286-295: Robust attribute type detection

Using ENTITY_ATTRIBUTE_TYPES with a “string” fallback is a sane default and keeps unknown attributes safe.


379-413: Operator set switches based on attribute type

Good split between NUMBER_CONDITION_OPERATORS and STRING_CONDITION_OPERATORS; prevents invalid operator/attribute combos in the UI.

apps/web/lib/zod/schemas/rewards.ts (3)

18-36: Partner entity and attributes are well-scoped

CONDITION_ENTITIES and CONDITION_*_ATTRIBUTES correctly introduce partner attributes without impacting existing entities.


38-56: Typed attribute mapping enables safer UI/validation

ENTITY_ATTRIBUTE_TYPES provides a clean foundation for number vs currency handling across the app.


58-83: Operator set extended for numeric comparisons

The added numeric operators are necessary for partner metrics; consistent with the UI logic split.

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 22, 2025

✅ Actions performed

Full review triggered.

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

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

231-239: formatValue guard: treat non-finite numbers as invalid

Current check uses isNaN(Number(value)); Infinity/-Infinity will pass this check but shouldn’t be considered valid numeric inputs.

Minimal tweak:

-    ["number", "currency"].includes(type)
-      ? value === "" || isNaN(Number(value))
+    ["number", "currency"].includes(type)
+      ? value === "" || !Number.isFinite(Number(value))

253-266: Currency formatting defaults to USD — consider making currency explicit

The currencyFormatter call doesn’t pass a currency code, so it defaults to USD. If workspaces can use non-USD currencies, consider threading a currencyCode (from workspace/org settings) into formatValue and passing it to currencyFormatter. This keeps chips/tokens consistent with the program currency.

Example (backward compatible):

-const formatValue = (
-  value: string | number | string[] | number[] | undefined,
-  type: "number" | "currency" | "string" = "string",
-) => {
+const formatValue = (
+  value: string | number | string[] | number[] | undefined,
+  type: "number" | "currency" | "string" = "string",
+  currencyCode: string = "USD",
+) => {
   ...
-      : currencyFormatter(
-          Number(value),
-          Number(value) % 1 !== 0
-            ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
-            : undefined,
-        );
+      : currencyFormatter(
+          Number(value),
+          Number(value) % 1 !== 0
+            ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
+            : undefined,
+          currencyCode,
+        );

Call sites can remain unchanged for now; later you can pass the real currency code where available.


381-411: Value shape normalization when operator changes — LGTM; minor sentinel nit

The normalization logic (array for in/not_in; number-or-empty for numeric; string-or-empty otherwise) is sound.

Using "" as a sentinel for “unset” numeric values works with your current invalid checks, but it mixes types in form state. Consider using undefined for “unset” and keeping the invalid check consistent, e.g., value == null || !Number.isFinite(Number(value)). This keeps the stored type purely numeric once set.


309-311: Small robustness tweaks: avoid capitalize on undefined and pre-filter items with a fallback

Two tiny UX/robustness improvements:

  • Don’t call capitalize with undefined.
  • Keep the entity label logic consistent with the attribute label logic.
-              text={capitalize(condition.entity) || "Select item"}
+              text={condition.entity ? capitalize(condition.entity) : "Select item"}

(Complementary to the earlier fallback suggestion for items when event is undefined.)

Also applies to: 323-331


733-758: Avoid marking the form dirty for initial type sync

The useEffect sets the modifier type from the parent with shouldDirty: true. This can incorrectly mark a pristine form as “changed” when merely syncing defaults.

Use shouldDirty: false for this initialization:

-      setValue(`${modifierKey}.type`, parentType, { shouldDirty: true });
+      setValue(`${modifierKey}.type`, parentType, { shouldDirty: false });

760-809: AmountInput: tighten numeric UX (mobile keyboards, patterns) and future-proof currency label

The component looks solid. Two low-impact UX polish items:

  • Hint appropriate mobile keyboards and restrict patterns:
-      <input
+      <input
         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 === "currency" && "pl-4 pr-12",
           type === "percentage" && "pr-7",
         )}
+        inputMode={type === "number" ? "numeric" : "decimal"}
+        pattern={type === "number" ? "[0-9]*" : "[0-9]*[.,]?[0-9]*"}
         {...register(fieldKey, {
           required: true,
           setValueAs: (value: string) => (value === "" ? undefined : +value),
           min: 0,
           max: type === "percentage" ? 100 : undefined,
           onChange: handleMoneyInputChange,
         })}
  • The “USD” suffix is hard-coded. If/when multi-currency becomes visible here, consider receiving a currencyLabel prop (default "USD") or deriving it from context. Not blocking for this PR.
📜 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 42e05cf and d53831c.

📒 Files selected for processing (1)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (13 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 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
📚 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/rewards-logic.tsx
🧬 Code graph analysis (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (4)
apps/web/lib/zod/schemas/rewards.ts (4)
  • CONDITION_PARTNER_ATTRIBUTES (24-30)
  • ENTITY_ATTRIBUTE_TYPES (38-56)
  • NUMBER_CONDITION_OPERATORS (74-82)
  • STRING_CONDITION_OPERATORS (71-72)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (76-76)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (1)
  • InlineBadgePopoverContext (27-33)
🔇 Additional comments (10)
apps/web/ui/partners/rewards/rewards-logic.tsx (10)

31-32: Users icon added for partner — LGTM

Icon coverage now correctly includes partner. No issues spotted.


33-40: Centralized utils usage — LGTM

Bringing currencyFormatter and helpers from @dub/utils aligns with existing patterns. No concerns.


218-221: Partner entity registration — LGTM

Adding partner to ENTITIES with CONDITION_PARTNER_ATTRIBUTES is clean and scoped. This matches the new schema surface.


286-294: Type-driven behavior via ENTITY_ATTRIBUTE_TYPES — LGTM

Deriving attributeType and using it for icon, operator set, and input rendering is correct and future-proof. Defaulting to "string" is defensive and fine.


293-293: Partner icon mapping — LGTM

Mapping partner to Users is consistent with the mental model used elsewhere.


417-425: Numeric validity check in badge — LGTM

The invalid guard matches the normalization strategy above; arrays vs numeric vs string are handled cleanly.


488-493: Numeric-aware input routing — LGTM

Switching to AmountInput for number/currency attributes is the right abstraction and keeps ConditionLogic lean.


679-680: Result amount now uses ResultAmountInput — LGTM

This centralizes money/percentage handling and aligns the modifier editor with the parent form’s types.


9-19: ATTRIBUTE_LABELS Export Verified—Imports Correct

Confirmed that ATTRIBUTE_LABELS is indeed exported from apps/web/lib/zod/schemas/rewards.ts (line 84), alongside CONDITION_PARTNER_ATTRIBUTES, ENTITY_ATTRIBUTE_TYPES, STRING_CONDITION_OPERATORS, and NUMBER_CONDITION_OPERATORS. The import path "@/lib/zod/schemas/rewards" is valid and requires no changes.


224-226: I’ve requested the form sheet to inspect where and how the “event” field is selected in the UI, which will confirm whether the entity picker can ever be rendered before an event is set.

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 (4)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (4)

108-124: Normalize modifier maxDuration to Infinity when inheriting a null default

When defaultRewardValues.maxDuration is null (lifetime) and a modifier leaves maxDuration undefined (inherit), the current mapping keeps null instead of Infinity, diverging from the top-level UI sentinel. Normalize here to keep UI consistent.

Apply this diff:

-        const maxDuration =
-          m.maxDuration === undefined
-            ? defaultValuesSource?.maxDuration
-            : m.maxDuration;
+        const rawMaxDuration =
+          m.maxDuration === undefined
+            ? defaultValuesSource?.maxDuration
+            : m.maxDuration;
+        const uiMaxDuration =
+          rawMaxDuration === null ? Infinity : rawMaxDuration;
@@
-          maxDuration: m.maxDuration === null ? Infinity : maxDuration,
+          maxDuration: uiMaxDuration,

186-194: showAdvancedUpsell state never resets to false — users can get stuck even after removing modifiers

The effect only sets true; it doesn’t revert when modifiers are cleared or plan changes. This can silently block submit (onSubmit early-return) while the button remains enabled.

Apply this diff:

-  useEffect(() => {
-    if (
-      modifiers?.length > 0 &&
-      !getPlanCapabilities(plan).canUseAdvancedRewardLogic
-    ) {
-      setShowAdvancedUpsell(true);
-    }
-  }, [modifiers, plan]);
+  useEffect(() => {
+    const needsUpsell =
+      Boolean(modifiers?.length) &&
+      !getPlanCapabilities(plan).canUseAdvancedRewardLogic;
+    setShowAdvancedUpsell(needsUpsell);
+  }, [modifiers, plan]);

235-242: Round primary flat amount to integer cents in the payload

Submit should match the rounding used in condition values to avoid off-by-one cents.

Apply this diff:

-      amount: type === "flat" ? data.amount * 100 : data.amount,
+      amount:
+        type === "flat"
+          ? (data.amount == null || Number.isNaN(Number(data.amount))
+              ? data.amount
+              : Math.round(Number(data.amount) * 100))
+          : data.amount,

423-436: Submit button should be disabled when upsell gating is active (and when amount is invalid)

Currently, the button stays enabled under showAdvancedUpsell, but onSubmit immediately returns — confusing UX.

Apply this diff:

-              disabled={
-                amount == null || isDeleting || isCreating || isUpdating
-              }
+              disabled={
+                showAdvancedUpsell ||
+                !Number.isFinite(amount as number) ||
+                isDeleting ||
+                isCreating ||
+                isUpdating
+              }
♻️ Duplicate comments (2)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)

115-121: Guard currency division to avoid blank/NaN coercing to 0 in the UI

As previously flagged, Number(c.value) / 100 turns ""/undefined into 0. Preserve blanks and only scale when numeric.

Apply this diff:

-          conditions: m.conditions.map((c) => ({
-            ...c,
-            value:
-              ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
-                ? Number(c.value) / 100
-                : c.value,
-          })),
+          conditions: m.conditions.map((c) => ({
+            ...c,
+            value:
+              ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency" &&
+              c.value !== "" &&
+              c.value != null &&
+              !Number.isNaN(Number(c.value))
+                ? Number(c.value) / 100
+                : c.value,
+          })),

Run this quick scan to catch any other naive divisions that might coerce blanks elsewhere:

#!/bin/bash
rg -n -C2 --type=ts --type=tsx 'Number\(\s*c\.value\s*\)\s*/\s*100'

212-219: Guard and round currency multiplication on submit to avoid accidental 0 and cent drift

As previously suggested, preserve blanks and round to integer cents.

Apply this diff:

-              conditions: m.conditions.map((c) => ({
-                ...c,
-                value:
-                  c.entity &&
-                  c.attribute &&
-                  ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
-                    ? Number(c.value) * 100
-                    : c.value,
-              })),
+              conditions: m.conditions.map((c) => ({
+                ...c,
+                value:
+                  c.entity &&
+                  c.attribute &&
+                  ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
+                    ? c.value === "" ||
+                      c.value == null ||
+                      Number.isNaN(Number(c.value))
+                      ? c.value
+                      : Math.round(Number(c.value) * 100)
+                    : c.value,
+              })),

Scan for other multiplications that should be rounded:

#!/bin/bash
rg -n -C2 --type=ts --type=tsx '(\bNumber\()?\s*(c\.value|data\.amount)\s*\)?\s*\*\s*100'
🧹 Nitpick comments (4)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (4)

16-20: Importing ENTITY_ATTRIBUTE_TYPES into a client component — consider a lean export to avoid bundling zod-heavy code

If "@/lib/zod/schemas/rewards" pulls in zod schemas and other server-only pieces, this increases the client bundle. Consider moving ENTITY_ATTRIBUTE_TYPES to a small shared constants module (e.g., "@/lib/rewards/constants") that does not import zod or server code, and import that here.


239-241: Minor: simplify Infinity check

No need to coerce with Number(). Keep it straightforward.

Apply this diff:

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

326-335: Prefer Number.isFinite for amount validity checks to avoid coercion semantics

This avoids isNaN coercing undefined or strings. It also better matches the disabled condition.

Apply this diff:

-                    text={
-                      !isNaN(amount)
+                    text={
+                      Number.isFinite(amount as number)
                         ? constructRewardAmount({
-                            amount: type === "flat" ? amount * 100 : amount,
+                            amount:
+                              type === "flat"
+                                ? Math.round((amount as number) * 100)
+                                : (amount as number),
                             type,
                             maxDuration,
                           })
                         : "amount"
                     }
-                    invalid={isNaN(amount)}
+                    invalid={!Number.isFinite(amount as number)}

115-121: Optional: extract currency scaling helpers to remove duplication and centralize rules

Two separate places implement nearly the same currency guards. Centralize for consistency and future changes.

You could add these helpers in this file (or a small shared util):

const toUiCurrency = (v: unknown) => {
  const n = typeof v === "string" ? Number(v.trim()) : Number(v);
  return v !== "" && v != null && Number.isFinite(n) ? n / 100 : v;
};

const toPayloadCurrency = (v: unknown) => {
  const n = typeof v === "string" ? Number(v.trim()) : Number(v);
  return v === "" || v == null || Number.isNaN(n) ? v : Math.round(n * 100);
};

Then the mappings become:

value:
  ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
    ? toUiCurrency(c.value)
    : c.value

and

value:
  c.entity &&
  c.attribute &&
  ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
    ? toPayloadCurrency(c.value)
    : c.value

Also applies to: 212-219

📜 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 d53831c and c5da39a.

📒 Files selected for processing (3)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (5 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (4 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (13 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
  • apps/web/ui/partners/rewards/rewards-logic.tsx
🧰 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 (2)
apps/web/lib/zod/schemas/rewards.ts (1)
  • ENTITY_ATTRIBUTE_TYPES (38-56)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-55)
⏰ 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/add-edit-reward-sheet.tsx (1)

63-72: Good call using a partial schema for conditions to support progressive form filling

This aligns with the previously adopted two-stage validation pattern for better UX (noted in retrieved learnings).

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 24, 2025

✅ Actions performed

Full review triggered.

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 (5)
apps/web/tests/rewards/reward-conditions.test.ts (5)

1156-1184: Type coercion: condition.value as string works

Good to see "100" being accepted and compared numerically. Consider adding one negative test for a non-numeric string to ensure it fails gracefully (e.g., "1,000" or "abc").

If you want, I can draft that additional test.


1216-1244: Decimal handling looks correct

Covers >= with a decimal threshold and a higher decimal field. Consider a companion case where the threshold is a string decimal ("999.99") to confirm consistent coercion paths.


715-1305: Reduce duplication with a small test helper

There’s a lot of repeated boilerplate for building single-condition groups. A tiny factory would improve readability and future maintenance.

Example helper you can add near the top of the file:

function group(entity: "customer"|"sale"|"partner", attribute: string, operator: any, value: any, amount = 5000) {
  return [{ operator: "AND" as const, amount, conditions: [{ entity, attribute, operator, value }] }];
}

Then tests become terser, e.g., group("partner", "totalClicks", "greater_than", 100).


1444-1499: Highest-amount selection behavior is validated; consider defining tie-breaking semantics

This confirms “pick highest amount” when multiple groups match. However, when amounts tie, behavior is not specified or tested. Depending on sort stability, order could be ambiguous.

Two follow-ups:

  • Decide and document tie-breaking (e.g., “first defined wins”).
  • Add a test to lock it in. Example:
test("returns deterministic group when amounts tie (first defined wins)", () => {
  const conditions = [
    { operator: "AND" as const, amount: 3000, conditions: [{ entity: "partner" as const, attribute: "totalClicks" as const, operator: "greater_than" as const, value: 10 }] },
    { operator: "AND" as const, amount: 3000, conditions: [{ entity: "partner" as const, attribute: "totalLeads" as const, operator: "greater_than_or_equal" as const, value: 5 }] },
  ];
  const context = { partner: { totalClicks: 50, totalLeads: 20 } } as any;
  const result = evaluateRewardConditions({ conditions, context });
  expect(result).toEqual(conditions[0]);
});

If you prefer “most specific” or “last defined”, adjust the assertion and implementation accordingly.


1156-1305: Add one partner-missing edge case

Since partner is a new entity in context, add a quick check that a partner-only condition yields null when context.partner is absent, mirroring the “undefined field” customer test.

I can draft this test if you want it added.

📜 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 61f90ff and a19c5bb.

📒 Files selected for processing (1)
  • apps/web/tests/rewards/reward-conditions.test.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/tests/rewards/reward-conditions.test.ts (2)
apps/web/lib/types.ts (1)
  • RewardContext (498-498)
apps/web/lib/partners/evaluate-reward-conditions.ts (1)
  • evaluateRewardConditions (8-63)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (9)
apps/web/tests/rewards/reward-conditions.test.ts (9)

715-805: Numeric greater_than semantics are correct and boundary cases are covered

All three cases (> / = / <) are asserted cleanly against partner.totalClicks. Good signal that the comparator logic is strict and not off-by-one.


807-897: Solid coverage for greater_than_or_equal, including equality boundary

The tests clearly assert success for both greater and equal, and failure for less. This aligns with intended semantics.


899-989: less_than tests validate strict inequality correctly

Covers match on less-than and non-match for equal/greater. Looks good.


991-1081: less_than_or_equal coverage is complete

Positive cases for less and equal, negative for greater: all good.


1246-1274: Zero boundary behavior is correct

Asserting that 0 is not greater than 0 prevents off-by-one mistakes around falsy values.


1276-1305: Negative number comparison behaves as expected

Good to include a negative threshold to ensure plain numeric ordering is applied.


1307-1352: Mixed AND scenario validates cross-entity numeric + string logic

Good end-to-end assertion of combined string equality and two numeric comparators.


1354-1398: AND group failure path is exercised

Demonstrates that one failing numeric condition nullifies the group. Nicely targeted.


1400-1442: OR group success path is exercised properly

One passing numeric condition yields a match despite others failing. Clear and accurate.

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 (6)
apps/web/lib/zod/schemas/rewards.ts (1)

84-92: Labels look good; ensure single source of truth across UI

“Product ID” and partner metric labels align with expected copy. To prevent drift, ensure all consumers import ATTRIBUTE_LABELS from here (previously, the tooltip duplicated labels).

#!/bin/bash
# Find other ATTRIBUTE_LABELS definitions or hard-coded duplicates
rg -nP -C2 'ATTRIBUTE_LABELS|["'\'']Product ID["'\'']|Total clicks|Total revenue' apps/web
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)

115-121: Guard currency division on read to avoid silently coercing blanks to 0

Dividing Number(c.value) by 100 turns ""/undefined into 0, which renders an empty condition as $0.00. Add explicit null/blank/NaN checks.

Apply this diff:

-          conditions: m.conditions.map((c) => ({
-            ...c,
-            value:
-              ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
-                ? Number(c.value) / 100
-                : c.value,
-          })),
+          conditions: m.conditions.map((c) => ({
+            ...c,
+            value:
+              ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency" &&
+              c.value !== "" &&
+              c.value != null &&
+              !Number.isNaN(Number(c.value))
+                ? Number(c.value) / 100
+                : c.value,
+          })),

212-220: Round and guard currency multiplication on submit

Same issue in the write path: "" becomes 0 and floating-point drift can occur; round to cents and preserve blank/NaN.

-              conditions: m.conditions.map((c) => ({
-                ...c,
-                value:
-                  c.entity &&
-                  c.attribute &&
-                  ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
-                    ? Number(c.value) * 100
-                    : c.value,
-              })),
+              conditions: m.conditions.map((c) => ({
+                ...c,
+                value:
+                  c.entity &&
+                  c.attribute &&
+                  ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
+                    ? c.value === "" ||
+                      c.value == null ||
+                      Number.isNaN(Number(c.value))
+                      ? c.value
+                      : Math.round(Number(c.value) * 100)
+                    : c.value,
+              })),
apps/web/lib/partners/determine-partner-reward.ts (1)

69-94: Fix crash: spreading possibly undefined partnerLinksStats

When links is null/empty, reduce returns undefined and { ...partnerLinksStats } throws. Default the accumulator and coalesce on spread.

-  // Aggregate the links metrics
-  const partnerLinksStats = partnerEnrollment.links?.reduce(
+  // Aggregate the links metrics
+  const defaultStats = {
+    totalClicks: 0,
+    totalLeads: 0,
+    totalConversions: 0,
+    totalSaleAmount: 0,
+  };
+  const partnerLinksStats = partnerEnrollment.links?.reduce(
     (acc, link) => {
       acc.totalClicks += link.clicks;
       acc.totalLeads += link.leads;
       acc.totalConversions += link.conversions;
       acc.totalSaleAmount += link.saleAmount;
       return acc;
     },
-    {
-      totalClicks: 0,
-      totalLeads: 0,
-      totalConversions: 0,
-      totalSaleAmount: 0,
-    },
+    { ...defaultStats },
   );

-  // Add the links metrics to the context
+  // Add the (defaulted) links metrics to the context
   context = {
     ...context,
     partner: {
-      ...partnerLinksStats,
+      ...(partnerLinksStats ?? defaultStats),
       totalCommissions: partnerEnrollment.totalCommissions,
     },
   };
apps/web/tests/rewards/reward-conditions.test.ts (1)

1186-1214: This test claims string field coercion but passes a number

To truly exercise string field coercion, pass "300" (string) and relax typing locally.

-      const context: RewardContext = {
-        partner: {
-          totalCommissions: 300, // Should work with Number() conversion
-        },
-      };
+      const context = {
+        partner: {
+          totalCommissions: "300", // exercise string field coercion
+        },
+      } as any;
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)

116-151: Render 0 values; add safe label fallback and currency guard

  • condition.value && … hides valid zeros.
  • REWARD_MODIFIER_LABELS[condition.attribute] can be undefined for future attributes.
  • Add finite-number guard when formatting currency.
-                  {idx === 0 ? "If" : capitalize(operator.toLowerCase())}{" "}
-                  {condition.entity}{" "}
-                  {REWARD_MODIFIER_LABELS[condition.attribute].toLowerCase()}{" "}
+                  {idx === 0 ? "If" : capitalize(operator.toLowerCase())}{" "}
+                  {condition.entity}{" "}
+                  {(REWARD_MODIFIER_LABELS[condition.attribute] ?? condition.attribute).toLowerCase()}{" "}
                   {CONDITION_OPERATOR_LABELS[condition.operator]}{" "}
-                  {condition.value &&
+                  {condition.value !== null &&
+                    condition.value !== undefined &&
                     (condition.attribute === "country"
                       ? // Country names
                         Array.isArray(condition.value)
                         ? (condition.value as any[])
                             .map((v) => COUNTRIES[v?.toString()] ?? v)
                             .join(", ")
                         : COUNTRIES[condition.value?.toString()] ??
                           condition.value
                       : // Non-country value(s)
                         Array.isArray(condition.value)
                         ? // Basic array
                           condition.value.join(", ")
                         : condition.attribute === "productId" && condition.label
                           ? // Product label
                             condition.label
-                          : ENTITY_ATTRIBUTE_TYPES[condition.entity]?.[
-                                condition.attribute
-                              ] === "currency"
-                            ? // Currency value
-                              currencyFormatter(Number(condition.value) / 100, {
-                                minimumFractionDigits: 2,
-                                maximumFractionDigits: 2,
-                              })
+                          : ENTITY_ATTRIBUTE_TYPES[condition.entity]?.[
+                                condition.attribute
+                              ] === "currency" &&
+                              Number.isFinite(Number(condition.value))
+                            ? // Currency value
+                              currencyFormatter(
+                                Math.round(Number(condition.value)) / 100,
+                                {
+                                  minimumFractionDigits: 2,
+                                  maximumFractionDigits: 2,
+                                },
+                              )
                             : // Everything else
                               condition.value.toString())}
🧹 Nitpick comments (14)
apps/web/lib/api/sales/construct-reward-amount.ts (1)

28-29: Update stale comment to match new behavior

The code now returns a single “Up to ” string, not a range; the comment still says “show a range”.

Apply this small doc fix:

-      // If the type AND maxDuration matches the primary, show a range
+      // If the type AND maxDuration matches the primary, show "Up to <max>"
apps/web/lib/actions/partners/update-reward.ts (1)

84-91: Verified route existence & revalidatePath usage

  • I confirmed that your Next.js app defines a literal partners.dub.co route under apps/web/app/(ee)/partners.dub.co, with its own layout.tsx, and that default-group apply pages live at
    /partners.dub.co/[programSlug]/apply
    /partners.dub.co/[programSlug]/apply/success
  • Since revalidatePath() returns void, placing it in the Promise.allSettled array simply executes it immediately (the only promise actually awaited is recordAuditLog).
  • If you’d like to make the intent more explicit, you can move the two revalidatePath(...) calls outside the array—e.g., call them before or after waitUntil(Promise.allSettled(...)). This is purely stylistic and optional.
apps/web/lib/zod/schemas/rewards.ts (2)

38-56: Type mapping only covers partner; consider explicit types for all entities

Downstream consumers (UI, validation) benefit from a complete map. Add customer/sale entries for stronger type checks and future refinements.

Example:

export const ENTITY_ATTRIBUTE_TYPES = {
  customer: { country: "string" },
  sale: { productId: "string" },
  partner: {
    totalClicks: "number",
    totalLeads: "number",
    totalConversions: "number",
    totalSaleAmount: "currency",
    totalCommissions: "currency",
  },
} as const;

205-214: Partner context shape: tighten numeric constraints

Counts should be integers; currency-like totals are integers in cents in the rest of the codebase. Consider int + nonnegative to prevent invalid data.

Example:

partner: z.object({
  totalClicks: z.number().int().nonnegative().nullish(),
  totalLeads: z.number().int().nonnegative().nullish(),
  totalConversions: z.number().int().nonnegative().nullish(),
  totalSaleAmount: z.number().int().nonnegative().nullish(),   // cents
  totalCommissions: z.number().int().nonnegative().nullish(),  // cents
}).optional()
apps/web/ui/partners/program-reward-description.tsx (1)

28-31: Lowercasing reward amount — LGTM; mirror casing for discount for consistency

This pairs well with “Up to …” from constructRewardAmount. For visual consistency, consider also lowercasing the discount amount in the block below.

You can adjust the discount block’s strong similarly:

<strong className={cn("font-semibold lowercase", amountClassName)}>
  {constructRewardAmount(discount)}
</strong>
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)

235-242: Minor: also round the primary flat amount to cents

To keep consistency with condition values and avoid 1¢ drift.

-      amount: type === "flat" ? data.amount * 100 : data.amount,
+      amount:
+        type === "flat"
+          ? (data.amount == null || Number.isNaN(Number(data.amount))
+              ? data.amount
+              : Math.round(Number(data.amount) * 100))
+          : data.amount,

326-335: Prefer Number.isFinite over global isNaN for amount validity

Global isNaN coerces; Number.isFinite is stricter and avoids edge cases. Also aligns invalid flag with the text guard.

-                    text={
-                      !isNaN(amount)
+                    text={
+                      Number.isFinite(Number(amount))
                         ? constructRewardAmount({
                             amount: type === "flat" ? amount * 100 : amount,
                             type,
                             maxDuration,
                           })
                         : "amount"
                     }
-                    invalid={isNaN(amount)}
+                    invalid={!Number.isFinite(Number(amount))}

424-425: Disable submit when amount is NaN

The visual invalid state is set, but submit isn’t disabled for NaN. Add the same guard used for invalid.

-              disabled={
-                amount == null || isDeleting || isCreating || isUpdating
-              }
+              disabled={
+                amount == null ||
+                !Number.isFinite(Number(amount)) ||
+                isDeleting ||
+                isCreating ||
+                isUpdating
+              }
apps/web/lib/partners/evaluate-reward-conditions.ts (2)

19-63: Select best match without sorting; add deterministic tie-break

Collecting then sorting mutates the array and doesn’t define behavior on ties. Use a single pass reduce; prefer earlier group on equal amounts for stability.

-  const matchingConditions: RewardConditions[] = [];
+  let bestMatch: RewardConditions | null = null;

   for (const conditionGroup of conditions) {
     // ... existing evaluation ...

-    if (conditionsMet) {
-      matchingConditions.push(conditionGroup);
-    }
+    if (conditionsMet) {
+      if (
+        !bestMatch ||
+        conditionGroup.amount > bestMatch.amount
+      ) {
+        bestMatch = conditionGroup;
+      }
+    }
   }

-  if (matchingConditions.length === 0) {
-    return null;
-  }
-
-  // Find the best matching condition (highest amount)
-  return matchingConditions.sort((a, b) => b.amount - a.amount)[0];
+  return bestMatch;

107-114: Numeric operators: explicitly guard NaN before comparing

Number() will produce NaN for junk inputs; comparisons with NaN are false but the intent is clearer (and extendable) with an explicit guard.

-    case "greater_than":
-      return Number(fieldValue) > Number(condition.value);
+    case "greater_than": {
+      const a = Number(fieldValue);
+      const b = Number(condition.value);
+      if (Number.isNaN(a) || Number.isNaN(b)) return false;
+      return a > b;
+    }
     case "greater_than_or_equal":
-      return Number(fieldValue) >= Number(condition.value);
+      return ![fieldValue, condition.value].some((v) => Number.isNaN(Number(v)))
+        ? Number(fieldValue) >= Number(condition.value)
+        : false;
     case "less_than":
-      return Number(fieldValue) < Number(condition.value);
+      return ![fieldValue, condition.value].some((v) => Number.isNaN(Number(v)))
+        ? Number(fieldValue) < Number(condition.value)
+        : false;
     case "less_than_or_equal":
-      return Number(fieldValue) <= Number(condition.value);
+      return ![fieldValue, condition.value].some((v) => Number.isNaN(Number(v)))
+        ? Number(fieldValue) <= Number(condition.value)
+        : false;
apps/web/ui/partners/rewards/rewards-logic.tsx (4)

224-226: Confirm event-to-entity mapping (partner in sale/lead, excluded from click)

Including partner for sale and lead makes sense. Please confirm that excluding partner for click is intentional per product requirements. If event can be temporarily undefined in the form, consider a safe fallback so the entity menu isn’t empty.

Optionally default to all supported entities when event is unset:

-                items={Object.keys(ENTITIES)
-                  .filter((e) =>
-                    EVENT_ENTITIES[event]?.includes(e as keyof typeof ENTITIES),
-                  )
+                items={(EVENT_ENTITIES[event] ?? (Object.keys(ENTITIES) as (keyof typeof ENTITIES)[]))
+                  .filter((e) =>
+                    (EVENT_ENTITIES[event] ?? (Object.keys(ENTITIES) as (keyof typeof ENTITIES)[]))
+                      .includes(e)
+                  )
                   .map((entity) => ({
                     text: capitalize(entity) || entity,
                     value: entity,
                   }))}

Can you confirm whether event is guaranteed to be set before conditions are added in this flow?


231-266: Numeric-aware formatting: good; minor readability + currency note

The numeric and currency handling is solid. Two small improvements:

  • Readability: avoid repeating the type check by extracting isNumericType.
  • Currency label: formatValue uses currencyFormatter without a currency code; meanwhile the input suffix shows “USD” explicitly. If multi-currency is planned, we should thread the currency code here.

Refactor for readability:

-const formatValue = (
+const formatValue = (
   value: string | number | string[] | number[] | undefined,
   type: "number" | "currency" | "string" = "string",
 ) => {
-  if (
-    ["number", "currency"].includes(type)
+  const isNumericType = type === "number" || type === "currency";
+  if (
+    isNumericType
       ? value === "" || isNaN(Number(value))
       : !value
   )
     return "Value";
   ...
-  if (["number", "currency"].includes(type)) {
+  if (isNumericType) {
     return type === "number"
       ? value!.toString()
       : currencyFormatter(
         Number(value),
         Number(value) % 1 !== 0
           ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
           : undefined,
       );
   }

If/when we support multiple currencies, pass a currency code (from workspace or reward context) into currencyFormatter and mirror that in the input suffix.


387-399: Operator change: prefer undefined over empty string for numeric reset; avoid shadowing “operator”

  • When switching to numeric operators, you currently reset value to "" (string). Using undefined avoids transient type churn and is semantically clearer.
  • Minor: the map callback parameter named operator shadows the group operator variable, which can be confusing.

Apply:

-                          // Update value to array / string / number if needed
+                          // Update value to array / string / number if needed
                           ...(["in", "not_in"].includes(value)
                             ? !Array.isArray(condition.value)
                               ? { value: [] }
                               : null
-                            : ["number", "currency"].includes(attributeType)
-                              ? typeof condition.value !== "number"
-                                ? { value: "" }
-                                : null
-                              : typeof condition.value !== "string"
-                                ? { value: "" }
-                                : null),
+                            : ["number", "currency"].includes(attributeType)
+                              ? typeof condition.value !== "number"
+                                ? { value: undefined }
+                                : null
+                              : typeof condition.value !== "string"
+                                ? { value: "" }
+                                : null),
-                    items={(["number", "currency"].includes(attributeType)
-                      ? NUMBER_CONDITION_OPERATORS
-                      : STRING_CONDITION_OPERATORS
-                    ).map((operator) => ({
-                      text: CONDITION_OPERATOR_LABELS[operator],
-                      value: operator,
+                    items={(["number", "currency"].includes(attributeType)
+                      ? NUMBER_CONDITION_OPERATORS
+                      : STRING_CONDITION_OPERATORS
+                    ).map((op) => ({
+                      text: CONDITION_OPERATOR_LABELS[op],
+                      value: op,
                     }))}

The invalid state logic below already treats undefined as invalid for numeric inputs, so this change is low-risk.

Also applies to: 405-413, 417-425


760-809: Currency label is hard-coded to USD; make it configurable

AmountInput renders a hard-coded "$" and "USD". If other currencies are (now or later) supported, this will mislead users. Suggest adding an optional currencyLabel prop with a sensible default and using it for the suffix. You can keep the "$" prefix for now or derive the symbol later.

Apply this minimal change:

 function AmountInput({
   fieldKey,
   type,
-}: {
+  currencyLabel = "USD",
+}: {
   fieldKey:
     | `modifiers.${number}.amount`
     | `modifiers.${number}.conditions.${number}.value`;
   type: "currency" | "percentage" | "number";
+  currencyLabel?: string;
 }) {
   const { register } = useAddEditRewardForm();
   const { setIsOpen } = useContext(InlineBadgePopoverContext);
@@
-      {["currency", "percentage"].includes(type) && (
+      {["currency", "percentage"].includes(type) && (
         <span className="absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400">
-          {type === "currency" ? "USD" : "%"}
+          {type === "currency" ? currencyLabel : "%"}
         </span>
       )}

Follow-up: we can thread the workspace or reward currency down to this component in a later pass if desired.

Also applies to: 775-778, 802-805

📜 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 86e9688 and a19c5bb.

📒 Files selected for processing (11)
  • apps/web/lib/actions/partners/update-reward.ts (2 hunks)
  • apps/web/lib/api/sales/construct-reward-amount.ts (1 hunks)
  • apps/web/lib/partners/determine-partner-reward.ts (5 hunks)
  • apps/web/lib/partners/evaluate-reward-conditions.ts (5 hunks)
  • apps/web/lib/zod/schemas/rewards.ts (3 hunks)
  • apps/web/tests/rewards/reward-conditions.test.ts (2 hunks)
  • apps/web/ui/partners/program-reward-description.tsx (1 hunks)
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (5 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (4 hunks)
  • apps/web/ui/partners/rewards/inline-badge-popover.tsx (1 hunks)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (13 hunks)
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
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-08-14T05:17:51.825Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.825Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.

Applied to files:

  • apps/web/lib/actions/partners/update-reward.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/actions/partners/update-reward.ts
  • apps/web/tests/rewards/reward-conditions.test.ts
  • apps/web/lib/partners/evaluate-reward-conditions.ts
  • apps/web/lib/zod/schemas/rewards.ts
  • apps/web/lib/partners/determine-partner-reward.ts
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.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/lib/partners/evaluate-reward-conditions.ts
  • apps/web/lib/zod/schemas/rewards.ts
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
  • apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
  • apps/web/ui/partners/rewards/rewards-logic.tsx
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
PR: dubinc/dub#2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.

Applied to files:

  • apps/web/lib/partners/determine-partner-reward.ts
🔇 Additional comments (23)
apps/web/lib/api/sales/construct-reward-amount.ts (1)

41-43: “Up to …” presentation for mixed modifier amounts — LGTM
Clearer copy and simpler output. No functional concerns and no existing tests reference the old “10%–20%” pattern, so there’s nothing else to update here.

apps/web/lib/actions/partners/update-reward.ts (1)

61-66: Default-group detection — LGTM

Simple and safe. Using .some((g) => g?.slug === "default") correctly handles null groups.

apps/web/lib/zod/schemas/rewards.ts (3)

24-31: Partner attributes set — LGTM

Comprehensive coverage of partner metrics (counts and currency totals).


71-83: Operator groupings — LGTM

Clear categorization. Useful for both UI gating and schema validation (see previous comment).


101-105: Operator labels — LGTM

Human-friendly and consistent phrasing for the new numeric operators.

apps/web/ui/partners/rewards/inline-badge-popover.tsx (1)

138-138: Slightly wider list — LGTM

The increase to max-w-52 helps longer labels (e.g., partner metrics). No concerns.

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

16-16: Good call importing ENTITY_ATTRIBUTE_TYPES

Centralizing attribute typing in the schema is the right move; it prevents UI/logic drift and keeps currency handling consistent.


300-386: Dependent-field reset on entity change is correctly implemented for all entities (including “partner”)

The entity selector’s onSelect in apps/web/ui/partners/rewards/rewards-logic.tsx replaces the entire condition object with { entity: … }, which inherently clears attribute, operator, and value. Because ENTITIES and EVENT_ENTITIES include "partner", this reset behavior automatically applies when switching to the partner entity.

• File apps/web/ui/partners/rewards/rewards-logic.tsx, lines 313–321:

<InlineBadgePopoverMenu
  selectedValue={condition.entity}
  onSelect={(value) =>
    setValue(
      conditionKey,
      { entity: value as keyof typeof ENTITIES },
      { shouldDirty: true },
    )
  }
  
/>

• ENTITIES and EVENT_ENTITIES definitions include “partner”, confirming coverage.

No changes required.

apps/web/lib/partners/evaluate-reward-conditions.ts (1)

30-32: Partner entity hookup looks correct

Reading partner fields from context.partner aligns with the new entity.

apps/web/lib/partners/determine-partner-reward.ts (2)

38-57: LGTM on expanded include

Including reward for the relevant event and link metrics provides the needed context for partner-based conditions.


110-117: Override semantics look good

Amount is overridden; type/maxDuration fall back correctly when undefined in the matched condition.

apps/web/tests/rewards/reward-conditions.test.ts (2)

715-1081: Nice coverage for numeric operators across partner metrics

The suites for greater_than, greater_than_or_equal, less_than, and less_than_or_equal comprehensively cover success, equality, and failure paths.


1307-1499: Mixed-condition scenarios and highest-amount selection are well exercised

Validates AND/OR interplay and best-match resolution. Solid.

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

4-9: Good consolidation of labels/types into shared schema

Importing ATTRIBUTE_LABELS/ENTITY_ATTRIBUTE_TYPES/rewardConditionsSchema avoids duplication and drift.


19-23: REWARD_MODIFIER_LABELS override is appropriate

Overriding productId label locally while inheriting canonical labels is reasonable.

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

31-32: Icon and utils imports look consistent

Users icon for partner and added utils (capitalize, currencyFormatter, etc.) are appropriately used below. No concerns.

Also applies to: 33-40


218-221: Adding partner entity to ENTITIES is correct and minimal

This wires partner attributes cleanly. Also confirms adherence to our prior learning about resetting dependent fields on entity changes (see menu handler below). LGTM.


286-291: Attribute-type gating is correct

Looks up attribute types by entity and falls back to "string" safely. This underpins operator selection and value coercion later. LGTM.


292-295: Partner icon mapping LGTM

Users icon for partner reads well next to User and InvoiceDollar. No issues.


488-493: Numeric inputs routed through AmountInput: good

Correctly renders number/currency inputs for numeric attributes. This aligns with operator set selection and validation.


669-678: Result amount rendering and validation: good

  • Using isNaN(amount) to gate constructRewardAmount prevents showing a malformed amount string.
  • Delegating input to ResultAmountInput centralizes the reward amount UX.

Also applies to: 679-679


733-759: ResultAmountInput back-compat behavior LGTM

Inheriting the parent type when unset avoids breaking existing rewards and keeps the UI consistent. Effect guard prevents loops.


9-19: I’ve requested definitions and test coverage for partner-related constants and operators. Let me know once the script results are in so we can confirm and finalize the review.

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

Caution

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

⚠️ Outside diff range comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-settings.tsx (1)

158-171: A11y: Label htmlFor points to 'slug' but the Group ID input has no matching id

The Group ID label doesn’t associate with its input, impairing accessibility and click-to-focus.

Apply this diff:

-            <label
-              htmlFor="slug"
+            <label
+              htmlFor="group-id"
               className="text-sm font-medium text-neutral-800"
             >
               Group ID
             </label>
             <div className="mt-2">
               <div className="relative">
                 <input
                   type="text"
+                  id="group-id"
                   readOnly
                   className="block w-full rounded-md border border-neutral-300 bg-neutral-100 px-3 py-2 pr-12 font-mono text-sm text-neutral-600 focus:border-neutral-300 focus:ring-0"
                   defaultValue={group.id}
                 />
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)

431-443: Disable submit when upsell is active so the tooltip actually shows

Currently, disabledTooltip renders when showAdvancedUpsell is true, but the button isn’t disabled in that case; onSubmit returns early, resulting in a no-op click and no tooltip. Include showAdvancedUpsell in disabled.

-              disabled={
-                amount == null || isDeleting || isCreating || isUpdating
-              }
+              disabled={
+                amount == null ||
+                isDeleting ||
+                isCreating ||
+                isUpdating ||
+                showAdvancedUpsell
+              }
♻️ Duplicate comments (2)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)

113-127: Guard also against whitespace-only inputs and avoid NaN from undefined modifier amounts

Great job adding NaN/blank checks for currency division. Two follow-ups:

  • Strings with only whitespace still coerce to 0 with Number(" "), leading to a silent 0 value.
  • m.amount / 100 will yield NaN if amount is undefined or a blanky string.

Tighten the guards and protect m.amount:

         return {
           ...m,
-          conditions: m.conditions.map((c) => ({
+          conditions: m.conditions.map((c) => ({
             ...c,
             value:
-              ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency" &&
-              c.value !== "" &&
-              c.value != null &&
-              !Number.isNaN(Number(c.value))
-                ? Number(c.value) / 100
-                : c.value,
+              ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency" &&
+              c.value != null &&
+              (typeof c.value === "number"
+                ? Number.isFinite(c.value)
+                : typeof c.value === "string" &&
+                  c.value.trim() !== "" &&
+                  Number.isFinite(Number(c.value.trim())))
+                ? Number(
+                    typeof c.value === "string" ? c.value.trim() : c.value,
+                  ) / 100
+                : c.value,
           })),
-          amount: type === "flat" ? m.amount / 100 : m.amount,
+          amount:
+            type === "flat"
+              ? m.amount == null ||
+                !Number.isFinite(Number(m.amount))
+                ? m.amount
+                : Number(m.amount) / 100
+              : m.amount,
           maxDuration: m.maxDuration === null ? Infinity : maxDuration,
         };

If you prefer, I can extract a tiny helper to centralize this numeric parsing to reduce duplication.


213-231: Symmetric currency write-path: trim whitespace and guard non-numeric before multiplying; also guard modifier amount rounding

Mirrors the read-path concern: whitespace-only strings become 0. Also, Math.round(m.amount * 100) should guard undefined/NaN.

             return {
               ...m,
-              conditions: m.conditions.map((c) => ({
+              conditions: m.conditions.map((c) => ({
                 ...c,
                 value:
                   c.entity &&
                   c.attribute &&
                   ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency"
-                    ? c.value === "" ||
-                      c.value == null ||
-                      Number.isNaN(Number(c.value))
-                      ? c.value
-                      : Math.round(Number(c.value) * 100)
+                    ? c.value == null ||
+                      (typeof c.value === "string" &&
+                        (c.value.trim() === "" ||
+                          !Number.isFinite(Number(c.value.trim())))) ||
+                      (typeof c.value === "number" &&
+                        !Number.isFinite(Number(c.value)))
+                      ? c.value
+                      : Math.round(
+                          Number(
+                            typeof c.value === "string"
+                              ? c.value.trim()
+                              : c.value,
+                          ) * 100,
+                        )
                     : c.value,
               })),
-              amount: type === "flat" ? Math.round(m.amount * 100) : m.amount,
+              amount:
+                type === "flat"
+                  ? m.amount == null ||
+                    !Number.isFinite(Number(m.amount))
+                    ? m.amount
+                    : Math.round(Number(m.amount) * 100)
+                  : m.amount,
               maxDuration: maxDuration === Infinity ? null : maxDuration,
             };

Optional: factor the parse/scale logic into helpers to DRY both maps.

🧹 Nitpick comments (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-settings.tsx (2)

61-63: Prefer replace over push when the slug changes

Using router.replace avoids leaving a stale route (old slug) in the browser history, which can 404 if navigated back to.

Apply this diff:

-          router.push(`/${slug}/program/groups/${data.slug}/settings`);
+          router.replace(`/${slug}/program/groups/${data.slug}/settings`);

55-66: Surface errors from updateGroup with an onError handler

Currently only onSuccess is handled; failures will be silent. Add an onError to notify users (and consider logging).

Apply this diff:

       await updateGroup(`/api/groups/${group.id}`, {
         method: "PATCH",
         body: data,
         onSuccess: async () => {
           await mutatePrefix("/api/groups");
           // If slug changed, redirect to new URL
           if (data.slug !== group.slug) {
-            router.push(`/${slug}/program/groups/${data.slug}/settings`);
+            router.replace(`/${slug}/program/groups/${data.slug}/settings`);
           }
           toast.success("Group updated successfully!");
         },
+        onError: (err: unknown) => {
+          const message =
+            err && typeof err === "object" && "message" in err
+              ? // @ts-expect-error runtime guard
+                String(err.message)
+              : "Failed to update group. Please try again.";
+          toast.error(message);
+        },
       });
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)

213-231: Reduce duplication with small helpers for currency normalization

The read/write maps duplicate non-trivial parsing logic. Extracting helpers will improve consistency and testability.

Additional helper proposal (place near the top of the component file):

const toNumberIfNumeric = (v: unknown): number | null => {
  if (v == null) return null;
  if (typeof v === "number") return Number.isFinite(v) ? v : null;
  if (typeof v === "string") {
    const t = v.trim();
    if (t === "") return null;
    const n = Number(t);
    return Number.isFinite(n) ? n : null;
  }
  return null;
};

const toMajorUnits = (v: unknown) => {
  const n = toNumberIfNumeric(v);
  return n == null ? v : n / 100;
};

const toMinorUnits = (v: unknown) => {
  const n = toNumberIfNumeric(v);
  return n == null ? v : Math.round(n * 100);
};

Then use:

  • read: value: isCurrency ? toMajorUnits(c.value) : c.value
  • write: value: isCurrency ? toMinorUnits(c.value) : c.value
  • read/write modifier.amount similarly when type === "flat".

Also applies to: 113-127


234-239: Use console.error and avoid noisy logs in production

Minor polish: prefer console.error and avoid logging in production builds.

-        console.log("parse error", error);
+        if (process.env.NODE_ENV !== "production") {
+          console.error("parse error", error);
+        }
📜 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 a19c5bb and e06aa9c.

📒 Files selected for processing (2)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-settings.tsx (1 hunks)
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (5 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
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/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 (2)
apps/web/lib/zod/schemas/rewards.ts (1)
  • ENTITY_ATTRIBUTE_TYPES (38-56)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-55)
⏰ 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/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-settings.tsx (2)

59-65: Single cache refresh + success toast placement look good

Consolidating to a single await mutatePrefix("/api/groups") and emitting the toast after the redirect logic reduces duplicate network churn and avoids premature success messaging. Nice cleanup.


59-59: mutatePrefix("/api/groups") covers all group-related SWR keys
After auditing the three SWR hooks—

  • apps/web/lib/swr/use-group.ts (keys: /api/groups/${groupSlug}?…)
  • apps/web/lib/swr/use-groups.ts (keys: /api/groups?…)
  • apps/web/lib/swr/use-groups-count.ts (keys: /api/groups/count…)

—all keys share the /api/groups prefix. No singular /api/group or alternate paths exist, so the current mutatePrefix("/api/groups") invalidation is sufficient.

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

16-16: Import looks correct for client-side currency typing

Bringing in ENTITY_ATTRIBUTE_TYPES here is appropriate for read/write scaling of condition values.


334-343: Nice: treat 0 as valid and only flag NaN for amount

Switching to isNaN(amount) both for display and invalid state avoids penalizing legitimate zero values. Good alignment with constructRewardAmount display.


246-246: Rounding flat amounts before submit is correct

Ensures minor-units integrity for flat rewards. Matches constructRewardAmount expectations elsewhere.

@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: 0

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

231-239: Currency formatting is solid; consider workspace currency awareness.

formatValue correctly handles number/currency types and gracefully falls back to “Value”. Today it always formats as USD. If workspaces can use non-USD currencies, consider passing the workspace currency to currencyFormatter and mirroring it in the UI suffix to avoid mixed signals.

I can wire workspace currency into formatValue/AmountInput if desired.

Also applies to: 253-266


350-365: Preserve operator/value on attribute change when still compatible.

Currently, selecting a new attribute overrides the whole condition object, dropping operator/value even when still valid. This can cause unnecessary re-entry. Preserve them when type/operator compatibility remains; reset only when incompatible.

Apply this diff:

-                    onSelect={(value) =>
-                      setValue(
-                        conditionKey,
-                        {
-                          entity: condition.entity,
-                          attribute:
-                            value as (typeof CONDITION_ATTRIBUTES)[number],
-                        },
-                        {
-                          shouldDirty: true,
-                        },
-                      )
-                    }
+                    onSelect={(value) => {
+                      const newAttr = value as (typeof CONDITION_ATTRIBUTES)[number];
+                      const newType =
+                        ENTITY_ATTRIBUTE_TYPES[condition.entity]?.[newAttr] ?? "string";
+                      const allowedOperators = ["number", "currency"].includes(newType)
+                        ? NUMBER_CONDITION_OPERATORS
+                        : STRING_CONDITION_OPERATORS;
+                      const nextOperator = allowedOperators.includes(
+                        (condition.operator as (typeof CONDITION_OPERATORS)[number])!,
+                      )
+                        ? condition.operator
+                        : undefined;
+                      const needsArray =
+                        nextOperator && ["in", "not_in"].includes(nextOperator);
+                      const nextValue = needsArray
+                        ? Array.isArray(condition.value)
+                          ? condition.value
+                          : []
+                        : ["number", "currency"].includes(newType)
+                          ? typeof condition.value === "number"
+                            ? condition.value
+                            : ""
+                          : typeof condition.value === "string"
+                            ? condition.value
+                            : "";
+
+                      setValue(
+                        conditionKey,
+                        {
+                          ...condition,
+                          attribute: newAttr,
+                          operator: nextOperator,
+                          value: nextValue,
+                        },
+                        { shouldDirty: true },
+                      );
+                    }}

329-337: Guard entity list when event is unset to avoid an empty menu.

When “event” is temporarily undefined, the filter yields no entities. If the form flow allows opening this popover before event is set, show all entities as a fallback.

-                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))

393-405: Value coercion on operator change is correct; consider a small readability lift.

Logic is right. A tiny readability win: extract isNumericType = ["number", "currency"].includes(attributeType) to avoid repeating the array literal.

-                          ...(["in", "not_in"].includes(value)
+                          const isNumericType = ["number", "currency"].includes(attributeType);
+                          ...(["in", "not_in"].includes(value)
                             ? !Array.isArray(condition.value)
                               ? { value: [] }
                               : null
-                            : ["number", "currency"].includes(attributeType)
+                            : isNumericType
                               ? typeof condition.value !== "number"
                                 ? { value: "" }
                                 : null

460-476: Mark country selection as dirty for consistent form behavior.

setValue here doesn’t mark the field dirty. Other handlers do. Add shouldDirty: true to ensure consistent validation/Save CTA enablement.

-                          onSelect={(value) => {
-                            setValue(conditionKey, {
+                          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 });
                           }}

766-774: Minor input UX improvements for numeric entry (mobile + accessibility).

The AmountInput is well-factored. Two small UX wins:

  • Add inputMode to surface numeric keyboards on mobile.
  • Optionally honor workspace currency for the suffix/prefix (if multi-currency is supported).
   return (
     <div className="relative rounded-md shadow-sm">
       {type === "currency" && (
         <span className="absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400">
           $
         </span>
       )}
       <input
         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 === "currency" && "pl-4 pr-12",
           type === "percentage" && "pr-7",
         )}
+        inputMode={type === "number" ? "numeric" : "decimal"}
+        autoComplete="off"
+        enterKeyHint="done"
         {...register(fieldKey, {
           required: true,
           setValueAs: (value: string) => (value === "" ? undefined : +value),
           min: 0,
           max: type === "percentage" ? 100 : undefined,
           onChange: handleMoneyInputChange,
         })}

If you want, I can also thread a workspace currency code through AmountInput and formatValue for non-USD workspaces.

Also applies to: 780-784, 787-790, 791-798, 807-812

📜 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 e06aa9c and f12d9bb.

📒 Files selected for processing (1)
  • apps/web/ui/partners/rewards/rewards-logic.tsx (14 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
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
📚 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/rewards-logic.tsx
🧬 Code graph analysis (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (5)
apps/web/lib/zod/schemas/rewards.ts (4)
  • CONDITION_PARTNER_ATTRIBUTES (24-30)
  • ENTITY_ATTRIBUTE_TYPES (38-56)
  • NUMBER_CONDITION_OPERATORS (74-82)
  • STRING_CONDITION_OPERATORS (71-72)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
  • constructRewardAmount (5-55)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
  • useAddEditRewardForm (76-76)
apps/web/ui/partners/rewards/inline-badge-popover.tsx (1)
  • InlineBadgePopoverContext (27-33)
⏰ 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 (8)

9-19: Good alignment with schema-driven labels and operator sets.

Importing ATTRIBUTE_LABELS, CONDITION_PARTNER_ATTRIBUTES, ENTITY_ATTRIBUTE_TYPES, NUMBER_CONDITION_OPERATORS, and STRING_CONDITION_OPERATORS centralizes logic with the zod schema and removes local drift. Pulling currencyFormatter and utils here is consistent with the formatting behavior elsewhere.

Also applies to: 31-40


218-221: Confirm event-to-entity mapping for click rewards.

Partner is enabled for sale and lead events (great), but not for click. If the intent includes conditions like “if partner.totalClicks > X” for click rewards, consider adding "partner" to EVENT_ENTITIES.click. If not supported by product requirements, no change needed—just confirming the intent.

Would you like me to scan tests/specs to ensure there’s no expectation of partner conditions on click?

Also applies to: 224-226


286-291: Type-aware attribute logic and icon mapping look correct.

Deriving attributeType from ENTITY_ATTRIBUTE_TYPES with "string" as the safe default is sound. The Users icon for partner blends well with existing User/InvoiceDollar icons.

Also applies to: 293-293


317-324: Nice: resetting dependent fields on entity change.

Clearing attribute/operator/value when entity changes adheres to the prior learning and prevents invalid combos/UX confusion.


423-431: Good invalid-state rules for numeric values.

Using condition.value === "" or isNaN(Number(...)) for numeric/currency inputs is consistent with the input coercion path and keeps UX predictable.


494-499: Correct numeric-aware rendering path.

Switching to AmountInput for number/currency attributes keeps validation/UI consistent with reward amounts.


675-686: Constructed amount label is correct; NaN guard is appropriate.

Multiplying flat amounts by 100 (to cents) before constructRewardAmount matches the formatter’s contract. The NaN guard prevents misleading “Up to …” text.


739-764: Good backward-compatibility: default modifier.type from parent once.

Effect runs only when type is undefined, avoiding churn after the user changes it. Solid.

@steven-tey steven-tey force-pushed the main branch 2 times, most recently from ea17ddf to f7d87c3 Compare August 24, 2025 20:37
@steven-tey steven-tey merged commit 3b84d10 into main Aug 25, 2025
8 of 9 checks passed
@steven-tey steven-tey deleted the reward-partner-attribute branch August 25, 2025 00:22
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