diff --git a/.gitignore b/.gitignore
index 32e7641..cad4ab9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,6 @@ node_modules
coverage
*.log
.idea/
-.env
\ No newline at end of file
+.env
+test-results
+playwright-report
diff --git a/e2e/promo-code-advance-from-ticket-step.spec.js b/e2e/promo-code-advance-from-ticket-step.spec.js
new file mode 100644
index 0000000..aad9202
--- /dev/null
+++ b/e2e/promo-code-advance-from-ticket-step.spec.js
@@ -0,0 +1,123 @@
+const { test, expect } = require('@playwright/test');
+const {
+ ticketType,
+ discoveryResponse,
+ ticketTypesResponse,
+ taxTypesResponse,
+ validationResponse,
+} = require('./fixtures');
+
+// Advancing past the ticket step re-validates an applied manual code and gates
+// changeStep on the result (registration-form's handleAdvanceFromTicketStep).
+// Nothing else covers that path: the other Next-clicking spec types a code
+// without applying it, so it exits on the unapplied-code warning first.
+
+// Routes everything the ticket step needs, letting the caller decide how each
+// successive validation call responds.
+const setup = async (page, validationResponses) => {
+ let call = 0;
+ await page.route('**/promo-codes/all/discover*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(discoveryResponse([])) })
+ );
+ await page.route('**/ticket-types/allowed*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ticketTypesResponse([ticketType()])) })
+ );
+ await page.route('**/tax-types*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(taxTypesResponse()) })
+ );
+ await page.route('**/promo-codes/*/apply*', route => {
+ // Last entry repeats, so a single-entry list means "always this".
+ const next = validationResponses[Math.min(call++, validationResponses.length - 1)];
+ route.fulfill({
+ status: next.status,
+ contentType: 'application/json',
+ body: JSON.stringify(next.body ?? validationResponse()),
+ });
+ });
+};
+
+// uicore raises its own modal for statuses it does not recognise. It overlays
+// the form and swallows clicks, so wait for it and clear it the way a user
+// would before touching anything underneath. Waiting rather than sampling
+// matters: the modal renders a beat after the response lands, and an
+// instantaneous check can miss it and leave it covering the next click.
+const dismissServerErrorModal = async (page) => {
+ const confirm = page.locator('.swal2-confirm');
+ await confirm.waitFor({ state: 'visible' });
+ await confirm.click();
+ await expect(page.locator('.swal2-container')).toHaveCount(0);
+};
+
+const applyCode = async (page, code) => {
+ await page.locator('[data-testid="ticket-dropdown"]').click();
+ await page.locator('[data-testid="ticket-list"] >> text=Early Bird Ticket').click();
+ await page.fill('input[placeholder="Enter your promo code"]', code);
+ await page.click('button:has-text("Apply")');
+};
+
+test('Next retries a validation that failed transiently, and advances once it succeeds', async ({ page }) => {
+ // A server error decides nothing about the code, so it must not strand the
+ // user: pressing Next again has to re-run the validation rather than sit on
+ // a dead button.
+ await setup(page, [{ status: 500, body: { message: 'Server error' } }, { status: 200 }]);
+ await page.goto('/');
+
+ await applyCode(page, 'EARLYCODE');
+ await dismissServerErrorModal(page);
+
+ const next = page.locator('button:has-text("Next")');
+ await expect(next).toBeEnabled();
+ await next.click();
+
+ await expect(page.locator('button:has-text("Back")')).toBeVisible();
+ await expect(page.locator('text=* Required fields')).toBeVisible();
+});
+
+test('Next does not advance while validation keeps failing', async ({ page }) => {
+ // The retry must not become a way through on an unverified code.
+ await setup(page, [{ status: 500, body: { message: 'Server error' } }]);
+ await page.goto('/');
+
+ await applyCode(page, 'EARLYCODE');
+ await dismissServerErrorModal(page);
+
+ await page.locator('button:has-text("Next")').click();
+ await dismissServerErrorModal(page);
+
+ // Still on the ticket step: neither of these renders until it is left.
+ await expect(page.locator('button:has-text("Back")')).toHaveCount(0);
+ await expect(page.locator('text=* Required fields')).toHaveCount(0);
+});
+
+test('Next advances to personal information with an applied promo code', async ({ page }) => {
+ await page.route('**/promo-codes/all/discover*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(discoveryResponse([])) })
+ );
+ await page.route('**/ticket-types/allowed*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ticketTypesResponse([ticketType()])) })
+ );
+ await page.route('**/tax-types*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(taxTypesResponse()) })
+ );
+ await page.route('**/promo-codes/*/apply*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(validationResponse()) })
+ );
+
+ await page.goto('/');
+
+ await page.locator('[data-testid="ticket-dropdown"]').click();
+ await page.locator('[data-testid="ticket-list"] >> text=Early Bird Ticket').click();
+
+ await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE');
+ await page.click('button:has-text("Apply")');
+ await expect(page.getByTestId('promo-applied')).toBeVisible();
+
+ await page.click('button:has-text("Next")');
+
+ // Both only render once the ticket step has been left (button-bar gates the
+ // Back button and the required-fields note on step !== STEP_SELECT_TICKET_TYPE).
+ // Purchaser Information is not a usable signal here: it is on screen during
+ // the ticket step too.
+ await expect(page.locator('button:has-text("Back")')).toBeVisible();
+ await expect(page.locator('text=* Required fields')).toBeVisible();
+});
diff --git a/e2e/promo-code-apply-without-ticket.spec.js b/e2e/promo-code-apply-without-ticket.spec.js
new file mode 100644
index 0000000..81d7008
--- /dev/null
+++ b/e2e/promo-code-apply-without-ticket.spec.js
@@ -0,0 +1,136 @@
+const { test, expect } = require('@playwright/test');
+const {
+ ticketType,
+ discoveryResponse,
+ ticketTypesResponse,
+ taxTypesResponse,
+ validationResponse,
+} = require('./fixtures');
+
+// ── Helpers ──
+
+const setupRoutes = async (page, { discovery = [], tickets = [], taxes = [], validation = null } = {}) => {
+ await page.route('**/promo-codes/all/discover*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(discoveryResponse(discovery)) })
+ );
+
+ await page.route('**/ticket-types/allowed*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ticketTypesResponse(tickets)) })
+ );
+
+ await page.route('**/tax-types*', route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(taxTypesResponse(taxes)) })
+ );
+
+ if (validation) {
+ await page.route('**/promo-codes/*/apply*', route =>
+ route.fulfill({
+ status: validation.status || 200,
+ contentType: 'application/json',
+ body: JSON.stringify(validation.body || validationResponse()),
+ })
+ );
+ }
+};
+
+const selectTicket = async (page, ticketName) => {
+ await page.locator('[data-testid="ticket-dropdown"]').click();
+ await page.locator(`[data-testid="ticket-list"] >> text=${ticketName}`).click();
+};
+
+// ── Apply before selecting a ticket ──
+
+// Two ticket types so the post-apply single-ticket auto-select does not kick
+// in — the applied code genuinely rests with no ticket picked.
+const twoTickets = [
+ ticketType(),
+ ticketType({ id: 189, name: 'General Admission', cost: 900 }),
+];
+
+const promoSpinner = (page) => page.getByTestId('promo-spinner');
+const promoApplied = (page) => page.getByTestId('promo-applied');
+
+test.describe('apply promo code before selecting a ticket', () => {
+ test('settles instead of spinning forever', async ({ page }) => {
+ await setupRoutes(page, {
+ tickets: twoTickets,
+ discovery: [],
+ validation: { status: 200, body: validationResponse() },
+ });
+ await page.goto('/');
+
+ // Apply a code with NO ticket selected
+ await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE');
+ await page.click('button:has-text("Apply")');
+
+ // Input locks with a Remove affordance
+ await expect(page.locator('input[placeholder="Enter your promo code"][readonly]')).toBeVisible();
+ await expect(page.locator('button:has-text("Remove")')).toBeVisible();
+
+ // No spinner. No success mark either: nothing has verified the code.
+ await expect(promoSpinner(page)).toHaveCount(0);
+ await expect(promoApplied(page)).toHaveCount(0);
+ });
+
+ test('validates the code once a ticket is picked afterwards', async ({ page }) => {
+ let validationCalls = 0;
+ await setupRoutes(page, {
+ tickets: twoTickets,
+ discovery: [],
+ });
+ await page.route('**/promo-codes/*/apply*', route => {
+ validationCalls += 1;
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(validationResponse()) });
+ });
+ await page.goto('/');
+
+ await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE');
+ await page.click('button:has-text("Apply")');
+ await expect(page.locator('button:has-text("Remove")')).toBeVisible();
+ await expect(promoApplied(page)).toHaveCount(0);
+ expect(validationCalls).toBe(0);
+
+ // Picking a ticket triggers the deferred promo+ticket validation,
+ // which is what promotes the code to verified.
+ await selectTicket(page, 'General Admission');
+ await expect.poll(() => validationCalls).toBeGreaterThan(0);
+ await expect(promoApplied(page)).toBeVisible();
+ await expect(promoSpinner(page)).toHaveCount(0);
+ });
+
+ test('shows the spinner only while validation is in flight', async ({ page }) => {
+ // The other assertions in this file check the spinner is absent, which
+ // would also pass if the locator stopped matching anything. Holding the
+ // validation open pins the spinner with a positive assertion, so a
+ // renamed or removed icon fails here instead of passing silently.
+ await setupRoutes(page, { tickets: twoTickets, discovery: [] });
+
+ // Slow the validation enough to observe the in-flight state.
+ await page.route('**/promo-codes/*/apply*', async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(validationResponse()),
+ });
+ });
+
+ await page.goto('/');
+ await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE');
+ await page.click('button:has-text("Apply")');
+
+ // Resting unverified: no validation has been requested yet.
+ await expect(page.locator('button:has-text("Remove")')).toBeVisible();
+ await expect(promoSpinner(page)).toHaveCount(0);
+
+ // Selecting a ticket starts the validation, which is now slow enough
+ // to catch mid-flight.
+ await selectTicket(page, 'General Admission');
+ await expect(promoSpinner(page)).toBeVisible();
+ await expect(promoApplied(page)).toHaveCount(0);
+
+ // And it resolves to verified once the request lands.
+ await expect(promoApplied(page)).toBeVisible();
+ await expect(promoSpinner(page)).toHaveCount(0);
+ });
+});
diff --git a/e2e/promo-code-discovery.spec.js b/e2e/promo-code-discovery.spec.js
index 364dca3..bfa49ed 100644
--- a/e2e/promo-code-discovery.spec.js
+++ b/e2e/promo-code-discovery.spec.js
@@ -292,6 +292,35 @@ test.describe('validation errors', () => {
await expect(page.locator('text=Promo code XYZ can not be applied to Ticket Type Early Bird Ticket.')).toBeVisible();
});
+ test('a server error leaves the field usable instead of stuck processing', async ({ page }) => {
+ // A 500 carries no verdict, so it never reaches the reducer. The
+ // in-flight state has to be cleared by whoever awaited the request,
+ // otherwise the promo field spins for the rest of the session and the
+ // user can neither retry nor continue.
+ await setupRoutes(page, {
+ tickets: [ticketType()],
+ discovery: [],
+ validation: { status: 500, body: { message: 'Server error' } },
+ });
+ await page.goto('/');
+ await selectTicket(page, 'Early Bird Ticket');
+ await page.fill('input[placeholder="Enter your promo code"]', 'ANYCODE');
+ await page.click('button:has-text("Apply")');
+
+ // uicore surfaces unhandled statuses in its own modal, which overlays
+ // the form. Wait for it rather than sampling: it renders a beat after
+ // the response lands, and dismissing it is what a user would do before
+ // looking at the field underneath.
+ const confirm = page.locator('.swal2-confirm');
+ await confirm.waitFor({ state: 'visible' });
+ await confirm.click();
+ await expect(page.locator('.swal2-container')).toHaveCount(0);
+
+ // Settled, not spinning, and still operable.
+ await expect(page.getByTestId('promo-spinner')).toHaveCount(0);
+ await expect(page.locator('button:has-text("Remove")')).toBeEnabled();
+ });
+
test('error clears when user types', async ({ page }) => {
await setupRoutes(page, {
tickets: [ticketType()],
diff --git a/src/actions.js b/src/actions.js
index 4779099..fd24e83 100644
--- a/src/actions.js
+++ b/src/actions.js
@@ -54,7 +54,6 @@ export const CLEAR_CURRENT_PROMO_CODE = 'CLEAR_CURRENT_PROMO_CODE';
export const VALIDATE_PROMO_CODE = 'VALIDATE_PROMO_CODE';
export const VALIDATE_PROMO_CODE_SUCCESS = 'VALIDATE_PROMO_CODE_SUCCESS';
export const VALIDATE_PROMO_CODE_ERROR = 'VALIDATE_PROMO_CODE_ERROR';
-export const VALIDATE_PROMO_CODE_RATE_LIMITED = 'VALIDATE_PROMO_CODE_RATE_LIMITED';
export const DISCOVER_PROMO_CODES = 'DISCOVER_PROMO_CODES';
export const DISCOVER_PROMO_CODES_SUCCESS = 'DISCOVER_PROMO_CODES_SUCCESS';
@@ -74,17 +73,11 @@ export const clearWidgetState = () => (dispatch) => {
}
const promoCodeErrorHandler = (err, res) => (dispatch, state) => {
- // 404: promo code or ticket type not found
- // 412: promo code invalid for this ticket type/qty
- if (res && [404, 412].includes(res.statusCode)) {
- dispatch(createAction(VALIDATE_PROMO_CODE_ERROR)({}));
- return;
- }
- // 429: rate limited - transient, preserve current promo state
- if (res && res.statusCode === 429) {
- dispatch(createAction(VALIDATE_PROMO_CODE_RATE_LIMITED)({}));
- return;
- }
+ // 404 and 412 are the API judging the code, and 429 is transient. None of
+ // them is an auth or session problem, and the caller sees the rejection
+ // either way, so there is nothing to escalate.
+ if (res && [404, 412, 429].includes(res.statusCode)) return;
+
return authErrorHandler(err, res)(dispatch, state);
};
diff --git a/src/components/promocode-input/index.js b/src/components/promocode-input/index.js
index 36bb93a..7986c2b 100644
--- a/src/components/promocode-input/index.js
+++ b/src/components/promocode-input/index.js
@@ -27,9 +27,11 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied,
}, [promoCode]);
// Lock the input + show Remove (instead of Apply) whenever a code is in flight
- // or has settled (valid or invalid). The user must explicitly Remove to edit again.
- const isLocked = promoStatus === PROMO_STATUS.APPLYING || promoStatus === PROMO_STATUS.VALIDATING
- || promoStatus === PROMO_STATUS.VALID || promoStatus === PROMO_STATUS.INVALID;
+ // or has settled (applied or invalid). The user must explicitly Remove to edit again.
+ const isLocked = promoStatus === PROMO_STATUS.PROCESSING
+ || promoStatus === PROMO_STATUS.APPLIED
+ || promoStatus === PROMO_STATUS.UNVERIFIED
+ || promoStatus === PROMO_STATUS.INVALID;
const inputValue = useMemo(() => {
if (promoCode) return promoCode;
@@ -39,11 +41,11 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied,
const label = useMemo(() => {
switch (promoStatus) {
- case PROMO_STATUS.VALID:
+ case PROMO_STATUS.APPLIED:
+ case PROMO_STATUS.UNVERIFIED:
if (isAutoApplied) return T.translate('promo_code.auto_applied_label');
return T.translate('promo_code.applied_label');
- case PROMO_STATUS.APPLYING:
- case PROMO_STATUS.VALIDATING:
+ case PROMO_STATUS.PROCESSING:
if (isAutoApplied) return T.translate('promo_code.auto_applied_label');
return T.translate('promo_code.applying_label');
case PROMO_STATUS.INVALID:
@@ -91,9 +93,9 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied,
}}
readOnly={isLocked} />
- {(promoStatus === PROMO_STATUS.VALIDATING || promoStatus === PROMO_STATUS.APPLYING) && }
- {promoStatus === PROMO_STATUS.VALID && ✓}
- {promoStatus === PROMO_STATUS.INVALID && ✕}
+ {promoStatus === PROMO_STATUS.PROCESSING && }
+ {promoStatus === PROMO_STATUS.APPLIED && ✓}
+ {promoStatus === PROMO_STATUS.INVALID && ✕}
{isLocked ?
diff --git a/src/components/registration-form/__tests__/registration-form.test.js b/src/components/registration-form/__tests__/registration-form.test.js
index c84a91e..9d7a841 100644
--- a/src/components/registration-form/__tests__/registration-form.test.js
+++ b/src/components/registration-form/__tests__/registration-form.test.js
@@ -186,7 +186,6 @@ const defaultReduxState = {
},
promoCode: '',
promoCodeVerified: null,
- promoCodeValidating: false,
promoCodeAllowsReassign: true,
discoveredPromoCodes: [],
requestedTicketTypes: false,
diff --git a/src/components/registration-form/index.js b/src/components/registration-form/index.js
index 882b9bf..1a6f3ec 100644
--- a/src/components/registration-form/index.js
+++ b/src/components/registration-form/index.js
@@ -66,7 +66,7 @@ import TicketTaxesError from '../ticket-taxes-error';
import T from 'i18n-react';
import { getCurrentUserLanguage } from '../../utils/utils';
import {
- ADD_TO_CART, BEGIN_CHECKOUT, PURCHASE_COMPLETE, PROMO_STATUS,
+ ADD_TO_CART, BEGIN_CHECKOUT, PURCHASE_COMPLETE,
STEP_COMPLETE,
STEP_PAYMENT,
STEP_PERSONAL_INFO,
@@ -162,9 +162,6 @@ const RegistrationFormContent = (
showCompanyInputDefaultOptions,
companyDDLOptions2Show,
promoCode,
- promoCodeVerified,
- promoCodeValidating,
- promoCodeAllowsReassign,
discoveredPromoCodes,
hasDiscount,
getTicketDiscount,
@@ -267,8 +264,6 @@ const RegistrationFormContent = (
const promo = usePromoCode({
discoveredPromoCodes,
promoCode,
- promoCodeVerified,
- promoCodeValidating,
applyPromoCode,
removePromoCode,
validatePromoCode,
@@ -293,11 +288,11 @@ const RegistrationFormContent = (
useEffect(() => {
if (!formValues?.promoCode
|| promoCode
- || promoState.status === PROMO_STATUS.SUGGESTED
+ || promoState.isSuggested
|| promoState.validationError) {
setUnappliedCodeWarning(null);
}
- }, [formValues?.promoCode, promoCode, promoState.status, promoState.validationError])
+ }, [formValues?.promoCode, promoCode, promoState.isSuggested, promoState.validationError])
const [ref, { height }] = useMeasure();
@@ -352,20 +347,23 @@ const RegistrationFormContent = (
}
const handleAdvanceFromTicketStep = async (data) => {
- if (formValues?.promoCode && !promoCode && promoState.status !== PROMO_STATUS.SUGGESTED) {
+ if (formValues?.promoCode && !promoCode && !promoState.isSuggested) {
setUnappliedCodeWarning(T.translate('promo_code.unapplied_code_warning'));
return;
}
- // Re-validate manual codes with final quantity before advancing
- if (promoCode && !promoState.isDiscoveredCode) {
+ // Re-validate the applied code against the final quantity before
+ // advancing. This is also the retry for a validation that failed
+ // earlier, so it covers discovered codes too: their quantity caps make
+ // them just as quantity-dependent as a manually entered one.
+ if (promoCode) {
startWidgetLoading();
- let valid = false;
+ let canAdvance = false;
try {
- valid = await promoActions.onRevalidate(formValues.ticketType, data.ticketQuantity);
+ canAdvance = await promoActions.onRevalidate(formValues.ticketType, data.ticketQuantity);
} finally {
stopWidgetLoading();
}
- if (!valid) return;
+ if (!canAdvance) return;
}
trackAddToCart(data);
changeStep(STEP_PERSONAL_INFO);
@@ -449,7 +447,7 @@ const RegistrationFormContent = (
promo={promo}
validationError={ticketStepError}
promoCode={promoCode}
- promoCodeAllowsReassign={promoCodeAllowsReassign}
+ promoCodeAllowsReassign={promoState.allowsReassign}
changeForm={mergeFormValues}
trackViewItem={trackViewItem}
showMultipleTicketTexts={showMultipleTicketTexts}
@@ -494,7 +492,7 @@ const RegistrationFormContent = (
companyDDLPlaceholder={companyDDLPlaceholder}
showCompanyInputDefaultOptions={showCompanyInputDefaultOptions}
companyDDLOptions2Show={companyDDLOptions2Show}
- promoCodeAllowsReassign={promoCodeAllowsReassign}
+ promoCodeAllowsReassign={promoState.allowsReassign}
/>
@@ -571,9 +569,6 @@ const mapStateToProps = ({ registrationLiteState }) => ({
passwordlessCodeSent: registrationLiteState.passwordless.code_sent,
passwordlessCodeError: registrationLiteState.passwordless.error,
promoCode: registrationLiteState.promoCode,
- promoCodeVerified: registrationLiteState.promoCodeVerified,
- promoCodeValidating: registrationLiteState.promoCodeValidating,
- promoCodeAllowsReassign: registrationLiteState.promoCodeAllowsReassign,
discoveredPromoCodes: registrationLiteState.discoveredPromoCodes,
})
diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js
index dc8d867..8c1c89c 100644
--- a/src/hooks/__tests__/usePromoCode.test.js
+++ b/src/hooks/__tests__/usePromoCode.test.js
@@ -32,8 +32,6 @@ const mockTicketNonQualifying = { id: 99, sub_type: 'Regular' };
const createDefaultProps = (overrides = {}) => ({
discoveredPromoCodes: [],
promoCode: '',
- promoCodeVerified: null,
- promoCodeValidating: false,
applyPromoCode: jest.fn(() => Promise.resolve()),
removePromoCode: jest.fn(),
validatePromoCode: jest.fn(() => Promise.resolve()),
@@ -41,6 +39,209 @@ const createDefaultProps = (overrides = {}) => ({
...overrides,
});
+// Whether the code was accepted is the hook's own state, reached only by
+// validating, so a test that needs an accepted code has to earn one the way the
+// app does: this renders the hook and runs one successful validation against it.
+const renderVerified = async (overrides = {}, response = {}) => {
+ const view = renderHook((props) =>
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => Promise.resolve({ response })),
+ ...overrides,
+ ...props,
+ })), { initialProps: {} }
+ );
+ await act(async () => {
+ await view.result.current.actions.onRevalidate(mockTicketQualifying, 1);
+ });
+ return view;
+};
+
+// Same, for a code the API turned down. 412 is the status that means "does not
+// apply to this ticket type or quantity".
+const renderRejected = async (overrides = {}, body = {}) => {
+ const view = renderHook(() =>
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => Promise.reject({ res: { statusCode: 412, body } })),
+ ...overrides,
+ }))
+ );
+ await act(async () => {
+ await view.result.current.actions.onRevalidate(mockTicketQualifying, 1);
+ });
+ return view;
+};
+
+// Lets a test hold a validation open and decide when (and how) it ends, so
+// in-flight state is produced by an actual pending request rather than asserted
+// from a value handed to the hook.
+const deferred = () => {
+ let settle = {};
+ const promise = new Promise((resolve, reject) => { settle = { resolve, reject }; });
+ // Nothing here awaits a rejection before it is attached below.
+ promise.catch(() => {});
+ return { promise, ...settle };
+};
+
+// ── Abandoning a validation ──
+
+// A validation is only about the code that was applied when it started. These
+// cover what has to happen to one that is still running when the user changes
+// that code: it stops owning the field, and it stops being allowed to answer.
+describe('abandoning an in-flight validation', () => {
+ const inFlight = async (extra = {}) => {
+ const pending = deferred();
+ const view = renderHook(() =>
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => pending.promise),
+ ...extra,
+ }))
+ );
+ act(() => { view.result.current.actions.onRevalidate(mockTicketQualifying, 1); });
+ expect(view.result.current.state.status).toBe(PROMO_STATUS.PROCESSING);
+ return { view, pending };
+ };
+
+ it('frees the field when the code is removed mid-validation', async () => {
+ // Otherwise the input stays read-only behind a spinner and Next stays
+ // disabled until the abandoned request lands, which for an aborted one
+ // is never.
+ const { view } = await inFlight();
+
+ act(() => { view.result.current.actions.onRemove(); });
+
+ expect(view.result.current.state.status).not.toBe(PROMO_STATUS.PROCESSING);
+ expect(view.result.current.state.isReady).toBe(true);
+ });
+
+ it('ignores the answer to a validation for a code since removed', async () => {
+ const { view, pending } = await inFlight();
+
+ act(() => { view.result.current.actions.onRemove(); });
+ await act(async () => { pending.resolve({ response: { allows_to_reassign: false } }); });
+
+ // The store blanking the code is a separate mechanism, so this asserts
+ // only what onRemove itself has to guarantee: the abandoned answer
+ // is never recorded, and its reassignment restriction never
+ // reaches the rest of the flow.
+ expect(view.result.current.state.status).not.toBe(PROMO_STATUS.APPLIED);
+ expect(view.result.current.state.allowsReassign).toBe(true);
+ });
+
+ it('ignores the answer to a validation for a code since replaced', async () => {
+ // The window between applying a code and validating it is a whole
+ // round trip; the previous code's answer must not fill it.
+ const { view, pending } = await inFlight();
+
+ await act(async () => { view.result.current.actions.onApply('OTHER', null, 1); });
+ await act(async () => { pending.resolve({ response: { allows_to_reassign: false } }); });
+
+ expect(view.result.current.state.status).not.toBe(PROMO_STATUS.APPLIED);
+ expect(view.result.current.state.allowsReassign).toBe(true);
+ });
+
+ it('drops the recorded validation when the applied code is cleared from outside', async () => {
+ // Checkout, logout and a cleared reservation all blank the code in the
+ // store without going through this hook.
+ const view = await renderVerified({}, { allows_to_reassign: false });
+ expect(view.result.current.state.allowsReassign).toBe(false);
+
+ view.rerender({ promoCode: '' });
+
+ expect(view.result.current.state.allowsReassign).toBe(true);
+ });
+});
+
+// ── Applying a code end to end ──
+
+describe('applying a code', () => {
+ it('reaches APPLIED once the applied code validates', async () => {
+ // Applying is a round trip, and the code only arrives as a prop when it
+ // is over, so the callback that starts the validation was created while
+ // no code was applied. Anything the validation records about "the
+ // applied code" has to be read when it runs, not when it was created,
+ // or the answer is filed against the wrong code and never counts.
+ let view;
+ const props = createDefaultProps({
+ promoCode: '',
+ ticketDataLoaded: true,
+ hasTickets: true,
+ applyPromoCode: jest.fn(async () => { view.rerender({ promoCode: 'CODE' }); }),
+ validatePromoCode: jest.fn(() => Promise.resolve({ response: {} })),
+ });
+ view = renderHook((over) => usePromoCode({ ...props, ...over }), { initialProps: {} });
+
+ await act(async () => {
+ await view.result.current.actions.onApply('CODE', mockTicketQualifying, 1);
+ });
+
+ expect(view.result.current.state.status).toBe(PROMO_STATUS.APPLIED);
+ });
+});
+
+// ── What a failure that decided nothing may change ──
+
+describe('a validation that decided nothing', () => {
+ const failing = (statusCode) => jest.fn()
+ .mockImplementationOnce(() => Promise.resolve({ response: { allows_to_reassign: true } }))
+ .mockImplementationOnce(() => Promise.reject({ res: { statusCode, body: {} } }));
+
+ it('does not downgrade an auto-applied code to a manual one', async () => {
+ // The label says the widget applied this code on the user's behalf.
+ // A server error says nothing about the code, so it cannot quietly
+ // rewrite how the code came to be applied.
+ // Built once: createDefaultProps mints new mocks per call, and unstable
+ // identities restart the auto-apply effect on every render.
+ const props = createDefaultProps({
+ discoveredPromoCodes: [mockDiscoveredCodes[1]],
+ promoCode: '',
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => Promise.reject({ res: { statusCode: 500, body: {} } })),
+ });
+ const view = renderHook((over) => usePromoCode({ ...props, ...over }), { initialProps: {} });
+
+ // Let the early auto-apply run, which is the only thing that marks a
+ // code as applied on the user's behalf.
+ await act(async () => {});
+ expect(view.result.current.state.isAutoApplied).toBe(true);
+
+ view.rerender({ promoCode: 'AUTO1' });
+
+ await act(async () => {
+ await view.result.current.actions.onRevalidate(mockTicketQualifying, 1);
+ });
+ expect(view.result.current.state.isAutoApplied).toBe(true);
+ });
+
+ it('stops applying the code quantity caps', async () => {
+ // status drops to UNVERIFIED after this, so the caps a verified code
+ // imposes must drop with it rather than keep acting on an answer that
+ // no longer covers the selection.
+ const view = await renderVerified({
+ discoveredPromoCodes: [mockDiscoveredCodes[1]],
+ promoCode: 'AUTO1',
+ validatePromoCode: failing(500),
+ });
+ expect(view.result.current.state.maxQuantityFromPromo).toBe(4);
+
+ await act(async () => {
+ await view.result.current.actions.onRevalidate(mockTicketQualifying, 1);
+ });
+ expect(view.result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED);
+ expect(view.result.current.state.maxQuantityFromPromo).toBeNull();
+ expect(view.result.current.state.perAccountLimit).toBeNull();
+ });
+});
+
// ── Discovery selection ──
describe('discovery selection', () => {
@@ -51,7 +252,7 @@ describe('discovery selection', () => {
expect(result.current.state.suggestedCode).toBe('AUTO1');
});
- it('falls back to first code when none has auto_apply', () => {
+ it('falls back to first code when none has auto_apply', async () => {
const codes = [{ code: 'A', auto_apply: false }, { code: 'B', auto_apply: false }];
const { result } = renderHook(() =>
usePromoCode(createDefaultProps({ discoveredPromoCodes: codes }))
@@ -75,34 +276,262 @@ describe('status derivation', () => {
expect(result.current.state.status).toBe(PROMO_STATUS.IDLE);
});
- it('returns APPLYING when code applied and promoCodeVerified is null', () => {
+ it('returns PROCESSING when code applied and ticket data has not loaded yet', () => {
const { result } = renderHook(() =>
usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: null }))
);
- expect(result.current.state.status).toBe(PROMO_STATUS.APPLYING);
+ expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING);
+ });
+
+ it('returns PROCESSING while a validation is in flight, and stops when it ends', async () => {
+ // ticketDataLoaded/promoCodeVerified are set so the other two inputs to
+ // isBusy are already false: the only thing that can produce PROCESSING
+ // here is the pending validation itself.
+ const pending = deferred();
+ const { result } = await renderVerified({
+ validatePromoCode: jest.fn()
+ .mockImplementationOnce(() => Promise.resolve({ response: {} }))
+ .mockImplementationOnce(() => pending.promise),
+ });
+ expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED);
+
+ act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); });
+ expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING);
+
+ await act(async () => { pending.resolve({ response: {} }); });
+ expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED);
});
- it('returns VALIDATING when code applied and promoCodeValidating is true', () => {
+ it('returns PROCESSING while re-validating a code the API turned down', async () => {
+ // A rejection stays recorded while a re-validation runs, so both signals
+ // coexist. In-flight must win over the stale rejection:
+ // spinner, not error icon.
+ const pending = deferred();
+ const { result } = await renderRejected({
+ validatePromoCode: jest.fn()
+ .mockImplementationOnce(() => Promise.reject({ res: { statusCode: 412, body: {} } }))
+ .mockImplementationOnce(() => pending.promise),
+ });
+ expect(result.current.state.status).toBe(PROMO_STATUS.INVALID);
+
+ act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); });
+ expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING);
+ expect(result.current.state.validationError).toBeNull();
+ });
+
+ it('stops PROCESSING when the validation fails without deciding anything', async () => {
+ // A server error, a timeout or a dropped connection never reaches the
+ // reducer. If the hook does not clear its own flag the field spins for
+ // the rest of the session.
+ const pending = deferred();
const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeValidating: true }))
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ promoCodeVerified: true,
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => pending.promise),
+ }))
+ );
+
+ act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); });
+ expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING);
+
+ await act(async () => { pending.reject({ res: { body: { message: 'Server error' } } }); });
+ expect(result.current.state.status).not.toBe(PROMO_STATUS.PROCESSING);
+ expect(result.current.state.validationError).toBe('Server error');
+ });
+
+ it('does not strand the applying flag when a validation never settles', async () => {
+ // uicore aborts an in-flight request as soon as a newer one targets the
+ // same URL, and superagent does not invoke the callback for an aborted
+ // request, so that promise neither resolves nor rejects. Any flag
+ // cleared only after awaiting it stays set for the rest of the session.
+ // Recovery comes from the newer request, which is the one that caused
+ // the abort, so nothing may depend on the abandoned one finishing.
+ const abandoned = new Promise(() => {});
+ const newer = deferred();
+ let call = 0;
+ const validatePromoCode = jest.fn(() => (call++ === 0 ? abandoned : newer.promise));
+
+ const { result } = renderHook(() =>
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode,
+ }))
);
- expect(result.current.state.status).toBe(PROMO_STATUS.VALIDATING);
+
+ // Deliberately not awaited: this call never returns.
+ await act(async () => { result.current.actions.onApply('CODE', mockTicketQualifying, 1); });
+ expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING);
+
+ // The newer request lands. It clears its own in-flight flag, and with
+ // nothing else stuck the field settles.
+ await act(async () => {
+ result.current.actions.onRevalidate(mockTicketQualifying, 1);
+ newer.resolve({ response: {} });
+ });
+ expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED);
+ });
+
+ it('stays busy across the handoff from applying to validating', async () => {
+ // Two flags cover consecutive stretches of one user action. React 16
+ // does not batch these updates, so clearing the first before the second
+ // is set renders a frame with neither: the spinner blinks off and Next
+ // is briefly clickable mid-apply.
+ const statuses = [];
+ const pending = deferred();
+ const { result } = renderHook(() => {
+ const promo = usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => pending.promise),
+ }));
+ statuses.push(promo.state.status);
+ return promo;
+ });
+
+ await act(async () => { result.current.actions.onApply('CODE', mockTicketQualifying, 1); });
+
+ // From the moment it goes busy until the validation settles, every
+ // rendered frame must still be busy.
+ const firstBusy = statuses.indexOf(PROMO_STATUS.PROCESSING);
+ expect(firstBusy).toBeGreaterThanOrEqual(0);
+ expect(statuses.slice(firstBusy)).toEqual(
+ statuses.slice(firstBusy).map(() => PROMO_STATUS.PROCESSING)
+ );
+
+ await act(async () => { pending.resolve({ response: {} }); });
+ expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED);
+ });
+
+ it('returns APPLIED when code verified for the selected ticket', async () => {
+ const { result } = await renderVerified();
+ expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED);
+ });
+
+ it('returns UNVERIFIED when code applied with tickets available but none picked yet', () => {
+ // Code was applied without a ticket selected: the ticket list came back
+ // non-empty, nothing is in flight, and no per-ticket validation has run.
+ // A resting state, but not a verified one: the catalog comes back
+ // populated even for a code that does not exist, so this must not
+ // render as success.
+ const { result } = renderHook(() =>
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ promoCodeVerified: null,
+ ticketDataLoaded: true,
+ hasTickets: true,
+ }))
+ );
+ expect(result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED);
+ });
+
+ it('returns UNVERIFIED when a verified code has an outstanding request error', async () => {
+ // A rate-limited or timed-out re-validation leaves promoCodeVerified at
+ // its previous value while setting an error. The last answer no longer
+ // covers the current selection, so it must not render as success.
+ const { result } = await renderVerified({
+ validatePromoCode: jest.fn()
+ .mockImplementationOnce(() => Promise.resolve({ response: {} }))
+ .mockImplementationOnce(() => Promise.reject({ res: { body: { errors: ['Too many requests'] } } })),
+ });
+ expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED);
+
+ await act(async () => {
+ await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' });
+ });
+ expect(result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED);
+ expect(result.current.state.validationError).toBe('Too many requests');
});
- it('returns VALID when code applied and promoCodeVerified is true', () => {
+ it('ignores a validation response that a later ticket switch superseded', async () => {
+ // Ticket A's request stays pending while ticket B's succeeds. When A
+ // then rejects, its error is about a ticket the user already left, so
+ // it must not surface or block the advance gate.
+ let rejectFirst;
+ const validatePromoCode = jest.fn()
+ .mockImplementationOnce(() => new Promise((_, reject) => { rejectFirst = reject; }))
+ .mockImplementationOnce(() => Promise.resolve({ response: {} }));
+
const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true }))
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode,
+ }))
);
- expect(result.current.state.status).toBe(PROMO_STATUS.VALID);
+
+ let firstAttempt;
+ await act(async () => {
+ firstAttempt = result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' });
+ await result.current.actions.onTicketSelected({ id: 2, sub_type: 'Regular' });
+ });
+
+ await act(async () => {
+ rejectFirst({ res: { body: { errors: ['stale failure'] } } });
+ await firstAttempt;
+ });
+
+ expect(result.current.state.validationError).toBeNull();
+ expect(result.current.state.isReady).toBe(true);
+ expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED);
});
- it('returns INVALID when code applied and promoCodeVerified is false', () => {
+ it('does not let a superseded attempt advance the caller', async () => {
+ // Resolves with a real response body, so nothing but the supersede
+ // check can stop the superseded attempt reporting success.
+ let resolveFirst;
+ const validatePromoCode = jest.fn()
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }))
+ .mockImplementationOnce(() => Promise.resolve({ response: {} }));
const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false }))
+ usePromoCode(createDefaultProps({ promoCode: 'CODE', validatePromoCode }))
);
+
+ let firstAttempt;
+ await act(async () => {
+ firstAttempt = result.current.actions.onRevalidate({ id: 1, sub_type: 'Regular' }, 1);
+ await result.current.actions.onRevalidate({ id: 2, sub_type: 'Regular' }, 1);
+ });
+
+ let canAdvance;
+ await act(async () => {
+ resolveFirst({ response: {} });
+ canAdvance = await firstAttempt;
+ });
+ expect(canAdvance).toBe(false);
+ });
+
+ it('returns INVALID when the API turned the code down', async () => {
+ const { result } = await renderRejected();
expect(result.current.state.status).toBe(PROMO_STATUS.INVALID);
});
+ it.each([404, 412])('treats %s as the API rejecting the code', async (statusCode) => {
+ const { result } = await renderRejected({
+ validatePromoCode: jest.fn(() => Promise.reject({ res: { statusCode, body: {} } })),
+ });
+ expect(result.current.state.status).toBe(PROMO_STATUS.INVALID);
+ });
+
+ it.each([429, 500, 503, undefined])('does not report the code as rejected on %s', async (statusCode) => {
+ // These decide nothing about the code. Showing INVALID would tell the
+ // user their code is bad when nothing ever judged it. undefined stands
+ // for a request that produced no response at all.
+ const { result } = await renderRejected({
+ validatePromoCode: jest.fn(() => Promise.reject(
+ statusCode === undefined ? {} : { res: { statusCode, body: {} } }
+ )),
+ });
+ expect(result.current.state.status).not.toBe(PROMO_STATUS.INVALID);
+ expect(result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED);
+ });
+
it('returns SUGGESTED after selecting qualifying ticket with discovered codes', async () => {
const { result } = renderHook(() =>
usePromoCode(createDefaultProps({ discoveredPromoCodes: [{ code: 'S1', auto_apply: false, allowed_ticket_types: [{ id: 1 }] }] }))
@@ -137,42 +566,99 @@ describe('derived values', () => {
expect(result.current.state.isReady).toBe(true);
});
- it('isReady true for VALID', () => {
+ it('isReady true for verified code', async () => {
+ const { result } = await renderVerified();
+ expect(result.current.state.isReady).toBe(true);
+ });
+
+ it('isReady true for applied code awaiting ticket selection', () => {
+ // Promo layer is settled; ticket selection is enforced by its own gate.
const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true }))
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ promoCodeVerified: null,
+ ticketDataLoaded: true,
+ hasTickets: true,
+ }))
);
expect(result.current.state.isReady).toBe(true);
});
- it('isReady false for APPLYING', () => {
+ it('isReady false while ticket data is loading', () => {
const { result } = renderHook(() =>
usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: null }))
);
expect(result.current.state.isReady).toBe(false);
});
- it('isReady false for VALIDATING', () => {
+ it('isReady false while validation is in flight', () => {
+ const pending = deferred();
const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeValidating: true }))
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ promoCodeVerified: true,
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => pending.promise),
+ }))
);
+ expect(result.current.state.isReady).toBe(true);
+
+ act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); });
expect(result.current.state.isReady).toBe(false);
});
- it('isReady false for INVALID', () => {
+ it('isReady false for INVALID', async () => {
+ const { result } = await renderRejected();
+ expect(result.current.state.status).toBe(PROMO_STATUS.INVALID);
+ expect(result.current.state.isReady).toBe(false);
+ });
+
+ it('isReady stays true after a request error so the user can retry', async () => {
+ // A failed request decides nothing, so it must not latch the gate
+ // shut: the user has to be able to try again. Not proceeding on an
+ // unverified code is enforced by re-validating on advance and refusing
+ // to move on unless it succeeds, which is covered end to end.
const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false }))
+ usePromoCode(createDefaultProps({
+ promoCode: 'CODE',
+ promoCodeVerified: null,
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => Promise.reject({ res: { body: { errors: ['Too many requests'] } } })),
+ }))
);
- expect(result.current.state.isReady).toBe(false);
+ expect(result.current.state.isReady).toBe(true);
+
+ await act(async () => {
+ await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' });
+ });
+ expect(result.current.state.isReady).toBe(true);
+ // The failure is still reported, it just doesn't disable the gate.
+ expect(result.current.state.validationError).toBe('Too many requests');
});
- it('perAccountLimit from active discovered code when valid', () => {
+ it('onRevalidate refuses to advance when the request fails', async () => {
+ // The gate that replaced isReady's error check.
const { result } = renderHook(() =>
usePromoCode(createDefaultProps({
- discoveredPromoCodes: mockDiscoveredCodes,
- promoCode: 'AUTO1',
- promoCodeVerified: true,
+ promoCode: 'CODE',
+ promoCodeVerified: null,
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode: jest.fn(() => Promise.reject({ res: { body: { message: 'Server error' } } })),
}))
);
+
+ let canAdvance;
+ await act(async () => {
+ canAdvance = await result.current.actions.onRevalidate({ id: 1, sub_type: 'Regular' }, 1);
+ });
+ expect(canAdvance).toBe(false);
+ });
+
+ it('perAccountLimit from active discovered code when valid', async () => {
+ const { result } = await renderVerified({ discoveredPromoCodes: mockDiscoveredCodes, promoCode: 'AUTO1' });
expect(result.current.state.perAccountLimit).toBe(4);
});
@@ -379,7 +865,7 @@ describe('onTicketSelected', () => {
const singleCode = [mockDiscoveredCodes[1]]; // AUTO1 only
const applyPromoCode = jest.fn(() => Promise.resolve());
const validatePromoCode = jest.fn(() => Promise.reject({
- res: { body: { errors: ['Quantity exceeded'] } }
+ res: { statusCode: 412, body: { errors: ['Quantity exceeded'] } }
}));
const { result } = renderHook(() =>
usePromoCode(createDefaultProps({
@@ -640,62 +1126,16 @@ describe('validationError', () => {
});
-// ── isDiscoveredCode ──
-
-describe('isDiscoveredCode', () => {
- it('true when applied code matches discovered code', () => {
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({
- discoveredPromoCodes: mockDiscoveredCodes,
- promoCode: 'AUTO1',
- promoCodeVerified: true,
- }))
- );
- expect(result.current.state.isDiscoveredCode).toBe(true);
- });
-
- it('false when applied code does not match discovered code', () => {
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({
- discoveredPromoCodes: mockDiscoveredCodes,
- promoCode: 'MANUAL',
- promoCodeVerified: true,
- }))
- );
- expect(result.current.state.isDiscoveredCode).toBe(false);
- });
-
- it('false when no code applied', () => {
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ discoveredPromoCodes: mockDiscoveredCodes }))
- );
- expect(result.current.state.isDiscoveredCode).toBe(false);
- });
-
- it('false when no discovered codes', () => {
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true }))
- );
- expect(result.current.state.isDiscoveredCode).toBe(false);
- });
-});
-
// ── maxQuantityFromPromo ──
describe('maxQuantityFromPromo', () => {
- it('returns tightest cap from remaining_quantity_per_account and quantity_available', () => {
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({
- discoveredPromoCodes: mockDiscoveredCodes,
- promoCode: 'AUTO1',
- promoCodeVerified: true,
- }))
- );
+ it('returns tightest cap from remaining_quantity_per_account and quantity_available', async () => {
+ const { result } = await renderVerified({ discoveredPromoCodes: mockDiscoveredCodes, promoCode: 'AUTO1' });
// remaining_quantity_per_account=4, quantity_available=100 → min is 4
expect(result.current.state.maxQuantityFromPromo).toBe(4);
});
- it('uses quantity_available when it is tighter', () => {
+ it('uses quantity_available when it is tighter', async () => {
const codes = [{
code: 'LIMITED',
auto_apply: true,
@@ -704,13 +1144,7 @@ describe('maxQuantityFromPromo', () => {
remaining_quantity_per_account: 8,
quantity_available: 3,
}];
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({
- discoveredPromoCodes: codes,
- promoCode: 'LIMITED',
- promoCodeVerified: true,
- }))
- );
+ const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'LIMITED' });
// remaining=8, quantity_available=3 → min is 3
expect(result.current.state.maxQuantityFromPromo).toBe(3);
});
@@ -733,7 +1167,7 @@ describe('maxQuantityFromPromo', () => {
expect(result.current.state.maxQuantityFromPromo).toBeNull();
});
- it('uses only remaining_quantity_per_account when quantity_available is null (unlimited)', () => {
+ it('uses only remaining_quantity_per_account when quantity_available is null (unlimited)', async () => {
const codes = [{
code: 'UNLIM',
auto_apply: true,
@@ -742,17 +1176,11 @@ describe('maxQuantityFromPromo', () => {
remaining_quantity_per_account: 3,
quantity_available: null,
}];
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({
- discoveredPromoCodes: codes,
- promoCode: 'UNLIM',
- promoCodeVerified: true,
- }))
- );
+ const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'UNLIM' });
expect(result.current.state.maxQuantityFromPromo).toBe(3);
});
- it('caps at 0 when quantity_available is 0 (sold out)', () => {
+ it('caps at 0 when quantity_available is 0 (sold out)', async () => {
const codes = [{
code: 'SOLDOUT',
auto_apply: true,
@@ -761,17 +1189,11 @@ describe('maxQuantityFromPromo', () => {
remaining_quantity_per_account: 3,
quantity_available: 0,
}];
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({
- discoveredPromoCodes: codes,
- promoCode: 'SOLDOUT',
- promoCodeVerified: true,
- }))
- );
+ const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'SOLDOUT' });
expect(result.current.state.maxQuantityFromPromo).toBe(0);
});
- it('uses only quantity_available when remaining_quantity_per_account is null', () => {
+ it('uses only quantity_available when remaining_quantity_per_account is null', async () => {
const codes = [{
code: 'NOACCOUNTLIMIT',
auto_apply: true,
@@ -780,17 +1202,11 @@ describe('maxQuantityFromPromo', () => {
remaining_quantity_per_account: null,
quantity_available: 5,
}];
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({
- discoveredPromoCodes: codes,
- promoCode: 'NOACCOUNTLIMIT',
- promoCodeVerified: true,
- }))
- );
+ const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'NOACCOUNTLIMIT' });
expect(result.current.state.maxQuantityFromPromo).toBe(5);
});
- it('null when both limits are unlimited', () => {
+ it('null when both limits are unlimited', async () => {
const codes = [{
code: 'ALLFREE',
auto_apply: true,
@@ -799,13 +1215,7 @@ describe('maxQuantityFromPromo', () => {
remaining_quantity_per_account: null,
quantity_available: null,
}];
- const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({
- discoveredPromoCodes: codes,
- promoCode: 'ALLFREE',
- promoCodeVerified: true,
- }))
- );
+ const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'ALLFREE' });
expect(result.current.state.maxQuantityFromPromo).toBeNull();
});
});
@@ -813,23 +1223,26 @@ describe('maxQuantityFromPromo', () => {
// ── onRevalidate ──
describe('onRevalidate', () => {
- it('returns true on successful validation', async () => {
- const validatePromoCode = jest.fn(() => Promise.resolve());
+ it('reports the caller may advance on success', async () => {
+ // registration-form gates changeStep on this value, so a missing or
+ // wrong return dead-ends the ticket step.
+ const validatePromoCode = jest.fn(() => Promise.resolve({ response: {} }));
const { result } = renderHook(() =>
- usePromoCode(createDefaultProps({ validatePromoCode }))
+ usePromoCode(createDefaultProps({ promoCode: 'CODE', validatePromoCode }))
);
- let valid;
+ let canAdvance;
await act(async () => {
- valid = await result.current.actions.onRevalidate(mockTicketQualifying, 3);
+ canAdvance = await result.current.actions.onRevalidate(mockTicketQualifying, 3);
});
- expect(valid).toBe(true);
+ expect(canAdvance).toBe(true);
expect(validatePromoCode).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, ticketQuantity: 3, sub_type: 'Regular' })
);
+ expect(result.current.state.validationError).toBeNull();
});
- it('returns false and sets validationError on failure', async () => {
+ it('reports the caller may not advance on failure, and sets validationError', async () => {
const validatePromoCode = jest.fn(() => Promise.reject({
res: { body: { errors: ['Promo code X can not be applied more than 3 times.'] } }
}));
@@ -837,11 +1250,11 @@ describe('onRevalidate', () => {
usePromoCode(createDefaultProps({ validatePromoCode }))
);
- let valid;
+ let canAdvance;
await act(async () => {
- valid = await result.current.actions.onRevalidate(mockTicketQualifying, 5);
+ canAdvance = await result.current.actions.onRevalidate(mockTicketQualifying, 5);
});
- expect(valid).toBe(false);
+ expect(canAdvance).toBe(false);
expect(result.current.state.validationError).toBe('Promo code X can not be applied more than 3 times.');
});
@@ -992,7 +1405,7 @@ describe('status: INVALID without ticket', () => {
expect(result.current.state.validationError).toBe(T.translate('promo_code.invalid_code'));
});
- it('stays APPLYING while ticket data is still loading', () => {
+ it('stays PROCESSING while ticket data is still loading', () => {
const { result } = renderHook(() =>
usePromoCode(createDefaultProps({
promoCode: 'PENDING',
@@ -1001,6 +1414,55 @@ describe('status: INVALID without ticket', () => {
hasTickets: false,
}))
);
- expect(result.current.state.status).toBe(PROMO_STATUS.APPLYING);
+ expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING);
+ });
+});
+
+// ── Applied without ticket: transition out on ticket pick ──
+
+describe('applied code awaiting ticket selection', () => {
+ it('fires validatePromoCode when the user then picks a ticket', async () => {
+ const validatePromoCode = jest.fn(() => Promise.resolve());
+ const { result } = renderHook(() =>
+ usePromoCode(createDefaultProps({
+ promoCode: 'MANUAL',
+ promoCodeVerified: null,
+ ticketDataLoaded: true,
+ hasTickets: true,
+ validatePromoCode,
+ }))
+ );
+ expect(result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED);
+ await act(async () => {
+ await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' });
+ });
+ expect(validatePromoCode).toHaveBeenCalledWith({ id: 1, ticketQuantity: 1, sub_type: 'Regular' });
+ });
+});
+
+// ── isSuggested signal (consumed by registration-form) ──
+
+describe('isSuggested', () => {
+ it('true while the suggestion banner is showing', async () => {
+ const { result } = renderHook(() =>
+ usePromoCode(createDefaultProps({
+ discoveredPromoCodes: [{ code: 'S1', auto_apply: false, allowed_ticket_types: [{ id: 1 }] }],
+ }))
+ );
+ await act(async () => {
+ await result.current.actions.onTicketSelected(mockTicketQualifying);
+ });
+ expect(result.current.state.isSuggested).toBe(true);
+ });
+
+ it('false once a code is applied, even if the suggestion was active', async () => {
+ const { result } = renderHook(() =>
+ usePromoCode(createDefaultProps({
+ discoveredPromoCodes: [{ code: 'S1', auto_apply: false, allowed_ticket_types: [{ id: 1 }] }],
+ promoCode: 'S1',
+ promoCodeVerified: true,
+ }))
+ );
+ expect(result.current.state.isSuggested).toBe(false);
});
});
diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js
index 7c9ed12..ae4ff1d 100644
--- a/src/hooks/usePromoCode.js
+++ b/src/hooks/usePromoCode.js
@@ -1,13 +1,18 @@
-import { useState, useCallback, useMemo, useEffect } from 'react';
+import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import T from 'i18n-react';
import { PROMO_STATUS } from '../utils/constants';
+// 404: the code or the ticket type does not exist.
+// 412: the code does not apply to this ticket type or quantity.
+// Both are the API judging the code. Anything else -- a server error, a rate
+// limit, a timeout, a dropped connection, which arrives with no response at all
+// -- says nothing about whether the code is good.
+const isRejection = (e) => [404, 412].includes(e?.res?.statusCode);
+
const usePromoCode = ({
// Redux state
discoveredPromoCodes,
promoCode,
- promoCodeVerified,
- promoCodeValidating,
// Redux dispatchers
applyPromoCode,
@@ -25,11 +30,28 @@ const usePromoCode = ({
const [isAutoApplied, setIsAutoApplied] = useState(false);
const [suggestionActive, setSuggestionActive] = useState(false);
const [suggestionDismissed, setSuggestionDismissed] = useState(false);
- // Error written by handleValidationError (API) or the form (unapplied-code warning).
- // The user-facing `validationError` is computed below by merging this with the
- // status-derived INVALID message.
- const [manualError, setManualError] = useState(null);
+ const [apiError, setApiError] = useState(null);
const [applyingCode, setApplyingCode] = useState(false);
+ // In-flight flags belong to whoever awaits the request. Keeping this next
+ // to applyingCode means every way a validation can end, including the
+ // failures that never reach the reducer, clears it in one place.
+ const [validatingCode, setValidatingCode] = useState(false);
+
+ // Whether the API accepted the applied code for the selected ticket, and
+ // which code it was asked about. Owned here because this is what awaits the
+ // request, and so the only thing that knows which attempt is still current.
+ //
+ // Recording the code is what lets an answer stop counting on its own once
+ // that code is no longer applied, including when the store clears it
+ // without asking: on checkout, logout or a dropped reservation.
+ const [lastValidation, setLastValidation] = useState(null);
+
+ // null means nothing has been accepted or turned down for the code now
+ // applied: nothing validated yet, the last attempt decided nothing, or what
+ // it decided was about a code since replaced.
+ const validation = lastValidation?.code === promoCode ? lastValidation : null;
+ const promoCodeVerified = validation === null ? null : validation.verified;
+ const allowsReassign = validation?.allowsReassign ?? true;
// Pick first auto_apply code, or first code if none has auto_apply
const discoveredPromoCode = useMemo(() => {
@@ -40,32 +62,57 @@ const usePromoCode = ({
const isApplied = !!promoCode;
const isDiscoveredCode = isApplied && discoveredPromoCode?.code === promoCode;
- // --- Status ---
+ // --- Canonical signals ---
+ // The raw Redux signals can overlap (e.g. a stale promoCodeVerified=false
+ // persists while a re-validation is in flight), so precedence is encoded
+ // here, once, rather than in each consumer.
+
+ // Something genuinely in flight: applying the code, validating it against
+ // a ticket, or waiting on the code-filtered ticket list with no settled
+ // answer to show in the meantime.
+ const isBusy = applyingCode || validatingCode
+ || (isApplied && promoCodeVerified == null && !ticketDataLoaded);
+
+ // Settled rejection: the backend rejected the code for the selected
+ // ticket, or the code-filtered ticket list came back empty.
+ const isInvalid = !isBusy && isApplied
+ && (promoCodeVerified === false || (promoCodeVerified == null && !hasTickets));
+
+ const isSuggested = !isApplied && suggestionActive && !suggestionDismissed;
+ // --- Display status: a pure projection of the signals, total order.
+ // Gate rendering on this; gate behavior on the signals above.
const status = useMemo(() => {
- if (isApplied) {
- if (promoCodeValidating) return PROMO_STATUS.VALIDATING;
- if (promoCodeVerified === true) return PROMO_STATUS.VALID;
- if (promoCodeVerified === false) return PROMO_STATUS.INVALID;
- // Applied but no tickets returned and not currently applying: code is invalid
- if (!applyingCode && ticketDataLoaded && !hasTickets) return PROMO_STATUS.INVALID;
- return PROMO_STATUS.APPLYING;
- }
- if (suggestionActive && !suggestionDismissed) return PROMO_STATUS.SUGGESTED;
+ if (isBusy) return PROMO_STATUS.PROCESSING;
+ if (isInvalid) return PROMO_STATUS.INVALID;
+ // APPLIED requires the API to have accepted the code for the selected
+ // ticket. Applying
+ // a code without a ticket runs no validation, and the catalog comes
+ // back populated even for a code that does not exist, so anything
+ // short of an accepted validation rests as UNVERIFIED.
+ if (isApplied) return (promoCodeVerified === true && apiError == null)
+ ? PROMO_STATUS.APPLIED
+ : PROMO_STATUS.UNVERIFIED;
+ if (isSuggested) return PROMO_STATUS.SUGGESTED;
return PROMO_STATUS.IDLE;
- }, [isApplied, promoCodeVerified, promoCodeValidating, suggestionActive, suggestionDismissed, applyingCode, ticketDataLoaded, hasTickets]);
+ }, [isBusy, isInvalid, isApplied, isSuggested, promoCodeVerified, apiError]);
- // Hook's own validation error. Composed from the in-flight API error (if any)
- // and the status-derived "invalid code" message when status is INVALID.
- // Consumers may layer their own warning on top before display.
- const validationError = manualError
- ?? (status === PROMO_STATUS.INVALID ? T.translate('promo_code.invalid_code') : null);
+ // Prefers the message the request returned, falling back to a generic
+ // invalid-code message when the code was rejected without one.
+ const validationError = apiError
+ ?? (isInvalid ? T.translate('promo_code.invalid_code') : null);
// --- Derived values ---
const suggestedCode = discoveredPromoCode?.code || null;
- const activeDiscoveredCode = (status === PROMO_STATUS.VALID && isDiscoveredCode)
+ // The caps a discovered code carries apply while it is the applied code and
+ // the last thing heard about it was that it was good. An outstanding error
+ // means that answer no longer covers the current selection, which is what
+ // status reflects too, so the caps go with it. They deliberately survive a
+ // re-validation in flight: dropping them there would briefly uncap the
+ // stepper for a code that is still applied.
+ const activeDiscoveredCode = (isDiscoveredCode && promoCodeVerified === true && apiError == null)
? discoveredPromoCode : null;
const perAccountLimit = activeDiscoveredCode?.quantity_per_account > 0
@@ -84,11 +131,14 @@ const usePromoCode = ({
return caps.length > 0 ? Math.min(...caps) : null;
}, [activeDiscoveredCode]);
- // True when the user can safely advance from the ticket step
- // (no in-flight promo apply/validate and no INVALID state to block on).
- const isReady = status === PROMO_STATUS.IDLE
- || status === PROMO_STATUS.SUGGESTED
- || status === PROMO_STATUS.VALID;
+ // True when the user may attempt to advance from the ticket step: nothing
+ // in flight and no rejection. Ticket selection is enforced by its own gate.
+ //
+ // A failed request deliberately does not block here. It says nothing about
+ // the code, and leaving the gate shut would stop the user retrying the very
+ // thing that failed. Advancing re-validates and refuses to move on unless
+ // that succeeds, so an unverified code still cannot get through.
+ const isReady = !isBusy && !isInvalid;
// --- Discovery: ticket qualification ---
@@ -109,22 +159,75 @@ const usePromoCode = ({
const msg = /is not a valid code/i.test(firstStr)
? T.translate('promo_code.invalid_code')
: firstStr;
- setManualError(msg);
+ setApiError(msg);
} else {
- setManualError(T.translate('promo_code.validation_error'));
+ setApiError(T.translate('promo_code.validation_error'));
}
}, []);
// --- Actions ---
+ // A ticket switch can leave an earlier validation in flight. Only the most
+ // recent attempt may report a result.
+ const latestValidation = useRef(0);
+
+ // The applied code as of the latest render. Read when a validation starts
+ // rather than closed over, because applying a code takes a round trip and
+ // the callback that starts the validation was created before it: closing
+ // over the prop would record the answer against the code being replaced.
+ const appliedCode = useRef(promoCode);
+ appliedCode.current = promoCode;
+
+ // Everything a validation in flight still owns: the right to answer, and
+ // the busy state it put the field into. Both have to go the moment the code
+ // it was asked about stops being the applied one, or the field stays locked
+ // behind a request nobody is waiting for and that request's answer lands on
+ // whatever the user did next.
+ //
+ // Advancing the counter is what withdraws the right to answer, so every
+ // caller that drops the recorded answer has to come through here.
+ const abandonValidation = useCallback(() => {
+ latestValidation.current += 1;
+ setValidatingCode(false);
+ setLastValidation(null);
+ setApiError(null);
+ }, []);
+
+
+ // Returns whether the caller may advance: true only when the code
+ // validated and no later attempt has replaced this one.
const onRevalidate = useCallback(async (ticket, quantity) => {
- setManualError(null);
+ const attempt = ++latestValidation.current;
+ // Which code this answer will be about, fixed now rather than when it
+ // lands, so a code applied afterwards cannot inherit it.
+ const code = appliedCode.current;
+ setApiError(null);
+ setValidatingCode(true);
try {
- await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type });
+ const result = await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type });
+ if (attempt !== latestValidation.current) return false;
+ // No code applied means no request went out, so nothing was decided
+ // and the caller has nothing to advance on.
+ if (!result) return false;
+ setLastValidation({ code, verified: true, allowsReassign: result.response?.allows_to_reassign ?? true });
return true;
} catch (e) {
+ if (attempt !== latestValidation.current) return false;
+ // Only these mean the API judged the code and turned it down.
+ // Every other failure decided nothing, so the previous answer, or
+ // the absence of one, stands and the error is surfaced instead.
+ if (isRejection(e)) {
+ setLastValidation({ code, verified: false, allowsReassign: true });
+ // How the code came to be applied is only worth revising when
+ // the API actually turned it down.
+ setIsAutoApplied(false);
+ }
handleValidationError(e);
return false;
+ } finally {
+ // A later attempt is still running and owns the flag, so leave it
+ // set for that one to clear.
+ if (attempt === latestValidation.current) setValidatingCode(false);
}
}, [validatePromoCode, handleValidationError]);
@@ -144,16 +247,13 @@ const usePromoCode = ({
const tryAutoApply = useCallback(async (ticket) => {
setIsAutoApplied(true);
setApplyingCode(true);
+ abandonValidation();
try {
await applyPromoCode(discoveredPromoCode.code);
- if (ticket) {
- const valid = await onRevalidate(ticket, 1);
- if (!valid) {
- setIsAutoApplied(false);
- return false;
- }
- }
- return true;
+ // onRevalidate reports a failed validation by returning false
+ // rather than throwing, so its result has to be passed on or this
+ // reports success for a code that was never verified.
+ return ticket ? await onRevalidate(ticket, 1) : true;
} catch (e) {
setIsAutoApplied(false);
handleValidationError(e);
@@ -173,7 +273,7 @@ const usePromoCode = ({
// doesn't surface a stale suggestion.
if (qualifies) setSuggestionActive(true);
setSuggestionDismissed(false);
- setManualError(null);
+ setApiError(null);
// Manual (non-discovered) code is applied: re-validate for new ticket
if (isApplied && !isDiscoveredCode) {
@@ -189,8 +289,7 @@ const usePromoCode = ({
// ticket). Previously we silently removed the code on a non-qualifying
// pick, which hid the rejection.
if (isDiscoveredCode) {
- const valid = await onRevalidate(ticket, 1);
- if (!valid) setIsAutoApplied(false);
+ await onRevalidate(ticket, 1);
return;
}
@@ -217,8 +316,12 @@ const usePromoCode = ({
}, [userRemovedAutoApply, ticketDataLoaded, discoveredPromoCode, discoveredPromoCodes, isApplied, tryAutoApply]);
const onApply = useCallback(async (code, ticket, quantity) => {
- setManualError(null);
setApplyingCode(true);
+ // A different application of a code, even the same string, has not been
+ // judged yet, and applying takes a whole round trip before any new
+ // validation starts. Withdrawing the old one now stops it filling that
+ // window with an answer about the code it replaced.
+ abandonValidation();
try {
await applyPromoCode(code);
} catch (e) {
@@ -226,17 +329,22 @@ const usePromoCode = ({
setApplyingCode(false);
return;
}
- if (ticket) {
- await onRevalidate(ticket, quantity);
- }
+ // This flag covers the apply request; revalidation has its own. Start
+ // revalidation before clearing this one so the two overlap: cleared
+ // first, there is a render with neither set and the field drops out of
+ // its busy state and back in. Clearing it only after awaiting the
+ // revalidation is worse still, because an aborted request never settles
+ // and the flag would never clear at all.
+ const revalidating = ticket ? onRevalidate(ticket, quantity) : null;
setApplyingCode(false);
+ await revalidating;
}, [applyPromoCode, onRevalidate, handleValidationError]);
const onRemove = useCallback(() => {
if (isAutoApplied || isDiscoveredCode) setUserRemovedAutoApply(true);
setIsAutoApplied(false);
- setManualError(null);
+ abandonValidation();
setSuggestionDismissed(false);
if (discoveredPromoCode) setSuggestionActive(true);
@@ -246,16 +354,18 @@ const usePromoCode = ({
}, [isAutoApplied, isDiscoveredCode, discoveredPromoCode, removePromoCode, setFormPromoCode]);
const onInputChange = useCallback((value) => {
- setManualError(null);
+ setApiError(null);
setSuggestionDismissed(value !== discoveredPromoCode?.code);
setFormPromoCode(value);
}, [discoveredPromoCode, setFormPromoCode]);
return {
state: {
- // Status (what's happening with the applied/suggested code)
+ // Display status (pure projection of the signals below)
status,
+ // Canonical signals
isReady,
+ isSuggested,
validationError,
// True while applyPromoCode is in flight (covers the window where
// promoCode is set but the refreshed ticketTypes haven't landed
@@ -263,8 +373,11 @@ const usePromoCode = ({
// until this clears to avoid acting on a stale list.
applyingCode,
+ // False only when the API said so for the applied code, so it
+ // defaults open while nothing has been decided.
+ allowsReassign,
+
// Applied code origin
- isDiscoveredCode,
isAutoApplied,
// Discovery / suggestion
diff --git a/src/reducer.js b/src/reducer.js
index b6549f5..a41a32d 100644
--- a/src/reducer.js
+++ b/src/reducer.js
@@ -34,10 +34,6 @@ import {
LOAD_PROFILE_DATA,
SET_CURRENT_PROMO_CODE,
CLEAR_CURRENT_PROMO_CODE,
- VALIDATE_PROMO_CODE,
- VALIDATE_PROMO_CODE_SUCCESS,
- VALIDATE_PROMO_CODE_ERROR,
- VALIDATE_PROMO_CODE_RATE_LIMITED,
DISCOVER_PROMO_CODES_SUCCESS,
} from './actions';
@@ -68,9 +64,6 @@ const DEFAULT_STATE = {
userProfile: null,
},
promoCode: '',
- promoCodeVerified: null,
- promoCodeValidating: false,
- promoCodeAllowsReassign: true,
discoveredPromoCodes: [],
};
@@ -102,8 +95,6 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => {
taxTypes: [],
invitation: null,
promoCode: '',
- promoCodeVerified: null,
- promoCodeAllowsReassign: true,
discoveredPromoCodes: [],
passwordless: { ...DEFAULT_STATE.passwordless },
settings: {
@@ -152,10 +143,10 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => {
return { ...state, reservation: null }
}
case CLEAR_RESERVATION: {
- return { ...state, reservation: null, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true, discoveredPromoCodes: [] }
+ return { ...state, reservation: null, promoCode: '', discoveredPromoCodes: [] }
}
case PAY_RESERVATION: {
- return { ...state, checkout: payload.response, reservation: null, userProfile: null, invitation: null, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true, discoveredPromoCodes: [] };
+ return { ...state, checkout: payload.response, reservation: null, userProfile: null, invitation: null, promoCode: '', discoveredPromoCodes: [] };
}
case GET_MY_INVITATION: {
return { ...state, invitation: payload.response };
@@ -164,24 +155,11 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => {
return { ...state, invitation: null };
}
case CLEAR_CURRENT_PROMO_CODE: {
- return { ...state, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true }
+ return { ...state, promoCode: '' }
}
case SET_CURRENT_PROMO_CODE:{
const { currentPromoCode } = payload;
- return { ...state, promoCode: currentPromoCode, promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true }
- }
- case VALIDATE_PROMO_CODE: {
- return { ...state, promoCodeValidating: true }
- }
- case VALIDATE_PROMO_CODE_SUCCESS: {
- const { allows_to_reassign } = payload.response;
- return { ...state, promoCodeVerified: true, promoCodeValidating: false, promoCodeAllowsReassign: allows_to_reassign ?? true }
- }
- case VALIDATE_PROMO_CODE_ERROR: {
- return { ...state, promoCodeVerified: false, promoCodeValidating: false, promoCodeAllowsReassign: true }
- }
- case VALIDATE_PROMO_CODE_RATE_LIMITED: {
- return { ...state, promoCodeValidating: false }
+ return { ...state, promoCode: currentPromoCode }
}
case DISCOVER_PROMO_CODES_SUCCESS: {
return { ...state, discoveredPromoCodes: payload.response?.data || [] }
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 5d3abd4..05dacc0 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -47,13 +47,16 @@ export const ERROR_TYPE_ERROR= 'error_type_error';
export const ERROR_TYPE_VALIDATION = 'error_type_validation';
export const ERROR_TYPE_PAYMENT = 'error_type_payment';
-// PROMO CODE STATUS
+// PROMO CODE DISPLAY STATUS
+// Derived presentation modes, projected in usePromoCode from the canonical
+// signals (isApplied / isBusy / isInvalid / isSuggested). Gate rendering on
+// these; gate behavior on the signals.
export const PROMO_STATUS = {
IDLE: 'idle',
SUGGESTED: 'suggested',
- APPLYING: 'applying',
- VALIDATING: 'validating',
- VALID: 'valid',
+ PROCESSING: 'processing',
+ UNVERIFIED: 'unverified',
+ APPLIED: 'applied',
INVALID: 'invalid',
};