Skip to content

Add program reward education and helper content#3421

Merged
steven-tey merged 5 commits intomainfrom
marcusljf/program-education
Feb 5, 2026
Merged

Add program reward education and helper content#3421
steven-tey merged 5 commits intomainfrom
marcusljf/program-education

Conversation

@marcusljf
Copy link
Collaborator

@marcusljf marcusljf commented Feb 4, 2026

  • Fixed an issue in which selecting lifetime in the onboarding flow deselects and defaults to one time.
  • Added most common tags to the onboarding reward builder, and new program setup to reduce decision paralysis
  • Added helper text to empty rewards to give more context.
  • Added additional helper text to the reward drawers that can be dismissed and never shown again.

Onboarding

CleanShot.2026-02-04.at.13.43.45.mp4

Program creation

CleanShot.2026-02-04.at.14.53.16.mp4

Group rewards

CleanShot 2026-02-05 at 09 33 09@2x CleanShot 2026-02-05 at 09 33 53@2x CleanShot 2026-02-05 at 09 35 16@2x CleanShot 2026-02-05 at 09 35 19@2x

Summary by CodeRabbit

  • New Features

    • Added Lifetime duration option for rewards.
    • Added a dismissible reward helper info panel for new reward setup.
    • Added "Most common" badges for recommended reward and commission options.
  • UI/UX Improvements

    • Redesigned reward type and commission selection into card/grid layouts with per-item badges.
    • Simplified reward pages by removing banner intro elements.
    • Amount field label now adapts to show "Percentage" or "Amount" based on selection.
    • Updated reward creation heading text.

…tates, and helper blocks

Changes: (1) Onboarding reward form - updated descriptions, "Most common" tags, payout model radio cards, dynamic labels; (2) New program reward form - similar updates with custom commission descriptions and page title change; (3) Group reward empty states - structured title + description instead of generic text; (4) Reward creation helper - per-event-type dismissible info blocks with independent localStorage persistence.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

Refactors reward configuration UI and form logic: introduces PAYOUT_MODELS and COMMISSION_STRUCTURE_DESCRIPTIONS, adds "Most common" flags, changes reward/commission selection UIs to card grids, updates duration handling (including Lifetime), adapts amount fields (cents vs percentage), removes Banner usages, and adds a dismissible RewardHelper panel.

Changes

Cohort / File(s) Summary
New-program & onboarding reward forms
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx, apps/web/app/(onboarding)/onboarding/(steps)/program/reward/form.tsx
Added PAYOUT_MODELS and COMMISSION_STRUCTURE_DESCRIPTIONS; changed reward type and commission UIs from radio lists to card/grid layouts with mostCommon badges; added selection side-effects (map reward → payout type, adjust maxDuration); updated amount inputs and submit mapping (amountInCents / amountInPercentage).
Reward sheet UI & helper
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
Introduced REWARD_HELPER_CONTENT and RewardHelperBlock (dismissible, persisted via localStorage); integrated helper display for new rewards; duplicated helper definitions present.
Group pages — discounts & rewards
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discounts/group-discounts.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/rewards/group-rewards.tsx
Removed Banner and related state/animations; simplified imports; introduced internal item components (DiscountItem, CopyDefaultDiscountButton, RewardItem) and replaced banner-centric flows with direct descriptive rendering.
Schema & defaults
apps/web/lib/zod/schemas/rewards.ts, apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/use-onboarding-program.tsx
Reordered COMMISSION_TYPES (moved "one-off" to end); changed onboarding default maxDuration from 0 to 12.
Page text & styling tweaks
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/page.tsx, packages/ui/src/button.tsx, manifest_file, package.json
Updated page heading ("Create default reward"); adjusted button outline hover style; minor manifest/package changes referenced in diff.

Sequence Diagram

