-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add support for the partner attribute in reward conditions
#2765
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAggregates 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45–60 minutes Possibly related PRs
Poem
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 unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
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 combinationsRight 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 statePer 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 changesChanging 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 accordinglyRight 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
amountisz.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 defaultsAll 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-breakerCollecting 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 surprisesAdd 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 linksFor 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
groupByon Link withsumofclicks,leads,conversions,saleAmountfiltered byprogramEnrollmentId.- 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 displayOptional: 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.
📒 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.tsapps/web/lib/actions/partners/update-reward.tsapps/web/lib/partners/evaluate-reward-conditions.tsapps/web/lib/zod/schemas/rewards.tsapps/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.tsapps/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 consistentAdding "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 — LGTMSimple and correct detection using slug === "default". This matches how groups are referenced elsewhere in the codebase.
84-91: Revalidation paths usage confirmedWe’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 callrevalidatePath(`/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 callrevalidatePath(`/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.cosegment, no changes are required here.apps/web/lib/partners/evaluate-reward-conditions.ts (1)
26-33: Partner entity support — LGTMReading 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 — LGTMClear 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 — LGTMPartner 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 — LGTMVisual consistency looks good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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 sortingitems.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 submitSame 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 attributestotalClicks/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/stepAdding 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 compatibilityServer-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 contextClarify 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: EnsureATTRIBUTE_LABELS.productIdis consistent across the codebaseTo keep the UX uniform, the
productIdlabel should read “Product ID” everywhere. Your schema inapps/web/lib/zod/schemas/rewards.tsis 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_LABELSinprogram-reward-modifiers-tooltip.tsxso thatproductIduses "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.
📒 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.tsxapps/web/ui/partners/rewards/add-edit-reward-sheet.tsxapps/web/lib/zod/schemas/rewards.tsapps/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.tsxapps/web/ui/partners/rewards/add-edit-reward-sheet.tsxapps/web/lib/zod/schemas/rewards.tsapps/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 usabilityBumping 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 correctlyForwarding 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 eventsIncluding partner in ENTITIES and EVENT_ENTITIES aligns the UI with schema capabilities. Looks correct.
229-266: formatValue handles numeric vs currency wellClear separation for number/currency, plus truncation for strings. Nice touch with conditional fraction digits.
286-295: Robust attribute type detectionUsing ENTITY_ATTRIBUTE_TYPES with a “string” fallback is a sane default and keeps unknown attributes safe.
379-413: Operator set switches based on attribute typeGood 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-scopedCONDITION_ENTITIES and CONDITION_*_ATTRIBUTES correctly introduce partner attributes without impacting existing entities.
38-56: Typed attribute mapping enables safer UI/validationENTITY_ATTRIBUTE_TYPES provides a clean foundation for number vs currency handling across the app.
58-83: Operator set extended for numeric comparisonsThe added numeric operators are necessary for partner metrics; consistent with the UI logic split.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (6)
apps/web/ui/partners/rewards/rewards-logic.tsx (6)
231-239: formatValue guard: treat non-finite numbers as invalidCurrent 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 explicitThe 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 nitThe 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 fallbackTwo 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 syncThe 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 labelThe 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.
📒 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 — LGTMIcon coverage now correctly includes partner. No issues spotted.
33-40: Centralized utils usage — LGTMBringing currencyFormatter and helpers from @dub/utils aligns with existing patterns. No concerns.
218-221: Partner entity registration — LGTMAdding 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 — LGTMDeriving 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 — LGTMMapping partner to Users is consistent with the mental model used elsewhere.
417-425: Numeric validity check in badge — LGTMThe invalid guard matches the normalization strategy above; arrays vs numeric vs string are handled cleanly.
488-493: Numeric-aware input routing — LGTMSwitching to AmountInput for number/currency attributes is the right abstraction and keeps ConditionLogic lean.
679-680: Result amount now uses ResultAmountInput — LGTMThis centralizes money/percentage handling and aligns the modifier editor with the parent form’s types.
9-19: ATTRIBUTE_LABELS Export Verified—Imports CorrectConfirmed that
ATTRIBUTE_LABELSis indeed exported fromapps/web/lib/zod/schemas/rewards.ts(line 84), alongsideCONDITION_PARTNER_ATTRIBUTES,ENTITY_ATTRIBUTE_TYPES,STRING_CONDITION_OPERATORS, andNUMBER_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.
Reward logic tweaks
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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 defaultWhen 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 modifiersThe 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 payloadSubmit 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 UIAs 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 driftAs 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 codeIf "@/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 checkNo 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 semanticsThis 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 rulesTwo 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.valueand
value: c.entity && c.attribute && ENTITY_ATTRIBUTE_TYPES[c.entity]?.[c.attribute] === "currency" ? toPayloadCurrency(c.value) : c.valueAlso 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.
📒 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 fillingThis aligns with the previously adopted two-stage validation pattern for better UX (noted in retrieved learnings).
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (5)
apps/web/tests/rewards/reward-conditions.test.ts (5)
1156-1184: Type coercion: condition.value as string worksGood 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 correctCovers >= 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 helperThere’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 semanticsThis 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 caseSince 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.
📒 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 coveredAll 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 boundaryThe 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 correctlyCovers match on less-than and non-match for equal/greater. Looks good.
991-1081: less_than_or_equal coverage is completePositive cases for less and equal, negative for greater: all good.
1246-1274: Zero boundary behavior is correctAsserting that 0 is not greater than 0 prevents off-by-one mistakes around falsy values.
1276-1305: Negative number comparison behaves as expectedGood to include a negative threshold to ensure plain numeric ordering is applied.
1307-1352: Mixed AND scenario validates cross-entity numeric + string logicGood end-to-end assertion of combined string equality and two numeric comparators.
1354-1398: AND group failure path is exercisedDemonstrates that one failing numeric condition nullifies the group. Nicely targeted.
1400-1442: OR group success path is exercised properlyOne passing numeric condition yields a match despite others failing. Clear and accurate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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/webapps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)
115-121: Guard currency division on read to avoid silently coercing blanks to 0Dividing 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 submitSame 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 partnerLinksStatsWhen 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 numberTo 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 behaviorThe 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.coroute underapps/web/app/(ee)/partners.dub.co, with its ownlayout.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 thePromise.allSettledarray simply executes it immediately (the only promise actually awaited isrecordAuditLog).- 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 afterwaitUntil(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 entitiesDownstream 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 constraintsCounts 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 consistencyThis 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 centsTo 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 validityGlobal 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 NaNThe 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-breakCollecting 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 comparingNumber() 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 noteThe 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 configurableAmountInput 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.
📒 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.tsapps/web/tests/rewards/reward-conditions.test.tsapps/web/lib/partners/evaluate-reward-conditions.tsapps/web/lib/zod/schemas/rewards.tsapps/web/lib/partners/determine-partner-reward.tsapps/web/ui/partners/rewards/add-edit-reward-sheet.tsxapps/web/ui/partners/program-reward-modifiers-tooltip.tsxapps/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.tsapps/web/lib/zod/schemas/rewards.tsapps/web/ui/partners/rewards/add-edit-reward-sheet.tsxapps/web/ui/partners/program-reward-modifiers-tooltip.tsxapps/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 — LGTMSimple 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 — LGTMComprehensive coverage of partner metrics (counts and currency totals).
71-83: Operator groupings — LGTMClear categorization. Useful for both UI gating and schema validation (see previous comment).
101-105: Operator labels — LGTMHuman-friendly and consistent phrasing for the new numeric operators.
apps/web/ui/partners/rewards/inline-badge-popover.tsx (1)
138-138: Slightly wider list — LGTMThe 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_TYPESCentralizing 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
onSelectin apps/web/ui/partners/rewards/rewards-logic.tsx replaces the entire condition object with{ entity: … }, which inherently clearsattribute,operator, andvalue. BecauseENTITIESandEVENT_ENTITIESinclude"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 correctReading partner fields from context.partner aligns with the new entity.
apps/web/lib/partners/determine-partner-reward.ts (2)
38-57: LGTM on expanded includeIncluding reward for the relevant event and link metrics provides the needed context for partner-based conditions.
110-117: Override semantics look goodAmount 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 metricsThe 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 exercisedValidates 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 schemaImporting ATTRIBUTE_LABELS/ENTITY_ATTRIBUTE_TYPES/rewardConditionsSchema avoids duplication and drift.
19-23: REWARD_MODIFIER_LABELS override is appropriateOverriding 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 consistentUsers 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 minimalThis 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 correctLooks 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 LGTMUsers icon for partner reads well next to User and InvoiceDollar. No issues.
488-493: Numeric inputs routed through AmountInput: goodCorrectly 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 LGTMInheriting 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
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 idThe 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 showsCurrently, 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 amountsGreat 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 roundingMirrors 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 changesUsing 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 handlerCurrently 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 normalizationThe 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 productionMinor 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.
📒 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 goodConsolidating 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/groupsprefix. No singular/api/groupor alternate paths exist, so the currentmutatePrefix("/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 typingBringing 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 amountSwitching 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 correctEnsures minor-units integrity for flat rewards. Matches constructRewardAmount expectations elsewhere.
|
/bug0 run |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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.
📒 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.
ea17ddf to
f7d87c3
Compare
Summary by CodeRabbit
New Features
UI
Chores
Behavior
Tests