-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Custom reward description configuration #2825
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.
|
WalkthroughAdds optional reward description end-to-end: schema accepts description, UI (Add/Edit sheet and InlineBadgePopoverInput) exposes editable description with animation and counter, and create/update actions persist/clear description in the database. No other control flow or exported signatures changed except the input component typing. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant Sheet as Add/Edit Reward Sheet
participant Pop as InlineBadgePopover/Input
participant API as Create/Update Action
participant DB as Prisma/DB
participant Cache as Cache/Invalidation
User->>Sheet: open sheet
User->>Pop: open description editor / type text
Pop-->>Sheet: update form.description
User->>Sheet: submit form
Sheet->>API: POST payload (includes description)
API->>DB: create/update reward (description|null)
DB-->>API: persisted reward
API->>Cache: invalidate related caches
API-->>Sheet: success
Sheet-->>User: updated UI
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Pre-merge checks (3 passed)✅ Passed checks (3 passed)
Poem
✨ 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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (3)
197-205: Fix stale upsell state (blocks submit after removing modifiers).
setShowAdvancedUpsell(true)is only ever set to true and never reset whenmodifiersis cleared or plan changes. This can permanently block submissions via the early return inonSubmit.Apply:
useEffect(() => { - if ( - modifiers?.length && - !getPlanCapabilities(plan).canUseAdvancedRewardLogic - ) { - setShowAdvancedUpsell(true); - } + setShowAdvancedUpsell( + Boolean( + modifiers?.length && + !getPlanCapabilities(plan).canUseAdvancedRewardLogic, + ), + ); }, [modifiers, plan]);
504-522: Disable submit when upsell is required so the tooltip/CTA actually appears.The button isn’t disabled when
showAdvancedUpsellis true, sodisabledTooltipnever shows and the form silently no-ops on submit. Disable the button in this case.<Button type="submit" variant="primary" text={reward ? "Update reward" : "Create reward"} className="w-fit" loading={isCreating || isUpdating} - disabled={ - amount == null || isDeleting || isCreating || isUpdating - } + disabled={ + amount == null || + isDeleting || + isCreating || + isUpdating || + showAdvancedUpsell + } disabledTooltip={ showAdvancedUpsell ? ( <TooltipContent title="Advanced reward structures are only available on the Advanced plan and above." cta="Upgrade to Advanced" onClick={() => setShowPartnersUpgradeModal(true)} /> ) : undefined } />
251-259: Normalize empty description to null and avoid variable shadowing.
- Persisting
""will leak empty strings to the backend/DB. Normalize trimmed empty tonull.- Local
let modifiersshadows the watchedmodifiers; rename for clarity.- let modifiers: RewardConditionsArray | null = null; + let serializedModifiers: RewardConditionsArray | null = null; @@ - if (data.modifiers?.length) { + if (data.modifiers?.length) { try { - modifiers = rewardConditionsArraySchema.parse( + serializedModifiers = rewardConditionsArraySchema.parse( data.modifiers.map((m) => { @@ ); } catch (error) { - console.log("parse error", error); + // Optional: keep console clean in production; toast already surfaces the error. + // console.error("reward condition parse error", error); setError("root.logic", { message: "Invalid reward condition" }); toast.error( "Invalid reward condition. Please fix the errors and try again.", ); return; } } @@ const payload = { ...data, workspaceId, amount: type === "flat" ? Math.round(data.amount * 100) : data.amount, maxDuration: Infinity === Number(data.maxDuration) ? null : data.maxDuration, - modifiers, + modifiers: serializedModifiers, + description: + typeof data.description === "string" + ? (data.description.trim().length ? data.description.trim() : null) + : data.description ?? null, };Also applies to: 211-241
🧹 Nitpick comments (8)
apps/web/lib/actions/partners/create-reward.ts (1)
21-29: Normalize description (trim and empty-to-null) before persistingIn apps/web/lib/actions/partners/create-reward.ts (lines 21–29 & 62–64), trim
parsedInput.description, convert empty strings tonull, and use that value in your Prisma call:.action(async ({ parsedInput, ctx }) => { const { …, description, … } = parsedInput; + const normalizedDescription = + typeof description === "string" + ? (description.trim() === "" ? null : description.trim()) + : null; // … await prisma.reward.create({ data: { // … - description: description || null, + description: normalizedDescription, // … }, });apps/web/lib/actions/partners/update-reward.ts (1)
18-19: Mirror description normalization on update; avoid losing Entered spaces and allow clearing.Trims, converts empty-to-null, and makes update path consistent with create.
.action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; const { rewardId, amount, maxDuration, type, description, modifiers } = parsedInput; + const normalizedDescription = + typeof description === "string" + ? (description.trim() === "" ? null : description.trim()) + : null; @@ amount, maxDuration, - description: description || null, + description: normalizedDescription, modifiers: modifiers === null ? Prisma.DbNull : modifiers,Also applies to: 44-46
apps/web/lib/zod/schemas/rewards.ts (1)
153-154: Add server-validated constraints to description (trim + max length) and keep in sync with UI.Prevents oversized text and whitespace-only values from reaching the DB.
Option A (minimal risk): keep schema as-is and rely on action-level normalization (see action diffs).
Option B (schema-level guard):
- description: z.string().nullish(), + // Keep in sync with UI maxLength; adjust constant as needed. + description: z + .union([z.string(), z.null(), z.undefined()]) + .transform((v) => (typeof v === "string" ? v.trim() : v)) + .refine( + (v) => v == null || (typeof v === "string" && v.length <= 160), + "Description must be 160 characters or fewer.", + ) + .transform((v) => (typeof v === "string" && v.length === 0 ? null : v)),If you prefer a shared constant, I can add
export const REWARD_DESCRIPTION_MAX_LEN = 160and reference it in both schema and UI. Want me to push that follow-up?apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (5)
436-452: Treat whitespace-only descriptions as invalid in UI.Current checks consider
" "valid (truthy). Usetrim()for validity, and surface the trimmed value in the badge text.- <InlineBadgePopover - text={description || "Reward description"} - invalid={!description} - > + <InlineBadgePopover + text={description?.trim() || "Reward description"} + invalid={!description?.trim()} + >
369-375: Parse “Infinity” explicitly for clarity.
Number("Infinity")works but is implicit. Making it explicit improves readability.- onSelect={(value) => - setValue("maxDuration", Number(value), { + onSelect={(value) => + setValue( + "maxDuration", + value === "Infinity" ? Infinity : Number(value), + { shouldDirty: true, - }) + }, + ) }
398-419: Improve description toggle UX/accessibility.
- Tooltip can reflect the current action (Add vs Remove).
- Advertise state to assistive tech via
aria-pressed/aria-expanded.- <Tooltip - content={"Add a custom reward description"} - disabled={description !== null} - > + <Tooltip + content={ + description === null + ? "Add a custom reward description" + : "Remove custom reward description" + } + > @@ - <Button + <Button variant="secondary" className={cn( "size-7 p-0", description !== null && "text-blue-600", )} icon={<Pen2 className="size-3.5" />} + aria-pressed={description !== null} + aria-expanded={description !== null} onClick={() => setValue( "description", description === null ? "" : null, { shouldDirty: true }, ) } />
242-246: Remove noisy console log in production paths.A toast is already shown; consider removing or gating the
console.log.- console.log("parse error", error); + // console.error("reward condition parse error", error);
342-353: Amount badge validity: consider negative/NaN explicitly.You mark invalid only on
isNaN(amount). If negative amounts somehow slip past, the badge still looks valid. Not blocking, but consider alsoamount < 0.- invalid={isNaN(amount)} + invalid={isNaN(amount) || Number(amount) < 0}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
apps/web/lib/actions/partners/create-reward.ts(2 hunks)apps/web/lib/actions/partners/update-reward.ts(2 hunks)apps/web/lib/zod/schemas/rewards.ts(1 hunks)apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx(6 hunks)apps/web/ui/shared/inline-badge-popover.tsx(2 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/lib/actions/partners/create-reward.ts
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/lib/actions/partners/create-reward.tsapps/web/lib/zod/schemas/rewards.tsapps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
📚 Learning: 2025-08-14T05:17:51.825Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.825Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
Applied to files:
apps/web/lib/actions/partners/update-reward.ts
⏰ 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: Vade Review
🔇 Additional comments (2)
apps/web/ui/shared/inline-badge-popover.tsx (1)
65-70: LGTM: left-aligning the trigger label improves readability in narrow layouts.Also applies to: 72-73
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx (1)
451-452: Match description max length in UI, Zod, and DB schema
UI enforcesmaxLength={100}in apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx. Verify that:
createRewardSchemaandupdateRewardSchemain lib/actions/partners/… definedescription: z.string().max(100)- the
Reward.descriptioncolumn in your Prisma schema allows at least 100 characters (e.g.@db.VarChar(100)or larger)
Adjust the UI or schemas to align if there’s a mismatch.
| export const InlineBadgePopoverInput = forwardRef< | ||
| HTMLInputElement, | ||
| HTMLProps<HTMLInputElement> | ||
| >(({ maxLength, className, ...rest }: HTMLProps<HTMLInputElement>, ref) => { | ||
| const { setIsOpen } = useContext(InlineBadgePopoverContext); | ||
|
|
||
| return ( | ||
| <div className="relative rounded-md shadow-sm"> | ||
| <input | ||
| ref={ref} | ||
| className={cn( | ||
| "block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 sm:w-32 sm:text-sm", | ||
| "focus:border-neutral-500 focus:outline-none focus:ring-neutral-500", | ||
| )} | ||
| onKeyDown={(e) => { | ||
| if (e.key === "Enter") { | ||
| e.preventDefault(); | ||
| setIsOpen(false); | ||
| } | ||
| }} | ||
| {...props} | ||
| /> | ||
| </div> | ||
| ); | ||
| }, | ||
| ); | ||
| return ( | ||
| <label | ||
| className={cn( | ||
| "flex w-full rounded-md border border-neutral-300 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500 sm:w-32", | ||
| className, | ||
| )} | ||
| > | ||
| <input | ||
| ref={ref} | ||
| className={cn( | ||
| "block min-w-0 grow rounded-md border-none px-1.5 py-1 text-neutral-900 placeholder-neutral-400 sm:text-sm", | ||
| "focus:outline-none focus:ring-0", | ||
| maxLength && "pr-0", | ||
| )} | ||
| onKeyDown={(e) => { | ||
| if (e.key === "Enter") { | ||
| e.preventDefault(); | ||
| setIsOpen(false); | ||
| } | ||
| }} | ||
| maxLength={maxLength} | ||
| {...rest} | ||
| /> | ||
| {maxLength && ( | ||
| <span className="relative -ml-4 flex shrink-0 items-center pl-5 pr-1.5 text-xs text-neutral-500"> | ||
| <span className="absolute inset-y-0 left-0 block w-4 bg-gradient-to-l from-white" /> | ||
| <span> | ||
| {rest.value?.toString().length || 0}/{maxLength} | ||
| </span> | ||
| </span> | ||
| )} | ||
| </label> | ||
| ); | ||
| }); |
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.
Enter key handler can be overridden by consumer; counter doesn’t update for uncontrolled inputs.
- {...rest} comes after onKeyDown, so rest.onKeyDown overrides the close-on-Enter behavior.
- Counter reads rest.value, which won’t change for uncontrolled/defaultValue usage.
Apply:
export const InlineBadgePopoverInput = forwardRef<
HTMLInputElement,
HTMLProps<HTMLInputElement>
->(({ maxLength, className, ...rest }: HTMLProps<HTMLInputElement>, ref) => {
+>(({ maxLength, className, onKeyDown: onKeyDownProp, onChange: onChangeProp, value, defaultValue, ...rest }: HTMLProps<HTMLInputElement>, ref) => {
const { setIsOpen } = useContext(InlineBadgePopoverContext);
+ const [charCount, setCharCount] = useState<number>(() => {
+ const initial = value ?? defaultValue;
+ return typeof initial === "string" || typeof initial === "number"
+ ? initial.toString().length
+ : 0;
+ });
return (
<label
className={cn(
- "flex w-full rounded-md border border-neutral-300 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500 sm:w-32",
+ "flex w-full rounded-md border border-neutral-300 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500 sm:w-32",
className,
)}
>
<input
ref={ref}
className={cn(
- "block min-w-0 grow rounded-md border-none px-1.5 py-1 text-neutral-900 placeholder-neutral-400 sm:text-sm",
+ "block min-w-0 grow rounded-md border-none px-1.5 py-1 text-neutral-900 placeholder-neutral-400 sm:text-sm",
"focus:outline-none focus:ring-0",
- maxLength && "pr-0",
+ typeof maxLength === "number" && "pr-0",
)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- setIsOpen(false);
- }
- }}
- maxLength={maxLength}
- {...rest}
+ maxLength={maxLength}
+ {...rest}
+ onKeyDown={(e) => {
+ onKeyDownProp?.(e);
+ if (!e.defaultPrevented && e.key === "Enter") {
+ e.preventDefault();
+ setIsOpen(false);
+ }
+ }}
+ onChange={(e) => {
+ onChangeProp?.(e);
+ if (typeof maxLength === "number") {
+ setCharCount(e.currentTarget.value.length);
+ }
+ }}
/>
- {maxLength && (
+ {typeof maxLength === "number" && (
<span className="relative -ml-4 flex shrink-0 items-center pl-5 pr-1.5 text-xs text-neutral-500">
<span className="absolute inset-y-0 left-0 block w-4 bg-gradient-to-l from-white" />
- <span>
- {rest.value?.toString().length || 0}/{maxLength}
- </span>
+ <span>{charCount}/{maxLength}</span>
</span>
)}
</label>
);
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const InlineBadgePopoverInput = forwardRef< | |
| HTMLInputElement, | |
| HTMLProps<HTMLInputElement> | |
| >(({ maxLength, className, ...rest }: HTMLProps<HTMLInputElement>, ref) => { | |
| const { setIsOpen } = useContext(InlineBadgePopoverContext); | |
| return ( | |
| <div className="relative rounded-md shadow-sm"> | |
| <input | |
| ref={ref} | |
| className={cn( | |
| "block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 sm:w-32 sm:text-sm", | |
| "focus:border-neutral-500 focus:outline-none focus:ring-neutral-500", | |
| )} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") { | |
| e.preventDefault(); | |
| setIsOpen(false); | |
| } | |
| }} | |
| {...props} | |
| /> | |
| </div> | |
| ); | |
| }, | |
| ); | |
| return ( | |
| <label | |
| className={cn( | |
| "flex w-full rounded-md border border-neutral-300 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500 sm:w-32", | |
| className, | |
| )} | |
| > | |
| <input | |
| ref={ref} | |
| className={cn( | |
| "block min-w-0 grow rounded-md border-none px-1.5 py-1 text-neutral-900 placeholder-neutral-400 sm:text-sm", | |
| "focus:outline-none focus:ring-0", | |
| maxLength && "pr-0", | |
| )} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") { | |
| e.preventDefault(); | |
| setIsOpen(false); | |
| } | |
| }} | |
| maxLength={maxLength} | |
| {...rest} | |
| /> | |
| {maxLength && ( | |
| <span className="relative -ml-4 flex shrink-0 items-center pl-5 pr-1.5 text-xs text-neutral-500"> | |
| <span className="absolute inset-y-0 left-0 block w-4 bg-gradient-to-l from-white" /> | |
| <span> | |
| {rest.value?.toString().length || 0}/{maxLength} | |
| </span> | |
| </span> | |
| )} | |
| </label> | |
| ); | |
| }); | |
| export const InlineBadgePopoverInput = forwardRef< | |
| HTMLInputElement, | |
| HTMLProps<HTMLInputElement> | |
| >(({ maxLength, className, onKeyDown: onKeyDownProp, onChange: onChangeProp, value, defaultValue, ...rest }: HTMLProps<HTMLInputElement>, ref) => { | |
| const { setIsOpen } = useContext(InlineBadgePopoverContext); | |
| const [charCount, setCharCount] = useState<number>(() => { | |
| const initial = value ?? defaultValue; | |
| return typeof initial === "string" || typeof initial === "number" | |
| ? initial.toString().length | |
| : 0; | |
| }); | |
| return ( | |
| <label | |
| className={cn( | |
| "flex w-full rounded-md border border-neutral-300 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500 sm:w-32", | |
| className, | |
| )} | |
| > | |
| <input | |
| ref={ref} | |
| className={cn( | |
| "block min-w-0 grow rounded-md border-none px-1.5 py-1 text-neutral-900 placeholder-neutral-400 sm:text-sm", | |
| "focus:outline-none focus:ring-0", | |
| typeof maxLength === "number" && "pr-0", | |
| )} | |
| maxLength={maxLength} | |
| {...rest} | |
| onKeyDown={(e) => { | |
| onKeyDownProp?.(e); | |
| if (!e.defaultPrevented && e.key === "Enter") { | |
| e.preventDefault(); | |
| setIsOpen(false); | |
| } | |
| }} | |
| onChange={(e) => { | |
| onChangeProp?.(e); | |
| if (typeof maxLength === "number") { | |
| setCharCount(e.currentTarget.value.length); | |
| } | |
| }} | |
| /> | |
| {typeof maxLength === "number" && ( | |
| <span className="relative -ml-4 flex shrink-0 items-center pl-5 pr-1.5 text-xs text-neutral-500"> | |
| <span className="absolute inset-y-0 left-0 block w-4 bg-gradient-to-l from-white" /> | |
| <span>{charCount}/{maxLength}</span> | |
| </span> | |
| )} | |
| </label> | |
| ); | |
| }); |
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 (1)
apps/web/lib/zod/schemas/rewards.ts (1)
136-144: Apply the same 100-char cap to RewardSchema for parity.
RewardSchema.descriptionstill accepts any length, creating inconsistency withcreateOrUpdateRewardSchemaand enabling oversized values to slip through when validating reads or legacy data.Apply:
- description: z.string().nullish(), + description: z.string().max(100).nullish(),If you'd like, I can add a quick Zod test to assert both schemas reject >100 chars and accept null/empty.
🧹 Nitpick comments (3)
apps/web/lib/zod/schemas/rewards.ts (3)
141-144: Type modifiers consistently across schemas.
RewardSchema.modifiersisz.any().nullish()while create/update usesrewardConditionsArraySchema. This loses validation on reads and increases risk of bad data.- modifiers: z.any().nullish(), // TODO: Fix this + modifiers: rewardConditionsArraySchema.nullish(),
140-142: Reuse maxDurationSchema in RewardSchema for consistency.
RewardSchema.maxDurationis a plainz.number().nullish(). Using the shared schema keeps constraints aligned across create/read/update.- maxDuration: z.number().nullish(), + maxDuration: maxDurationSchema,
157-164: Prefer enum constant over string literal.Inside
superRefine, setdata.typewithRewardStructure.flatto avoid drift if enum values change.- data.type = "flat"; + data.type = RewardStructure.flat;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/lib/zod/schemas/rewards.ts(1 hunks)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (1)
apps/web/lib/zod/schemas/rewards.ts (1)
153-154: LGTM: server-side cap matches UI limit.
.max(100)oncreateOrUpdateRewardSchema.descriptionaligns backend with the UI and blocks client bypasses. Nice.
Summary by CodeRabbit
New Features
Style