sequenceDiagram
    participant User
    participant RewardForm as "Reward Form UI"
    participant FormState as "Form State / Hook"
    participant Validator as "Validation & Submit"

    User->>RewardForm: choose reward event (sale/lead/click)
    RewardForm->>FormState: set defaultRewardType
    FormState->>FormState: side-effect → set payoutType, adjust maxDuration

    User->>RewardForm: choose commission structure (recurring/one-off)
    RewardForm->>FormState: set commissionType
    FormState->>FormState: side-effect → recurring => maxDuration/null, one-off => maxDuration=0

    User->>RewardForm: enter amount
    RewardForm->>FormState: update amount (flat vs percentage)

    User->>RewardForm: submit
    RewardForm->>Validator: validate
    Validator->>FormState: compute final payload (amountInCents or amountInPercentage)
    Validator->>User: return success / error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

🐇 Hop, nibble, patch and play—

I rearranged the rewards today.
Cards now sparkle, helpers beam,
Durations stretch like a dream,
Percent or cents in tidy rows—hooray!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding educational content and helper components to the reward builder UI across onboarding and program creation flows.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch marcusljf/program-education

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@marcusljf marcusljf changed the title Update partner reward UI across onboarding Add partner reward education and helper content Feb 4, 2026
@marcusljf marcusljf changed the title Add partner reward education and helper content Add program reward education and helper content Feb 4, 2026
- Fixed issue that selecting lifetime deselects and defaults to one time.
@vercel
Copy link
Contributor

vercel bot commented Feb 5, 2026

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Feb 5, 2026 8:21pm

Request Review

@marcusljf marcusljf marked this pull request as ready for review February 5, 2026 17:46
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

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

⚠️ Outside diff range comments (1)
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx (1)

296-303: ⚠️ Potential issue | 🟡 Minor

Remove redundant selected attribute on <option> elements.

In React controlled components, the selected attribute on <option> is ignored and causes a warning. The selection is controlled by the <select>'s value prop, which register() already handles.

Proposed fix
                   {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>
                   ))}

If you need a default value of 12 months, set it via the form's defaultValues instead.

🧹 Nitpick comments (2)
apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx (2)

122-189: Consider extracting the card-based radio selection into a reusable component.

The same card-based selection pattern (container div, label with radio, mostCommon badge) is repeated three times for reward type, commission structure, and payout model. Extracting this into a generic RadioCardGroup component would reduce ~150 lines of duplication and improve maintainability.

♻️ Example component extraction
type RadioCardOption<T extends string> = {
  key: T;
  label: string;
  description: string;
  mostCommon?: boolean;
};

function RadioCardGroup<T extends string>({
  options,
  value,
  onChange,
}: {
  options: readonly RadioCardOption<T>[];
  value: T;
  onChange: (key: T) => void;
}) {
  return (
    <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
      {options.map(({ key, label, description, mostCommon }) => {
        const isSelected = key === value;
        return (
          <div
            key={key}
            className={cn(
              "flex flex-col items-center",
              mostCommon && "rounded-md border border-neutral-200 bg-neutral-100",
            )}
          >
            <label
              className={cn(
                "relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50",
                "transition-all duration-150",
                mostCommon && "border-transparent shadow-sm",
                isSelected && "border-black bg-neutral-50 text-neutral-900 ring-1 ring-black",
              )}
            >
              <input
                type="radio"
                value={key}
                className="hidden"
                checked={isSelected}
                onChange={() => onChange(key)}
              />
              {/* ... rest of card content */}
            </label>
            {mostCommon && (
              <span className="py-0.5 text-xs font-medium text-neutral-500">
                Most common
              </span>
            )}
          </div>
        );
      })}
    </div>
  );
}

Also applies to: 206-273, 313-368


297-302: Dead code in duration text rendering.

The ternary duration === 1 ? "month" : "months" at Line 301 is unreachable since 1 is filtered out at Line 298. The condition will always evaluate to "months".

🧹 Simplify to remove dead branch
                     {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}>
-                        {duration} {duration === 1 ? "month" : "months"}
+                        {duration} months
                       </option>
                     ))}

Also cleaning up leftover items found by coderabbit
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

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

⚠️ Outside diff range comments (2)
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx (1)

