tixbit 0.1.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/cli.js ADDED
@@ -0,0 +1,646 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/client.ts
7
+ var DEFAULT_BASE_URL = "https://tixbit.com";
8
+ var DEFAULT_TIMEOUT_MS = 15e3;
9
+ var USER_AGENT = "@tixbit/sdk";
10
+ var TixBitClient = class {
11
+ baseUrl;
12
+ timeoutMs;
13
+ apiKey;
14
+ constructor(config = {}) {
15
+ this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
16
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
17
+ this.apiKey = config.apiKey;
18
+ }
19
+ // ── HTTP helpers ──────────────────────────────────────────────────────────
20
+ async request(path, init) {
21
+ const url = `${this.baseUrl}${path}`;
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
24
+ const headers = {
25
+ Accept: "application/json",
26
+ "User-Agent": USER_AGENT,
27
+ ...this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}
28
+ };
29
+ try {
30
+ const res = await fetch(url, {
31
+ ...init,
32
+ headers: { ...headers, ...init?.headers },
33
+ signal: controller.signal
34
+ });
35
+ if (!res.ok) {
36
+ const text = await res.text().catch(() => "");
37
+ throw new TixBitApiError(
38
+ `${res.status} ${res.statusText}: ${text.slice(0, 300)}`,
39
+ res.status,
40
+ url
41
+ );
42
+ }
43
+ return await res.json();
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+ qs(params) {
49
+ const entries = Object.entries(params).filter(([, v]) => v !== void 0 && v !== null && v !== "").map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
50
+ return entries.length ? `?${entries.join("&")}` : "";
51
+ }
52
+ // ── Search Events ─────────────────────────────────────────────────────────
53
+ /**
54
+ * Search for events by keyword, city, state, category, or date range.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const results = await client.searchEvents({ query: "Hawks", state: "GA" });
59
+ * ```
60
+ */
61
+ async searchEvents(params = {}) {
62
+ const query = this.qs({
63
+ q: params.query,
64
+ city: params.city,
65
+ state: params.state,
66
+ category: params.category,
67
+ startDate: params.startDate,
68
+ endDate: params.endDate,
69
+ page: params.page,
70
+ size: params.size ?? 25
71
+ });
72
+ const data = await this.request(`/api/events/search${query}`);
73
+ return {
74
+ events: (data.events ?? []).map(normalizeEvent),
75
+ pagination: data.pagination
76
+ };
77
+ }
78
+ // ── Browse (Location-Aware) ───────────────────────────────────────────────
79
+ /**
80
+ * Browse upcoming events near a location (homepage-style).
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const nearby = await client.browse({ city: "Atlanta", state: "GA" });
85
+ * ```
86
+ */
87
+ async browse(params = {}) {
88
+ const query = this.qs({
89
+ size: params.size ?? 18,
90
+ context: "homepage",
91
+ recommendation: "upcoming",
92
+ nearLat: params.latitude,
93
+ nearLng: params.longitude,
94
+ preferCity: params.city,
95
+ preferState: params.state,
96
+ categoryEventType: params.categoryEventType
97
+ });
98
+ const data = await this.request(`/api/events${query}`);
99
+ return {
100
+ events: (data.events ?? []).map(normalizeEvent),
101
+ total: data.total
102
+ };
103
+ }
104
+ // ── Listings ──────────────────────────────────────────────────────────────
105
+ /**
106
+ * Get available ticket listings for an event.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * const listings = await client.getListings({ eventId: "abc123" });
111
+ * ```
112
+ */
113
+ async getListings(params) {
114
+ const query = this.qs({
115
+ size: params.size ?? 50,
116
+ page: params.page ?? 1,
117
+ order_by_direction: params.orderByDirection ?? "asc"
118
+ });
119
+ const data = await this.request(`/api/events/${encodeURIComponent(params.eventId)}/listings${query}`);
120
+ const listings = (data.data ?? []).map(normalizeListing);
121
+ return {
122
+ listings,
123
+ meta: {
124
+ total: data.meta?.total ?? listings.length,
125
+ page: data.meta?.page ?? params.page ?? 1,
126
+ size: data.meta?.size ?? params.size ?? 50,
127
+ cacheSource: data.meta?.cacheSource
128
+ }
129
+ };
130
+ }
131
+ // ── Checkout Link ──────────────────────────────────────────────────────────
132
+ /**
133
+ * Create a checkout link for a listing.
134
+ *
135
+ * Returns a URL to the TixBit checkout page where the user can
136
+ * sign in and complete their purchase (card, crypto, etc.).
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * const link = client.createCheckoutLink({
141
+ * listingId: "P2JO5OBX",
142
+ * quantity: 2,
143
+ * });
144
+ *
145
+ * console.log(link.url);
146
+ * // → "https://tixbit.com/checkout/process?listing=P2JO5OBX&quantity=2"
147
+ * ```
148
+ */
149
+ createCheckoutLink(params) {
150
+ const quantity = Math.max(1, Math.min(8, Math.round(params.quantity)));
151
+ const url = `${this.baseUrl}/checkout/process?listing=${encodeURIComponent(params.listingId)}&quantity=${quantity}`;
152
+ return {
153
+ url,
154
+ listingId: params.listingId,
155
+ quantity
156
+ };
157
+ }
158
+ // ── Event URL helper ──────────────────────────────────────────────────────
159
+ /**
160
+ * Build the direct URL to an event page on tixbit.com.
161
+ */
162
+ eventUrl(slugOrId) {
163
+ return `${this.baseUrl}/events/${slugOrId}`;
164
+ }
165
+ // ── Seatmap ───────────────────────────────────────────────────────────────
166
+ /**
167
+ * Get the seating chart for an event's venue.
168
+ *
169
+ * Returns section-level data including section names, positions, and
170
+ * the venue background image URL. Use this to help users understand
171
+ * where their tickets are located.
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * const seatmap = await client.getSeatmap({ eventId: "4BKJMDZ" });
176
+ * console.log(seatmap.venue_name); // "State Farm Arena"
177
+ * console.log(seatmap.section_names); // ["101", "102", ...]
178
+ * ```
179
+ */
180
+ async getSeatmap(params) {
181
+ const data = await this.request(`/api/events/${encodeURIComponent(params.eventId)}/seating-chart`);
182
+ let zones = [];
183
+ let sectionNames = [];
184
+ if (data.has_coordinates && data.coordinates) {
185
+ try {
186
+ zones = await this.fetchAndParseCoordinates(data.coordinates);
187
+ sectionNames = zones.flatMap(
188
+ (z) => z.sections.map((s) => s.name)
189
+ );
190
+ } catch {
191
+ }
192
+ }
193
+ return {
194
+ success: data.success,
195
+ event_id: data.event_id,
196
+ venue_id: data.venue_id,
197
+ venue_name: data.venue_name,
198
+ configuration_id: data.configuration_id,
199
+ configuration_name: data.configuration_name,
200
+ background_image: data.background_image ?? null,
201
+ coordinates_url: data.coordinates ?? null,
202
+ has_coordinates: data.has_coordinates,
203
+ capacity: data.capacity ?? null,
204
+ venue: data.venue_data,
205
+ zones,
206
+ section_names: sectionNames
207
+ };
208
+ }
209
+ /**
210
+ * Fetch the coordinates JSON and parse it into zones/sections.
211
+ */
212
+ async fetchAndParseCoordinates(coordinatesUrl) {
213
+ const fullUrl = coordinatesUrl.startsWith("http") ? coordinatesUrl : `${this.baseUrl}${coordinatesUrl}`;
214
+ const controller = new AbortController();
215
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
216
+ try {
217
+ const res = await fetch(fullUrl, {
218
+ headers: {
219
+ Accept: "application/json",
220
+ "User-Agent": USER_AGENT
221
+ },
222
+ signal: controller.signal
223
+ });
224
+ if (!res.ok) return [];
225
+ const coords = await res.json();
226
+ if (!coords.zones) return [];
227
+ return coords.zones.map((zone) => ({
228
+ id: zone.id,
229
+ name: zone.name,
230
+ sections: (zone.sections ?? []).map((section) => {
231
+ const label = section.labels?.[0];
232
+ return {
233
+ id: section.id,
234
+ name: section.name,
235
+ x: label?.x ?? 0,
236
+ y: label?.y ?? 0
237
+ };
238
+ })
239
+ }));
240
+ } finally {
241
+ clearTimeout(timer);
242
+ }
243
+ }
244
+ };
245
+ function normalizeEvent(raw) {
246
+ const e = raw;
247
+ return {
248
+ id: str(e.id) ?? str(e.external_event_id) ?? "",
249
+ external_event_id: str(e.external_event_id) ?? str(e.externalEventId) ?? str(e.id) ?? "",
250
+ slug: str(e.slug) ?? str(e.external_event_id) ?? "",
251
+ name: str(e.name) ?? str(e.performer) ?? "",
252
+ date: resolveDate(e),
253
+ venue_name: str(e.venue_name) ?? str(e.venueName) ?? null,
254
+ venue_city: str(e.venue_city) ?? str(e.venueCity) ?? null,
255
+ venue_state: str(e.venue_state) ?? str(e.venueState) ?? null,
256
+ image_url: str(e.image_url) ?? str(e.imageUrl) ?? null,
257
+ category_name: str(e.category_name) ?? str(e.categoryName) ?? null,
258
+ category_event_type: str(e.category_event_type) ?? str(e.categoryEventType) ?? null,
259
+ has_listings: Boolean(e.has_listings),
260
+ inventory: normalizeInventory(e.inventory)
261
+ };
262
+ }
263
+ function normalizeInventory(raw) {
264
+ if (!raw || typeof raw !== "object") {
265
+ return { total_available: 0, min_price: 0, max_price: 0 };
266
+ }
267
+ const inv = raw;
268
+ return {
269
+ total_available: num(inv.total_available) ?? 0,
270
+ min_price: num(inv.min_price) ?? 0,
271
+ max_price: num(inv.max_price) ?? 0
272
+ };
273
+ }
274
+ function normalizeListing(raw) {
275
+ const outer = raw;
276
+ const attrs = outer.attributes ?? outer;
277
+ return {
278
+ id: str(outer.id) ?? str(attrs.id) ?? "",
279
+ // price_per_ticket is the fee-inclusive per-ticket price (in dollars, not cents)
280
+ price: num(attrs.price_per_ticket) ?? num(attrs.price) ?? 0,
281
+ quantity: num(attrs.quantity) ?? num(attrs.available_quantity) ?? 0,
282
+ quantities_list: Array.isArray(attrs.quantities_list) ? attrs.quantities_list : [],
283
+ section: str(attrs.section) ?? null,
284
+ row: str(attrs.row) ?? null,
285
+ seat_numbers: str(attrs.seat_numbers) ?? null,
286
+ listing_hash: str(attrs.listing_hash) ?? "",
287
+ notes: str(attrs.notes) ?? null,
288
+ delivery_method: str(attrs.delivery_method) ?? str(attrs.delivery_type) ?? null,
289
+ splits: Array.isArray(attrs.splits) ? attrs.splits : [],
290
+ raw: outer
291
+ };
292
+ }
293
+ function resolveDate(e) {
294
+ if (typeof e.date === "string") return e.date;
295
+ if (typeof e.date === "number") return new Date(e.date).toISOString();
296
+ if (e.date && typeof e.date === "object") {
297
+ const d = e.date;
298
+ if (d.month && d.day && d.year) {
299
+ return `${d.month} ${d.day}, ${d.year}`;
300
+ }
301
+ }
302
+ if (typeof e.date_ms === "number") return new Date(e.date_ms).toISOString();
303
+ return "";
304
+ }
305
+ function str(v) {
306
+ return typeof v === "string" && v.length > 0 ? v : null;
307
+ }
308
+ function num(v) {
309
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
310
+ }
311
+ var TixBitApiError = class extends Error {
312
+ constructor(message, status, url) {
313
+ super(message);
314
+ this.status = status;
315
+ this.url = url;
316
+ this.name = "TixBitApiError";
317
+ }
318
+ };
319
+
320
+ // src/cli.ts
321
+ var client = new TixBitClient({
322
+ baseUrl: process.env.TIXBIT_BASE_URL,
323
+ apiKey: process.env.TIXBIT_API_KEY
324
+ });
325
+ function output(data, json) {
326
+ if (json) {
327
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
328
+ return;
329
+ }
330
+ if (Array.isArray(data)) {
331
+ for (const item of data) {
332
+ process.stdout.write(formatItem(item) + "\n");
333
+ }
334
+ return;
335
+ }
336
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
337
+ }
338
+ function formatItem(item) {
339
+ if (!item || typeof item !== "object") return String(item);
340
+ const e = item;
341
+ if (e.name && e.external_event_id) {
342
+ const parts = [
343
+ ` ${e.name}`,
344
+ ` ID: ${e.external_event_id}`
345
+ ];
346
+ if (e.date) parts.push(` Date: ${e.date}`);
347
+ const location = [e.venue_city, e.venue_state].filter(Boolean).join(", ");
348
+ if (location) parts.push(` Location: ${location}`);
349
+ if (e.venue_name) parts.push(` Venue: ${e.venue_name}`);
350
+ if (e.has_listings) {
351
+ const inv = e.inventory;
352
+ if (inv?.min_price) {
353
+ parts.push(` From: $${inv.min_price.toFixed(2)} (${inv.total_available} available)`);
354
+ }
355
+ }
356
+ return parts.join("\n") + "\n";
357
+ }
358
+ if (e.listing_hash !== void 0) {
359
+ const parts = [
360
+ ` $${e.price.toFixed(2)} \xD7 ${e.quantity} ticket(s)`,
361
+ ` ID: ${e.id}`
362
+ ];
363
+ if (e.section) parts.push(` Section: ${e.section}${e.row ? ` Row ${e.row}` : ""}`);
364
+ if (e.delivery_method) parts.push(` Delivery: ${e.delivery_method}`);
365
+ if (e.quantities_list?.length > 0) {
366
+ parts.push(` Qty options: ${e.quantities_list.join(", ")}`);
367
+ }
368
+ return parts.join("\n") + "\n";
369
+ }
370
+ return JSON.stringify(item, null, 2);
371
+ }
372
+ function handleError(err) {
373
+ const msg = err instanceof Error ? err.message : String(err);
374
+ process.stderr.write(`Error: ${msg}
375
+ `);
376
+ process.exit(1);
377
+ }
378
+ var program = new Command().name("tixbit").description("Search events, browse listings, and buy tickets on TixBit").version("0.1.0");
379
+ program.command("search [query]").description("Search for events by keyword, city, state, or category").option("--city <city>", "Filter by city").option("--state <state>", "Filter by state (2-letter code)").option("--category <slug>", "Filter by category slug (e.g. nba-basketball)").option("--start-date <date>", "Events on or after this date (ISO)").option("--end-date <date>", "Events on or before this date (ISO)").option("--page <n>", "Page number", "1").option("--size <n>", "Results per page", "10").option("--json", "Output raw JSON (for agents)", false).action(async (query, opts) => {
380
+ try {
381
+ const params = {
382
+ query,
383
+ city: opts.city,
384
+ state: opts.state,
385
+ category: opts.category,
386
+ startDate: opts.startDate,
387
+ endDate: opts.endDate,
388
+ page: parseInt(opts.page, 10),
389
+ size: parseInt(opts.size, 10)
390
+ };
391
+ const result = await client.searchEvents(params);
392
+ const isJson = opts.json === true || opts.json === "true";
393
+ if (isJson) {
394
+ output(result, true);
395
+ } else {
396
+ const { pagination } = result;
397
+ process.stdout.write(
398
+ `
399
+ Found ${pagination.total} event(s) \u2014 page ${pagination.page}/${pagination.totalPages}
400
+
401
+ `
402
+ );
403
+ output(result.events, false);
404
+ }
405
+ } catch (err) {
406
+ handleError(err);
407
+ }
408
+ });
409
+ program.command("browse").description("Browse upcoming events near a location").option("--city <city>", "Preferred city").option("--state <state>", "Preferred state (2-letter code)").option("--lat <lat>", "Latitude").option("--lng <lng>", "Longitude").option("--category <type>", "SPORT, CONCERT, THEATER, or ALL", "ALL").option("--size <n>", "Number of results", "10").option("--json", "Output raw JSON (for agents)", false).action(async (opts) => {
410
+ try {
411
+ const params = {
412
+ city: opts.city,
413
+ state: opts.state,
414
+ latitude: opts.lat ? parseFloat(opts.lat) : void 0,
415
+ longitude: opts.lng ? parseFloat(opts.lng) : void 0,
416
+ categoryEventType: opts.category ?? "ALL",
417
+ size: parseInt(opts.size, 10)
418
+ };
419
+ const result = await client.browse(params);
420
+ const isJson = opts.json === true || opts.json === "true";
421
+ if (isJson) {
422
+ output(result, true);
423
+ } else {
424
+ process.stdout.write(
425
+ `
426
+ ${result.events.length} upcoming event(s) near ${opts.city ?? "you"}
427
+
428
+ `
429
+ );
430
+ output(result.events, false);
431
+ }
432
+ } catch (err) {
433
+ handleError(err);
434
+ }
435
+ });
436
+ program.command("listings <eventId>").description("Get available ticket listings for an event").option("--size <n>", "Results per page", "20").option("--page <n>", "Page number", "1").option("--sort <dir>", "Price sort: asc or desc", "asc").option("--json", "Output raw JSON (for agents)", false).action(async (eventId, opts) => {
437
+ try {
438
+ const params = {
439
+ eventId,
440
+ size: parseInt(opts.size, 10),
441
+ page: parseInt(opts.page, 10),
442
+ orderByDirection: opts.sort
443
+ };
444
+ const result = await client.getListings(params);
445
+ const isJson = opts.json === true || opts.json === "true";
446
+ if (isJson) {
447
+ output(result, true);
448
+ } else {
449
+ process.stdout.write(
450
+ `
451
+ ${result.listings.length} listing(s) for event ${eventId}
452
+
453
+ `
454
+ );
455
+ output(result.listings, false);
456
+ }
457
+ } catch (err) {
458
+ handleError(err);
459
+ }
460
+ });
461
+ program.command("checkout <listingId>").description("Get a checkout link to buy tickets for a listing").requiredOption("--quantity <n>", "Number of tickets to buy").option("--json", "Output raw JSON", false).action(async (listingId, opts) => {
462
+ try {
463
+ const quantity = parseInt(opts.quantity, 10);
464
+ if (!Number.isFinite(quantity) || quantity < 1) {
465
+ process.stderr.write("Error: --quantity must be a positive integer\n");
466
+ process.exit(1);
467
+ }
468
+ const isJson = opts.json === true || opts.json === "true";
469
+ let listingInfo;
470
+ try {
471
+ const result = await client.getListings({ eventId: listingId, size: 1 });
472
+ if (result.listings.length > 0) {
473
+ listingInfo = result.listings.find((l) => l.id === listingId);
474
+ }
475
+ } catch {
476
+ }
477
+ const link = client.createCheckoutLink({ listingId, quantity });
478
+ if (isJson) {
479
+ output({ ...link, listing: listingInfo ?? null }, true);
480
+ return;
481
+ }
482
+ process.stdout.write("\n\u{1F39F} Checkout Link\n\n");
483
+ if (listingInfo) {
484
+ process.stdout.write(` Listing: ${listingInfo.id}
485
+ `);
486
+ if (listingInfo.section) {
487
+ process.stdout.write(
488
+ ` Section: ${listingInfo.section}${listingInfo.row ? ` Row ${listingInfo.row}` : ""}
489
+ `
490
+ );
491
+ }
492
+ process.stdout.write(
493
+ ` Price: $${listingInfo.price.toFixed(2)} \xD7 ${quantity} = $${(listingInfo.price * quantity).toFixed(2)}
494
+ `
495
+ );
496
+ }
497
+ process.stdout.write(` Quantity: ${quantity}
498
+
499
+ `);
500
+ process.stdout.write(` ${link.url}
501
+
502
+ `);
503
+ process.stdout.write(
504
+ " Open the link above in your browser to complete checkout.\n\n"
505
+ );
506
+ } catch (err) {
507
+ handleError(err);
508
+ }
509
+ });
510
+ program.command("seatmap <eventId>").description("Show the seating chart / section map for an event's venue").option("--section <name>", "Highlight a specific section (case-insensitive)").option("--json", "Output raw JSON (for agents)", false).action(async (eventId, opts) => {
511
+ try {
512
+ const result = await client.getSeatmap({ eventId });
513
+ const isJson = opts.json === true || opts.json === "true";
514
+ if (isJson) {
515
+ output(result, true);
516
+ return;
517
+ }
518
+ if (!result.success) {
519
+ process.stderr.write("Seatmap not available for this event.\n");
520
+ process.exit(1);
521
+ }
522
+ process.stdout.write(`
523
+ \u{1F3DF} ${result.venue_name}
524
+ `);
525
+ process.stdout.write(` ${result.configuration_name}
526
+ `);
527
+ if (result.venue.address) {
528
+ process.stdout.write(` ${result.venue.address}, ${result.venue.city}, ${result.venue.region}
529
+ `);
530
+ }
531
+ if (result.capacity) {
532
+ process.stdout.write(` Capacity: ${result.capacity.toLocaleString()}
533
+ `);
534
+ }
535
+ process.stdout.write("\n");
536
+ if (!result.has_coordinates || result.zones.length === 0) {
537
+ process.stdout.write(" No section-level seating data available for this venue.\n\n");
538
+ return;
539
+ }
540
+ const groups = categorizeSections(result.zones.flatMap((z) => z.sections));
541
+ const highlightSection = opts.section?.toUpperCase();
542
+ for (const [groupName, sections] of Object.entries(groups)) {
543
+ process.stdout.write(` \u2500\u2500 ${groupName} \u2500\u2500
544
+ `);
545
+ const names = sections.map((s) => s.name);
546
+ for (let i = 0; i < names.length; i += 8) {
547
+ const row = names.slice(i, i + 8);
548
+ const formatted = row.map((name) => {
549
+ if (highlightSection && name.toUpperCase() === highlightSection) {
550
+ return ` \u25B8${name}\u25C2 `;
551
+ }
552
+ return ` ${name} `;
553
+ }).join(" ");
554
+ process.stdout.write(` ${formatted}
555
+ `);
556
+ }
557
+ process.stdout.write("\n");
558
+ }
559
+ if (highlightSection) {
560
+ const allSections = result.zones.flatMap((z) => z.sections);
561
+ const match = allSections.find(
562
+ (s) => s.name.toUpperCase() === highlightSection
563
+ );
564
+ if (match) {
565
+ const pos = describePosition(match, allSections);
566
+ process.stdout.write(` \u{1F4CD} Section ${match.name}: ${pos}
567
+
568
+ `);
569
+ } else {
570
+ process.stdout.write(` \u26A0 Section "${opts.section}" not found in this venue.
571
+ `);
572
+ process.stdout.write(` Available: ${result.section_names.slice(0, 20).join(", ")}${result.section_names.length > 20 ? "..." : ""}
573
+
574
+ `);
575
+ }
576
+ }
577
+ process.stdout.write(
578
+ ` Total sections: ${result.section_names.length}
579
+
580
+ `
581
+ );
582
+ } catch (err) {
583
+ handleError(err);
584
+ }
585
+ });
586
+ program.command("url <slug>").description("Print the TixBit event page URL for a slug or ID").action((slug) => {
587
+ process.stdout.write(client.eventUrl(slug) + "\n");
588
+ });
589
+ function categorizeSections(sections) {
590
+ const groups = {};
591
+ const addToGroup = (groupName, section) => {
592
+ if (!groups[groupName]) groups[groupName] = [];
593
+ groups[groupName].push(section);
594
+ };
595
+ const hasLowerLevel = sections.some((s) => /^1\d{2}/.test(s.name));
596
+ const hasSmallNumbers = sections.some((s) => /^\d{1,2}$/.test(s.name));
597
+ for (const section of sections) {
598
+ const name = section.name.toUpperCase();
599
+ if (/^FLOOR\d/.test(name) || /^FLR\d/.test(name)) {
600
+ addToGroup("\u{1F3C0} Floor", section);
601
+ } else if (/^1\d{2}/.test(name)) {
602
+ addToGroup("\u2B07 Lower Level (100s)", section);
603
+ } else if (/^2\d{2}/.test(name)) {
604
+ addToGroup("\u2B06 Upper Level (200s)", section);
605
+ } else if (/^3\d{2}/.test(name)) {
606
+ addToGroup("\u{1F535} 300 Level", section);
607
+ } else if (/^4\d{2}/.test(name)) {
608
+ addToGroup("\u{1F7E3} 400 Level", section);
609
+ } else if (/^\d{1,2}$/.test(name) && hasSmallNumbers) {
610
+ addToGroup(hasLowerLevel ? "\u{1F3DF} Field Level" : "\u2B07 Lower Level", section);
611
+ } else if (/^L\d/.test(name)) {
612
+ addToGroup("\u{1FAB5} Loge", section);
613
+ } else if (/^S\d/.test(name)) {
614
+ addToGroup("\u2601 Sky", section);
615
+ } else if (/^T\d/.test(name)) {
616
+ addToGroup("\u{1F307} Terrace", section);
617
+ } else if (/^V\d/.test(name)) {
618
+ addToGroup("\u{1F440} Vista", section);
619
+ } else if (/^STE/.test(name)) {
620
+ addToGroup("\u{1F3E2} Suites", section);
621
+ } else if (name.includes("SUITE") || name.includes("SUITES")) {
622
+ addToGroup("\u{1F3E2} Suites", section);
623
+ } else if (name.includes("STANDING") || name === "SRO" || name === "UPPER") {
624
+ addToGroup("\u{1F9CD} Standing Room", section);
625
+ } else if (name === "DECK" || name === "ROOF" || name === "GA" || name === "HAT") {
626
+ addToGroup("\u{1F3AA} General / Special", section);
627
+ } else {
628
+ addToGroup("\u{1F4CD} Other", section);
629
+ }
630
+ }
631
+ return groups;
632
+ }
633
+ function describePosition(section, allSections) {
634
+ if (!allSections.length) return "position unknown";
635
+ const centerX = allSections.reduce((sum, s) => sum + s.x, 0) / allSections.length;
636
+ const centerY = allSections.reduce((sum, s) => sum + s.y, 0) / allSections.length;
637
+ const dx = section.x - centerX;
638
+ const dy = section.y - centerY;
639
+ const horizontal = Math.abs(dx) < 50 ? "center" : dx < 0 ? "left side" : "right side";
640
+ const vertical = Math.abs(dy) < 50 ? "center" : dy < 0 ? "near side (closer to stage/court)" : "far side";
641
+ if (horizontal === "center" && vertical === "center") {
642
+ return "center of venue";
643
+ }
644
+ return [vertical, horizontal].filter((p) => p !== "center").join(", ");
645
+ }
646
+ program.parse();