Skip to content

feat: prevent ticket reassignment based on promo code#139

Merged
smarcet merged 17 commits intomainfrom
feature/prevent-ticket-reassignment
Apr 20, 2026
Merged

feat: prevent ticket reassignment based on promo code#139
smarcet merged 17 commits intomainfrom
feature/prevent-ticket-reassignment

Conversation

@gcutrini
Copy link
Copy Markdown
Member

@gcutrini gcutrini commented Feb 26, 2026

  • Use the /apply endpoint's allows_to_reassign response to control ticket reassignment UI
  • Add VALIDATE_PROMO_CODE_ERROR action to reset reassignment flag when validation fails (e.g. switching to an unsupported ticket type)

ref: https://app.clickup.com/t/86b7fapmr

Summary by CodeRabbit

  • New Features

    • Promo codes now control whether tickets can be reassigned and may auto-assign non-transferable tickets to the purchaser.
  • UI/UX Improvements

    • Promo code input redesigned into a vertical layout with tighter spacing.
    • Added a clear non-transferable notice when a selected ticket cannot be reassigned.
    • Promo validation shows widget loading during checks.
  • Bug Fixes

    • More consistent promo validation and error handling with clearer feedback.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough
📝 Walkthrough
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: prevent ticket reassignment based on promo code' accurately summarizes the main objective of this PR, which is to control ticket reassignment eligibility via promo code validation responses.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/prevent-ticket-reassignment

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.

Copy link
Copy Markdown

@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.

🧹 Nitpick comments (5)
src/utils/utils.js (1)

146-146: isSentryInitialized is now dead code.

This function is no longer called anywhere since handleSentryException was changed to only log to console. Either remove it along with the Sentry changes, or keep it if you plan to restore Sentry functionality soon.

🧹 Proposed cleanup if Sentry won't be restored soon
-const isSentryInitialized = () => !!window.SENTRY_DSN;
-
 // TODO: Fix Sentry dependency issue
 export const handleSentryException = (err) =>
     console.log("Error on registration: ", err);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/utils.js` at line 146, The isSentryInitialized helper is dead code
since handleSentryException no longer uses Sentry; remove the unused function
isSentryInitialized from utils.js and any related imports/usages, or if you
intend to restore Sentry telemetry, rewire handleSentryException to check
isSentryInitialized (and call Sentry.captureException) instead of only logging
to console; refer to the isSentryInitialized function name and
handleSentryException to locate the change points and make the corresponding
cleanup or restoration.
src/components/registration-lite.js (1)

287-310: Review the error handling condition.

The error handler only calls onError when e?.res?.body?.errors exists. If the API returns a different error structure (e.g., network error, timeout, or error without body.errors), the UI won't display feedback to the user—only Sentry logging occurs.

Consider whether all error scenarios should surface to the user:

♻️ Proposed fix to handle broader error cases
             .catch((e) => {
-                if (e?.res?.body?.errors) {
-                    onError(e, e.res);
-                }
+                if (e?.res?.body?.errors) {
+                    onError(e, e.res);
+                } else if (e?.res) {
+                    // Handle errors with different structure
+                    const fallbackErrors = [e?.res?.body?.message || 'An error occurred validating the promo code.'];
+                    onError(e, { body: { errors: fallbackErrors } });
+                }
                 handleSentryException(e);
             })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/registration-lite.js` around lines 287 - 310,
handleValidatePromocode currently only calls onError when e?.res?.body?.errors
exists, so other failures (network, timeout, different error shapes) never
surface to the UI; update the catch in handleValidatePromocode to always call
onError with a user-friendly error payload for any rejected promise from
validatePromoCode (pass the original error as first arg and a normalized
response object as second), preserving the existing branch that uses e.res when
available (i.e., prefer e.res for detailed errors but fall back to a default {
body: { errors: [<friendly message or e.message>] } }); keep calling
handleSentryException(e) and ensure stopWidgetLoading() still runs in finally.
src/components/ticket-type/index.js (1)

102-105: Consider adding minimal error logging in the catch block.