410-415: ⚠️ Potential issue | 🟠 Major

Allow resubmission after errors

hasSubmitted is never reset when the action fails, so Line 414 keeps the button disabled and blocks retries after an error. Reset it in onError (or avoid gating with hasSubmitted).

🛠️ Suggested fix
  const { executeAsync, isPending } = useAction(onboardProgramAction, {
    onSuccess: () => {
      router.push(`/${workspaceSlug}/program/new/partners`);
      mutate();
    },
    onError: ({ error }) => {
      toast.error(error.serverError);
+     setHasSubmitted(false);
    },
  });
apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx (1)

408-413: ⚠️ Potential issue | 🟠 Major

Allow resubmission after errors

hasSubmitted isn’t reset on failure, so Line 412 keeps the button disabled after an error and prevents retry. Reset it in onError (or drop hasSubmitted gating).

🛠️ Suggested fix
  const { executeAsync, isPending } = useAction(onboardProgramAction, {
    onSuccess: () => {
      continueTo("plan");
      mutate();
    },
    onError: ({ error }) => {
      toast.error(error.serverError);
+     setHasSubmitted(false);
    },
  });

@steven-tey
Copy link
Collaborator

@coderabbitai full review, any breaking changes

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

🧩 Analysis chain

@steven-tey I'll conduct a full review and identify any breaking changes in this PR.


🏁 Script executed:

#!/bin/bash

# 1. Check for duplicate definitions in add-edit-reward-sheet.tsx
echo "=== Checking for duplicate definitions in add-edit-reward-sheet.tsx ==="
rg -n "REWARD_HELPER_CONTENT|RewardHelperBlock" apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx

echo -e "\n=== Examining the duplicate definition context ==="
cat apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx | grep -A 50 "REWARD_HELPER_CONTENT"

echo -e "\n=== Checking COMMISSION_TYPES usage across codebase ==="
rg -n "COMMISSION_TYPES\[" --type ts --type tsx

echo -e "\n=== Checking if COMMISSION_TYPES is used with index access ==="
rg -n "COMMISSION_TYPES\[0\]|COMMISSION_TYPES\[1\]" --type ts --type tsx

echo -e "\n=== Checking for external references to Banner component ==="
rg -n "import.*Banner.*from.*group-discounts|import.*Banner.*from.*group-rewards" --type ts --type tsx

echo -e "\n=== Checking for uses of maxDuration default value ==="
rg -n "maxDuration.*=.*0|maxDuration:\s*0" apps/web/app --type ts --type tsx

Length of output: 3400


🏁 Script executed:

#!/bin/bash

# 2. Check the rewards schema changes
echo "=== Examining COMMISSION_TYPES in rewards.ts ==="
cat apps/web/lib/zod/schemas/rewards.ts | grep -B 5 -A 15 "COMMISSION_TYPES"

echo -e "\n=== Check for any API endpoints that might be affected by default changes ==="
rg -n "maxDuration|defaultRewardType|commission" apps/web/app/api --type ts -C 3

echo -e "\n=== Check button.tsx for potential breaking CSS changes ==="
cat packages/ui/src/button.tsx | grep -A 5 -B 5 "outline.*hover"

Length of output: 3264


🏁 Script executed:

#!/bin/bash

# Check for indexed access or assumptions about COMMISSION_TYPES order
echo "=== Checking for COMMISSION_TYPES usage patterns ==="
rg -n "COMMISSION_TYPES" apps/web/lib/zod/schemas/rewards.ts apps/web/app -A 3 -B 1

echo -e "\n=== Check if any code assumes 'one-off' is first in COMMISSION_TYPES ==="
rg -n "COMMISSION_TYPES\[0\]|COMMISSION_TYPES\[1\]|COMMISSION_TYPES\.find|COMMISSION_TYPES\.filter" apps/web/

echo -e "\n=== Verify button.tsx outline variant change ==="
cat packages/ui/src/button.tsx | grep -A 20 "variant.*outline"

