-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Support mix & match flat fee and recurring commissions for rewards #2757
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.
|
WalkthroughRefactors reward amount construction to accept a reward object and derive ranges from modifiers. Extends schemas to include condition labels, type, and maxDuration. Propagates type/maxDuration through UI, updates selection options, and unifies formatting across tooltips, descriptions, OG image, and FAQs. Adjusts partner reward resolution to rebuild objects on condition matches. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant UI as UI (Rewards/Descriptions)
participant Logic as constructRewardAmount
participant Schema as Zod (rewardConditionsArraySchema)
User->>UI: View reward
UI->>Logic: constructRewardAmount(reward)
alt reward.modifiers present
Logic->>Schema: safeParse(reward.modifiers)
alt Valid and all modifiers match primary type & maxDuration
Logic-->>UI: "min - max" (currency or %)
else Invalid or mismatch
Logic-->>UI: Primary amount (currency or %)
end
else No modifiers
Logic-->>UI: Primary amount (currency or %)
end
sequenceDiagram
autonumber
participant Engine as Partner Reward Resolver
participant Cond as Matching Condition
participant Out as RewardSchema
Engine->>Engine: Initialize partnerReward
Engine->>Cond: Evaluate conditions
alt Match found
Engine->>Engine: partnerReward = { ...partnerReward, amount/type/maxDuration }
end
Engine->>Out: RewardSchema.parse(partnerReward)
Out-->>Engine: Parsed reward or error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60–90 minutes Possibly related PRs
Suggested reviewers
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
🧹 Nitpick comments (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)
51-51: Unnecessary blank line addition.The extra blank line after the closing
</strong>tag doesn't add value and may be inconsistent with the codebase's formatting standards.</strong> - {(
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx(1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)
apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(64-66)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(4-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)
54-54: LGTM! Correctly destructures modifier-specific type.The destructuring now extracts the
typefield from each modifier, enabling per-modifier reward type handling. This aligns with the schema changes that addedtype: z.nativeEnum(RewardStructure)to reward conditions.
60-63: LGTM! Uses modifier-specific type for amount formatting.The
constructRewardAmountfunction now receives the modifier's owntypeinstead of the parent reward'stype, which correctly supports the mix & match reward structure feature where each modifier can have its own type (flat fee vs percentage).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
603-642: Amount input should respect parent type when modifier type is unsetAmount input currently watches only
modifiers.{i}.type, so suffix/validation defaults to “USD” even if the parent type is “percentage”. Use a fallback to parent type for consistent UX and validation.Apply this diff:
-function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) { - const { watch, register } = useAddEditRewardForm(); - const type = watch(`${modifierKey}.type`); +function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) { + const { watch, register } = useAddEditRewardForm(); + const localType = watch(`${modifierKey}.type`); + const parentType = watch("type"); + const type = localType || parentType; @@ - className={cn( + className={cn( "block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:w-32 sm:text-sm", type === "flat" ? "pl-4 pr-12" : "pr-7", )} {...register(`${modifierKey}.amount`, { required: true, setValueAs: (value: string) => (value === "" ? undefined : +value), min: 0, max: type === "percentage" ? 100 : undefined, onChange: handleMoneyInputChange, })} @@ - <span className="absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400"> - {type === "flat" ? "USD" : "%"} - </span> + <span className="absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400"> + {type === "percentage" ? "%" : "USD"} + </span>Additionally, ensure the display at lines 544-546 already uses
displayType(with parent fallback), which is good.
🧹 Nitpick comments (3)
apps/web/lib/partners/determine-partner-reward.ts (1)
77-78: Remove debug log of partnerRewardStray server-side
console.logcan leak data and clutter logs.Apply this diff:
- console.log(partnerReward); -apps/web/ui/partners/rewards/rewards-logic.tsx (2)
270-285: Reset dependent fields when entity changes (avoid invalid state)When switching
entity, keep the form state valid by resettingattribute,operator, andvalue(per prior UX guidance in this codebase).Apply this diff:
- onSelect={(value) => - setValue( - conditionKey, - { entity: value as keyof typeof ENTITIES }, - { - shouldDirty: true, - }, - ) - } + onSelect={(value) => + setValue( + conditionKey, + { + entity: value as keyof typeof ENTITIES, + attribute: undefined, + operator: undefined, + value: undefined, + }, + { shouldDirty: true }, + ) + }
557-565: Handle undefined maxDuration in badge textWhen neither modifier nor parent provides
maxDuration, the current string interpolation yields “for undefined month(s)”. Add a neutral fallback.Apply this diff:
- <InlineBadgePopover - text={ - displayMaxDuration === 0 - ? "one time" - : displayMaxDuration === Infinity - ? "for the customer's lifetime" - : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}` - } - > + <InlineBadgePopover + text={ + displayMaxDuration === undefined + ? "duration" + : displayMaxDuration === 0 + ? "one time" + : displayMaxDuration === Infinity + ? "for the customer's lifetime" + : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}` + } + >
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (5)
apps/web/lib/partners/determine-partner-reward.ts(2 hunks)apps/web/lib/partners/evaluate-reward-conditions.ts(1 hunks)apps/web/lib/zod/schemas/rewards.ts(1 hunks)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx(5 hunks)apps/web/ui/partners/rewards/rewards-logic.tsx(7 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
- apps/web/lib/partners/evaluate-reward-conditions.ts
- apps/web/lib/zod/schemas/rewards.ts
- apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/lib/partners/determine-partner-reward.tsapps/web/ui/partners/rewards/rewards-logic.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/lib/partners/determine-partner-reward.ts
🧬 Code Graph Analysis (2)
apps/web/lib/partners/determine-partner-reward.ts (1)
apps/web/lib/partners/evaluate-reward-conditions.ts (1)
evaluateRewardConditions(7-58)
apps/web/ui/partners/rewards/rewards-logic.tsx (5)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
useAddEditRewardForm(75-75)apps/web/ui/partners/rewards/inline-badge-popover.tsx (2)
InlineBadgePopover(35-72)InlineBadgePopoverMenu(81-175)packages/prisma/client.ts (1)
RewardStructure(19-19)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(4-26)apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (5)
apps/web/lib/partners/determine-partner-reward.ts (2)
45-45: Good pivot to a mutable binding for reward reassignmentSwitching
consttoletenables clean, immutable-style reassignment when a condition matches.
58-73: Correctly rebuild reward from matched condition (no in-place mutation)Reconstructing
partnerRewardfrom the matched condition (amount/type/maxDuration) aligns with the updated condition evaluator and keeps the object shape consistent for downstream parsing viaRewardSchema.apps/web/ui/partners/rewards/rewards-logic.tsx (3)
41-50: Nice centralized reward type optionsExporting
REWARD_TYPEShere makes reuse easy across the UI.
103-109: Good: seed per-condition type/maxDuration from parent valuesPre-populating modifier entries with parent
typeandmaxDurationsimplifies UX and keeps derived displays consistent.
544-546: Display conversion for flat amounts looks correctConverting flat amounts to cents before passing to
constructRewardAmountmatches howformatCurrencyexpects values. Passingtype: displayType || "flat"is sensible given the fallback logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
627-666: AmountInput should fall back to parent type for prefix/suffix and validationCurrently it only watches the modifier’s type, so the input can display “%” while the result uses the parent’s flat type. Watch the parent type and derive an effective type.
-function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) { - const { watch, register } = useAddEditRewardForm(); - const type = watch(`${modifierKey}.type`); +function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) { + const { watch, register } = useAddEditRewardForm(); + const parentType = watch("type"); + const type = watch(`${modifierKey}.type`) ?? parentType;No further changes are needed since the existing uses of type (prefix, suffix, max) will now reflect the effective type.
♻️ Duplicate comments (2)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
549-552: Guard against undefined type when rendering the type badgecapitalize(displayType) can throw when displayType is undefined. The diff above addresses this by providing a fallback label.
271-275: Guard capitalize against undefined entitycapitalize(condition.entity) will throw when entity is unset. Use a conditional fallback.
- <InlineBadgePopover - text={capitalize(condition.entity) || "Select item"} - invalid={!condition.entity} - > + <InlineBadgePopover + text={condition.entity ? capitalize(condition.entity) : "Select item"} + invalid={!condition.entity} + >
🧹 Nitpick comments (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
42-51: Type the REWARD_TYPES constant to RewardStructure for safetyEnsure menu item values stay narrowed to RewardStructure and not widened to string.
-export const REWARD_TYPES = [ +export const REWARD_TYPES = [ { text: "Flat", value: "flat", }, { text: "Percentage", value: "percentage", }, -]; +] as const satisfies { text: string; value: RewardStructure }[];
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
apps/web/lib/zod/schemas/rewards.ts(1 hunks)apps/web/ui/partners/rewards/rewards-logic.tsx(9 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/lib/zod/schemas/rewards.ts
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/lib/zod/schemas/rewards.tsapps/web/ui/partners/rewards/rewards-logic.tsx
🧬 Code Graph Analysis (2)
apps/web/lib/zod/schemas/rewards.ts (2)
packages/prisma/client.ts (1)
RewardStructure(19-19)apps/web/lib/zod/schemas/misc.ts (1)
maxDurationSchema(56-61)
apps/web/ui/partners/rewards/rewards-logic.tsx (7)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
RewardIconSquare(3-7)apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
InlineBadgePopover(35-72)InlineBadgePopoverMenu(81-175)InlineBadgePopoverInputs(202-276)InlineBadgePopoverInput(177-200)apps/web/lib/zod/schemas/rewards.ts (3)
CONDITION_ATTRIBUTES(22-25)CONDITION_OPERATOR_LABELS(36-43)CONDITION_OPERATORS(27-34)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
useAddEditRewardForm(75-75)packages/prisma/client.ts (1)
RewardStructure(19-19)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(4-26)apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (1)
104-110: Good: Propagate parent type/maxDuration into newly-added modifiersSeeding new modifiers with the current form’s type and maxDuration keeps UI state coherent.
… reward calculations across various components.
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 (4)
apps/web/ui/partners/rewards/rewards-logic.tsx (3)
585-604: Avoid “for NaN months” when maxDuration is unsetWhen displayMaxDuration is undefined, the label renders NaN. Use the resolvedMaxDuration computed earlier and a safe selectedValue.
- <InlineBadgePopover - text={ - displayMaxDuration === 0 - ? "one time" - : displayMaxDuration === Infinity - ? "for the customer's lifetime" - : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}` - } - > + <InlineBadgePopover + text={ + resolvedMaxDuration === 0 + ? "one time" + : resolvedMaxDuration === Infinity + ? "for the customer's lifetime" + : `for ${resolvedMaxDuration} ${pluralize("month", Number(resolvedMaxDuration))}` + } + > <InlineBadgePopoverMenu - selectedValue={ - displayMaxDuration === Infinity - ? "Infinity" - : displayMaxDuration?.toString() - } + selectedValue={ + resolvedMaxDuration === Infinity + ? "Infinity" + : resolvedMaxDuration.toString() + } onSelect={(value) => setValue( `${modifierKey}.maxDuration`, value === "Infinity" ? Infinity : Number(value), { shouldDirty: true, }, ) }
547-563: Guard badge text and “of” copy by using effectiveTypeAvoid capitalize(undefined) and ensure the “of” prefix aligns with the resolved type.
- {event === "sale" && ( + {event === "sale" && ( <> a{" "} - <InlineBadgePopover text={capitalize(displayType)}> + <InlineBadgePopover text={displayType ? capitalize(displayType) : "Type"}> <InlineBadgePopoverMenu selectedValue={type} onSelect={(value) => setValue(`${modifierKey}.type`, value as RewardStructure, { shouldDirty: true, }) } items={REWARD_TYPES} /> </InlineBadgePopover>{" "} - {displayType === "percentage" && "of "} + {effectiveType === "percentage" && "of "} </> )}
539-543: Compute an effective type and resolved duration to avoid undefined/NaN display pathsBoth type and maxDuration can be unset on a modifier; compute a single effectiveType (default to "flat") and a resolvedMaxDuration to keep downstream rendering and formatting safe.
- // Use parent values as fallbacks if modifier doesn't have type or maxDuration - const displayType = type || parentType; - const displayMaxDuration = - maxDuration !== undefined ? maxDuration : parentMaxDuration; + // Use parent values as fallbacks if modifier doesn't have type or maxDuration + const displayType = type || parentType; + const displayMaxDuration = + maxDuration !== undefined ? maxDuration : parentMaxDuration; + const effectiveType = (displayType ?? "flat") as RewardStructure; + const resolvedMaxDuration = + displayMaxDuration === undefined ? 0 : displayMaxDuration;apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)
52-56: Database backfill or default for new modifier.type remains requiredThis tooltip now destructures type from modifiers. Ensure existing records have type populated or a schema default in place; otherwise rendering can fail.
If not already done in this PR, add a migration to backfill modifier.type and, if applicable, modifier.maxDuration. Also consider a
.default(...)in the Zod schema for resilience.
🧹 Nitpick comments (10)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
44-46: Nit: comment wording (“timelines”)The comment says “type AND timelines doesn't match”. Consider “type AND maxDuration don't match” for precision and grammar.
- // 2. type AND timelines doesn't match the primary reward + // 2. type AND maxDuration don't match the primary rewardapps/web/ui/partners/rewards/rewards-logic.tsx (1)
287-295: Optional: fallback entities if event is unsetIf event is temporarily undefined in form state, this filter returns an empty list, blocking selection. Consider defaulting to all entities until event is chosen.
- items={Object.keys(ENTITIES) - .filter((e) => - EVENT_ENTITIES[event]?.includes(e as keyof typeof ENTITIES), - ) + items={(event + ? Object.keys(ENTITIES).filter((e) => + EVENT_ENTITIES[event]?.includes(e as keyof typeof ENTITIES), + ) + : Object.keys(ENTITIES)) .map((entity) => ({ text: capitalize(entity) || entity, value: entity, }))}apps/web/ui/partners/format-reward-description.ts (1)
16-16: Updated call site is correct; consider passing modifiers to enable rangesThe wrapper call is correct. If you want the description to reflect ranges when modifiers exist (as in ProgramRewardList), ensure the reward object includes modifiers (it will at runtime if you pass the original object).
apps/web/ui/partners/program-reward-list.tsx (1)
20-21: Nit: variable name implies sorting but only filterssortedFilteredRewards isn’t sorted. Either sort or rename to filteredRewards for clarity.
- const sortedFilteredRewards = rewards.filter((r) => r.amount >= 0); + const filteredRewards = rewards.filter((r) => r.amount >= 0); ... - {sortedFilteredRewards.map((reward) => ( + {filteredRewards.map((reward) => (apps/web/app/api/og/program/route.tsx (1)
153-159: Unify duration copy: handle one-time, single-month, and Infinity consistentlyThis block handles only null and multi-month cases. Elsewhere (e.g., ProgramRewardModifiersTooltip) Infinity is treated as lifetime, and one-time (0) is also surfaced. Suggest harmonizing here and gating the recurrence phrasing to sale events.
Apply this diff:
- {rewards[0].maxDuration === null ? ( - "for the customer's lifetime" - ) : rewards[0].maxDuration && rewards[0].maxDuration > 1 ? ( - <> - , and again every month for {rewards[0].maxDuration} months - </> - ) : null} + {rewards[0].event === "sale" && ( + <> + {rewards[0].maxDuration === 0 + ? ", one time" + : rewards[0].maxDuration === 1 + ? ", and again for the first month" + : rewards[0].maxDuration === Infinity || rewards[0].maxDuration === null + ? ", for the customer's lifetime" + : rewards[0].maxDuration && rewards[0].maxDuration > 1 + ? `, and again every month for ${rewards[0].maxDuration} months` + : null} + </> + )}apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx (1)
24-29: Cover one-time, single-month, and Infinity cases in copyAdd handling for 0 (one-time), 1 (first month), and Infinity (lifetime) to mirror other components.
Apply this diff:
- ? `For each new customer you refer, you'll earn a ${constructRewardAmount({ reward })} commission on their subscription${ - reward.maxDuration === null - ? " for their lifetime" - : reward.maxDuration && reward.maxDuration > 1 - ? ` for up to ${reward.maxDuration} months` - : "" - }. There are no limits to how much you can earn.` + ? `For each new customer you refer, you'll earn a ${constructRewardAmount({ reward })} commission on their subscription${ + reward.maxDuration === 0 + ? " one time" + : reward.maxDuration === 1 + ? " for the first month" + : reward.maxDuration === Infinity || reward.maxDuration === null + ? " for their lifetime" + : reward.maxDuration && reward.maxDuration > 1 + ? ` for up to ${reward.maxDuration} months` + : "" + }. There are no limits to how much you can earn.`apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)
52-93: Parse and validate modifiers before rendering; add label fallbackDirectly mapping over reward.modifiers assumes schema compliance. Given the new required fields (type, maxDuration), parse via rewardConditionsArraySchema to avoid runtime issues on legacy data, and provide a safe fallback for unknown operator labels.
Apply this diff:
- {( - reward.modifiers as z.infer<typeof rewardConditionsArraySchema> - ).map( - ({ amount, type, operator, conditions, maxDuration }, idx) => ( - <Fragment key={idx}> + {(() => { + const parsed = rewardConditionsArraySchema.safeParse( + reward.modifiers, + ); + const modifiers = parsed.success ? parsed.data : []; + return modifiers.map( + ({ amount, type, operator, conditions, maxDuration }, idx) => ( + <Fragment key={idx}> <div className="mt-1 flex items-start gap-1.5"> <ArrowTurnRight2 className="mt-0.5 size-3 shrink-0" /> <div className="min-w-0"> <strong className="text-content-default font-semibold"> {constructRewardAmount({ reward: { amount, type: type as RewardStructure, maxDuration, modifiers: undefined, }, })} </strong> <ul className="overflow-hidden pl-1 text-xs text-neutral-600"> {conditions.map((condition, idx) => ( <li key={idx} className="flex items-center gap-1"> <span className="shrink-0 text-lg leading-none"> • </span> <span className="min-w-0 truncate"> {idx === 0 ? "If" : capitalize(operator.toLowerCase())} {` ${condition.entity}`} {` ${condition.attribute}`} - {` ${CONDITION_OPERATOR_LABELS[condition.operator]}`} + {` ${ + CONDITION_OPERATOR_LABELS[condition.operator] ?? + String(condition.operator).replaceAll("_", " ") + }`} {` ${ condition.value && truncate( Array.isArray(condition.value) ? condition.value.join(", ") : condition.value.toString(), 16, ) }`} </span> </li> ))} </ul> </div> </div> - </Fragment> - ), - )} + </Fragment> + ), + ); + })()}
63-68: Minor: avoid unnecessary cast if possibleIf
typeis already of the same union as RewardStructure, theas RewardStructurecast can be removed. Not blocking.apps/web/ui/partners/program-reward-description.tsx (2)
38-56: Handle single-month and Infinity; align copy across componentsCurrently 1-month rewards show no period text, and Infinity isn’t treated as lifetime. Recommend aligning with tooltip/OG route semantics.
Apply this diff:
- {reward.maxDuration === null ? ( + {reward.maxDuration === Infinity || reward.maxDuration === null ? ( <> {" "} for the{" "} <strong className={cn("font-semibold", periodClassName)}> customer's lifetime </strong> </> - ) : reward.maxDuration && reward.maxDuration > 1 ? ( + ) : reward.maxDuration === 1 ? ( + <> + {" "} + for the{" "} + <strong className={cn("font-semibold", periodClassName)}> + first month + </strong> + </> + ) : reward.maxDuration && reward.maxDuration > 1 ? ( <> {" "} for{" "} <strong className={cn("font-semibold", periodClassName)}> {reward.maxDuration % 12 === 0 ? `${reward.maxDuration / 12} year${reward.maxDuration / 12 > 1 ? "s" : ""}` : `${reward.maxDuration} months`} </strong> </> ) : null}
78-94: Discount duration: include Infinity handling to mirror lifetime semanticsAdd Infinity alongside null for lifetime to keep behavior consistent with other components.
Apply this diff:
- {discount.maxDuration === null ? ( + {discount.maxDuration === Infinity || discount.maxDuration === null ? ( <strong className={cn("font-semibold", periodClassName)}> for their lifetime </strong>
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (10)
apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx(1 hunks)apps/web/app/api/og/program/route.tsx(1 hunks)apps/web/lib/api/sales/construct-reward-amount.ts(1 hunks)apps/web/ui/partners/format-discount-description.ts(1 hunks)apps/web/ui/partners/format-reward-description.ts(1 hunks)apps/web/ui/partners/program-reward-description.tsx(2 hunks)apps/web/ui/partners/program-reward-list.tsx(2 hunks)apps/web/ui/partners/program-reward-modifiers-tooltip.tsx(3 hunks)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx(6 hunks)apps/web/ui/partners/rewards/rewards-logic.tsx(9 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/program-reward-list.tsxapps/web/ui/partners/program-reward-description.tsxapps/web/app/api/og/program/route.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/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code Graph Analysis (7)
apps/web/ui/partners/format-discount-description.ts (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)
apps/web/ui/partners/format-reward-description.ts (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)
apps/web/app/api/og/program/route.tsx (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)
apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)
apps/web/lib/api/sales/construct-reward-amount.ts (2)
apps/web/lib/types.ts (1)
RewardProps(474-474)apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(69-71)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (2)
rewardConditionsArraySchema(69-71)CONDITION_OPERATOR_LABELS(36-43)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)packages/prisma/client.ts (1)
RewardStructure(19-19)
apps/web/ui/partners/rewards/rewards-logic.tsx (6)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
RewardIconSquare(3-7)apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
InlineBadgePopover(35-72)InlineBadgePopoverMenu(81-175)InlineBadgePopoverInputs(202-276)InlineBadgePopoverInput(177-200)apps/web/lib/zod/schemas/rewards.ts (3)
CONDITION_ATTRIBUTES(22-25)CONDITION_OPERATOR_LABELS(36-43)CONDITION_OPERATORS(27-34)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
useAddEditRewardForm(75-75)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (12)
apps/web/lib/api/sales/construct-reward-amount.ts (2)
16-40: Range logic via modifiers is solid and easy to followThe safeParse + “all modifiers share type and maxDuration” gate before rendering a range is a clear and correct approach. Using Math.min/Math.max across primary and modifiers reads well.
21-25: Infinity ↔ null normalization is already handled in the UI and schema
The Zod maxDurationSchema only accepts numbers in RECURRING_MAX_DURATIONS or null/undefined, and the UI’s add-edit-reward and add-edit-discount sheets convert between Infinity and null on load and on submit. As a result, by the time modifiers and reward come through the schema, both lifetime values are null andmodifier.maxDuration === reward.maxDurationstill holds. No changes needed here.apps/web/ui/partners/rewards/rewards-logic.tsx (2)
42-51: REWARD_TYPES looks good and is reusableExplicit export and simple structure is perfect for menus and badges. Good centralization.
631-667: Modifier amount input UX and constraints look goodSuffix/Prefix adapt to type; min/max validation for percentage; money input handlers wired. Once the dollars→cents conversion above is in place, this stays coherent end-to-end.
apps/web/ui/partners/format-discount-description.ts (1)
17-19: Wrapper call aligns with constructRewardAmount’s new signatureThis change keeps the function concise and leverages the shared formatter. Looks good.
apps/web/ui/partners/program-reward-list.tsx (1)
33-35: Good: unified wrapper usage for both rewards and discountsconstructRewardAmount({ reward }) adoption is consistent and enables range display for rewards with modifiers.
apps/web/app/api/og/program/route.tsx (1)
150-152: Good API migration to constructRewardAmount({ reward })Passing the full reward object aligns with the new helper signature and reduces local formatting logic.
apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx (1)
23-23: Nice consolidation around constructRewardAmount({ reward })Centralizing amount formatting improves consistency with modifier-aware ranges.
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)
9-9: Import of RewardStructure is appropriateUsed to type the per-modifier reward object passed into constructRewardAmount.
34-39: Header amount switch to constructRewardAmount({ reward }) looks goodThis aligns with the helper’s new API and ensures range rendering when modifiers align with type/maxDuration.
apps/web/ui/partners/program-reward-description.tsx (2)
29-31: Good adoption of constructRewardAmount({ reward })Removes local branching and centralizes amount/range formatting.
73-76: Discount amount call updated correctlyPassing the discount object into constructRewardAmount keeps formatting consistent across reward/discount.
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 (1)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
54-63: Fix currency fraction logic: using% 100on dollar amounts is incorrect
formatCurrencyreceives dollar amounts (already divided by 100), but the check usesamount % 100 === 0, which only hides decimals for multiples of $100 and incorrectly forces cents for $1, $2, etc. Use an integer check on dollars.Apply this diff:
-const formatCurrency = (amount: number) => - currencyFormatter( - amount, - amount % 100 === 0 - ? undefined - : { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - ); +const formatCurrency = (amount: number) => + currencyFormatter( + amount, + Number.isInteger(amount) + ? undefined + : { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }, + );
♻️ Duplicate comments (5)
apps/web/ui/partners/rewards/rewards-logic.tsx (5)
271-275: Guard capitalize() when entity/operator may be unsetCalling
capitalize(condition.entity)andoperator.toLowerCase()can throw when unset. Use safe fallbacks.Apply this diff:
- {conditionIndex === 0 ? "If" : capitalize(operator.toLowerCase())}{" "} + {conditionIndex === 0 ? "If" : capitalize((operator ?? "AND").toLowerCase())}{" "} <InlineBadgePopover - text={capitalize(condition.entity) || "Select item"} + text={condition.entity ? capitalize(condition.entity) : "Select item"} invalid={!condition.entity} >
539-543: Compute an effective type and resolved maxDuration once; use them everywhereUnify the fallback logic to avoid undefined type/NaN month edge cases and keep formatting consistent with dollars→cents conversions.
Apply this diff:
- // Use parent values as fallbacks if modifier doesn't have type or maxDuration - const displayType = type || parentType; - const displayMaxDuration = - maxDuration !== undefined ? maxDuration : parentMaxDuration; + // Use parent values as fallbacks if modifier doesn't have type or maxDuration + const displayType = type || parentType; + const displayMaxDuration = + maxDuration !== undefined ? maxDuration : parentMaxDuration; + const effectiveType = (displayType ?? "flat") as RewardStructure; + const resolvedMaxDuration = + displayMaxDuration === undefined ? 0 : displayMaxDuration;
547-563: Guard badge text and rely on effectiveType for labelPrevents
capitalizeon undefined and ensures “of” renders only for percentage.Apply this diff:
- {event === "sale" && ( + {event === "sale" && ( <> a{" "} - <InlineBadgePopover text={capitalize(displayType)}> + <InlineBadgePopover text={displayType ? capitalize(displayType) : "Type"}> <InlineBadgePopoverMenu selectedValue={type} onSelect={(value) => setValue(`${modifierKey}.type`, value as RewardStructure, { shouldDirty: true, }) } items={REWARD_TYPES} /> </InlineBadgePopover>{" "} - {displayType === "percentage" && "of "} + {effectiveType === "percentage" && "of "} </> )}
566-575: Ensure correct unit conversion using effectiveTypeFlat amounts should be converted dollars→cents for
constructRewardAmount; also pass a non-undefined type.Apply this diff:
- ? constructRewardAmount({ - reward: { - amount: displayType === "flat" ? amount * 100 : amount, - type: displayType, - maxDuration: displayMaxDuration, - }, - }) + ? constructRewardAmount({ + reward: { + amount: effectiveType === "flat" ? Math.round((amount ?? 0) * 100) : amount, + type: effectiveType, + maxDuration: resolvedMaxDuration, + }, + })
585-623: Resolve undefined maxDuration to avoid “NaN months” and stabilize selectionUse
resolvedMaxDurationfor label/selection so text and values are always valid.Apply this diff:
- <InlineBadgePopover - text={ - displayMaxDuration === 0 - ? "one time" - : displayMaxDuration === Infinity - ? "for the customer's lifetime" - : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}` - } - > + <InlineBadgePopover + text={ + resolvedMaxDuration === 0 + ? "one time" + : resolvedMaxDuration === Infinity + ? "for the customer's lifetime" + : `for ${resolvedMaxDuration} ${pluralize("month", Number(resolvedMaxDuration))}` + } + > <InlineBadgePopoverMenu - selectedValue={ - displayMaxDuration === Infinity - ? "Infinity" - : displayMaxDuration?.toString() - } + selectedValue={ + resolvedMaxDuration === Infinity + ? "Infinity" + : resolvedMaxDuration.toString() + } onSelect={(value) => setValue( `${modifierKey}.maxDuration`, value === "Infinity" ? Infinity : Number(value), { shouldDirty: true, }, ) }
🧹 Nitpick comments (2)
apps/web/lib/api/sales/construct-reward-amount.ts (1)
27-41: Collapse identical min/max into a single value and avoid duplicate mapsIf min equals max, render a single amount (e.g., “10%” or “$5”) instead of a redundant range. Also precompute the amounts array to avoid mapping twice.
Apply this diff:
- // If the type AND maxDuration matches the primary, show a range + // If the type AND maxDuration matches the primary, show a range if (matchPrimary) { - const min = Math.min( - reward.amount, - ...modifiers.map((modifier) => modifier.amount), - ); - - const max = Math.max( - reward.amount, - ...modifiers.map((modifier) => modifier.amount), - ); - - return reward.type === "percentage" - ? `${min}% - ${max}%` - : `${formatCurrency(min / 100)} - ${formatCurrency(max / 100)}`; + const amounts = [reward.amount, ...modifiers.map((m) => m.amount)]; + const min = Math.min(...amounts); + const max = Math.max(...amounts); + + if (min === max) { + return reward.type === "percentage" + ? `${min}%` + : formatCurrency(min / 100); + } + + return reward.type === "percentage" + ? `${min}% - ${max}%` + : `${formatCurrency(min / 100)} - ${formatCurrency(max / 100)}`; }apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
312-344: Max-duration UI: consider handling Infinity explicitly in setter for clarity
Number(value)works for"Infinity"today, but being explicit improves readability and guards future regressions.Apply this diff:
- onSelect={(value) => - setValue("maxDuration", Number(value), { + onSelect={(value) => + setValue("maxDuration", value === "Infinity" ? Infinity : Number(value), { shouldDirty: true, }) }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
apps/web/lib/api/sales/construct-reward-amount.ts(1 hunks)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx(6 hunks)apps/web/ui/partners/rewards/rewards-logic.tsx(9 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/rewards/rewards-logic.tsxapps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧬 Code Graph Analysis (3)
apps/web/ui/partners/rewards/rewards-logic.tsx (5)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
RewardIconSquare(3-7)apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
InlineBadgePopover(35-72)InlineBadgePopoverMenu(81-175)InlineBadgePopoverInputs(202-276)InlineBadgePopoverInput(177-200)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
useAddEditRewardForm(75-75)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-52)apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
apps/web/lib/api/sales/construct-reward-amount.ts (3)
apps/web/lib/types.ts (1)
RewardProps(474-474)apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(69-71)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(1-11)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(69-71)packages/prisma/client.ts (1)
RewardStructure(19-19)apps/web/ui/partners/rewards/rewards-logic.tsx (1)
REWARD_TYPES(42-51)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (8)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
103-110: Good: new modifiers inherit parent type and maxDurationSeeding a new modifier with the current parent type/maxDuration improves UX and keeps units consistent.
444-462: Nice UX addition: “Shown as” product label inline editorGood conditional display for productId conditions with a compact input UX.
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (6)
21-21: Type import looks correctUsing
RewardStructurefrom Prisma ensures stronger typing across the form.
53-54: Centralizing reward-type options via REWARD_TYPES is a good movePrevents drift between screens and keeps option text/values consistent.
106-108: Correct: normalize modifier amounts and lifetime sentinel in defaultsDividing flat amounts by 100 for display and mapping
null→Infinityimproves UI consistency.
190-192: Correct: normalize modifiers before schema parse on submitCents conversion for flat and
Infinity→nullavoids backend validation issues.
282-287: Type selection is safely cast and options centralizedUsing
RewardStructurecast andREWARD_TYPESmenu avoids stray literals.
296-301: Amount display correctly converts flat dollars → cents for formatterPassing the new reward object shape to
constructRewardAmountaligns with the updated API.
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 (4)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)
24-49: Prefer schema validation over type assertion when mapping modifiersCurrently, modifiers are asserted as the schema type. Validating with zod avoids runtime failures if upstream data is malformed and brings this component in line with constructRewardAmount’s safeParse pattern.
Apply this diff to validate once and map only valid modifiers:
- {( - reward.modifiers as z.infer<typeof rewardConditionsArraySchema> - ).map((modifier, idx) => ( - <div key={idx} className="space-y-2"> - <span className="flex w-full items-center justify-center rounded bg-neutral-100 px-2 py-1 text-xs font-semibold text-neutral-600"> - OR - </span> - - <RewardItem - reward={{ - event: reward.event, - amount: modifier.amount, - type: - modifier.type === undefined ? reward.type : modifier.type, // fallback to primary - maxDuration: - modifier.maxDuration === undefined - ? reward.maxDuration - : modifier.maxDuration, // fallback to primary - }} - conditions={modifier.conditions} - /> - </div> - ))} + {(() => { + const parsed = rewardConditionsArraySchema.safeParse( + reward.modifiers, + ); + const modifiers = parsed.success ? parsed.data : []; + return modifiers.map((modifier, idx) => ( + <div key={idx} className="space-y-2"> + <span className="flex w-full items-center justify-center rounded bg-neutral-100 px-2 py-1 text-xs font-semibold text-neutral-600"> + OR + </span> + + <RewardItem + reward={{ + event: reward.event, + amount: modifier.amount, + type: + modifier.type === undefined ? reward.type : modifier.type, // fallback to primary + maxDuration: + modifier.maxDuration === undefined + ? reward.maxDuration + : modifier.maxDuration, // fallback to primary + }} + conditions={modifier.conditions} + /> + </div> + )); + })()}
29-29: Prefer stable keys over array indexIf a stable identifier exists on modifier (e.g., id), prefer it over idx to avoid potential reconciliation issues if the array order changes.
39-40: Use nullish coalescing for type fallbackSlightly clearer and idiomatic for optional type fields. Keep maxDuration logic as-is to preserve null semantics.
Apply this diff:
- type: - modifier.type === undefined ? reward.type : modifier.type, // fallback to primary + type: modifier.type ?? reward.type, // fallback to primary
72-85: Extract duration text logic into a helper for readability and reuseThe nested ternaries are compact but hard to scan. A small helper improves readability and enables reuse in other reward views.
Apply this diff:
- const durationText = - reward.maxDuration === null - ? "for the customer's lifetime" - : reward.maxDuration === 0 - ? "one time" - : reward.maxDuration && reward.maxDuration % 12 === 0 - ? `for ${reward.maxDuration / 12} ${pluralize( - "year", - reward.maxDuration / 12, - )}` - : reward.maxDuration - ? `for ${reward.maxDuration} months` - : ""; + const durationText = formatRewardDuration(reward.maxDuration);Add this helper (can live in this file or a shared util):
function formatRewardDuration(maxDuration: number | null | undefined) { if (maxDuration === null) return "for the customer's lifetime"; if (maxDuration === 0) return "one time"; if (typeof maxDuration === "number") { if (maxDuration % 12 === 0) { const years = maxDuration / 12; return `for ${years} ${pluralize("year", years)}`; } return `for ${maxDuration} months`; } return ""; }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx(1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)
apps/web/lib/types.ts (1)
RewardProps(474-474)packages/ui/src/tooltip.tsx (1)
InfoTooltip(193-199)apps/web/lib/zod/schemas/rewards.ts (2)
rewardConditionsArraySchema(69-71)CONDITION_OPERATOR_LABELS(36-43)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-52)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (4)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)
7-8: InfoTooltip/pluralize imports: consistent UI utilities usageGood choice leaning on shared UI/utils packages to keep visuals and pluralization consistent across the app.
14-15: Prop widened to RewardProps | null — aligns with constructRewardAmount({ reward }) flowAccepting the full RewardProps simplifies consumers and keeps this component future-proof as reward fields evolve.
16-18: Null/empty modifiers guard prevents empty tooltip renderingThe early return avoids rendering an empty tooltip and keeps UI clean.
65-70: Correctly isolates primary amount by omitting modifiersPassing modifiers: undefined ensures header displays the base reward amount and not the range. Matches constructRewardAmount’s updated API.
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
♻️ Duplicate comments (2)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (2)
36-45: Fallbacks for modifier type/maxDuration are good; ensure upstream data is migratedThe UI fallbacks are sensible. However, per schema updates requiring
type/maxDurationon modifiers, missing fields should be backfilled/migrated to avoid validation failures elsewhere.Would you confirm that a migration/default is in place for modifiers missing
typeand/ormaxDuration? If needed, I can draft a migration or schema defaults.
99-107: Don’t suppress falsy values; add label fallbacks to avoid “undefined”
- Falsy but valid values (0/false/empty string) are currently dropped by the truthiness check.
- ATTRIBUTE/OPERATOR label lookups can render as “undefined” if new keys are introduced; add safe fallbacks.
Apply this diff:
- {idx === 0 ? "If" : "Or"} {condition.entity}{" "} - {ATTRIBUTE_LABELS[condition.attribute]}{" "} - {CONDITION_OPERATOR_LABELS[condition.operator]}{" "} - {condition.value && - (Array.isArray(condition.value) - ? condition.value.join(", ") - : condition.attribute === "productId" && condition.label - ? condition.label - : condition.value.toString())} + {idx === 0 ? "If" : "Or"} {condition.entity}{" "} + {(ATTRIBUTE_LABELS as Record<string, string>)[condition.attribute] ?? + condition.attribute}{" "} + {(CONDITION_OPERATOR_LABELS as Record<string, string>)[condition.operator] ?? + condition.operator}{" "} + {condition.value != null && + (Array.isArray(condition.value) + ? condition.value.map((v) => String(v)).join(", ") + : condition.attribute === "productId" && condition.label + ? condition.label + : String(condition.value))}
🧹 Nitpick comments (4)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)
27-49: Validate modifiers before mapping to avoid runtime errorsThe current cast trusts
reward.modifiersto matchrewardConditionsArraySchema. Prefer safe parsing to avoid crashes if legacy/invalid data slips through.Apply this diff to parse and short-circuit on invalid data:
- {( - reward.modifiers as z.infer<typeof rewardConditionsArraySchema> - ).map((modifier, idx) => ( + {(() => { + const parsed = rewardConditionsArraySchema.safeParse( + reward.modifiers, + ); + if (!parsed.success) return null; + return parsed.data.map((modifier, idx) => ( <div key={idx} className="space-y-2"> <span className="flex w-full items-center justify-center rounded bg-neutral-100 px-2 py-1 text-xs font-semibold text-neutral-600"> OR </span> <RewardItem reward={{ event: reward.event, amount: modifier.amount, type: modifier.type === undefined ? reward.type : modifier.type, // fallback to primary maxDuration: modifier.maxDuration === undefined ? reward.maxDuration : modifier.maxDuration, // fallback to primary }} conditions={modifier.conditions} /> </div> - ))} + )); + })()}
83-85: Pluralize “month” for correct grammarCurrently renders “1 months”. Use the existing
pluralizeutil for months as well.Apply this diff:
- : reward.maxDuration - ? `for ${reward.maxDuration} months` - : ""; + : reward.maxDuration + ? `for ${reward.maxDuration} ${pluralize("month", reward.maxDuration)}` + : "";
96-96: Use a stable key for condition itemsIndex keys can cause reconciliation issues on edits. Construct a stable key from condition fields.
Apply this diff:
- <li key={idx} className="flex items-start gap-1"> + <li + key={`${condition.entity}-${condition.attribute}-${condition.operator}-${Array.isArray(condition.value) ? condition.value.join("|") : String(condition.value ?? "")}`} + className="flex items-start gap-1" + >
29-33: Optional: prefer a stable key for modifier blocksIf modifiers have an id or deterministic composite key, use that instead of
idxto reduce reconciliation churn.Example:
<div key={`${modifier.amount}-${modifier.type ?? reward.type}-${modifier.maxDuration ?? reward.maxDuration}-${idx}`} />
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
apps/web/lib/zod/schemas/rewards.ts(2 hunks)apps/web/ui/partners/program-reward-modifiers-tooltip.tsx(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/lib/zod/schemas/rewards.ts
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (3)
apps/web/lib/types.ts (1)
RewardProps(474-474)apps/web/lib/zod/schemas/rewards.ts (3)
rewardConditionsArraySchema(74-76)ATTRIBUTE_LABELS(36-39)CONDITION_OPERATOR_LABELS(41-48)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-52)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (3)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (3)
15-19: Prop shape update and early-return guard look solidAccepting
RewardProps | nulland guarding onreward?.modifiers?.lengthis correct and prevents unnecessary renders.
66-71: Confirm intent: header amount ignores modifiers/rangesPassing
modifiers: undefinedtoconstructRewardAmountensures the header never shows a range, even when all modifiers align with the primary. If the product requirement is to display a range in this tooltip header when appropriate, remove that override.If the header should show ranges, change to:
- const rewardAmount = constructRewardAmount({ - reward: { - ...reward, - modifiers: undefined, - }, - }); + const rewardAmount = constructRewardAmount({ reward });
25-27: Nice extraction: RewardItem improves readability and reusePulling header/conditions formatting into
RewardItemsimplifies the tooltip and centralizes reward amount computation and duration text.Also applies to: 87-91
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (4)
apps/web/ui/partners/rewards/rewards-logic.tsx (4)
636-649: Resolve undefined maxDuration to avoid “undefined/NaN months”When both modifier and parent maxDuration are unset, the label currently renders “for undefined months”. Use the resolvedMaxDuration (0 = one time) for label and selection.
Apply this diff (assuming resolvedMaxDuration is defined as suggested above):
- <InlineBadgePopover - text={ - displayMaxDuration === 0 - ? "one time" - : displayMaxDuration === Infinity - ? "for the customer's lifetime" - : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}` - } - > + <InlineBadgePopover + text={ + resolvedMaxDuration === 0 + ? "one time" + : resolvedMaxDuration === Infinity + ? "for the customer's lifetime" + : `for ${resolvedMaxDuration} ${pluralize( + "month", + Number(resolvedMaxDuration), + )}` + } + >- selectedValue={ - displayMaxDuration === Infinity - ? "Infinity" - : displayMaxDuration?.toString() - } + selectedValue={ + resolvedMaxDuration === Infinity + ? "Infinity" + : resolvedMaxDuration.toString() + }Also applies to: 651-658
660-672: Verify backend/schema support for Infinity used in the UIThe menu includes an “Infinity” option, but validation/persistence must accept and map it. Ensure zod schemas allow Infinity and the server maps it to the intended DB sentinel (e.g., null or -1).
Run this script to verify and find mapping gaps:
#!/bin/bash set -euo pipefail echo "Check zod schemas for maxDuration allowed values (Infinity support):" rg -n -C2 'RECURRING_MAX_DURATIONS|maxDurationSchema|maxDuration|Infinity' apps/web/lib/zod echo echo "Search usages/mapping for Infinity to DB sentinel across app/server:" rg -n -C3 '\bmaxDuration\b|Infinity|\b(one[- ]?time|lifetime)\b' apps | sed -n '1,400p'
270-273: Guard capitalize calls to prevent runtime errors when values are unsetBoth operator and condition.entity can be temporarily undefined; calling toLowerCase() or capitalize() on undefined will throw.
Apply this diff:
- {conditionIndex === 0 ? "If" : capitalize(operator.toLowerCase())}{" "} + {conditionIndex === 0 + ? "If" + : operator + ? capitalize(operator.toLowerCase()) + : "And"}{" "} <InlineBadgePopover - text={capitalize(condition.entity) || "Select item"} + text={ + condition.entity ? capitalize(condition.entity) : "Select item" + } invalid={!condition.entity} >
590-593: Fix: unify effectiveType, guard badge text, and format flat amounts in cents
- displayType can be undefined (no modifier or parent selection), causing:
- capitalize(displayType) to throw
- Incorrect amount formatting (passing dollars to constructRewardAmount’s cents path)
- Passing undefined type to constructRewardAmount (type narrowing expects RewardStructure)
- Also add a resolvedMaxDuration to avoid undefined/NaN text later.
Apply this diff:
const displayType = type || parentType; const displayMaxDuration = maxDuration !== undefined ? maxDuration : parentMaxDuration; + const effectiveType = (displayType ?? "flat") as RewardStructure; + const resolvedMaxDuration = + displayMaxDuration === undefined ? 0 : displayMaxDuration;- <InlineBadgePopover text={capitalize(displayType)}> + <InlineBadgePopover + text={displayType ? capitalize(displayType) : "Type"} + >- {displayType === "percentage" && "of "} + {effectiveType === "percentage" && "of "}- ? constructRewardAmount({ - reward: { - amount: displayType === "flat" ? amount * 100 : amount, - type: displayType, - maxDuration: displayMaxDuration, - }, - }) + ? constructRewardAmount({ + reward: { + amount: + effectiveType === "flat" + ? Math.round(amount * 100) + : amount, + type: effectiveType, + maxDuration: resolvedMaxDuration, + modifiers: undefined, + }, + })Also applies to: 602-613, 619-624
🧹 Nitpick comments (2)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
683-684: Use parent fallback for AmountInput suffix/validationAmountInput currently reads only the modifier’s type; when unset, the suffix and max validation can diverge from the parent type. Consider falling back to the parent type for consistency.
Apply this diff:
- const type = watch(`${modifierKey}.type`); + const [localType, parentType] = watch([`${modifierKey}.type`, "type"]); + const type = (localType || parentType) as RewardStructure | undefined;
618-626: Zero amount handling: treat 0 as defined if allowedThe invalid flag and display fallback use truthiness, so 0 shows “amount” and marks invalid. If $0 or 0% is a valid reward in your domain, compare against null/undefined instead of falsy.
- text={ - amount + text={ + amount !== undefined ? constructRewardAmount({ ... }) : "amount" } - invalid={!amount} + invalid={amount === undefined}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
apps/web/ui/partners/rewards/rewards-logic.tsx(11 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/rewards/rewards-logic.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (7)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
RewardIconSquare(3-7)apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
InlineBadgePopover(35-78)InlineBadgePopoverMenu(87-181)InlineBadgePopoverInputs(208-282)InlineBadgePopoverInput(183-206)apps/web/lib/zod/schemas/rewards.ts (3)
ATTRIBUTE_LABELS(36-39)CONDITION_OPERATOR_LABELS(41-48)CONDITION_OPERATORS(27-34)packages/ui/src/icons/nucleo/chevron-right.tsx (1)
ChevronRight(3-24)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
useAddEditRewardForm(75-75)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-52)apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
43-52: Good addition: centralized REWARD_TYPES for consistent menusExporting REWARD_TYPES here keeps type choices consistent across call sites and avoids duplication. Looks good.
109-111: Pre-fill new modifier with parent type and durationSeeding modifiers with the parent’s type and maxDuration improves UX and keeps display/validation coherent with parent defaults.
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
♻️ Duplicate comments (5)
apps/web/ui/partners/rewards/rewards-logic.tsx (4)
276-285: Reset dependent fields when entity changes to avoid invalid state carryoverExplicitly clearing attribute/operator/value/label on entity change avoids stale combinations and is more maintainable than overwriting the whole object.
Apply this diff:
- onSelect={(value) => - setValue( - conditionKey, - { entity: value as keyof typeof ENTITIES }, - { - shouldDirty: true, - }, - ) - } + onSelect={(value) => { + const next = value as keyof typeof ENTITIES; + setValue(`${conditionKey}.entity`, next, { shouldDirty: true }); + setValue(`${conditionKey}.attribute`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.operator`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.value`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.label`, undefined, { shouldDirty: true }); + }}
634-671: Prevent “NaN months” when maxDuration is unset; resolve a fallback onceWhen both modifier and parent maxDuration are undefined, labels render “NaN months”. Resolve a single fallback (e.g., 0 = one time) and use it for the badge text and selectedValue.
Apply this diff:
- <InlineBadgePopover - text={ - displayMaxDuration === 0 - ? "one time" - : displayMaxDuration === Infinity - ? "for the customer's lifetime" - : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}` - } - > + {(() => { + const resolvedMaxDuration = + displayMaxDuration === undefined ? 0 : displayMaxDuration; + return ( + <InlineBadgePopover + text={ + resolvedMaxDuration === 0 + ? "one time" + : resolvedMaxDuration === Infinity + ? "for the customer's lifetime" + : `for ${resolvedMaxDuration} ${pluralize("month", Number(resolvedMaxDuration))}` + } + > <InlineBadgePopoverMenu - selectedValue={ - displayMaxDuration === Infinity - ? "Infinity" - : displayMaxDuration?.toString() - } + selectedValue={ + resolvedMaxDuration === Infinity + ? "Infinity" + : resolvedMaxDuration.toString() + } onSelect={(value) => setValue( `${modifierKey}.maxDuration`, value === "Infinity" ? Infinity : Number(value), { shouldDirty: true, }, ) } items={[ { text: "one time", value: "0", }, ...RECURRING_MAX_DURATIONS.filter((v) => v !== 0).map((v) => ({ text: `for ${v} ${pluralize("month", Number(v))}`, value: v.toString(), })), { text: "for the customer's lifetime", value: "Infinity", }, ]} /> - </InlineBadgePopover> + </InlineBadgePopover> + ); + })()}
270-274: Fix crash: capitalize(condition.entity) when entity is unsetcapitalize(undefined) throws. This will crash when the user hasn’t selected an entity yet.
Apply this diff:
- <InlineBadgePopover - text={capitalize(condition.entity) || "Select item"} + <InlineBadgePopover + text={condition.entity ? capitalize(condition.entity) : "Select item"} invalid={!condition.entity} >
590-594: Guard type badge and unify an effectiveType for formatting/semanticsdisplayType can be undefined (e.g., modifier not set yet), which crashes on capitalize(displayType) and misformats the amount. Compute an effectiveType once with a safe fallback and use it consistently.
Apply this diff:
// Use parent values as fallbacks if modifier doesn't have type or maxDuration - const displayType = type || parentType; + const displayType = type || parentType; + const effectiveType = (displayType ?? "flat") as RewardStructure; const displayMaxDuration = maxDuration !== undefined ? maxDuration : parentMaxDuration; @@ - {event === "sale" && ( + {event === "sale" && ( <> a{" "} - <InlineBadgePopover text={capitalize(displayType)}> + <InlineBadgePopover text={displayType ? capitalize(displayType) : "Type"}> <InlineBadgePopoverMenu selectedValue={type} onSelect={(value) => setValue(`${modifierKey}.type`, value as RewardStructure, { shouldDirty: true, }) } items={REWARD_TYPES} /> </InlineBadgePopover>{" "} - {displayType === "percentage" && "of "} + {effectiveType === "percentage" && "of "} </> )} @@ amount ? constructRewardAmount({ - amount: displayType === "flat" ? amount * 100 : amount, - type: displayType, + amount: effectiveType === "flat" ? Math.round(amount * 100) : amount, + type: effectiveType, maxDuration: displayMaxDuration, }) : "amount"Note: Using Math.round avoids floating-point precision artifacts when converting dollars → cents.
Also applies to: 601-614, 618-624
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)
104-109: Don’t suppress valid falsy values (e.g., 0/false) in condition valueThe truthy check hides valid values. Switch to a nullish check and safely stringify.
Apply this diff:
- {condition.value && - (Array.isArray(condition.value) - ? condition.value.join(", ") - : condition.attribute === "productId" && condition.label - ? condition.label - : condition.value.toString())} + {condition.value !== undefined && condition.value !== null && + (Array.isArray(condition.value) + ? condition.value.map((v) => String(v)).join(", ") + : condition.attribute === "productId" && condition.label + ? condition.label + : String(condition.value))}
🧹 Nitpick comments (7)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
212-214: Avoid duplicating attribute labels; import from schema for consistencyThis file defines a local ATTRIBUTE_LABELS that diverges from the shared schema. Use the centralized constant to keep labels consistent across UI.
Apply this diff:
--- a/apps/web/ui/partners/rewards/rewards-logic.tsx +++ b/apps/web/ui/partners/rewards/rewards-logic.tsx @@ -import { - CONDITION_ATTRIBUTES, - CONDITION_CUSTOMER_ATTRIBUTES, - CONDITION_OPERATOR_LABELS, - CONDITION_OPERATORS, - CONDITION_SALE_ATTRIBUTES, -} from "@/lib/zod/schemas/rewards"; +import { + ATTRIBUTE_LABELS, + CONDITION_ATTRIBUTES, + CONDITION_CUSTOMER_ATTRIBUTES, + CONDITION_OPERATOR_LABELS, + CONDITION_OPERATORS, + CONDITION_SALE_ATTRIBUTES, +} from "@/lib/zod/schemas/rewards"; @@ -const ATTRIBUTE_LABELS = { - productId: "Product ID", -}; +// Use shared ATTRIBUTE_LABELS from schemaOptionally, if you want “Product ID” title-casing here, consider updating the shared constant to match the product copy guidelines.
Also applies to: 325-329
680-715: AmountInput: derive effective type from parent for correct $/USD adornersIf a modifier doesn’t set its type, the input won’t show the dollar prefix/suffix even when the parent type is “flat”. Watch the parent type and derive an effective type.
Apply this diff:
function AmountInput({ modifierKey }: { modifierKey: `modifiers.${number}` }) { const { watch, register } = useAddEditRewardForm(); - const type = watch(`${modifierKey}.type`); + const type = watch(`${modifierKey}.type`); + const parentType = watch("type"); + const effectiveType = (type ?? parentType) as RewardStructure; @@ - {type === "flat" && ( + {effectiveType === "flat" && ( <span className="absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400"> $ </span> )} @@ - type === "flat" ? "pl-4 pr-12" : "pr-7", + effectiveType === "flat" ? "pl-4 pr-12" : "pr-7", )} @@ - {type === "flat" ? "USD" : "%"} + {effectiveType === "flat" ? "USD" : "%"}apps/web/lib/api/sales/construct-reward-amount.ts (1)
5-12: Range logic and formatting look solid; minor nit on cents precisionThe modifiers-match logic is correct, and currency formatting via formatCurrency(min/100) is consistent. One minor nit: ensure upstream callers always pass integer cents for flat amounts to avoid floating precision issues (your UI changes mostly do this; consider Math.round there).
Also applies to: 25-41, 44-49
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)
74-86: Duration text: prefer explicit handling for undefined to avoid blank statesIf maxDuration is undefined, durationText becomes empty even for sale events. Consider aligning with the rest of the UI by treating undefined like 0 (“one time”) or omitting the badge intentionally.
Proposed tweak:
- Decide a consistent fallback (e.g., undefined => no suffix) or reuse the same “resolvedMaxDuration” pattern as in rewards-logic.tsx for parity across surfaces.
Also applies to: 90-93
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
282-287: Type badge safety: guard capitalize(type) in case type is momentarily unsetWhile defaults usually set type, guarding the badge avoids rare crashes during initial form hydration.
Apply this diff:
- <InlineBadgePopover text={capitalize(type)}> + <InlineBadgePopover text={type ? capitalize(type) : "Type"}>
295-300: Minor: round cents to avoid floating artifactsWhen converting dollars → cents, use Math.round to prevent values like 10.009999... from leaking through.
Apply this diff:
- amount: type === "flat" ? amount * 100 : amount, + amount: type === "flat" ? Math.round(amount * 100) : amount,
320-341: Ensure Infinity selection is round-trippable and typedConverting "Infinity" via Number(value) works, but selectedValue should also handle Infinity explicitly for clarity.
Apply this diff:
- selectedValue={maxDuration?.toString()} - onSelect={(value) => - setValue("maxDuration", Number(value), { + selectedValue={ + maxDuration === Infinity ? "Infinity" : maxDuration?.toString() + } + onSelect={(value) => + setValue("maxDuration", value === "Infinity" ? Infinity : Number(value), { shouldDirty: true, }) }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (10)
apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx(1 hunks)apps/web/app/api/og/program/route.tsx(1 hunks)apps/web/lib/api/sales/construct-reward-amount.ts(1 hunks)apps/web/lib/partners/determine-partner-reward.ts(2 hunks)apps/web/ui/partners/format-discount-description.ts(1 hunks)apps/web/ui/partners/program-reward-description.tsx(2 hunks)apps/web/ui/partners/program-reward-list.tsx(2 hunks)apps/web/ui/partners/program-reward-modifiers-tooltip.tsx(2 hunks)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx(6 hunks)apps/web/ui/partners/rewards/rewards-logic.tsx(11 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
- apps/web/ui/partners/program-reward-description.tsx
- apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx
- apps/web/lib/partners/determine-partner-reward.ts
- apps/web/ui/partners/format-discount-description.ts
- apps/web/ui/partners/program-reward-list.tsx
- apps/web/app/api/og/program/route.tsx
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/program-reward-modifiers-tooltip.tsxapps/web/ui/partners/rewards/add-edit-reward-sheet.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/ui/partners/program-reward-modifiers-tooltip.tsxapps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧬 Code Graph Analysis (4)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (4)
packages/ui/src/tooltip.tsx (1)
InfoTooltip(193-199)apps/web/lib/zod/schemas/rewards.ts (3)
rewardConditionsArraySchema(74-76)ATTRIBUTE_LABELS(36-39)CONDITION_OPERATOR_LABELS(41-48)apps/web/lib/types.ts (1)
RewardProps(474-474)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)
apps/web/lib/api/sales/construct-reward-amount.ts (3)
apps/web/lib/types.ts (1)
RewardProps(474-474)apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(74-76)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(1-11)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(74-76)packages/prisma/client.ts (1)
RewardStructure(19-19)apps/web/ui/partners/rewards/rewards-logic.tsx (1)
REWARD_TYPES(43-52)
apps/web/ui/partners/rewards/rewards-logic.tsx (6)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
RewardIconSquare(3-7)apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
InlineBadgePopover(35-78)InlineBadgePopoverMenu(87-181)InlineBadgePopoverInputs(208-282)InlineBadgePopoverInput(183-206)apps/web/lib/zod/schemas/rewards.ts (4)
ATTRIBUTE_LABELS(36-39)CONDITION_ATTRIBUTES(22-25)CONDITION_OPERATOR_LABELS(41-48)CONDITION_OPERATORS(27-34)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
useAddEditRewardForm(75-75)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
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 (2)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
269-274: Guard capitalize() when entity is unset to avoid runtime error
Calling capitalize(condition.entity) throws when entity is undefined for a new/blank condition.Apply this diff:
- text={capitalize(condition.entity) || "Select item"} + text={condition.entity ? capitalize(condition.entity) : "Select item"}
277-285: Reset dependent fields when entity changes to avoid invalid state combos
When changing entity, clear attribute/operator/value/label; different entities have different attributes. This aligns with prior learnings.Apply this diff:
- onSelect={(value) => - setValue( - conditionKey, - { entity: value as keyof typeof ENTITIES }, - { - shouldDirty: true, - }, - ) - } + onSelect={(value) => { + const next = value as keyof typeof ENTITIES; + setValue(`${conditionKey}.entity`, next, { shouldDirty: true }); + setValue(`${conditionKey}.attribute`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.operator`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.value`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.label`, undefined, { shouldDirty: true }); + }}
🧹 Nitpick comments (4)
apps/web/ui/partners/rewards/rewards-logic.tsx (4)
383-424: Mark form as dirty when changing country values
setValue calls here don’t pass options; add { shouldDirty: true } so validation/submit states reflect edits.Apply this diff:
- onSelect={(value) => { - setValue(conditionKey, { - ...condition, - value: isArrayValue - ? Array.isArray(condition.value) - ? (condition.value as string[]).includes( - value, - ) - ? (condition.value.filter( - (v) => v !== value, - ) as string[]) - : ([...condition.value, value] as string[]) - : [value] - : value, - }); - }} + onSelect={(value) => { + setValue( + conditionKey, + { + ...condition, + value: isArrayValue + ? Array.isArray(condition.value) + ? (condition.value as string[]).includes(value) + ? (condition.value.filter((v) => v !== value) as string[]) + : ([...condition.value, value] as string[]) + : [value] + : value, + }, + { shouldDirty: true }, + ); + }}
426-441: Also mark dirty when editing array values
Same rationale for InlineBadgePopoverInputs.Apply this diff:
- onChange={(values) => { - setValue(conditionKey, { - ...condition, - value: values, - }); - }} + onChange={(values) => { + setValue( + conditionKey, + { ...condition, value: values }, + { shouldDirty: true }, + ); + }}
496-510: Don’t flag product label as invalid unless the editor is expanded
Showing invalid style when the “Shown as” editor is collapsed can be noisy.Apply this diff:
- <InlineBadgePopover - text={condition.label || "Product name"} - invalid={!condition.label} - > + <InlineBadgePopover + text={condition.label || "Product name"} + invalid={displayProductLabel && !condition.label} + >
681-716: Use parent fallback for AmountInput type to keep suffix/validation accurate
If the modifier’s type is unset, the input suffix and max validation may be wrong. Compute an effective type using the parent reward’s type.Apply this diff:
- const type = watch(`${modifierKey}.type`); + const modifierType = watch(`${modifierKey}.type`); + const parentType = watch("type"); + const type = (modifierType ?? parentType ?? "flat") as RewardStructure;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
apps/web/ui/partners/rewards/rewards-logic.tsx(11 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/rewards/rewards-logic.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/rewards/rewards-logic.tsx (8)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
RewardIconSquare(3-7)apps/web/ui/partners/rewards/inline-badge-popover.tsx (4)
InlineBadgePopover(35-78)InlineBadgePopoverMenu(87-181)InlineBadgePopoverInputs(208-282)InlineBadgePopoverInput(183-206)apps/web/lib/zod/schemas/rewards.ts (4)
ATTRIBUTE_LABELS(36-39)CONDITION_ATTRIBUTES(22-25)CONDITION_OPERATOR_LABELS(41-48)CONDITION_OPERATORS(27-34)packages/ui/src/icons/nucleo/chevron-right.tsx (1)
ChevronRight(3-24)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
useAddEditRewardForm(75-75)packages/prisma/client.ts (1)
RewardStructure(19-19)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-50)apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (6)
apps/web/ui/partners/rewards/rewards-logic.tsx (6)
43-52: REWARD_TYPES constant reads well and is reusableExporting a shared REWARD_TYPES list keeps UI and logic consistent.
109-111: Seeding new modifiers with parent type/maxDuration is a good callThis ensures nested editors start with sensible fallbacks and keeps formatting/validation stable.
164-174: Delegating per-condition remove via onRemove is a cleaner responsibility splitNice refactor. It simplifies ConditionLogic.
262-263: Local UI state for product label expansionThe state handling is straightforward and scoped appropriately.
286-293: Entity options filtered by event looks correctFiltering against EVENT_ENTITIES prevents invalid choices.
322-331: Attribute and operator flows are solid
- Selecting attribute resets other fields — good for consistency.
- Operator change toggles value shape (array vs string) — correct and defensive.
Also applies to: 341-358
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 (3)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
107-109: Defaulting modifier maxDuration: handle undefined as well as nullWhen loading older rewards or partially-filled modifier forms, maxDuration may be undefined. Treating both null and undefined as Infinity makes the UI consistent for “lifetime”.
Apply this diff:
- amount: m.type === "flat" ? m.amount / 100 : m.amount, - maxDuration: m.maxDuration === null ? Infinity : m.maxDuration, + amount: m.type === "flat" ? m.amount / 100 : m.amount, + maxDuration: m.maxDuration == null ? Infinity : m.maxDuration,Note: Using == here is intentional to match null or undefined without affecting 0.
191-193: Normalize modifiers before validation: add rounding and undefined handling
- Rounding: When converting flat amounts back to cents, prefer Math.round to avoid floating-point drift (e.g., 12.34 * 100 → 1233.999…).
- Undefined handling: Convert undefined maxDuration to null so the schema doesn’t see undefined for a nullable field.
- amount: m.type === "flat" ? m.amount * 100 : m.amount, - maxDuration: m.maxDuration === Infinity ? null : m.maxDuration, + amount: m.type === "flat" ? Math.round(m.amount * 100) : m.amount, + maxDuration: + m.maxDuration == null + ? null + : m.maxDuration === Infinity + ? null + : m.maxDuration,Note: Consider making the same Math.round change for the top-level amount conversion for consistency.
283-288: Prefer casting to the form’s own type rather than the Prisma enumThis avoids coupling the UI form to the Prisma enum and relies solely on the zod-derived type that the form actually uses.
Apply this diff:
- setValue("type", value as RewardStructure, { + setValue("type", value as FormData["type"], { shouldDirty: true, })Additionally, this continues to work after the type-only import in Line 21.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx(6 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧬 Code Graph Analysis (1)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(74-76)packages/prisma/client.ts (1)
RewardStructure(19-19)apps/web/ui/partners/rewards/rewards-logic.tsx (1)
REWARD_TYPES(43-52)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)
53-53: Good move: centralize reward types via REWARD_TYPESSourcing options from a single shared constant avoids drift between UI and logic.
296-301: constructRewardAmount signature updated and call sites migratedThe function in apps/web/lib/api/sales/construct-reward-amount.ts now accepts a single object
export const constructRewardAmount = ( reward: Pick<RewardProps, "amount" | "type" | "maxDuration" | "modifiers">, ) => { … }All 12+ call sites (e.g. in add-edit-reward-sheet.tsx, format-discount-description.ts, program-reward-list.tsx, etc.) now pass exactly one object with
amount,type,maxDuration(and implicitlymodifiers), and none use the old multi-arg form. No further changes needed.
|
/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: 1
♻️ Duplicate comments (3)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
276-285: Reset dependent fields when entity changes to avoid invalid statesChanging
entityshould clearattribute,operator,value, andlabelto prevent incompatible combinations.- onSelect={(value) => - setValue( - conditionKey, - { entity: value as keyof typeof ENTITIES }, - { - shouldDirty: true, - }, - ) - } + onSelect={(value) => { + const next = value as keyof typeof ENTITIES; + setValue(`${conditionKey}.entity`, next, { shouldDirty: true }); + setValue(`${conditionKey}.attribute`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.operator`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.value`, undefined, { shouldDirty: true }); + setValue(`${conditionKey}.label`, undefined, { shouldDirty: true }); + }}
590-594: Fix crashes and incorrect amount formatting when type/maxDuration are unset
capitalize(displayType)can throw whendisplayTypeis undefined.- Flat amounts are passed in dollars to
constructRewardAmountwhendisplayTypeis unset, causing $10 to render as $0.10.- Duration text can render “NaN months” when unset.
Compute
effectiveTypeandresolvedMaxDurationonce and use them consistently.// Use parent values as fallbacks if modifier doesn't have type or maxDuration - const displayType = type || parentType; - const displayMaxDuration = - maxDuration !== undefined ? maxDuration : parentMaxDuration; + const displayType = type || parentType; + const displayMaxDuration = + maxDuration !== undefined ? maxDuration : parentMaxDuration; + const effectiveType = (displayType ?? "flat") as RewardStructure; + const typeBadgeLabel = displayType ? capitalize(displayType) : "Type"; + const resolvedMaxDuration = + displayMaxDuration === undefined ? 0 : displayMaxDuration; @@ - {event === "sale" && ( + {event === "sale" && ( <> a{" "} - <InlineBadgePopover text={capitalize(displayType)}> + <InlineBadgePopover text={typeBadgeLabel}> <InlineBadgePopoverMenu selectedValue={type} onSelect={(value) => setValue(`${modifierKey}.type`, value as RewardStructure, { shouldDirty: true, }) } items={REWARD_TYPES} /> </InlineBadgePopover>{" "} - {displayType === "percentage" && "of "} + {effectiveType === "percentage" && "of "} </> )} @@ - amount - ? constructRewardAmount({ - amount: displayType === "flat" ? amount * 100 : amount, - type: displayType, - maxDuration: displayMaxDuration, - }) + amount + ? constructRewardAmount({ + amount: effectiveType === "flat" ? Math.round(amount * 100) : amount, + type: effectiveType, + maxDuration: resolvedMaxDuration, + }) : "amount" @@ - <InlineBadgePopover - text={ - displayMaxDuration === 0 - ? "one time" - : displayMaxDuration === Infinity - ? "for the customer's lifetime" - : `for ${displayMaxDuration} ${pluralize("month", Number(displayMaxDuration))}` - } - > + <InlineBadgePopover + text={ + resolvedMaxDuration === 0 + ? "one time" + : resolvedMaxDuration === Infinity + ? "for the customer's lifetime" + : `for ${resolvedMaxDuration} ${pluralize("month", Number(resolvedMaxDuration))}` + } + > <InlineBadgePopoverMenu - selectedValue={ - displayMaxDuration === Infinity - ? "Infinity" - : displayMaxDuration?.toString() - } + selectedValue={ + resolvedMaxDuration === Infinity ? "Infinity" : resolvedMaxDuration.toString() + } onSelect={(value) => setValue( `${modifierKey}.maxDuration`, value === "Infinity" ? Infinity : Number(value), { shouldDirty: true, }, ) } items={[ { text: "one time", value: "0", }, ...RECURRING_MAX_DURATIONS.filter( (v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts) ).map((v) => ({ text: `for ${v} ${pluralize("month", Number(v))}`, value: v.toString(), })), { text: "for the customer's lifetime", value: "Infinity", }, ]} /> </InlineBadgePopover>Also applies to: 598-614, 618-623, 634-673
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
21-21: Use type-only Prisma imports in client componentsThis is a client module. Import Prisma types with
import typeto avoid bundling server code.-import { EventType, RewardStructure } from "@dub/prisma/client"; +import type { EventType, RewardStructure } from "@dub/prisma/client";
🧹 Nitpick comments (7)
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx (1)
427-437: Avoid per-optionselected; control the select via value/defaultValueUsing
selectedon<option>conflicts with react-hook-form’s control pattern and can lead to unexpected defaults. Set the select’sdefaultValue(or a controlledvalue) and remove the per-optionselected.Apply this diff:
@@ - <select + <select {...register("maxDuration", { setValueAs: (v) => { if (v === "" || v === null) { return null; } return parseInt(v); }, })} + defaultValue={12} className="mt-2 block w-full rounded-md border border-neutral-300 bg-white py-2 pl-3 pr-10 text-sm text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500" > - {RECURRING_MAX_DURATIONS.filter( + {RECURRING_MAX_DURATIONS.filter( (v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts) ).map((duration) => ( <option key={duration} value={duration} - selected={duration === 12} > {duration} {duration === 1 ? "month" : "months"} </option> ))} <option value="">Lifetime</option> </select>Also applies to: 415-426
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
16-16: Use type-only Prisma imports in client componentsThis is a client module. Import Prisma types with
import typeto avoid pulling runtime code into the bundle.-import { EventType, RewardStructure } from "@dub/prisma/client"; +import type { EventType, RewardStructure } from "@dub/prisma/client";
688-696: Normalize type in AmountInput to stabilize UI and validationBefore the effect syncs parent type,
displayTypecan be undefined, producing wrong padding/units and missing max validation. ComputeeffectiveTypeand use it.- const displayType = type || parentType; + const displayType = type || parentType; + const effectiveType = (displayType ?? "flat") as RewardStructure; @@ - {displayType === "flat" && ( + {effectiveType === "flat" && ( @@ - displayType === "flat" ? "pl-4 pr-12" : "pr-7", + effectiveType === "flat" ? "pl-4 pr-12" : "pr-7", @@ - max: displayType === "percentage" ? 100 : undefined, + max: effectiveType === "percentage" ? 100 : undefined, @@ - {displayType === "flat" ? "USD" : "%"} + {effectiveType === "flat" ? "USD" : "%"}Also applies to: 699-699, 707-707, 714-714, 727-727
apps/web/lib/api/sales/construct-reward-amount.ts (2)
9-13: Avoid parsing empty modifier arrays
reward.modifiersbeing an empty array is truthy; parsing then failing on.min(1)is unnecessary overhead. Short-circuit on length.- if (reward.modifiers) { + if (Array.isArray(reward.modifiers) && reward.modifiers.length > 0) { const parsedModifiers = rewardConditionsArraySchema.safeParse( reward.modifiers, );
47-52: Minor: fix comment typo“timelines” should be “timelines”/“timeline” singular. Also consider clarifying that “maxDuration/type doesn’t match”.
- // 2. type AND timelines doesn't match the primary reward + // 2. type AND timeline don't match the primary rewardapps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)
221-224: Prefer direct Infinity check for readability
Infinity === Number(data.maxDuration)works but is opaque. A direct comparison is clearer.- maxDuration: - Infinity === Number(data.maxDuration) ? null : data.maxDuration, + maxDuration: data.maxDuration === Infinity ? null : data.maxDuration,
346-348: Deduplicate “exclude 0 and 1 month” logicThis filter appears in multiple components. Consider a shared helper, e.g.,
getRecurringDurationOptions({ excludeOneTime: true, excludeOneMonth: true })to keep UI consistent.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (5)
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx(1 hunks)apps/web/lib/api/sales/construct-reward-amount.ts(1 hunks)apps/web/ui/partners/add-edit-discount-sheet.tsx(1 hunks)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx(7 hunks)apps/web/ui/partners/rewards/rewards-logic.tsx(11 hunks)
✅ Files skipped from review due to trivial changes (1)
- apps/web/ui/partners/add-edit-discount-sheet.tsx
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/rewards/rewards-logic.tsxapps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
🧬 Code Graph Analysis (4)
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx (1)
apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
apps/web/ui/partners/rewards/rewards-logic.tsx (7)
apps/web/ui/partners/rewards/reward-icon-square.tsx (1)
RewardIconSquare(3-7)apps/web/ui/partners/rewards/inline-badge-popover.tsx (5)
InlineBadgePopover(35-78)InlineBadgePopoverMenu(87-176)InlineBadgePopoverInputs(203-277)InlineBadgePopoverInput(178-201)InlineBadgePopoverContext(27-33)apps/web/lib/zod/schemas/rewards.ts (4)
ATTRIBUTE_LABELS(36-39)CONDITION_ATTRIBUTES(22-25)CONDITION_OPERATOR_LABELS(41-48)CONDITION_OPERATORS(27-34)packages/ui/src/icons/nucleo/chevron-right.tsx (1)
ChevronRight(3-24)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
useAddEditRewardForm(75-75)apps/web/lib/api/sales/construct-reward-amount.ts (1)
constructRewardAmount(5-53)apps/web/lib/zod/schemas/misc.ts (1)
RECURRING_MAX_DURATIONS(6-6)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (2)
apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(74-76)apps/web/ui/partners/rewards/rewards-logic.tsx (1)
REWARD_TYPES(43-52)
apps/web/lib/api/sales/construct-reward-amount.ts (3)
apps/web/lib/types.ts (1)
RewardProps(474-474)apps/web/lib/zod/schemas/rewards.ts (1)
rewardConditionsArraySchema(74-76)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(1-11)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (3)
apps/web/ui/partners/rewards/rewards-logic.tsx (2)
43-52: LGTM: Centralized reward type menuExporting
REWARD_TYPEShere and reusing across panes keeps the UI consistent and reduces duplication.
109-111: LGTM: Seed new modifier with parent type/durationInitializing each modifier with parent
typeandmaxDurationaligns downstream formatting and avoids undefined state on first render.apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
197-207: LGTM: Normalize modifier amounts and lifetime mappingConverting flat amounts dollars→cents and mapping
Infinity→nullfor persistence aligns with schema expectations.
Summary by CodeRabbit
New Features
UI/UX
Refactor