Skip to content

Pre-select the ticket a manually applied promo code affects - #155

Open
gcutrini wants to merge 1 commit into
mainfrom
feature/preselect-ticket-affected-by-promo-code
Open

Pre-select the ticket a manually applied promo code affects#155
gcutrini wants to merge 1 commit into
mainfrom
feature/preselect-ticket-affected-by-promo-code

Conversation

@gcutrini

Copy link
Copy Markdown
Member

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

Applying a promo code now selects the ticket it affected. Before, only suggested codes did, because they arrive with a list of their tickets. A typed code selected nothing unless the summit had a single ticket type.

The API already marks what a code did, so the widget reads that instead of comparing the catalog before and after:

  • audience WithPromoCode, only returned while a live code unlocks the type
  • sub_type PrePaid, only returned for a ticket a prepaid code claims
  • cost_with_applied_discount, the API marking it discounted

One affected ticket is selected. Several selects nothing, since that is a real choice. Applying a code also moves an existing selection, per JP. A ticket outside its sales window is never selected.

Suggested codes keep using their own list and are unchanged.

Also here: a type on public sale that a prepaid code claims comes back twice, as two offers sharing an id. Identity is (id, sub_type), so selecting, re-syncing and the dropdown keys all compare both.

Applying a code only moved the selection for discovered codes, which carry an
allowed_ticket_types list. A typed code selected nothing unless the summit had
one ticket type, so the user had to work out which ticket their code was for.

The API already marks what a code did, so read that off the refreshed catalog
instead of comparing it against the one from before: a WithPromoCode audience
means a live code unlocked the type, a PrePaid subtype means a prepaid code
claims it, and a discount is marked on the ticket. Unlock and discount stay
separate signals, since a free type unlocked by a plain discount code is never
wrapped and only its audience says a code revealed it.

A type on public sale that a prepaid code also claims comes back twice, sharing
an id and differing by subtype. Identity is the pair, so selecting, re-syncing
and the dropdown keys all compare both.

A typed code moves the selection even when one was already made, and decides
once: the catalog is rebuilt whenever a sales window opens or closes, and
deciding again would overrule what the user chose after. A discovered code takes
the first of the several it names, so it only decides while nothing is chosen.
@gcutrini
gcutrini requested a review from smarcet August 21, 2026 14:53
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 72c26f4e-9f91-435d-8b35-a8c9704bcc2e


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.

@santipalenque santipalenque left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good, but please @gcutrini double check the comments the AI adds. We should not explain every single change with a comment, only critic logic that is not easy to understand by reading the code.

@@ -0,0 +1,302 @@
const { test, expect } = require('@playwright/test');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

shouldn't this be a .test.js file ? why .spec ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

and why is this file not placed under a tests dir like all tests in our codebase ?

@smarcet smarcet Aug 28, 2026

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.

@santipalenque these are e2e test ran using playwright we use this new convention to differentiate it from the unit test using jest

const [ticket, setTicket] = useState(null);
// The code whose pre-selection has already been made. Applying a code
// decides once; the catalog is rebuilt whenever any sales window opens or
// closes, and deciding again then would overrule what the user chose after.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Too long! this should be something like "We must keep selection between sales windows" or something

}, [ticket, quantity, maxQuantity])

// A type on public sale that a prepaid code also claims comes back twice,
// as the regular offer and the prepaid one. Identity is the pair.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

again, too long.

