@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.
- package/dist/__tests__/seatMap.test.d.ts +2 -0
- package/dist/__tests__/seatMap.test.d.ts.map +1 -0
- package/dist/__tests__/seatMap.test.js +42 -0
- package/dist/__tests__/seatMap.test.js.map +1 -0
- package/dist/__tests__/seatMapBestAvailable.test.d.ts +2 -0
- package/dist/__tests__/seatMapBestAvailable.test.d.ts.map +1 -0
- package/dist/__tests__/seatMapBestAvailable.test.js +101 -0
- package/dist/__tests__/seatMapBestAvailable.test.js.map +1 -0
- package/dist/accessScreening.js +0 -1
- package/dist/aiApproval.js +0 -1
- package/dist/aiEventPlan.js +0 -1
- package/dist/bashFeedTypes.js +0 -1
- package/dist/bookingIntent.js +0 -1
- package/dist/causeTags.js +0 -1
- package/dist/definitions.d.ts +10 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js.map +1 -1
- package/dist/entertainmentNotableVenues.js +0 -1
- package/dist/extendedSchemas.d.ts +1 -1
- package/dist/groupTicketUtils.js +0 -1
- package/dist/guestPartySize.js +0 -1
- package/dist/hostCrmAutomation.js +0 -1
- package/dist/hostCrmConstants.js +0 -1
- package/dist/hostCrmInsights.js +0 -1
- package/dist/hostCrmSegments.js +0 -1
- package/dist/hostPromoter.js +0 -1
- package/dist/icebreakerPrompts.js +0 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/influencerAudience.js +0 -1
- package/dist/legalTemplates.js +0 -1
- package/dist/membershipDefinitions.js +0 -1
- package/dist/mirroredPrismaEnums.js +0 -1
- package/dist/onSaleCapabilityRecommendations.js +0 -1
- package/dist/partnerStoreTypes.js +0 -1
- package/dist/productLaunch.js +0 -1
- package/dist/relationshipHealth.js +0 -1
- package/dist/requestAuthUser.js +0 -1
- package/dist/seatMap.d.ts +80 -0
- package/dist/seatMap.d.ts.map +1 -0
- package/dist/seatMap.js +250 -0
- package/dist/seatMap.js.map +1 -0
- package/dist/seatMapBestAvailable.d.ts +24 -0
- package/dist/seatMapBestAvailable.d.ts.map +1 -0
- package/dist/seatMapBestAvailable.js +143 -0
- package/dist/seatMapBestAvailable.js.map +1 -0
- package/dist/serviceTypeLabels.js +0 -1
- package/dist/sideQuestIdeas.js +0 -1
- package/dist/sms/smsTemplates.d.ts +15 -1
- package/dist/sms/smsTemplates.d.ts.map +1 -1
- package/dist/sms/smsTemplates.js +3 -0
- package/dist/sms/smsTemplates.js.map +1 -1
- package/dist/storePrintArtworkUtils.js +0 -1
- package/dist/storeTypes.js +0 -1
- package/dist/stripeListingSubscriptionMessages.js +0 -1
- package/dist/targetAudienceLabels.js +0 -1
- package/dist/templates/eventTemplates.js +0 -1
- package/dist/ticketBnplPaymentMethods.js +0 -1
- package/dist/userReportTypes.js +0 -1
- package/dist/utils/__tests__/paymentUtils.test.js +1 -1
- package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
- package/dist/utils/__tests__/ticketListUtils.test.js +41 -0
- package/dist/utils/__tests__/ticketListUtils.test.js.map +1 -1
- package/dist/utils/agencyBookingCheckoutCopy.js +0 -1
- package/dist/utils/arrayUtils.js +0 -1
- package/dist/utils/awsS3Utils.js +0 -1
- package/dist/utils/bashPointsPaymentUtils.js +0 -1
- package/dist/utils/birthdayExperienceQuality.js +0 -1
- package/dist/utils/contentFilterUtils.js +0 -1
- package/dist/utils/discountEngine/bestPriceResolver.js +0 -1
- package/dist/utils/discountEngine/eligibilityValidator.js +0 -1
- package/dist/utils/displayDateUtils.js +0 -1
- package/dist/utils/entertainmentDrawFit.js +0 -1
- package/dist/utils/entityUtils.js +0 -1
- package/dist/utils/eventCitySlug.js +0 -1
- package/dist/utils/eventTimezoneIana.js +0 -1
- package/dist/utils/featuredDiscoveryGeo.js +0 -1
- package/dist/utils/flyerUtils.js +0 -1
- package/dist/utils/generalDateTimeUtils.js +0 -1
- package/dist/utils/lobFlyerUtils.js +0 -1
- package/dist/utils/localPartnerHours.js +0 -1
- package/dist/utils/locationVisibility.js +0 -1
- package/dist/utils/luxonUtils.js +0 -1
- package/dist/utils/mathUtils.js +0 -1
- package/dist/utils/mediaClientDefaults.js +0 -1
- package/dist/utils/nonprofitUtils.js +0 -1
- package/dist/utils/objUtils.js +0 -1
- package/dist/utils/orgPromotionalPackageUtils.js +0 -1
- package/dist/utils/organizationEntitlements.js +0 -1
- package/dist/utils/paymentUtils.d.ts +1 -1
- package/dist/utils/paymentUtils.js +1 -1
- package/dist/utils/promotionBlastUtils.js +0 -1
- package/dist/utils/reputationUtils.js +0 -1
- package/dist/utils/reviewDimensionUtils.js +0 -1
- package/dist/utils/service/attendeeOptionUtils.js +0 -1
- package/dist/utils/service/billingCapUtils.js +0 -1
- package/dist/utils/service/eventPromoterUtils.js +0 -1
- package/dist/utils/service/offerAllowListUtils.js +0 -1
- package/dist/utils/service/offerComparisonUtils.js +0 -1
- package/dist/utils/service/regexUtils.js +0 -1
- package/dist/utils/service/serviceBookingExposureUtils.js +0 -1
- package/dist/utils/service/serviceFeaturedSortUtils.js +0 -1
- package/dist/utils/service/unifiedPricingResolver.js +0 -1
- package/dist/utils/service/venueSettlementUtils.js +0 -1
- package/dist/utils/service/volunteerCompensationUtils.js +0 -1
- package/dist/utils/serviceAvailabilityDisplay.js +0 -1
- package/dist/utils/sortUtils.js +0 -1
- package/dist/utils/stringUtils.js +0 -1
- package/dist/utils/ticketCheckInDisplay.js +0 -1
- package/dist/utils/ticketListUtils.d.ts.map +1 -1
- package/dist/utils/ticketListUtils.js +17 -0
- package/dist/utils/ticketListUtils.js.map +1 -1
- package/dist/utils/trustScoreUtils.js +0 -1
- package/dist/utils/typeUtils.js +0 -1
- package/dist/utils/userPromoCodeUtils.js +0 -1
- package/dist/utils/userUtils.js +0 -1
- package/dist/utils/venueLoyalty/redemptionCodeUtils.js +0 -1
- package/dist/utils/venueTypeFormatter.js +0 -1
- package/dist/utmAttribution.js +0 -1
- package/dist/venueMatch.js +0 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +23 -0
- package/src/__tests__/seatMap.test.ts +59 -0
- package/src/__tests__/seatMapBestAvailable.test.ts +111 -0
- package/src/definitions.ts +10 -0
- package/src/extendedSchemas.ts +1 -1
- package/src/index.ts +2 -0
- package/src/seatMap.ts +337 -0
- package/src/seatMapBestAvailable.ts +174 -0
- package/src/sms/smsTemplates.ts +11 -0
- package/src/utils/__tests__/paymentUtils.test.ts +1 -1
- package/src/utils/__tests__/ticketListUtils.test.ts +44 -0
- package/src/utils/paymentUtils.ts +1 -1
- 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
|
+
}
|
package/src/sms/smsTemplates.ts
CHANGED
|
@@ -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
|
|
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
|
|
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);
|