-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Improve payout confirmation UX #3253
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.
|
WalkthroughDetects touch devices and adapts confirmation prompt text; refactors the hold-to-confirm flow by wrapping the confirm button in a Popover, adding a cancel counter and handleCancel logic, and changing Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10–15 minutes
Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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
🧹 Nitpick comments (2)
apps/web/ui/partners/confirm-payouts-sheet.tsx (2)
889-891: Clarify the Popover close behavior.The
setOpenPopover={() => {}}pattern prevents the Popover from closing via ESC key or outside clicks, forcing users to click the content text to dismiss it. This deviates from standard Popover UX and may confuse users.Consider either:
- Using a proper
setOpenPopoverhandler if you want controlled closing- Documenting why normal close behavior is intentionally disabled
🔎 Alternative: Allow standard Popover close behavior
<Popover openPopover={cancelCounter >= 2} - setOpenPopover={() => {}} + setOpenPopover={(open) => { + if (!open) setCancelCounter(0); + }} content={ <div className="text-content-subtle select-none px-2 py-0.5 text-xs" - onClick={() => setCancelCounter(0)} > Keep holding the button to confirm </div> }
914-914: TODO: Keyboard control implementation.The hold-to-confirm interaction currently lacks keyboard support, which creates an accessibility barrier for keyboard-only users.
Would you like me to help implement keyboard control (e.g., holding Space or Enter key) or open an issue to track this accessibility improvement?
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/ui/partners/confirm-payouts-sheet.tsx(7 hunks)
🧰 Additional context used
🧠 Learnings (7)
📓 Common learnings
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-08-25T21:03:24.285Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2736
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx:1-1
Timestamp: 2025-08-25T21:03:24.285Z
Learning: In Next.js App Router, Server Components that use hooks can work without "use client" directive if they are only imported by Client Components, as they get "promoted" to run on the client side within the Client Component boundary.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-18T16:33:17.719Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2858
File: apps/web/ui/partners/partner-application-tabs.tsx:1-1
Timestamp: 2025-09-18T16:33:17.719Z
Learning: When a React component in Next.js App Router uses non-serializable props (like setState functions), adding "use client" directive can cause serialization warnings. If the component is only imported by Client Components, it's better to omit the "use client" directive to avoid these warnings while still getting client-side execution through promotion.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-17T17:40:35.470Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx:95-121
Timestamp: 2025-09-17T17:40:35.470Z
Learning: In the Dub UI Switch component, providing a truthy `disabledTooltip` prop automatically disables the switch and prevents user interaction, so an explicit `disabled` prop is not needed when using `disabledTooltip`. The component computes `switchDisabled = disabledTooltip ? true : disabled || loading` and passes this to the underlying Radix Switch primitive.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-17T17:40:35.470Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx:95-121
Timestamp: 2025-09-17T17:40:35.470Z
Learning: In the Dub UI Switch component, providing a truthy `disabledTooltip` prop automatically disables the switch and prevents user interaction, so an explicit `disabled` prop is not needed when using `disabledTooltip`.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-10-08T21:33:23.553Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2936
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/add-hostname-modal.tsx:28-34
Timestamp: 2025-10-08T21:33:23.553Z
Learning: In the dub/ui Button component, when the `disabledTooltip` prop is set to a non-undefined value (e.g., a string), the button is automatically disabled. Therefore, it's not necessary to also add the same condition to the `disabled` prop—setting `disabledTooltip={permissionsError || undefined}` is sufficient to disable the button when there's a permissions error.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
🧬 Code graph analysis (1)
apps/web/ui/partners/confirm-payouts-sheet.tsx (2)
packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(7-22)packages/ui/src/shimmer-dots.tsx (1)
ShimmerDots(52-188)
⏰ 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
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/ui/partners/confirm-payouts-sheet.tsx(7 hunks)
🧰 Additional context used
🧠 Learnings (9)
📓 Common learnings
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-11-24T16:36:36.196Z
Learnt from: CR
Repo: dubinc/dub PR: 0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-11-24T16:36:36.196Z
Learning: Applies to packages/hubspot-app/app/cards/**/*.{js,jsx,ts,tsx} : The global `window` object is not available in card components
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-11-24T16:36:36.196Z
Learnt from: CR
Repo: dubinc/dub PR: 0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-11-24T16:36:36.196Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : The global `window` object is not available in settings components
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-08-25T21:03:24.285Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2736
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx:1-1
Timestamp: 2025-08-25T21:03:24.285Z
Learning: In Next.js App Router, Server Components that use hooks can work without "use client" directive if they are only imported by Client Components, as they get "promoted" to run on the client side within the Client Component boundary.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-18T16:33:17.719Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2858
File: apps/web/ui/partners/partner-application-tabs.tsx:1-1
Timestamp: 2025-09-18T16:33:17.719Z
Learning: When a React component in Next.js App Router uses non-serializable props (like setState functions), adding "use client" directive can cause serialization warnings. If the component is only imported by Client Components, it's better to omit the "use client" directive to avoid these warnings while still getting client-side execution through promotion.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-17T17:40:35.470Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx:95-121
Timestamp: 2025-09-17T17:40:35.470Z
Learning: In the Dub UI Switch component, providing a truthy `disabledTooltip` prop automatically disables the switch and prevents user interaction, so an explicit `disabled` prop is not needed when using `disabledTooltip`. The component computes `switchDisabled = disabledTooltip ? true : disabled || loading` and passes this to the underlying Radix Switch primitive.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-17T17:40:35.470Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx:95-121
Timestamp: 2025-09-17T17:40:35.470Z
Learning: In the Dub UI Switch component, providing a truthy `disabledTooltip` prop automatically disables the switch and prevents user interaction, so an explicit `disabled` prop is not needed when using `disabledTooltip`.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-10-08T21:33:23.553Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2936
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/analytics/add-hostname-modal.tsx:28-34
Timestamp: 2025-10-08T21:33:23.553Z
Learning: In the dub/ui Button component, when the `disabledTooltip` prop is set to a non-undefined value (e.g., a string), the button is automatically disabled. Therefore, it's not necessary to also add the same condition to the `disabled` prop—setting `disabledTooltip={permissionsError || undefined}` is sufficient to disable the button when there's a permissions error.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
🧬 Code graph analysis (1)
apps/web/ui/partners/confirm-payouts-sheet.tsx (2)
packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(7-22)packages/ui/src/shimmer-dots.tsx (1)
ShimmerDots(52-188)
⏰ 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/ui/partners/confirm-payouts-sheet.tsx (5)
33-33: LGTM: Import additions are appropriate.The new imports support the Popover wrapper, type flexibility (
ReactNode), and client-side touch detection logic.Also applies to: 53-60
738-739: Conditional text logic is correct.The adaptive "Press" vs "Click" wording improves UX for touch devices. Ensure the touch detection is fixed per the previous comment.
807-807: LGTM: Type broadened appropriately.Changing
textfromstringtoReactNodeenables the complex inline JSX structure for the hold-to-confirm animation.
848-848: LGTM: Cancel counter logic is sound.Tracking cancellation attempts and resetting on submission enables the progressive hint UX (popover appears after 2+ cancellations).
Also applies to: 857-857
881-887: LGTM: Cancel handler is well-guarded.The handler appropriately checks holding state and success state before incrementing the counter.
| const [isTouchDevice, setIsTouchDevice] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| setIsTouchDevice(window.matchMedia("(pointer: coarse)").matches); | ||
| }, []); |
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.
Hydration mismatch risk and missing media query listener.
The SSR crash is fixed by using useEffect, but two issues remain:
- Hydration mismatch: The initial state is
false, so SSR and the first client render will show "Click" even on touch devices. AfteruseEffectruns, it switches to "Press", potentially causing a React hydration warning and a visible text flicker. - No adaptive behavior: Without a media query change listener, the UI won't update if the user switches input methods (e.g., connecting a mouse to a tablet).
🔎 Proposed fix: Initialize with `undefined` and add change listener
- const [isTouchDevice, setIsTouchDevice] = useState(false);
+ const [isTouchDevice, setIsTouchDevice] = useState<boolean | undefined>(undefined);
useEffect(() => {
- setIsTouchDevice(window.matchMedia("(pointer: coarse)").matches);
+ const mediaQuery = window.matchMedia("(pointer: coarse)");
+ setIsTouchDevice(mediaQuery.matches);
+
+ const handleChange = (e: MediaQueryListEvent) => setIsTouchDevice(e.matches);
+ mediaQuery.addEventListener("change", handleChange);
+ return () => mediaQuery.removeEventListener("change", handleChange);
}, []);Then update the text to handle the undefined state during SSR:
text={
amount && amount > 0
- ? `${isTouchDevice ? "Press" : "Click"} and hold to confirm ${currencyFormatter(amount)} payout`
- : `${isTouchDevice ? "Press" : "Click"} and hold to confirm payout`
+ ? `${isTouchDevice === undefined ? "Press or click" : isTouchDevice ? "Press" : "Click"} and hold to confirm ${currencyFormatter(amount)} payout`
+ : `${isTouchDevice === undefined ? "Press or click" : isTouchDevice ? "Press" : "Click"} and hold to confirm payout`
}📝 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.
| const [isTouchDevice, setIsTouchDevice] = useState(false); | |
| useEffect(() => { | |
| setIsTouchDevice(window.matchMedia("(pointer: coarse)").matches); | |
| }, []); | |
| const [isTouchDevice, setIsTouchDevice] = useState<boolean | undefined>(undefined); | |
| useEffect(() => { | |
| const mediaQuery = window.matchMedia("(pointer: coarse)"); | |
| setIsTouchDevice(mediaQuery.matches); | |
| const handleChange = (e: MediaQueryListEvent) => setIsTouchDevice(e.matches); | |
| mediaQuery.addEventListener("change", handleChange); | |
| return () => mediaQuery.removeEventListener("change", handleChange); | |
| }, []); |
🤖 Prompt for AI Agents
In apps/web/ui/partners/confirm-payouts-sheet.tsx around lines 643-647, the
state for isTouchDevice is initialized to false causing a hydration mismatch and
there is no media query listener to react to input-method changes; change the
initial state to undefined (or null) so SSR and first client render are
consistent, create a window.matchMedia("(pointer: coarse)") media query in
useEffect, set the state from mq.matches on mount, add an mq.addEventListener or
mq.addListener change handler to update the state when the media query changes,
and remove the listener in the cleanup; update the UI rendering to handle the
undefined state (render a neutral fallback or nothing) to avoid showing "Click"
or "Press" until the client determination is available.
| <Popover | ||
| openPopover={cancelCounter >= 2} | ||
| setOpenPopover={() => {}} | ||
| content={ | ||
| <div | ||
| className="text-content-subtle select-none px-2 py-0.5 text-xs" | ||
| onClick={() => setCancelCounter(0)} | ||
| > | ||
| Keep holding the button to confirm | ||
| </div> | ||
| } | ||
| disabled={disabled} | ||
| disabledTooltip={disabledTooltip} | ||
| /> | ||
| side="top" | ||
| > | ||
| <div className="w-full"> | ||
| <Button | ||
| type="button" | ||
| variant="primary" | ||
| className={cn( | ||
| "relative overflow-hidden", | ||
| isSuccess && "border-green-500 bg-green-500", | ||
| "active:scale-[0.98]", | ||
| )} | ||
| textWrapperClassName="!overflow-visible select-none" | ||
| {...(!disabled && | ||
| !disabledTooltip && { | ||
| // TODO: Handle keyboard control | ||
| onPointerDown: () => (holding.current = true), | ||
| onPointerUp: handleCancel, | ||
| onPointerLeave: handleCancel, | ||
| onPointerCancel: handleCancel, | ||
| })} | ||
| text={ | ||
| <> | ||
| <div | ||
| ref={loadingBar} | ||
| className={cn( | ||
| "pointer-events-none absolute inset-y-0 left-0 overflow-hidden", | ||
| !isSuccess && "bg-[linear-gradient(90deg,#fff1,#fff4)]", | ||
| )} | ||
| > | ||
| <ShimmerDots | ||
| className="inset-[unset] inset-y-0 left-0 w-[600px] opacity-30" | ||
| color={[1, 1, 1]} | ||
| /> | ||
| </div> | ||
| <div className="relative text-center"> | ||
| <div | ||
| className={cn( | ||
| "truncate transition-[transform,opacity] duration-300", | ||
| roundedProgress >= 0.5 && "-translate-y-4 opacity-0", | ||
| )} | ||
| > | ||
| {text} | ||
| </div> | ||
| <div | ||
| className={cn( | ||
| "pointer-events-none absolute inset-0 transition-[transform,opacity] duration-300", | ||
| roundedProgress < 0.5 && "translate-y-4 opacity-0", | ||
| roundedProgress >= 1 && "-translate-y-4 opacity-0", | ||
| )} | ||
| aria-hidden | ||
| > | ||
| Preparing payout... | ||
| </div> | ||
| <div | ||
| className={cn( | ||
| "pointer-events-none absolute inset-0 flex items-center justify-center transition-[transform,opacity] duration-300", | ||
| roundedProgress < 1 && | ||
| "-translate-x-1 translate-y-4 opacity-0", | ||
| roundedProgress >= 1 && | ||
| isSuccess && | ||
| "-translate-y-4 translate-x-3 opacity-0", | ||
| )} | ||
| aria-hidden | ||
| > | ||
| <PaperPlane className="size-4" /> | ||
| </div> | ||
| <div | ||
| className={cn( | ||
| "pointer-events-none absolute inset-0 flex items-center justify-center transition-[transform,opacity] duration-300", | ||
| (roundedProgress < 1 || !isSuccess) && | ||
| "translate-y-4 opacity-0", | ||
| )} | ||
| aria-hidden | ||
| > | ||
| Payout sent | ||
| </div> | ||
| </div> | ||
| </> | ||
| } | ||
| disabled={disabled} | ||
| disabledTooltip={disabledTooltip} | ||
| /> | ||
| </div> | ||
| </Popover> |
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.
Keyboard accessibility is missing.
The TODO comment on line 916 indicates that keyboard control is not implemented. This means keyboard-only users cannot use the hold-to-confirm feature, which is a significant accessibility barrier.
Consider implementing keyboard support:
- Space/Enter key pressed → start holding
- Space/Enter key released → trigger
handleCancel - Add
onKeyDownandonKeyUphandlers similar to the pointer events - Ensure focus states are clearly visible
🔎 Example implementation for keyboard support
{...(!disabled &&
!disabledTooltip && {
// TODO: Handle keyboard control
onPointerDown: () => (holding.current = true),
onPointerUp: handleCancel,
onPointerLeave: handleCancel,
onPointerCancel: handleCancel,
+ onKeyDown: (e) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ holding.current = true;
+ }
+ },
+ onKeyUp: (e) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ handleCancel();
+ }
+ },
})}
Summary by CodeRabbit
Improvements
New Features
✏️ Tip: You can customize this high-level summary in your review settings.