The empty .catch(() => {}) silently swallows errors. While validatePromoCode handles the Redux state update via VALIDATE_PROMO_CODE_ERROR, having no logging here can make debugging difficult if issues arise.

♻️ Optional: Add console logging for debugging
         // Validate promo code if already applied (ticket selected after promo applied)
         if (promoCode) {
-            validatePromoCode({ id: t.id, ticketQuantity: minQuantity, sub_type: t.sub_type }).catch(() => {});
+            validatePromoCode({ id: t.id, ticketQuantity: minQuantity, sub_type: t.sub_type }).catch((e) => {
+                console.log('Promo code validation failed for ticket change:', e);
+            });
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ticket-type/index.js` around lines 102 - 105, The empty catch
on the validatePromoCode call swallows errors; update the promise error handler
so it logs a minimal, contextual message and the error (for example include
promoCode, ticket id t.id, minQuantity and t.sub_type) instead of doing nothing
— locate the validatePromoCode invocation in the ticket-type component and
replace .catch(() => {}) with a catch that logs a short message plus the caught
error to console or the existing logger.
src/components/personal-information/index.js (1)

54-65: Consider using constants or useMemo instead of functions for computed values.

isSingleTicketOrder and shouldDisplayTicketAssignment are defined as functions but don't take any parameters - they read from component scope. This is inconsistent with ticketTypeAllowsReassign, canReassign, and isNonTransferable which are computed as constants.

♻️ Proposed refactor for consistency
-  // if there's only one ticket on the order and there is no invitation available, handle ticket assignment
-  const isSingleTicketOrder = () => formValues.ticketQuantity === 1 && !invitation && !isPrePaidTicketType(formValues.ticketType);
-
-  // check if reassignment is allowed by both promo code AND ticket type
-  const ticketTypeAllowsReassign = formValues.ticketType?.allows_to_reassign !== false;
-  const canReassign = promoCodeAllowsReassign && ticketTypeAllowsReassign;
-
-  // show radio options only if reassignment is allowed by both sources
-  const shouldDisplayTicketAssignment = () => isSingleTicketOrder() && canReassign;
-
-  // show notice when ticket is non-transferable (either promo code or ticket type disallows)
-  const isNonTransferable = isSingleTicketOrder() && !canReassign;
+  // if there's only one ticket on the order and there is no invitation available, handle ticket assignment
+  const isSingleTicketOrder = formValues.ticketQuantity === 1 && !invitation && !isPrePaidTicketType(formValues.ticketType);
+
+  // check if reassignment is allowed by both promo code AND ticket type
+  const ticketTypeAllowsReassign = formValues.ticketType?.allows_to_reassign !== false;
+  const canReassign = promoCodeAllowsReassign && ticketTypeAllowsReassign;
+
+  // show radio options only if reassignment is allowed by both sources
+  const shouldDisplayTicketAssignment = isSingleTicketOrder && canReassign;
+
+  // show notice when ticket is non-transferable (either promo code or ticket type disallows)
+  const isNonTransferable = isSingleTicketOrder && !canReassign;

Then update usages from isSingleTicketOrder() and shouldDisplayTicketAssignment() to isSingleTicketOrder and shouldDisplayTicketAssignment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/personal-information/index.js` around lines 54 - 65, Replace
the zero-arg functions isSingleTicketOrder and shouldDisplayTicketAssignment
with computed constants (or wrap them in useMemo if you want memoization) that
read from formValues, invitation, promoCodeAllowsReassign and
isPrePaidTicketType (i.e., compute const isSingleTicketOrder =
formValues.ticketQuantity === 1 && !invitation &&
!isPrePaidTicketType(formValues.ticketType) and const
shouldDisplayTicketAssignment = isSingleTicketOrder && canReassign), keep
ticketTypeAllowsReassign, canReassign and isNonTransferable as-is, and update
all usages to refer to these identifiers without calling them as functions
(remove the trailing () where used).
src/reducer.js (1)

169-172: Consider resetting promoCodeAllowsReassign when setting a new promo code.

When SET_CURRENT_PROMO_CODE is dispatched (e.g., user applies a different promo code), the promoCodeAllowsReassign value from the previous promo code persists until validation completes. This could cause a brief UI inconsistency where the reassignment state reflects the old promo code.

♻️ Proposed fix to reset the flag when applying a new code
 case SET_CURRENT_PROMO_CODE:{
     const { currentPromoCode } = payload;
-    return { ...state, promoCode: currentPromoCode }
+    return { ...state, promoCode: currentPromoCode, promoCodeAllowsReassign: true }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/reducer.js` around lines 169 - 172, In the reducer case handling
SET_CURRENT_PROMO_CODE (in src/reducer.js) you currently set promoCode from
payload.currentPromoCode but leave promoCodeAllowsReassign from the previous
state; update that case to also reset promoCodeAllowsReassign (e.g., to false or
null/undefined) when applying a new promo code so the old flag doesn’t briefly
persist in the UI; ensure you modify the object returned in the
SET_CURRENT_PROMO_CODE branch (the case block that references currentPromoCode)
to include promoCodeAllowsReassign: <desired-reset-value>.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/components/personal-information/index.js`:
- Around line 54-65: Replace the zero-arg functions isSingleTicketOrder and
shouldDisplayTicketAssignment with computed constants (or wrap them in useMemo
if you want memoization) that read from formValues, invitation,
promoCodeAllowsReassign and isPrePaidTicketType (i.e., compute const
isSingleTicketOrder = formValues.ticketQuantity === 1 && !invitation &&
!isPrePaidTicketType(formValues.ticketType) and const
shouldDisplayTicketAssignment = isSingleTicketOrder && canReassign), keep
ticketTypeAllowsReassign, canReassign and isNonTransferable as-is, and update
all usages to refer to these identifiers without calling them as functions
(remove the trailing () where used).

In `@src/components/registration-lite.js`:
- Around line 287-310: handleValidatePromocode currently only calls onError when
e?.res?.body?.errors exists, so other failures (network, timeout, different
error shapes) never surface to the UI; update the catch in
handleValidatePromocode to always call onError with a user-friendly error
payload for any rejected promise from validatePromoCode (pass the original error
as first arg and a normalized response object as second), preserving the
existing branch that uses e.res when available (i.e., prefer e.res for detailed
errors but fall back to a default { body: { errors: [<friendly message or
e.message>] } }); keep calling handleSentryException(e) and ensure
stopWidgetLoading() still runs in finally.

In `@src/components/ticket-type/index.js`:
- Around line 102-105: The empty catch on the validatePromoCode call swallows
errors; update the promise error handler so it logs a minimal, contextual
message and the error (for example include promoCode, ticket id t.id,
minQuantity and t.sub_type) instead of doing nothing — locate the
validatePromoCode invocation in the ticket-type component and replace .catch(()
=> {}) with a catch that logs a short message plus the caught error to console
or the existing logger.

In `@src/reducer.js`:
- Around line 169-172: In the reducer case handling SET_CURRENT_PROMO_CODE (in
src/reducer.js) you currently set promoCode from payload.currentPromoCode but
leave promoCodeAllowsReassign from the previous state; update that case to also
reset promoCodeAllowsReassign (e.g., to false or null/undefined) when applying a
new promo code so the old flag doesn’t briefly persist in the UI; ensure you
modify the object returned in the SET_CURRENT_PROMO_CODE branch (the case block
that references currentPromoCode) to include promoCodeAllowsReassign:
<desired-reset-value>.

In `@src/utils/utils.js`:
- Line 146: The isSentryInitialized helper is dead code since
handleSentryException no longer uses Sentry; remove the unused function
isSentryInitialized from utils.js and any related imports/usages, or if you
intend to restore Sentry telemetry, rewire handleSentryException to check
isSentryInitialized (and call Sentry.captureException) instead of only logging
to console; refer to the isSentryInitialized function name and
handleSentryException to locate the change points and make the corresponding
cleanup or restoration.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2e9f95b and 27da930.

📒 Files selected for processing (8)
  • src/actions.js
  • src/components/personal-information/index.js
  • src/components/promocode-input/index.module.scss
  • src/components/registration-lite.js
  • src/components/ticket-type/index.js
  • src/components/ticket-type/index.module.scss
  • src/reducer.js
  • src/utils/utils.js

@gcutrini gcutrini force-pushed the feature/prevent-ticket-reassignment branch from 27da930 to d26a8f2 Compare April 14, 2026 16:10
Copy link
Copy Markdown

@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.

Caution

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

⚠️ Outside diff range comments (1)
src/components/personal-information/index.js (1)

282-282: ⚠️ Potential issue | 🔴 Critical

Critical: shouldDisplayTicketAssignment is invoked as a function but is a boolean.

shouldDisplayTicketAssignment is defined as a boolean constant on line 62, but here it's called with (). This will throw a TypeError: shouldDisplayTicketAssignment is not a function at runtime.

🐛 Proposed fix
-                {shouldDisplayTicketAssignment() &&
+                {shouldDisplayTicketAssignment &&
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/personal-information/index.js` at line 282, The code calls
shouldDisplayTicketAssignment as a function but it is defined as a boolean
(causing a TypeError); fix by removing the invocation at the render site and use
the boolean directly (change {shouldDisplayTicketAssignment() && ...} to
{shouldDisplayTicketAssignment && ...}), or if the intent was a computed check,
convert the boolean constant into a function (e.g., rename/implement
shouldDisplayTicketAssignment() accordingly) and update the definition
referenced in the component so the call site and definition types match.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/components/personal-information/index.js`:
- Line 282: The code calls shouldDisplayTicketAssignment as a function but it is
defined as a boolean (causing a TypeError); fix by removing the invocation at
the render site and use the boolean directly (change
{shouldDisplayTicketAssignment() && ...} to {shouldDisplayTicketAssignment &&
...}), or if the intent was a computed check, convert the boolean constant into
a function (e.g., rename/implement shouldDisplayTicketAssignment() accordingly)
and update the definition referenced in the component so the call site and
definition types match.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0036bddd-0209-4947-8324-054cc799212d

📥 Commits

Reviewing files that changed from the base of the PR and between 27da930 and d26a8f2.

📒 Files selected for processing (7)
  • src/actions.js
  • src/components/personal-information/index.js
  • src/components/promocode-input/index.module.scss
  • src/components/registration-form/index.js
  • src/components/ticket-type/index.js
  • src/components/ticket-type/index.module.scss
  • src/reducer.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/components/ticket-type/index.module.scss
  • src/components/promocode-input/index.module.scss
  • src/components/ticket-type/index.js

Rebased on v7.0.0 (registration-form/index.js replaces registration-lite.js).

Use the /apply endpoint's allows_to_reassign response to control
whether tickets can be reassigned. When a promo code disallows
reassignment, the widget hides the assignee radio options and shows
a non-transferable notice. Reset the flag on validation errors so
switching to an unsupported ticket type clears the notice.

Changes:
- Add promoCodeAllowsReassign state in reducer from /apply response
- Add VALIDATE_PROMO_CODE_ERROR action to reset on errors
- Rework personal-info reassignment gate: dual-source check
  (promo code + ticket type allows_to_reassign)
- Auto-assign to purchaser when non-transferable
- Validate promo code on ticket change and on apply
- Reset promoCodeAllowsReassign on SET_CURRENT_PROMO_CODE (CodeRabbit fix)
- Move promo code input below quantity stepper
- Add non-transferable notice in ticket-type component
- Refactor validatePromoCode: remove UI concerns from action,
  caller handles step change and error display
@gcutrini gcutrini force-pushed the feature/prevent-ticket-reassignment branch from d26a8f2 to 67b40db Compare April 14, 2026 18:47
Copy link
Copy Markdown

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/actions.js`:
- Around line 199-202: The early return in validatePromoCode (when summitId or
currentPromoCode is falsy) returns null and breaks promise chains; change that
branch to return a resolved Promise (e.g., return Promise.resolve(null)) so
validatePromoCode always returns a Promise and callers like
handleValidatePromocode can safely call .then(...).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 045ab2ec-d7c0-47fc-bcce-61d193019232

📥 Commits

Reviewing files that changed from the base of the PR and between d26a8f2 and 67b40db.

📒 Files selected for processing (7)
  • src/actions.js
  • src/components/personal-information/index.js
  • src/components/promocode-input/index.module.scss
  • src/components/registration-form/index.js
  • src/components/ticket-type/index.js
  • src/components/ticket-type/index.module.scss
  • src/reducer.js
✅ Files skipped from review due to trivial changes (2)
  • src/components/ticket-type/index.module.scss
  • src/components/promocode-input/index.module.scss
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/components/personal-information/index.js
  • src/components/ticket-type/index.js
  • src/components/registration-form/index.js

Comment thread src/actions.js
Comment thread src/actions.js
dispatch(stopWidgetLoading());
return res;
}).catch((error) => {
dispatch(stopWidgetLoading());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gcutrini
The old validatePromoCode had a custom error handler that routed 404/412/429 to the onError callback (inline form errors). The new code passes authErrorHandler to getRequest at src/actions.js:218, which shows SweetAlert modal dialogs for these status codes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. Restored custom error handler that lets 404/412/429 bubble up to the caller for inline display. Also fixed the catch block to normalize all response shapes (412 has body.errors[], 404 has body.message, 429 varies) into a consistent errors array for onError.

</ReactTooltip>

{allowPromoCodes &&
<>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gcutrini
The "cannot be reassigned" notice in TicketTypeComponent is nested inside the {allowPromoCodes && ...} JSX block. If a ticket type has allows_to_reassign: false but promo codes are disabled for the event, users won't see the notice on the ticket selection step. The personal-info component correctly auto-assigns, but the user gets no advance warning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. Moved the non-transferable notice outside the allowPromoCodes conditional block so it renders based purely on canReassign, regardless of whether promo codes are enabled for the event.

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

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

@gcutrini please review comments
also please provide unit tests for the new feature ( No test coverage )

Route 404 (code not found), 412 (invalid for ticket type/qty),
and 429 (rate limited) to the caller for inline form error display
instead of letting authErrorHandler intercept them with SweetAlert
modals. These are user-actionable validation errors that belong
inline, not in modal dialogs.
The notice was nested inside the allowPromoCodes conditional block,
so it would not render when promo codes are disabled for the event.
A ticket type with allows_to_reassign: false should always show the
notice regardless of promo code settings.
Test the dual-source reassignment check: promo code allows_to_reassign
and ticket type allows_to_reassign. Covers radio visibility for each
combination and default prop behavior.
@gcutrini
Copy link
Copy Markdown
Member Author

Addressed all review comments:

  1. Restored custom error handler for 404/412/429 so they route to inline form errors instead of modal dialogs. Also fixed the catch block to normalize different response shapes (412 has errors array, 404 has message string).

  2. Moved non-transferable notice outside the allowPromoCodes block so it shows even when promo codes are disabled for the event.

  3. Added 4 unit tests for the reassignment gate covering promo code and ticket type allows_to_reassign combinations. All passing.

Ready for re-review.

@gcutrini gcutrini requested a review from smarcet April 15, 2026 00:34
Copy link
Copy Markdown

@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: 1

♻️ Duplicate comments (1)
src/actions.js (1)

199-202: ⚠️ Potential issue | 🔴 Critical

Always return a Promise from validatePromoCode.

When this branch returns null, callers like handleValidatePromocode immediately do validatePromoCode(data).then(...), which throws synchronously on the no-promo path and can leave the widget stuck in loading state.

🐛 Proposed fix
     // Nothing to validate if no promo code applied
     if (!summitId || !currentPromoCode) {
-        return null;
+        return Promise.resolve(null);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions.js` around lines 199 - 202, The branch in validatePromoCode
synchronously returns null causing callers like handleValidatePromocode to call
.then on a non-promise and crash; change that early return to always return a
Promise (e.g., return Promise.resolve(null) or Promise.resolve({/* same shape as
success case */})) so validatePromoCode consistently returns a Promise and
downstream .then/.catch flows remain safe; update the validatePromoCode function
(and any related return paths) to ensure a Promise is returned when !summitId ||
!currentPromoCode.
🧹 Nitpick comments (1)
src/components/personal-information/__tests__/reassignment-gate.test.js (1)

72-74: Also assert Leave Unassigned is hidden in both disallowed scenarios.

Both “hidden” tests currently check only two options. If Leave Unassigned is mistakenly rendered alone, these tests would still pass.

✅ Suggested test hardening
 it('hides assignment radio when promo code disallows reassignment', () => {
@@
     expect(screen.queryByText('Myself')).toBeNull();
     expect(screen.queryByText('Someone Else')).toBeNull();
+    expect(screen.queryByText('Leave Unassigned')).toBeNull();
 });
@@
 it('hides assignment radio when ticket type disallows reassignment', () => {
@@
     expect(screen.queryByText('Myself')).toBeNull();
     expect(screen.queryByText('Someone Else')).toBeNull();
+    expect(screen.queryByText('Leave Unassigned')).toBeNull();
 });

Also applies to: 91-93

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/personal-information/__tests__/reassignment-gate.test.js`
around lines 72 - 74, The two "hidden" tests in reassignment-gate.test.js only
assert that 'Myself' and 'Someone Else' are absent, which would miss the case
where 'Leave Unassigned' is rendered alone; update both disallowed-scenario
tests (the blocks containing expect(screen.queryByText('Myself')).toBeNull() and
expect(screen.queryByText('Someone Else')).toBeNull()) to also assert that
screen.queryByText('Leave Unassigned') is null so all three options are verified
hidden.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/actions.js`:
- Around line 229-231: The current catch unconditionally dispatches
createAction(VALIDATE_PROMO_CODE_ERROR)({}) which resets promoCodeAllowsReassign
on any rejection (500/network/auth), incorrectly re-enabling reassignment;
update the catch in the promise returned by the validation call (the block
starting with ")({})(dispatch).catch((e) => {") to inspect the error (e.status
or e.response?.status) and only dispatch VALIDATE_PROMO_CODE_ERROR for
client/validation failures (e.g., 4xx statuses or explicit validation error
codes), otherwise rethrow or handle server/auth/network errors without
dispatching that action so promoCodeAllowsReassign remains unchanged for
non-validation errors.

---

Duplicate comments:
In `@src/actions.js`:
- Around line 199-202: The branch in validatePromoCode synchronously returns
null causing callers like handleValidatePromocode to call .then on a non-promise
and crash; change that early return to always return a Promise (e.g., return
Promise.resolve(null) or Promise.resolve({/* same shape as success case */})) so
validatePromoCode consistently returns a Promise and downstream .then/.catch
flows remain safe; update the validatePromoCode function (and any related return
paths) to ensure a Promise is returned when !summitId || !currentPromoCode.

---

Nitpick comments:
In `@src/components/personal-information/__tests__/reassignment-gate.test.js`:
- Around line 72-74: The two "hidden" tests in reassignment-gate.test.js only
assert that 'Myself' and 'Someone Else' are absent, which would miss the case
where 'Leave Unassigned' is rendered alone; update both disallowed-scenario
tests (the blocks containing expect(screen.queryByText('Myself')).toBeNull() and
expect(screen.queryByText('Someone Else')).toBeNull()) to also assert that
screen.queryByText('Leave Unassigned') is null so all three options are verified
hidden.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bf74a539-545c-421a-8321-fdb45557c400

📥 Commits

Reviewing files that changed from the base of the PR and between 67b40db and 608d780.

📒 Files selected for processing (4)
  • src/actions.js
  • src/components/personal-information/__tests__/reassignment-gate.test.js
  • src/components/registration-form/index.js
  • src/components/ticket-type/index.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/ticket-type/index.js

Comment thread src/actions.js Outdated
Move VALIDATE_PROMO_CODE_ERROR dispatch into the error handler where
the status code is already known, instead of re-checking in the catch
block. Removes code duplication and prevents network/auth errors from
incorrectly resetting the reassignment state.
Comment thread src/reducer.js Outdated
return { ...state, promoCodeAllowsReassign: allows_to_reassign ?? true }
}
case VALIDATE_PROMO_CODE_ERROR: {
return { ...state, promoCodeAllowsReassign: true }
Copy link
Copy Markdown
Collaborator

@smarcet smarcet Apr 16, 2026

Choose a reason for hiding this comment

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

@gcutrini
There is no validationPending flag. Between "user applied promo" and "response arrived", the reducer holds promoCodeAllowsReassign: true from SET_CURRENT_PROMO_CODE, a slow false response can arrive after a fast error → true response, leaving the UI permanently permissive until the user forces another revalidation.
here at error should be promoCodeAllowsReassign: false

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. SET_CURRENT_PROMO_CODE and VALIDATE_PROMO_CODE_ERROR now default to false.

Comment thread src/actions.js Outdated
// 429: rate limited
// User-actionable errors: reset reassignment state and bubble up
// to the caller for inline display
if (res && [404, 412, 429].includes(res.statusCode)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gcutrini i do think here
clearing the promo entirely on 404/412 (the code is genuinely invalid) so the user isn't stuck in a "promo applied but everything blocked" state:

 if (res && [404, 412].includes(res.statusCode)) {
          // invalid promo — clear it outright
          dispatch(createAction(CLEAR_CURRENT_PROMO_CODE)({}));
          return;
      }
      if (res && res.statusCode === 429) {
          // transient — keep promo, fail closed on the gate
          dispatch(createAction(VALIDATE_PROMO_CODE_ERROR)({}));
          return;
      }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. 404/412 now dispatch removePromoCode() to clear the code and re-fetch ticket types. 429 keeps the code and dispatches VALIDATE_PROMO_CODE_ERROR.

changeStep(STEP_PERSONAL_INFO);
})
.catch((e) => {
if (e?.res?.body) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gcutrini
In handleValidatePromocode, onError only runs when e?.res?.body is truthy. On timeouts, CORS errors, or offline, the promise rejects without res, so the spinner stops, step doesn't advance, and the user sees nothing ( a dead-click UX )

proposed fix
Always call onError with a generic fallback message when e?.res?.body is missing (reuse the existing "An error occurred" constant) and still call handleSentryException(e).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Network errors (timeout, CORS, offline) fall through the error handler to authErrorHandler, which shows a modal via the default switch case (undefined status code). The user does see feedback. The catch block skipping onError for these is correct since the modal already handled it.

Comment thread src/components/ticket-type/index.js Outdated
}

const handleApplyPromoCode = async (code) => {
await applyPromoCode(code);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gcutrini
applyPromoCode leaves promo state applied after ticket-types fetch fails

  1. User types SPEAKER50, clicks Apply.
  2. handleApplyPromoCode('SPEAKER50') runs.
  3. dispatch(SET_CURRENT_PROMO_CODE) writes promoCode: 'SPEAKER50', promoCodeAllowsReassign: true to Redux immediately.
  4. await dispatch(getTicketTypes(summitId)) starts. The input becomes readOnly because promoCode is now truthy (see readOnly={!isEmptyString(promoCode)} at promocode-input/index.js:48), and the
    Apply button flips to Remove.
  5. getTicketTypes rejects — for example, a 500 from /ticket-types/allowed, a lost connection, or an expired token (customErrorHandler in actions.js:73-84 passes anything non-404/500 to
    authErrorHandler).
  6. await throws. Control leaves handleApplyPromoCode with an uncaught promise rejection — the second block (if (ticket) { validatePromoCode(...) }) never runs, so no validation gets issued.
  7. Redux still holds promoCode: 'SPEAKER50' and the old ticketTypes array. The UI shows:
    - Promo input in "applied" visual state (green checkmark, Remove button).
    - The pre-promo ticket list (no discount, possibly including tickets the promo would have filtered out).
    - promoCodeAllowsReassign: true with no validation ever run.

suggested fixes
1.Make the action atomic (only commit the promo code on success)

export const applyPromoCode = (currentPromoCode) => async (dispatch, getState) => {
      const { registrationLiteState: { settings: { summitId } } } = getState();
      try {
          dispatch(createAction(SET_CURRENT_PROMO_CODE)({ currentPromoCode }));
          await dispatch(getTicketTypes(summitId));
      } catch (e) {
          dispatch(createAction(CLEAR_CURRENT_PROMO_CODE)({}));
          throw e;
      }
  }

** This guarantees: either the promo code and refreshed ticket types are in state, or neither is

Handle the rejection at the caller so the user sees feedback (src/components/ticket-type/index.js:125)

const handleApplyPromoCode = async (code) => {
      try {
          await applyPromoCode(code);
          if (ticket) {
              await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type });
          }
      } catch (e) {
          handleSentryException(e);
          // surface to user — reuse the existing formErrors/promocodeError channel
          // e.g. via a new `onApplyError` prop from RegistrationForm
      }
  }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. applyPromoCode now wraps in try/catch and dispatches CLEAR_CURRENT_PROMO_CODE on failure.

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

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

@gcutrini please review

@gcutrini gcutrini requested a review from smarcet April 17, 2026 16:27
Consolidate both validation flows into a shared method with consistent
inline error display. Add PromoCodeNotice component for unified notice
rendering. Track validation state with promoCodeValid to show correct
icon (checkmark on success, error on failure). Move error handler to
module level. Reset promo state on widget init. Hide ticket summary
when expanded.
@gcutrini
Copy link
Copy Markdown
Member Author

Your review raised valid concerns about stuck states and error handling. Here's what we found and changed:

The fail-closed default (item 1) solved the stuck state but caused a new issue: the non-transferable notice showed for invalid codes, since promoCodeAllowsReassign was false on validation error. An invalid code shouldn't impose any reassignment restriction. We reverted to defaulting true on error and instead prevent advancement by disabling the Next button when promoCodeVerified is not true.

The removePromoCode on 404/412 (item 2) fixed the stuck state but created silent clearing with no explanation. Users saw the code disappear without knowing why. We reverted to keeping the code applied on validation errors and showing inline errors via a unified PromoCodeNotice component. The user sees what went wrong and decides to remove or retry.

To support this, we unified both validation flows (Apply button and Next button) into a shared handlePromoCodeValidation method. Previously the Apply flow caught errors with console.log while the Next flow showed inline errors, leading to inconsistent behavior.

We also added promoCodeVerified (null/true/false) and promoCodeValidating to Redux so the promo code input shows a spinner during validation, checkmark on success, and X on failure, replacing the old SVG icon that showed a checkmark even for invalid codes.

The atomic applyPromoCode (item 4) is kept: if getTicketTypes fails during code application, the code is cleared since ticket data would be stale.

Separately, removing a promo code previously cleared the ticket selection upfront (setTicket(null)), forcing users to re-select even when the ticket was still available. Now the existing useEffect that syncs with the refreshed ticket types list handles this: if the ticket still exists after re-fetch, it updates with fresh pricing; if it no longer exists (e.g. a promo-only ticket type), it clears the selection automatically.

// auto-assign to purchaser when ticket is non-transferable
data = {
...data,
attendee: { firstName: personalInfo.firstName, lastName: personalInfo.lastName, email: personalInfo.email }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gcutrini
Auto-assign uses stale personalInfo state instead of current form data
In PersonalInfoComponent.onSubmit, when isNonTransferable, the attendee is populated from personalInfo React state (initialized from userProfile) rather than data (the react-hook-form submission values). If the user's profile has no given_name (the name field becomes editable), and they type a name, the ticket is assigned with the original empty value ( not what they entered.)

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

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

LGTM

@smarcet smarcet merged commit 3e86d70 into main Apr 20, 2026
1 check passed
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