@bash-app/bash-common 30.336.0 → 30.338.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/__tests__/seatMap.test.d.ts +2 -0
  2. package/dist/__tests__/seatMap.test.d.ts.map +1 -0
  3. package/dist/__tests__/seatMap.test.js +42 -0
  4. package/dist/__tests__/seatMap.test.js.map +1 -0
  5. package/dist/__tests__/seatMapBestAvailable.test.d.ts +2 -0
  6. package/dist/__tests__/seatMapBestAvailable.test.d.ts.map +1 -0
  7. package/dist/__tests__/seatMapBestAvailable.test.js +101 -0
  8. package/dist/__tests__/seatMapBestAvailable.test.js.map +1 -0
  9. package/dist/accessScreening.js +0 -1
  10. package/dist/aiApproval.js +0 -1
  11. package/dist/aiEventPlan.js +0 -1
  12. package/dist/bashFeedTypes.js +0 -1
  13. package/dist/bookingIntent.js +0 -1
  14. package/dist/causeTags.js +0 -1
  15. package/dist/definitions.d.ts +10 -0
  16. package/dist/definitions.d.ts.map +1 -1
  17. package/dist/definitions.js.map +1 -1
  18. package/dist/entertainmentNotableVenues.js +0 -1
  19. package/dist/extendedSchemas.d.ts +1 -1
  20. package/dist/groupTicketUtils.js +0 -1
  21. package/dist/guestPartySize.js +0 -1
  22. package/dist/hostCrmAutomation.js +0 -1
  23. package/dist/hostCrmConstants.js +0 -1
  24. package/dist/hostCrmInsights.js +0 -1
  25. package/dist/hostCrmSegments.js +0 -1
  26. package/dist/hostPromoter.js +0 -1
  27. package/dist/icebreakerPrompts.js +0 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/influencerAudience.js +0 -1
  33. package/dist/legalTemplates.js +0 -1
  34. package/dist/membershipDefinitions.js +0 -1
  35. package/dist/mirroredPrismaEnums.js +0 -1
  36. package/dist/onSaleCapabilityRecommendations.js +0 -1
  37. package/dist/partnerStoreTypes.js +0 -1
  38. package/dist/productLaunch.js +0 -1
  39. package/dist/relationshipHealth.js +0 -1
  40. package/dist/requestAuthUser.js +0 -1
  41. package/dist/seatMap.d.ts +80 -0
  42. package/dist/seatMap.d.ts.map +1 -0
  43. package/dist/seatMap.js +250 -0
  44. package/dist/seatMap.js.map +1 -0
  45. package/dist/seatMapBestAvailable.d.ts +24 -0
  46. package/dist/seatMapBestAvailable.d.ts.map +1 -0
  47. package/dist/seatMapBestAvailable.js +143 -0
  48. package/dist/seatMapBestAvailable.js.map +1 -0
  49. package/dist/serviceTypeLabels.js +0 -1
  50. package/dist/sideQuestIdeas.js +0 -1
  51. package/dist/sms/smsTemplates.d.ts +15 -1
  52. package/dist/sms/smsTemplates.d.ts.map +1 -1
  53. package/dist/sms/smsTemplates.js +3 -0
  54. package/dist/sms/smsTemplates.js.map +1 -1
  55. package/dist/storePrintArtworkUtils.js +0 -1
  56. package/dist/storeTypes.js +0 -1
  57. package/dist/stripeListingSubscriptionMessages.js +0 -1
  58. package/dist/targetAudienceLabels.js +0 -1
  59. package/dist/templates/eventTemplates.js +0 -1
  60. package/dist/ticketBnplPaymentMethods.js +0 -1
  61. package/dist/userReportTypes.js +0 -1
  62. package/dist/utils/__tests__/paymentUtils.test.js +1 -1
  63. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  64. package/dist/utils/__tests__/ticketListUtils.test.js +41 -0
  65. package/dist/utils/__tests__/ticketListUtils.test.js.map +1 -1
  66. package/dist/utils/agencyBookingCheckoutCopy.js +0 -1
  67. package/dist/utils/arrayUtils.js +0 -1
  68. package/dist/utils/awsS3Utils.js +0 -1
  69. package/dist/utils/bashPointsPaymentUtils.js +0 -1
  70. package/dist/utils/birthdayExperienceQuality.js +0 -1
  71. package/dist/utils/contentFilterUtils.js +0 -1
  72. package/dist/utils/discountEngine/bestPriceResolver.js +0 -1
  73. package/dist/utils/discountEngine/eligibilityValidator.js +0 -1
  74. package/dist/utils/displayDateUtils.js +0 -1
  75. package/dist/utils/entertainmentDrawFit.js +0 -1
  76. package/dist/utils/entityUtils.js +0 -1
  77. package/dist/utils/eventCitySlug.js +0 -1
  78. package/dist/utils/eventTimezoneIana.js +0 -1
  79. package/dist/utils/featuredDiscoveryGeo.js +0 -1
  80. package/dist/utils/flyerUtils.js +0 -1
  81. package/dist/utils/generalDateTimeUtils.js +0 -1
  82. package/dist/utils/lobFlyerUtils.js +0 -1
  83. package/dist/utils/localPartnerHours.js +0 -1
  84. package/dist/utils/locationVisibility.js +0 -1
  85. package/dist/utils/luxonUtils.js +0 -1
  86. package/dist/utils/mathUtils.js +0 -1
  87. package/dist/utils/mediaClientDefaults.js +0 -1
  88. package/dist/utils/nonprofitUtils.js +0 -1
  89. package/dist/utils/objUtils.js +0 -1
  90. package/dist/utils/orgPromotionalPackageUtils.js +0 -1
  91. package/dist/utils/organizationEntitlements.js +0 -1
  92. package/dist/utils/paymentUtils.d.ts +1 -1
  93. package/dist/utils/paymentUtils.js +1 -1
  94. package/dist/utils/promotionBlastUtils.js +0 -1
  95. package/dist/utils/reputationUtils.js +0 -1
  96. package/dist/utils/reviewDimensionUtils.js +0 -1
  97. package/dist/utils/service/attendeeOptionUtils.js +0 -1
  98. package/dist/utils/service/billingCapUtils.js +0 -1
  99. package/dist/utils/service/eventPromoterUtils.js +0 -1
  100. package/dist/utils/service/offerAllowListUtils.js +0 -1
  101. package/dist/utils/service/offerComparisonUtils.js +0 -1
  102. package/dist/utils/service/regexUtils.js +0 -1
  103. package/dist/utils/service/serviceBookingExposureUtils.js +0 -1
  104. package/dist/utils/service/serviceFeaturedSortUtils.js +0 -1
  105. package/dist/utils/service/unifiedPricingResolver.js +0 -1
  106. package/dist/utils/service/venueSettlementUtils.js +0 -1
  107. package/dist/utils/service/volunteerCompensationUtils.js +0 -1
  108. package/dist/utils/serviceAvailabilityDisplay.js +0 -1
  109. package/dist/utils/sortUtils.js +0 -1
  110. package/dist/utils/stringUtils.js +0 -1
  111. package/dist/utils/ticketCheckInDisplay.js +0 -1
  112. package/dist/utils/ticketListUtils.d.ts.map +1 -1
  113. package/dist/utils/ticketListUtils.js +17 -0
  114. package/dist/utils/ticketListUtils.js.map +1 -1
  115. package/dist/utils/trustScoreUtils.js +0 -1
  116. package/dist/utils/typeUtils.js +0 -1
  117. package/dist/utils/userPromoCodeUtils.js +0 -1
  118. package/dist/utils/userUtils.js +0 -1
  119. package/dist/utils/venueLoyalty/redemptionCodeUtils.js +0 -1
  120. package/dist/utils/venueTypeFormatter.js +0 -1
  121. package/dist/utmAttribution.js +0 -1
  122. package/dist/venueMatch.js +0 -1
  123. package/package.json +1 -1
  124. package/prisma/schema.prisma +23 -0
  125. package/src/__tests__/seatMap.test.ts +59 -0
  126. package/src/__tests__/seatMapBestAvailable.test.ts +111 -0
  127. package/src/definitions.ts +10 -0
  128. package/src/extendedSchemas.ts +1 -1
  129. package/src/index.ts +2 -0
  130. package/src/seatMap.ts +337 -0
  131. package/src/seatMapBestAvailable.ts +174 -0
  132. package/src/sms/smsTemplates.ts +11 -0
  133. package/src/utils/__tests__/paymentUtils.test.ts +1 -1
  134. package/src/utils/__tests__/ticketListUtils.test.ts +44 -0
  135. package/src/utils/paymentUtils.ts +1 -1
  136. package/src/utils/ticketListUtils.ts +26 -0
