Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ src/features/public/ticket-payment.ts:873:47 ?? → || # packageMaps?.quantit
# Parent/child curried atoms (shared.tsx).
src/ui/templates/public/shared.tsx:318:41 ?? → || # span: null or a duration ≥1, never falsy-non-null; null??1===null||1

# Parent/child per-unit render (reservations page-meta.ts).
src/ui/templates/public/reservations/page-meta.ts:66:55 ?? → || # addOns?.some(...) is boolean|undefined; ?? and || agree on every boolean and on undefined
# Parent/child per-unit render (reservations contact-fields.ts).
src/ui/templates/public/reservations/contact-fields.ts:107:51 ?? → || # addOns?.some(...) is boolean|undefined; ?? and || agree on every boolean and on undefined

# Attendee phone blind-index (attendee-phone-index.ts).
src/shared/db/attendee-phone-index.ts:36:17 ?? → || # row?.id is undefined or an AUTOINCREMENT id (≥1, never 0), so ?? and || agree
Expand Down
2 changes: 1 addition & 1 deletion src/features/public/qr-book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { qrBookErrorPage } from "#templates/public/errors.tsx";
import type {
BookingPrefill,
TicketPrefill,
} from "#templates/public/reservations/inputs.ts";
} from "#templates/public/reservations/types.ts";
import { getTicketContext, runCheckoutFlow } from "./ticket-payment.ts";
import { handleTicket } from "./ticket-submit.ts";

Expand Down
2 changes: 1 addition & 1 deletion src/features/public/ticket-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
import type {
BookingPrefill,
TicketPrefill,
} from "#templates/public/reservations/inputs.ts";
} from "#templates/public/reservations/types.ts";
import {
applyBookingPageParentSoldOut,
childCapacityInfo,
Expand Down
2 changes: 1 addition & 1 deletion src/features/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
ItemImageProjection,
ListingWithCount,
} from "#shared/types.ts";
import type { BookingPrefill } from "#templates/public/reservations/inputs.ts";
import type { BookingPrefill } from "#templates/public/reservations/types.ts";

/** Parent listing id → its bookable-child candidates, each hydrated to a
* {@link TicketListing} so availability (isSoldOut/isClosed/maxPurchasable) is
Expand Down
16 changes: 16 additions & 0 deletions src/shared/booking/package-cap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ export const packageLimitInfo = (
...groupCapacityInfo(groupRemainingByGroupId, groupIdsByListingId),
});

/** Each page package's whole-bundle limit, keyed by group id. Builds the
* page-level {@link PackageLimitInfo} once and maps each package over
* {@link pagePackageBundleLimit}, so callers stop re-stating the four
* limit-info arguments per call site. */
export const pageBundleLimits = (
tree: BookingTree,
packages: readonly TreePackage[],
page: PackageLimitInfo,
): Map<number, number> =>
new Map(
packages.map((pkg) => [
pkg.groupId,
pagePackageBundleLimit(tree, pkg, page),
]),
);

export const childCanBeBooked: (child: TicketListing) => boolean =
childPassesAllChecks([childActive, childOpen, childInStock]);

Expand Down
103 changes: 103 additions & 0 deletions src/ui/templates/public/reservations/availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/** Page-level availability and tree-shaping: the unavailability message, the
* booking tree plus which listings get their own standalone row, the overall
* sold-out check, and the lone header listing whose details head the page. */

/* jscpd:ignore-start */
import { reduce } from "#fp";
import { t } from "#i18n";
import {
type BuildTreeInput,
buildBookingTree,
} from "#shared/booking/build-tree.ts";
import type { TicketListing } from "#shared/booking/model.ts";
import {
type PackageLimitInfo,
pageBundleLimits,
} from "#shared/booking/package-cap.ts";
import type { PagePackage } from "#shared/booking/page-packages.ts";
import type { BookingNode, BookingTree } from "#shared/booking/tree.ts";
import { standaloneListingIds } from "#shared/booking/tree.ts";
import { isReadOnly } from "#shared/env.ts";
import type { ListingWithCount } from "#shared/types.ts";
/* jscpd:ignore-end */

/** Unavailability message shown when all listings are sold out or closed */
export const unavailableMessage = (
allClosed: boolean,
isSingleListing: boolean,
): string => {
if (isReadOnly() || allClosed) return t("public.ticket.registration_closed");
return isSingleListing
? t("public.ticket.listing_full")
: t("public.multi.all_sold_out");
};

/** Each page package's bundle limit, plus whether the whole page should show as
* sold out (nothing standalone left AND no package bookable). */
export const packagePageAvailability = (
packages: PagePackage[],
tree: BookingTree,
listings: TicketListing[],
standaloneRowIds: ReadonlySet<number>,
page: PackageLimitInfo,
): { packageLimits: Map<number, number>; soldOut: boolean } => {
const packageLimits = pageBundleLimits(tree, packages, page);
const standaloneUnavailable = listings
.filter((info) => standaloneRowIds.has(info.listing.id))
.every((e) => e.isSoldOut || e.isClosed);
const packagesUnavailable = [...packageLimits.values()].every(
(limit) => limit === 0,
);
return {
packageLimits,
// Sold out only when every standalone row AND every bundle is dead (a
// bundle with an unavailable member already reads limit 0; a package-less
// page has no bundles, leaving just its listings' own availability).
soldOut: standaloneUnavailable && packagesUnavailable,
};
};

/** The lone listing whose rich details (image/date/location) head the page and
* feed its OpenGraph tags, or null for a multi-listing page OR a hidden package
* — a hidden package with one active member must not expose that member here. */
export const headerListing = (
listings: TicketListing[],
packages: PagePackage[],
): ListingWithCount | null =>
listings.length === 1 && !packages.some((pkg) => pkg.hideListings)
? listings[0]!.listing
: null;

/** Build the page's booking tree and the row-shaping facts read off it: which
* listings get their own quantity row (those with a standalone BUYER_CHOICE
* node), which node each row reads its field names from (a dual-path listing
* resolves to its standalone node, not its member node), and whether the page
* IS one package (every listing a member, nothing sold beside it — the classic
* package-page layout). */
export const buildPageTree = (
input: BuildTreeInput,
packageCount: number,
): {
tree: BookingTree;
standaloneRowIds: Set<number>;
nodeByListingId: Map<number, BookingNode>;
singlePackagePage: boolean;
} => {
const tree = buildBookingTree(input);
const standaloneRowIds = standaloneListingIds(tree);
// A BUYER_CHOICE node wins over any earlier node for the same listing id
// (a dual-path listing resolves to its standalone node, not its member node);
// every other kind keeps the first node seen.
const nodeByListingId = reduce((acc, node: BookingNode) => {
if (!acc.has(node.listingId) || node.quantityRule.kind === "BUYER_CHOICE") {
acc.set(node.listingId, node);
}
return acc;
}, new Map<number, BookingNode>())([...tree.nodes]);
return {
nodeByListingId,
singlePackagePage: packageCount === 1 && standaloneRowIds.size === 0,
standaloneRowIds,
tree,
};
};
Loading