// only decides while nothing is chosen.
const toSelect = ticketToPreSelect();
const mayMove = !ticket || !isDiscoveredCode;
if (toSelect && mayMove && !isSameOffer(toSelect, ticket)) {

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 This pre-selection re-fires on every mount and overrides the ticket and quantity restored from an existing reservation.

preSelectedFor is a ref initialised to null per mount, so a reload at the personal-info or payment step re-decides for the persisted promo code (promoCode is not blacklisted in src/store.js, and getTicketTypes re-filters by it at src/actions.js:152-161). The restore effect at :80-85 and this one run in the same commit, so ticket is still null here — !isSameOffer(toSelect, null) is true even when toSelect IS the reserved ticket, and handleTicketChange resets quantity to minQuantity.

Verified against a probe render (reservation of 3× the affected ticket, typed code): this branch reports ticketType: Discounted Ticket, ticketQuantity: 1, while the same probe on main reports quantity: 3. The payment-step summary then renders ${ticket.name} (1) above reservation totals for 3, and going Back re-reserves 1 ticket instead of 3.

Careful with the obvious fix: seeding preSelectedFor.current = promoCode in the restore effect repairs the quantity but unmasks the id-only lookup at :82 (see my other comment) — a prepaid claim then binds the Regular offer at full price instead of the PrePaid one at 0. Both sites need to move together.

}
const updatedCurrentTicket = originalTicketTypes.find(t => t?.id === ticket.id);
if (!ticket) return;
const updatedCurrentTicket = originalTicketTypes.find(t => isSameOffer(t, ticket));

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 This PR establishes (id, sub_type) as offer identity and applies it here and at :140, but the third comparison site was missed: :82 still matches the reservation on t.id === reservation.tickets[0].ticket_type_id.

With the prepaid duplicates this PR documents, array_merge($regular_ticket_types, array_values($prepaid_ticket_types)) in summit-api's PrePaidPromoCodeTicketTypesStrategy puts the regular offer first, so find returns the paid one. Today it is masked in the common case — the pre-selection overwrites it with the correct PrePaid offer — but it stands whenever a prepaid code claims two or more types (pre-selection suppressed): verified bound to General / Regular / cost 700 instead of General [PREPAID] / cost 0.

Note BaseSummitAttendeeTicketSerializer:32 exposes only ticket_type_id, no subtype, so this needs either a new serializer field or a different disambiguator (the ticket's cost, or expanding promo_code on it) — not a one-line swap to isSameOffer.

// Three marks the catalog cannot carry unless a code produced them. The
// subtype is safe to key on because it is derived, not stored: nothing but
// the prepaid decorator can report one.
const isAffectedByPromoCode = (t) =>

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 audience === WithPromoCode marker is not exclusively produced by the applied code.

RegularPromoCodeTicketTypesStrategy::getTicketTypes merges $invitation->getRemainingAllowedTicketTypes() with no audience or canSell filter, and SummitRegistrationInvitationService::add:220-235 validates only that the type exists. So an invitation listing a WithPromoCode-audience type returns it regardless of any code, and it would be counted as affected forever: a typed code that marks nothing pre-selects it (count 1), or a code that legitimately marks one ticket is suppressed by it (count 2), with the follow-up onRevalidate then surfacing INVALID for a code that is fine.

I could not confirm this configuration exists in the field — it is self-contradictory, and nothing in the code produces it, only fails to prevent it. If you want the marker airtight, count a WithPromoCode type only when it was absent from the pre-apply catalog.

// Closing Soon must actually leave the list, or this proves nothing: the
// catalog has to be rebuilt for the re-decide it guards against to be
// possible at all.
await page.waitForTimeout(12000);

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 This hardcoded sleep puts the test at 85% of the Playwright timeout on a green CI run.

Run 32494588576 (PR head 9b6700f) records this test at 25.5s against timeout: 30000 in playwright.config.js:4 — 4.5s of headroom, while the other 13 tests in the file sit at 6-13s. The 12s is pure additive wait, so any contention on the 2-worker runner tips it over.

Suggest replacing it with the condition the test already asserts right after: await expect(page.locator('[data-testid="ticket-list"]')).not.toContainText('Closing Soon', { timeout: 20000 }), dropping the sleep — or test.setTimeout() for this case if the sleep has to stay.

@smarcet
smarcet requested a lite review from Copilot August 28, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@smarcet smarcet left a comment

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 please review

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.

4 participants