package/src/seatMap.ts ADDED
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Shared seat map document shape (persisted on BashEvent.seatMap).
3
+ * Aligns with bash-app features/seatmap types.
4
+ */
5
+
6
+ export type SeatMapSeatType = "seat" | "space";
7
+
8
+ export type SeatMapSeat = {
9
+ id: string;
10
+ row: string;
11
+ type: string;
12
+ label: string;
13
+ };
14
+
15
+ /** Named block within a flat seat list (row keys belong to this section). */
16
+ export type SeatMapSection = {
17
+ id: string;
18
+ name: string;
19
+ rowKeys: string[];
20
+ };
21
+
22
+ export type SeatMapDocument = {
23
+ id: string;
24
+ name: string;
25
+ venueId?: number;
26
+ venueName?: string;
27
+ blockId?: number;
28
+ blockName?: string;
29
+ stageText: string;
30
+ seatMapData?: SeatMapSeat[];
31
+ /** Multi-block maps from builder templates / CSV / AI import. */
32
+ sections?: SeatMapSection[];
33
+ };
34
+
35
+ function isSeatMapSection(value: unknown): value is SeatMapSection {
36
+ if (!value || typeof value !== "object") return false;
37
+ const s = value as Record<string, unknown>;
38
+ if (typeof s.id !== "string" || typeof s.name !== "string") return false;
39
+ if (!Array.isArray(s.rowKeys)) return false;
40
+ return s.rowKeys.every((k) => typeof k === "string");
41
+ }
42
+
43
+ export function isSeatMapDocument(value: unknown): value is SeatMapDocument {
44
+ if (!value || typeof value !== "object") return false;
45
+ const v = value as Record<string, unknown>;
46
+ if (typeof v.id !== "string" || typeof v.name !== "string") return false;
47
+ if (typeof v.stageText !== "string") return false;
48
+ if (v.seatMapData !== undefined && !Array.isArray(v.seatMapData)) return false;
49
+ if (Array.isArray(v.seatMapData)) {
50
+ for (const seat of v.seatMapData) {
51
+ if (!seat || typeof seat !== "object") return false;
52
+ const s = seat as Record<string, unknown>;
53
+ if (
54
+ typeof s.id !== "string" ||
55
+ typeof s.row !== "string" ||
56
+ typeof s.type !== "string" ||
57
+ typeof s.label !== "string"
58
+ ) {
59
+ return false;
60
+ }
61
+ }
62
+ }
63
+ if (v.sections !== undefined) {
64
+ if (!Array.isArray(v.sections)) return false;
65
+ if (!v.sections.every(isSeatMapSection)) return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ /** Seat ids that are actual seats (not aisle/space placeholders). */
71
+ export function seatIdsFromMap(map: SeatMapDocument | null | undefined): string[] {
72
+ if (!map?.seatMapData?.length) return [];
73
+ return map.seatMapData
74
+ .filter((s) => s.type === "seat" || (!s.type.startsWith("space") && s.type !== "space"))
75
+ .map((s) => s.id);
76
+ }
77
+
78
+ export function filterSeatMapToSeatIds(
79
+ map: SeatMapDocument,
80
+ seatIds: string[]
81
+ ): SeatMapDocument {
82
+ const allow = new Set(seatIds);
83
+ const seatMapData = (map.seatMapData ?? []).filter(
84
+ (s) => allow.has(s.id) || s.type === "space" || s.type.startsWith("empty")
85
+ );
86
+ return { ...map, seatMapData };
87
+ }
88
+
89
+ function isHallwayOrSpaceRow(rowKey: string, seatsInRow: SeatMapSeat[]): boolean {
90
+ if (rowKey.startsWith("empty-") || rowKey.startsWith("space")) return true;
91
+ return (
92
+ seatsInRow.length > 0 &&
93
+ seatsInRow.every(
94
+ (s) => s.type === "space" || s.type.startsWith("space") || s.type.startsWith("empty")
95
+ )
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Prefix before the last `-` for keys like `Orchestra-A` → Orchestra.
101
+ * Plain `A` has no section prefix.
102
+ */
103
+ export function sectionPrefixFromRowKey(rowKey: string): string | null {
104
+ const trimmed = rowKey.trim();
105
+ if (!trimmed || trimmed.startsWith("empty-")) return null;
106
+ const idx = trimmed.lastIndexOf("-");
107
+ if (idx <= 0) return null;
108
+ const prefix = trimmed.slice(0, idx).trim();
109
+ return prefix.length > 0 ? prefix : null;
110
+ }
111
+
112
+ /**
113
+ * Resolve sections for overview UI: prefer persisted `sections`, else derive
114
+ * from row-key prefixes / aisle separators.
115
+ */
116
+ export function sectionsFromSeatMap(map: SeatMapDocument | null | undefined): SeatMapSection[] {
117
+ if (!map) return [];
118
+ if (map.sections?.length) {
119
+ return map.sections.filter((s) => s.rowKeys.length > 0);
120
+ }
121
+
122
+ const seats = map.seatMapData ?? [];
123
+ const rowOrder: string[] = [];
124
+ const byRow = new Map<string, SeatMapSeat[]>();
125
+ for (const s of seats) {
126
+ if (!byRow.has(s.row)) {
127
+ byRow.set(s.row, []);
128
+ rowOrder.push(s.row);
129
+ }
130
+ byRow.get(s.row)!.push(s);
131
+ }
132
+
133
+ type Acc = { name: string; rowKeys: string[] };
134
+ const derived: Acc[] = [];
135
+ let current: Acc | null = null;
136
+
137
+ for (const row of rowOrder) {
138
+ const inRow = byRow.get(row) ?? [];
139
+ if (isHallwayOrSpaceRow(row, inRow)) {
140
+ current = null;
141
+ continue;
142
+ }
143
+ const prefix = sectionPrefixFromRowKey(row);
144
+ const name =
145
+ prefix ?? (map.blockName?.trim() || map.name?.trim() || "Section");
146
+ if (!current || current.name !== name) {
147
+ current = { name, rowKeys: [row] };
148
+ derived.push(current);
149
+ } else {
150
+ current.rowKeys.push(row);
151
+ }
152
+ }
153
+
154
+ return derived.map((d, i) => ({
155
+ id: `derived-${i}-${d.name.replace(/\s+/g, "-").slice(0, 24)}`,
156
+ name: d.name,
157
+ rowKeys: d.rowKeys,
158
+ }));
159
+ }
160
+
161
+ /** Section display name for a row key (ticket denormalization). */
162
+ export function sectionNameForRow(
163
+ map: SeatMapDocument | null | undefined,
164
+ rowKey: string
165
+ ): string | null {
166
+ if (!map) return null;
167
+ const sections = sectionsFromSeatMap(map);
168
+ const hit = sections.find((s) => s.rowKeys.includes(rowKey));
169
+ if (hit?.name?.trim()) return hit.name.trim();
170
+ return sectionFromMap(map);
171
+ }
172
+
173
+ export type SeatLocationDisplay = {
174
+ section: string | null;
175
+ row: string | null;
176
+ seat: string | null;
177
+ /** Full line for ticket details, e.g. "Orchestra · Row A · Seat 12". */
178
+ line: string;
179
+ /** Compact line for lists, e.g. "A12" or "Rows A–C". */
180
+ shortLine: string;
181
+ };
182
+
183
+ function sectionFromMap(map: SeatMapDocument | null | undefined): string | null {
184
+ if (!map) return null;
185
+ const block = map.blockName?.trim();
186
+ if (block) return block;
187
+ const name = map.name?.trim();
188
+ return name || null;
189
+ }
190
+
191
+ function joinLocationParts(parts: Array<string | null | undefined>): string {
192
+ return parts.filter((p): p is string => !!p && p.trim().length > 0).join(" · ");
193
+ }
194
+
195
+ function sortRowKeys(rows: string[]): string[] {
196
+ return [...rows].sort((a, b) =>
197
+ a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })
198
+ );
199
+ }
200
+
201
+ function formatRowRange(rows: string[]): string | null {
202
+ const sorted = sortRowKeys(rows);
203
+ if (sorted.length === 0) return null;
204
+ if (sorted.length === 1) return `Row ${sorted[0]}`;
205
+ return `Rows ${sorted[0]}–${sorted[sorted.length - 1]}`;
206
+ }
207
+
208
+ /** Single assigned seat (ticket row or map lookup). */
209
+ export function formatAssignedSeatLocation(input: {
210
+ section?: string | null;
211
+ row?: string | null;
212
+ seat?: string | null;
213
+ map?: SeatMapDocument | null;
214
+ seatId?: string | null;
215
+ }): SeatLocationDisplay | null {
216
+ let section = input.section?.trim() || null;
217
+ let row = input.row?.trim() || null;
218
+ let seat = input.seat?.trim() || null;
219
+
220
+ if ((!row || !seat) && input.map && input.seatId) {
221
+ const found = (input.map.seatMapData ?? []).find((s) => s.id === input.seatId);
222
+ if (found && found.type === "seat") {
223
+ row = row ?? found.row;
224
+ seat = seat ?? found.label;
225
+ if (!section) {
226
+ section = sectionNameForRow(input.map, found.row);
227
+ }
228
+ }
229
+ }
230
+ section = section ?? sectionFromMap(input.map ?? null);
231
+
232
+ if (!row && !seat && !section) return null;
233
+
234
+ const seatPart = seat ? `Seat ${seat}` : null;
235
+ const rowPart = row ? `Row ${row}` : null;
236
+ const short =
237
+ row && seat
238
+ ? `${row}${seat}`
239
+ : row
240
+ ? `Row ${row}`
241
+ : seat
242
+ ? `Seat ${seat}`
243
+ : section ?? "Seated";
244
+
245
+ return {
246
+ section,
247
+ row,
248
+ seat,
249
+ line: joinLocationParts([section, rowPart, seatPart]),
250
+ shortLine: short,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Tier inventory summary when seats aren't assigned per ticket yet
256
+ * (quantity checkout MVP).
257
+ */
258
+ export function formatTierSeatingArea(
259
+ map: SeatMapDocument | null | undefined,
260
+ seatIds: string[] | null | undefined
261
+ ): SeatLocationDisplay | null {
262
+ if (!map || !seatIds?.length) return null;
263
+ const allow = new Set(seatIds);
264
+ const seats = (map.seatMapData ?? []).filter(
265
+ (s) => allow.has(s.id) && s.type === "seat"
266
+ );
267
+ if (seats.length === 0) return null;
268
+
269
+ const section = sectionFromMap(map);
270
+ const rows = [...new Set(seats.map((s) => s.row))];
271
+ const rowPart = formatRowRange(rows);
272
+ const countPart =
273
+ seats.length === 1 ? "1 seat" : `${seats.length} seats`;
274
+
275
+ const labels = seats
276
+ .map((s) => parseInt(s.label, 10))
277
+ .filter((n) => Number.isFinite(n))
278
+ .sort((a, b) => a - b);
279
+ let seatPart: string | null = countPart;
280
+ if (labels.length === seats.length && labels.length > 0) {
281
+ const min = labels[0];
282
+ const max = labels[labels.length - 1];
283
+ seatPart =
284
+ min === max ? `Seat ${min}` : `Seats ${min}–${max}`;
285
+ }
286
+
287
+ return {
288
+ section,
289
+ row: rows.length === 1 ? rows[0] : null,
290
+ seat: null,
291
+ line: joinLocationParts([section, rowPart, seatPart]),
292
+ shortLine: joinLocationParts([
293
+ section,
294
+ rowPart,
295
+ seats.length > 1 ? `${seats.length} seats` : seatPart,
296
+ ]),
297
+ };
298
+ }
299
+
300
+ export type TicketSeatingFields = {
301
+ seatId?: string | null;
302
+ seatSection?: string | null;
303
+ seatRow?: string | null;
304
+ seatLabel?: string | null;
305
+ };
306
+
307
+ export type TierSeatingFields = {
308
+ usesSeatMap?: boolean | null;
309
+ seatIds?: string[] | null;
310
+ };
311
+
312
+ /** Prefer a specific assigned seat; otherwise seated-tier area summary. */
313
+ export function resolveTicketSeatingDisplay(
314
+ ticket: TicketSeatingFields | null | undefined,
315
+ tier: TierSeatingFields | null | undefined,
316
+ map: SeatMapDocument | null | undefined
317
+ ): SeatLocationDisplay | null {
318
+ if (
319
+ ticket?.seatId ||
320
+ ticket?.seatRow ||
321
+ ticket?.seatLabel ||
322
+ ticket?.seatSection
323
+ ) {
324
+ const assigned = formatAssignedSeatLocation({
325
+ section: ticket.seatSection,
326
+ row: ticket.seatRow,
327
+ seat: ticket.seatLabel,
328
+ map,
329
+ seatId: ticket.seatId,
330
+ });
331
+ if (assigned) return assigned;
332
+ }
333
+ if (tier?.usesSeatMap && tier.seatIds?.length) {
334
+ return formatTierSeatingArea(map, tier.seatIds);
335
+ }
336
+ return null;
337
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Best-available seating: prefer contiguous seats in one row, then any contiguous
3
+ * row, then a split within the same section (marked non-contiguous).
4
+ */
5
+
6
+ import {
7
+ sectionNameForRow,
8
+ type SeatMapDocument,
9
+ type SeatMapSeat,
10
+ } from "./seatMap.js";
11
+
12
+ export type BestAvailableResult =
13
+ | { ok: true; seatIds: string[]; contiguous: boolean }
14
+ | { ok: false; error: string };
15
+
16
+ function isRealSeat(s: SeatMapSeat): boolean {
17
+ return (
18
+ s.type === "seat" ||
19
+ (!s.type.startsWith("space") &&
20
+ s.type !== "space" &&
21
+ !s.type.startsWith("empty"))
22
+ );
23
+ }
24
+
25
+ function seatSortKey(label: string): [number, string] {
26
+ const n = parseInt(label, 10);
27
+ if (Number.isFinite(n)) return [n, label];
28
+ return [Number.POSITIVE_INFINITY, label];
29
+ }
30
+
31
+ function sortSeatsInRow(seats: SeatMapSeat[]): SeatMapSeat[] {
32
+ return [...seats].sort((a, b) => {
33
+ const [na, sa] = seatSortKey(a.label);
34
+ const [nb, sb] = seatSortKey(b.label);
35
+ if (na !== nb) return na - nb;
36
+ return sa.localeCompare(sb, undefined, { numeric: true, sensitivity: "base" });
37
+ });
38
+ }
39
+
40
+ /** Labels are contiguous when consecutive numeric labels differ by 1. */
41
+ function areAdjacent(a: SeatMapSeat, b: SeatMapSeat): boolean {
42
+ const na = parseInt(a.label, 10);
43
+ const nb = parseInt(b.label, 10);
44
+ if (Number.isFinite(na) && Number.isFinite(nb)) {
45
+ return Math.abs(na - nb) === 1;
46
+ }
47
+ return true;
48
+ }
49
+
50
+ function findContiguousRuns(sorted: SeatMapSeat[], qty: number): SeatMapSeat[][] {
51
+ if (qty <= 0 || sorted.length < qty) return [];
52
+ const runs: SeatMapSeat[][] = [];
53
+ for (let start = 0; start < sorted.length; start += 1) {
54
+ const run: SeatMapSeat[] = [sorted[start]];
55
+ for (let i = start + 1; i < sorted.length && run.length < qty; i += 1) {
56
+ if (!areAdjacent(run[run.length - 1], sorted[i])) break;
57
+ run.push(sorted[i]);
58
+ }
59
+ if (run.length === qty) {
60
+ runs.push(run);
61
+ }
62
+ }
63
+ return runs;
64
+ }
65
+
66
+ /**
67
+ * Pick `qty` open seats from the tier's inventory.
68
+ * Prefers contiguous seats in one row (earlier rows / toward stage first).
69
+ */
70
+ export function findBestAvailableSeats(input: {
71
+ map: SeatMapDocument;
72
+ allowedSeatIds: string[];
73
+ blockedSeatIds: Iterable<string>;
74
+ qty: number;
75
+ }): BestAvailableResult {
76
+ const qty = Math.floor(input.qty);
77
+ if (!Number.isFinite(qty) || qty <= 0) {
78
+ return { ok: false, error: "Choose how many seats you need" };
79
+ }
80
+
81
+ const allowed = new Set(input.allowedSeatIds);
82
+ const blocked = new Set(input.blockedSeatIds);
83
+ const open = (input.map.seatMapData ?? []).filter(
84
+ (s) => isRealSeat(s) && allowed.has(s.id) && !blocked.has(s.id)
85
+ );
86
+
87
+ if (open.length < qty) {
88
+ return {
89
+ ok: false,
90
+ error: "Not enough open seats left in this ticket — try fewer seats.",
91
+ };
92
+ }
93
+
94
+ const rowOrder: string[] = [];
95
+ const byRow = new Map<string, SeatMapSeat[]>();
96
+ for (const s of open) {
97
+ if (!byRow.has(s.row)) {
98
+ byRow.set(s.row, []);
99
+ rowOrder.push(s.row);
100
+ }
101
+ byRow.get(s.row)!.push(s);
102
+ }
103
+
104
+ type Candidate = { seats: SeatMapSeat[]; rowIndex: number; centerDist: number };
105
+ const contiguous: Candidate[] = [];
106
+
107
+ for (let ri = 0; ri < rowOrder.length; ri += 1) {
108
+ const sorted = sortSeatsInRow(byRow.get(rowOrder[ri])!);
109
+ const runs = findContiguousRuns(sorted, qty);
110
+ for (const run of runs) {
111
+ const mid = (sorted.length - 1) / 2;
112
+ const runMid =
113
+ (seatSortKey(run[0].label)[0] +
114
+ seatSortKey(run[run.length - 1].label)[0]) /
115
+ 2;
116
+ const centerDist = Number.isFinite(runMid)
117
+ ? Math.abs(runMid - mid)
118
+ : Math.abs((run.length - 1) / 2 - mid);
119
+ contiguous.push({ seats: run, rowIndex: ri, centerDist });
120
+ }
121
+ }
122
+
123
+ contiguous.sort((a, b) => {
124
+ if (a.rowIndex !== b.rowIndex) return a.rowIndex - b.rowIndex;
125
+ return a.centerDist - b.centerDist;
126
+ });
127
+
128
+ if (contiguous.length > 0) {
129
+ return {
130
+ ok: true,
131
+ seatIds: contiguous[0].seats.map((s) => s.id),
132
+ contiguous: true,
133
+ };
134
+ }
135
+
136
+ const bySection = new Map<string, string[]>();
137
+ for (const row of rowOrder) {
138
+ const sec = sectionNameForRow(input.map, row) ?? "Section";
139
+ const list = bySection.get(sec) ?? [];
140
+ list.push(row);
141
+ bySection.set(sec, list);
142
+ }
143
+
144
+ for (const rows of bySection.values()) {
145
+ const picked: SeatMapSeat[] = [];
146
+ for (const row of rows) {
147
+ const sorted = sortSeatsInRow(byRow.get(row)!);
148
+ for (const s of sorted) {
149
+ picked.push(s);
150
+ if (picked.length === qty) {
151
+ return {
152
+ ok: true,
153
+ seatIds: picked.map((x) => x.id),
154
+ contiguous: false,
155
+ };
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ const any = open.slice(0, qty);
162
+ if (any.length === qty) {
163
+ return {
164
+ ok: true,
165
+ seatIds: any.map((s) => s.id),
166
+ contiguous: false,
167
+ };
168
+ }
169
+
170
+ return {
171
+ ok: false,
172
+ error: "Couldn't find seats together — try picking manually.",
173
+ };
174
+ }
@@ -8,6 +8,9 @@ export const SMS_TEMPLATE_KEYS = [
8
8
  "EVENT_REMINDER_3H",
9
9
  "EVENT_REMINDER_1H",
10
10
  "EVENT_VENUE_CHANGED",
11
+ "EVENT_TIME_CHANGED",
12
+ "EVENT_CANCELLED",
13
+ "EVENT_DETAILS_CHANGED",
11
14
  "EVENT_WAITLIST_APPROVED",
12
15
  "TICKET_CONFIRMED",
13
16
  "COAT_CHECK_CLAIM",
@@ -22,6 +25,14 @@ export type SmsTemplateContext = {
22
25
  EVENT_REMINDER_3H: { eventTitle: string; shortUrl: string };
23
26
  EVENT_REMINDER_1H: { eventTitle: string; shortUrl: string };
24
27
  EVENT_VENUE_CHANGED: { eventTitle: string; shortUrl: string };
28
+ EVENT_TIME_CHANGED: { eventTitle: string; shortUrl: string };
29
+ EVENT_CANCELLED: { eventTitle: string; shortUrl: string };
30
+ /** Batched critical updates (time + venue, etc.) from the 5-min debounce path. */
31
+ EVENT_DETAILS_CHANGED: {
32
+ eventTitle: string;
33
+ shortUrl: string;
34
+ whatChanged: string;
35
+ };
25
36
  EVENT_WAITLIST_APPROVED: { eventTitle: string; shortUrl: string };
26
37
  TICKET_CONFIRMED: { eventTitle: string; ticketCount: number; ticketsUrl?: string };
27
38
  COAT_CHECK_CLAIM: {
@@ -423,7 +423,7 @@ describe("PaymentUtils - Core Functions", () => {
423
423
  });
424
424
  });
425
425
 
426
- describe("guest ticket checkout breakdown (No Fees for Attendees)", () => {
426
+ describe("guest ticket checkout breakdown (No Platform Fees. Free for Everyone.)", () => {
427
427
  test("HostAbsorbs — $20 ticket → $20 due, no processing line", () => {
428
428
  const r = computeGuestTicketCheckoutBreakdown({
429
429
  discountedSubtotalCents: 2000,
@@ -113,4 +113,48 @@ describe("ticketListUtils", () => {
113
113
  );
114
114
  });
115
115
  });
116
+
117
+ test("round-trips seatIds for seated tiers", () => {
118
+ const ticketList = new Map<string, NumberOfTicketsForDate[]>([
119
+ [
120
+ "tier-seated",
121
+ [
122
+ {
123
+ numberOfTickets: 2,
124
+ ticketDateTime: isoDate,
125
+ seatIds: ["seat-a1", "seat-a2"],
126
+ },
127
+ ],
128
+ ],
129
+ ]);
130
+
131
+ const decoded = ticketListStrToTicketList(ticketListToString(ticketList));
132
+ expect(decoded.get("tier-seated")![0].seatIds).toEqual([
133
+ "seat-a1",
134
+ "seat-a2",
135
+ ]);
136
+ expect(decoded.get("tier-seated")![0].numberOfTickets).toBe(2);
137
+ });
138
+
139
+ test("round-trips seatHoldId with seatIds", () => {
140
+ const ticketList = new Map<string, NumberOfTicketsForDate[]>([
141
+ [
142
+ "tier-seated",
143
+ [
144
+ {
145
+ numberOfTickets: 2,
146
+ ticketDateTime: isoDate,
147
+ seatIds: ["seat-a1", "seat-a2"],
148
+ seatHoldId: "hold-xyz",
149
+ },
150
+ ],
151
+ ],
152
+ ]);
153
+ const decoded = ticketListStrToTicketList(ticketListToString(ticketList));
154
+ expect(decoded.get("tier-seated")![0].seatHoldId).toBe("hold-xyz");
155
+ expect(decoded.get("tier-seated")![0].seatIds).toEqual([
156
+ "seat-a1",
157
+ "seat-a2",
158
+ ]);
159
+ });
116
160
  });
@@ -345,7 +345,7 @@ export function hostAbsorbsTicketProcessing(
345
345
  }
346
346
 
347
347
  /**
348
- * Whether the event qualifies for the public "No fees for attendees" badge.
348
+ * Whether the event qualifies for the public "No Platform Fees. Free for Everyone." badge.
349
349
  * External-ticket events are excluded (Bash does not control checkout).
350
350
  */
351
351
  export function guestCheckoutHasNoAddedFees(
@@ -79,11 +79,23 @@ export function ticketListToString(
79
79
  }
80
80
  }
81
81
 
82
+ const needsSeatHold =
83
+ !!ticketNumAndDates.seatHoldId && ticketNumAndDates.seatIds?.length;
82
84
  ticketArgs.push(
83
85
  `${ticketNumAndDates.numberOfTickets}${URL_PARAMS_TICKETS_DATE_DELIM}${dateTimeStr}${
84
86
  ticketNumAndDates.unitPriceCents != null &&
85
87
  ticketNumAndDates.unitPriceCents > 0
86
88
  ? `${URL_PARAMS_TICKETS_DATE_DELIM}${ticketNumAndDates.unitPriceCents}`
89
+ : ticketNumAndDates.seatIds?.length
90
+ ? `${URL_PARAMS_TICKETS_DATE_DELIM}`
91
+ : ""
92
+ }${
93
+ ticketNumAndDates.seatIds?.length
94
+ ? `${URL_PARAMS_TICKETS_DATE_DELIM}${ticketNumAndDates.seatIds.join("|")}`
95
+ : ""
96
+ }${
97
+ needsSeatHold
98
+ ? `${URL_PARAMS_TICKETS_DATE_DELIM}${ticketNumAndDates.seatHoldId}`
87
99
  : ""
88
100
  }`
89
101
  );
@@ -157,6 +169,8 @@ export function ticketListStrToTicketList(
157
169
  const numberOfTickets = segments[0];
158
170
  const ticketDateTimeStr = segments[1];
159
171
  const unitPriceCentsRaw = segments[2];
172
+ const seatIdsRaw = segments[3];
173
+ const seatHoldIdRaw = segments[4];
160
174
  const ticketDateTime = DateTime.fromISO(ticketDateTimeStr);
161
175
  let ticketDateTimeFormattedStr: string | undefined = undefined;
162
176
 
@@ -169,12 +183,24 @@ export function ticketListStrToTicketList(
169
183
  ? parseInt(unitPriceCentsRaw, 10)
170
184
  : undefined;
171
185
 
186
+ const seatIds =
187
+ seatIdsRaw != null && seatIdsRaw !== ""
188
+ ? seatIdsRaw.split("|").map((id) => id.trim()).filter(Boolean)
189
+ : undefined;
190
+
191
+ const seatHoldId =
192
+ seatHoldIdRaw != null && seatHoldIdRaw.trim() !== ""
193
+ ? seatHoldIdRaw.trim()
194
+ : undefined;
195
+
172
196
  ticketNumAndDatesArr.push({
173
197
  numberOfTickets: parseInt(numberOfTickets),
174
198
  ticketDateTime: ticketDateTimeFormattedStr,
175
199
  ...(unitPriceCents != null && !Number.isNaN(unitPriceCents)
176
200
  ? { unitPriceCents }
177
201
  : {}),
202
+ ...(seatIds?.length ? { seatIds } : {}),
203
+ ...(seatHoldId ? { seatHoldId } : {}),
178
204
  });
179
205
  }
180
206
  ticketList.set(ticketTierId, ticketNumAndDatesArr);