diff --git a/TODO.md b/TODO.md index 9ae0204c26..a64f715b46 100644 --- a/TODO.md +++ b/TODO.md @@ -277,32 +277,6 @@ keeps the bundles honest by failing when a route reads a key it didn't declare. computing bundle caps for several packages in one pass over shared capacity maps — worthwhile only if a page with many package links shows up hot. -## Standalone child selector suppressed for sold-out / hidden package members - -`src/ui/templates/public/reservations/packages.ts` — `buildPageListingRows` -derives `memberIds` from `packageMemberIds(opts.packages)` (every package's -members), then suppresses the child selector on any standalone row whose listing -is in that set (a rendered package member carries the one selector). But a -**sold-out** package (`limit < 1`) renders a sold-out card with no member rows, -and a `hideListings` package renders no member rows either — so their members' -child selectors are never rendered in the package section. If such a member is -ALSO a standalone parent whose own row is still bookable, its child selector is -suppressed on both paths, so a buyer can't satisfy its multi-choice child -requirements. - -CodeRabbit flagged this on PR #1693; it is pre-existing behaviour (verbatim from -the original monolith), not introduced by the template split, so it's out of -scope for that mechanical refactor. Fix: build the suppression set from only the -packages that actually render member rows (skip `pkg.hideListings` and -`packageLimits.get(pkg.groupId)! < 1`), so standalone parents whose package rows -were omitted keep `opts.childCtx`. Ships with a regression test that books a -standalone parent whose sibling-capacity sold-out package hid its member row and -asserts the child selector still renders. (Note the `hideListings` half may be -unreachable — the code assumes only visible packages contain parents — so verify -that invariant before widening the fix.) - ---- - ## Test-suite speed — remaining opportunities *Origin: the test-suite performance pass (lazy Sentry, fast `toContain`, diff --git a/src/ui/templates/public/reservations/packages.ts b/src/ui/templates/public/reservations/packages.ts index d9dfb2dc4d..7ad3ee7287 100644 --- a/src/ui/templates/public/reservations/packages.ts +++ b/src/ui/templates/public/reservations/packages.ts @@ -1,9 +1,6 @@ import { t } from "#i18n"; import type { TicketListing } from "#shared/booking/model.ts"; -import { - type PagePackage, - packageMemberIds, -} from "#shared/booking/page-packages.ts"; +import type { PagePackage } from "#shared/booking/page-packages.ts"; import { type BookingNode, packageQuantityFieldName, @@ -164,7 +161,18 @@ export const buildPageListingRows = (opts: { const packageSections = opts.packages .map((pkg) => renderPackageSection(renderFor(pkg))) .join(""); - const memberIds = packageMemberIds(opts.packages); + // Suppress a standalone row's child selector only when a package section + // actually renders that member's row (which carries the one selector). A + // SOLD-OUT package (`limit < 1`) renders a bare sold-out card with no member + // rows, so a standalone parent that is also one of its members would + // otherwise lose its child selector on both paths — leaving a multi-choice + // parent unbookable. (A `hideListings` package also renders no member rows, + // but its members are never standalone rows, so they need no handling here.) + const renderedMemberIds = new Set( + opts.packages + .filter((pkg) => opts.packageLimits.get(pkg.groupId)! >= 1) + .flatMap((pkg) => [...pkg.memberListingIds]), + ); const standalone = opts.listings.filter((info) => opts.standaloneRowIds.has(info.listing.id), ); @@ -183,7 +191,8 @@ export const buildPageListingRows = (opts: { opts.isSingleListing && opts.packages.length === 0, opts.hideQuantity, opts.prefill, - (info) => (memberIds.has(info.listing.id) ? undefined : opts.childCtx), + (info) => + renderedMemberIds.has(info.listing.id) ? undefined : opts.childCtx, opts.attributesByListing, ) ); diff --git a/test/templates/public/ticket-page-packages.test.ts b/test/templates/public/ticket-page-packages.test.ts index becc63440d..f2d9caf256 100644 --- a/test/templates/public/ticket-page-packages.test.ts +++ b/test/templates/public/ticket-page-packages.test.ts @@ -166,6 +166,50 @@ describe("ticketPage — packages", () => { expect(html).toContain("Sorry, all listings are sold out."); }); + test("keeps a standalone parent's child selector when its package is sold out", () => { + // Listing 1 is a bookable standalone row AND a member of a package whose + // OTHER member (listing 2) is full, so the whole bundle is sold out and its + // section renders no member rows. Its child selector must still render on + // the standalone row — otherwise a buyer can't satisfy the parent's child + // requirement. Regression: the suppression set used to include members of + // packages that render no member rows (sold-out or hide-listings). + const parent = ticketListing({ + attendee_count: 0, + id: 1, + max_attendees: 100, + max_quantity: 5, + name: "Tent", + slug: "tent1", + }); + const soldOutSibling = ticketListing({ + attendee_count: 100, + id: 2, + max_attendees: 100, + name: "Chair", + slug: "chr12", + }); + const child = ticketListing({ + attendee_count: 0, + id: 3, + max_attendees: 100, + max_quantity: 5, + name: "Add-on", + slug: "add01", + }); + const html = ticketPage({ + childDatesById: new Map(), + childrenByParentId: new Map([[1, [child]]]), + listings: [parent, soldOutSibling, child], + packages: [pagePackage(5, [1, 2], { name: "Camp Kit" })], + slugs: [PKG_SLUG, "tent1"], + }); + // The bundle is sold out (its sibling member is full)… + expect(html).toContain('class="ticket-package sold-out"'); + // …but the standalone parent keeps its own row and its child selector. + expect(html).toContain('name="quantity_1"'); + expect(html).toContain('data-parent-id="1"'); + }); + test("caps the package by a SECOND capped group the members share", () => { const listings = bigAndSmallListings(); // The package group (7) is roomy — floor(10 / 2) = 5 packages fit — but both