echo -e "\n=== Check for Banner component exports/imports ==="
rg -n "export.*Banner|import.*Banner" apps/web/app/app.dub.co/

echo -e "\n=== Look for public API exports that might have changed ==="
rg -n "export.*COMMISSION_STRUCTURE_DESCRIPTIONS|export.*PAYOUT_MODELS|export.*DEFAULT_REWARD_TYPES" apps/web/app

Length of output: 5836


🏁 Script executed:

#!/bin/bash

# Check the actual file content to understand the changes better
echo "=== Examining the rewards schema file ==="
cat apps/web/lib/zod/schemas/rewards.ts

echo -e "\n=== Checking if maxDuration default change affects database schema or APIs ==="
rg -n "maxDuration" apps/web/app/api -C 2 | head -100

Length of output: 10790


🏁 Script executed:

#!/bin/bash

# Let me verify there are actually no duplicate definitions by checking the full file
echo "=== Count occurrences of 'const REWARD_HELPER_CONTENT' and 'function RewardHelperBlock' ==="
grep -c "^const REWARD_HELPER_CONTENT" apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
grep -c "^function RewardHelperBlock" apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx

echo -e "\n=== Get line numbers of all definitions ==="
grep -n "^const REWARD_HELPER_CONTENT\|^function RewardHelperBlock" apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx

echo -e "\n=== Verify the file can be parsed (check for syntax errors) ==="
# Try to parse the TypeScript file - if there are duplicate const declarations, this would fail
node -e "const fs = require('fs'); const content = fs.readFileSync('apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx', 'utf8'); console.log('File has', content.split('\\n').length, 'lines');"

echo -e "\n=== Check if there are any existing issues with imports or exports ==="
head -50 apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx | grep -E "^import|^export"

Length of output: 1544


🏁 Script executed:

#!/bin/bash

# Final checks for any actual breaking changes
echo "=== Check if any component depends on the old Banner component from group files ==="
rg -n "from.*group-discounts.*Banner|from.*group-rewards.*Banner" apps/web/

echo -e "\n=== Check if COMMISSION_TYPES ordering matters in any tests ==="
rg -n "COMMISSION_TYPES" apps/web/ --type-add 'test:*.test.{ts,tsx}' --type test -C 2

echo -e "\n=== Verify new exports are additive only ==="
cat apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx | grep -E "^export " | head -20

echo -e "\n=== Check for any database migrations related to rewards ==="
fd -e sql -e prisma . | head -10

Length of output: 398


🏁 Script executed:

#!/bin/bash

# Fix the path issue - need to escape or quote properly
echo "=== Verify new exports in form.tsx ==="
cat 'apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx' | grep -E "^export " | head -20

echo -e "\n=== Check for COMMISSION_STRUCTURE_DESCRIPTIONS and PAYOUT_MODELS exports ==="
rg -n "^export const COMMISSION_STRUCTURE_DESCRIPTIONS|^const COMMISSION_STRUCTURE_DESCRIPTIONS" apps/web/
rg -n "^export const PAYOUT_MODELS|^const PAYOUT_MODELS" apps/web/

echo -e "\n=== Verify DEFAULT_REWARD_TYPES changes ==="
rg -n "DEFAULT_REWARD_TYPES" apps/web/ -A 2 | head -30

echo -e "\n=== Check for any references to the old default value of 0 for maxDuration ==="
rg -n "maxDuration.*default.*0|defaultValues.*maxDuration" apps/web/ | grep -v node_modules

Length of output: 2594


🏁 Script executed:

#!/bin/bash

# Check if the button hover change could affect external packages
echo "=== Check Button component usage and if outline variant is used externally ==="
rg -n 'variant="outline"' packages/ apps/ -C 1 | head -50

echo -e "\n=== Summary: Check what actually changed in button.tsx ==="
cat packages/ui/src/button.tsx | grep -B 5 -A 5 "hover:bg"

Length of output: 5155


@steven-tey I've completed a thorough review of this PR. Good news: there are no breaking changes. All modifications are internal improvements and UI enhancements.

Breaking Changes Analysis: ✅ None Found

Changes Reviewed:

  1. COMMISSION_TYPES array reordering (apps/web/lib/zod/schemas/rewards.ts)

    • Moved "recurring" before "one-off"
    • Not breaking: All usage iterates via .map() and accesses by value property, not by index
  2. Default maxDuration change (0 → 12 months)

    • Only affects new program onboarding flow defaults
    • Not breaking: Doesn't affect existing data, APIs, or database schema
  3. New internal constants

    • COMMISSION_STRUCTURE_DESCRIPTIONS, PAYOUT_MODELS, DEFAULT_REWARD_TYPES enhancements
    • Not breaking: These are internal, non-exported constants used only within form components
  4. Button outline variant styling (packages/ui/src/button.tsx)

    • Changed hover from hover:bg-subtle to hover:bg-neutral-900/5
    • Not breaking: Visual-only change, no API or functional impact
  5. Banner component removal (from group-discounts.tsx and group-rewards.tsx)

    • Not breaking: No external components import these Banners
  6. Helper content additions (add-edit-reward-sheet.tsx)

    • New dismissible educational content with localStorage persistence
    • Not breaking: Pure addition, no existing functionality affected

Note on AI Summary:

The AI summary incorrectly flagged duplicate definitions in add-edit-reward-sheet.tsx. My verification confirms only one definition each of REWARD_HELPER_CONTENT and RewardHelperBlock (lines 617 and 645 respectively). No syntax errors present.

Summary:

This PR safely adds educational content, improves UX with "Most common" tags, fixes the lifetime selection bug, and updates default values for better onboarding—all without breaking existing functionality. ✅

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

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

⚠️ Outside diff range comments (2)
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx (1)

410-415: ⚠️ Potential issue | 🟠 Major

Re-enable submit after errors.
hasSubmitted flips to true on submit and never resets on error, so the CTA stays disabled if onboarding fails. Reset it in the error handler (or a finally) to allow retry.

💡 Proposed fix (reset on error)
  const { executeAsync, isPending } = useAction(onboardProgramAction, {
    onSuccess: () => {
      router.push(`/${workspaceSlug}/program/new/partners`);
      mutate();
    },
    onError: ({ error }) => {
+     setHasSubmitted(false);
      toast.error(error.serverError);
    },
  });
apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx (1)

408-414: ⚠️ Potential issue | 🟠 Major

Re-enable submit after errors.
hasSubmitted never resets on failure, so the button stays disabled after a server error. Reset it in the error handler to allow retry.

💡 Proposed fix (reset on error)
  const { executeAsync, isPending } = useAction(onboardProgramAction, {
    onSuccess: () => {
      continueTo("plan");
      mutate();
    },
    onError: ({ error }) => {
+     setHasSubmitted(false);
      toast.error(error.serverError);
    },
  });
🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discounts/group-discounts.tsx (1)

40-49: Make the clickable card keyboard-accessible.

The entire card is clickable but not focusable, which limits keyboard access. If you want the full card to act like a button, add role/tabIndex and keyboard handling (or drop the card-level click and rely solely on the button).

♿ Suggested accessibility tweak
       <div
+        role="button"
+        tabIndex={0}
         className={cn(
           "flex cursor-pointer flex-col gap-4 rounded-lg p-6 transition-all md:flex-row md:items-center",
           discount && "border border-neutral-200 hover:border-neutral-300",
           !discount && "bg-neutral-50 hover:bg-neutral-100",
         )}
         onClick={(e) => {
           if (isClickOnInteractiveChild(e)) return;
           setIsOpen(true);
         }}
+        onKeyDown={(e) => {
+          if (e.key === "Enter" || e.key === " ") {
+            e.preventDefault();
+            setIsOpen(true);
+          }
+        }}
       >

@steven-tey steven-tey merged commit 05313c4 into main Feb 5, 2026
10 of 11 checks passed
@steven-tey steven-tey deleted the marcusljf/program-education branch February 5, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants