@theatrical/sdk 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/index.mjs ADDED
@@ -0,0 +1,2080 @@
1
+ // src/utils/validate-config.ts
2
+ import { ZodError } from "zod";
3
+
4
+ // src/types/config.ts
5
+ import { z } from "zod";
6
+ var theatricalConfigSchema = z.object({
7
+ apiKey: z.string().min(1, "apiKey must not be empty").regex(/^[A-Za-z0-9_-]+$/, "apiKey must contain only alphanumeric characters, hyphens, and underscores"),
8
+ environment: z.enum(["sandbox", "staging", "production"]).optional().default("sandbox"),
9
+ baseUrl: z.string().url("baseUrl must be a valid URL").optional(),
10
+ timeout: z.number().int("timeout must be an integer").positive("timeout must be a positive number").max(12e4, "timeout must not exceed 120000ms").optional().default(3e4),
11
+ maxRetries: z.number().int("maxRetries must be an integer").min(0, "maxRetries must be 0 or greater").max(10, "maxRetries must not exceed 10").optional().default(3),
12
+ debug: z.boolean().optional().default(false)
13
+ });
14
+
15
+ // src/errors/codes.ts
16
+ var VISTA_ERROR_CODES = {
17
+ // Auth
18
+ AUTH_TOKEN_EXPIRED: "AUTH_TOKEN_EXPIRED",
19
+ AUTH_TOKEN_INVALID: "AUTH_TOKEN_INVALID",
20
+ AUTH_TOKEN_MISSING: "AUTH_TOKEN_MISSING",
21
+ AUTH_INSUFFICIENT_SCOPE: "AUTH_INSUFFICIENT_SCOPE",
22
+ AUTH_API_KEY_INVALID: "AUTH_API_KEY_INVALID",
23
+ // Rate limiting
24
+ RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
25
+ QUOTA_EXCEEDED: "QUOTA_EXCEEDED",
26
+ // Validation
27
+ VALIDATION_FAILED: "VALIDATION_FAILED",
28
+ INVALID_PARAMETER: "INVALID_PARAMETER",
29
+ MISSING_REQUIRED_FIELD: "MISSING_REQUIRED_FIELD",
30
+ INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT",
31
+ INVALID_CURRENCY: "INVALID_CURRENCY",
32
+ // Resources
33
+ SESSION_NOT_FOUND: "SESSION_NOT_FOUND",
34
+ SESSION_EXPIRED: "SESSION_EXPIRED",
35
+ SESSION_SOLD_OUT: "SESSION_SOLD_OUT",
36
+ SEAT_UNAVAILABLE: "SEAT_UNAVAILABLE",
37
+ FILM_NOT_FOUND: "FILM_NOT_FOUND",
38
+ SITE_NOT_FOUND: "SITE_NOT_FOUND",
39
+ ORDER_NOT_FOUND: "ORDER_NOT_FOUND",
40
+ ORDER_ALREADY_CONFIRMED: "ORDER_ALREADY_CONFIRMED",
41
+ ORDER_CANCELLATION_WINDOW_EXPIRED: "ORDER_CANCELLATION_WINDOW_EXPIRED",
42
+ MEMBER_NOT_FOUND: "MEMBER_NOT_FOUND",
43
+ // Server
44
+ INTERNAL_ERROR: "INTERNAL_ERROR",
45
+ SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
46
+ UPSTREAM_TIMEOUT: "UPSTREAM_TIMEOUT"
47
+ };
48
+ var VISTA_ERROR_MESSAGES = {
49
+ [VISTA_ERROR_CODES.AUTH_TOKEN_EXPIRED]: "Your access token has expired. Re-authenticate and retry.",
50
+ [VISTA_ERROR_CODES.AUTH_TOKEN_INVALID]: "The provided access token is invalid.",
51
+ [VISTA_ERROR_CODES.AUTH_TOKEN_MISSING]: "No access token was provided.",
52
+ [VISTA_ERROR_CODES.AUTH_INSUFFICIENT_SCOPE]: "The access token does not have the required permissions.",
53
+ [VISTA_ERROR_CODES.AUTH_API_KEY_INVALID]: "The API key is invalid or has been revoked.",
54
+ [VISTA_ERROR_CODES.RATE_LIMIT_EXCEEDED]: "Too many requests. Please slow down.",
55
+ [VISTA_ERROR_CODES.QUOTA_EXCEEDED]: "API quota exceeded for this billing period.",
56
+ [VISTA_ERROR_CODES.VALIDATION_FAILED]: "One or more fields failed validation.",
57
+ [VISTA_ERROR_CODES.INVALID_PARAMETER]: "A request parameter contains an invalid value.",
58
+ [VISTA_ERROR_CODES.MISSING_REQUIRED_FIELD]: "A required field is missing from the request.",
59
+ [VISTA_ERROR_CODES.INVALID_DATE_FORMAT]: "Date must be in ISO 8601 format (YYYY-MM-DD).",
60
+ [VISTA_ERROR_CODES.INVALID_CURRENCY]: "Currency code must be a valid ISO 4217 code (e.g. NZD, AUD).",
61
+ [VISTA_ERROR_CODES.SESSION_NOT_FOUND]: "The requested session does not exist.",
62
+ [VISTA_ERROR_CODES.SESSION_EXPIRED]: "This session has already passed.",
63
+ [VISTA_ERROR_CODES.SESSION_SOLD_OUT]: "This session is sold out.",
64
+ [VISTA_ERROR_CODES.SEAT_UNAVAILABLE]: "One or more selected seats are no longer available.",
65
+ [VISTA_ERROR_CODES.FILM_NOT_FOUND]: "The requested film does not exist.",
66
+ [VISTA_ERROR_CODES.SITE_NOT_FOUND]: "The requested cinema site does not exist.",
67
+ [VISTA_ERROR_CODES.ORDER_NOT_FOUND]: "The requested order does not exist.",
68
+ [VISTA_ERROR_CODES.ORDER_ALREADY_CONFIRMED]: "This order has already been confirmed.",
69
+ [VISTA_ERROR_CODES.ORDER_CANCELLATION_WINDOW_EXPIRED]: "The cancellation window for this order has passed.",
70
+ [VISTA_ERROR_CODES.MEMBER_NOT_FOUND]: "The requested member does not exist.",
71
+ [VISTA_ERROR_CODES.INTERNAL_ERROR]: "An internal server error occurred. Please try again.",
72
+ [VISTA_ERROR_CODES.SERVICE_UNAVAILABLE]: "The service is temporarily unavailable.",
73
+ [VISTA_ERROR_CODES.UPSTREAM_TIMEOUT]: "The upstream service timed out."
74
+ };
75
+ function resolveVistaMessage(code, fallback) {
76
+ if (!code) return fallback;
77
+ return VISTA_ERROR_MESSAGES[code] ?? fallback;
78
+ }
79
+
80
+ // src/errors/parser.ts
81
+ async function tryParseBody(response) {
82
+ try {
83
+ const text = await response.text();
84
+ if (!text) return null;
85
+ return JSON.parse(text);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+ function isVistaEnvelope(body) {
91
+ return typeof body === "object" && body !== null && ("code" in body || "message" in body || "errors" in body);
92
+ }
93
+ function isOcapiEnvelope(body) {
94
+ return typeof body === "object" && body !== null && "fault" in body;
95
+ }
96
+ function extractFieldErrors(errors) {
97
+ if (!errors || errors.length === 0) return {};
98
+ return Object.fromEntries(errors.map((e) => [e.field, e.message]));
99
+ }
100
+ function parseRetryAfter(response) {
101
+ const raw = response.headers.get("Retry-After");
102
+ if (!raw) return 60;
103
+ const seconds = parseInt(raw, 10);
104
+ if (!isNaN(seconds) && seconds >= 0) return seconds;
105
+ const date = new Date(raw);
106
+ if (!isNaN(date.getTime())) {
107
+ const delta = Math.ceil((date.getTime() - Date.now()) / 1e3);
108
+ return Math.max(0, delta);
109
+ }
110
+ return 60;
111
+ }
112
+ function extractResource(vistaCode, requestUrl) {
113
+ const CODE_TO_RESOURCE = {
114
+ SESSION_NOT_FOUND: "Session",
115
+ FILM_NOT_FOUND: "Film",
116
+ SITE_NOT_FOUND: "Site",
117
+ ORDER_NOT_FOUND: "Order",
118
+ MEMBER_NOT_FOUND: "Member"
119
+ };
120
+ const resource = (vistaCode && CODE_TO_RESOURCE[vistaCode]) ?? inferResourceFromUrl(requestUrl);
121
+ const id = requestUrl ? new URL(requestUrl, "https://api.vista.co").pathname.split("/").filter(Boolean).pop() ?? "unknown" : "unknown";
122
+ return { resource, resourceId: id };
123
+ }
124
+ function inferResourceFromUrl(url) {
125
+ if (!url) return "Resource";
126
+ const segments = url.split("/").filter(Boolean);
127
+ if (segments.length >= 2) {
128
+ const noun = segments[segments.length - 2];
129
+ return noun.charAt(0).toUpperCase() + noun.slice(1, -1);
130
+ }
131
+ return "Resource";
132
+ }
133
+ async function parseErrorResponse(response, requestUrl) {
134
+ const body = await tryParseBody(response);
135
+ const status = response.status;
136
+ let vistaCode;
137
+ let message;
138
+ let requestId;
139
+ let fieldErrors = {};
140
+ if (body && isVistaEnvelope(body)) {
141
+ vistaCode = body.code;
142
+ message = body.message;
143
+ requestId = body.requestId;
144
+ fieldErrors = extractFieldErrors(body.errors);
145
+ } else if (body && isOcapiEnvelope(body)) {
146
+ message = body.fault?.message;
147
+ vistaCode = body.fault?.type;
148
+ }
149
+ const resolvedMessage = resolveVistaMessage(vistaCode, message ?? `Request failed with status ${status}`);
150
+ if (status === 401 || status === 403) {
151
+ return new AuthenticationError(resolvedMessage, vistaCode, requestId);
152
+ }
153
+ if (status === 429) {
154
+ const retryAfter = parseRetryAfter(response);
155
+ return new RateLimitError(retryAfter, requestId);
156
+ }
157
+ if (status === 400 || status === 422) {
158
+ return new ValidationError(resolvedMessage, fieldErrors, requestId);
159
+ }
160
+ if (status === 404) {
161
+ const { resource, resourceId } = extractResource(vistaCode, requestUrl);
162
+ return new NotFoundError(resource, resourceId, requestId);
163
+ }
164
+ if (status >= 500) {
165
+ return new ServerError(resolvedMessage, requestId);
166
+ }
167
+ return new TheatricalError(resolvedMessage, status, vistaCode, requestId);
168
+ }
169
+
170
+ // src/errors/index.ts
171
+ var TheatricalError = class extends Error {
172
+ statusCode;
173
+ vistaErrorCode;
174
+ requestId;
175
+ constructor(message, statusCode, vistaErrorCode, requestId) {
176
+ super(message);
177
+ this.name = "TheatricalError";
178
+ this.statusCode = statusCode;
179
+ this.vistaErrorCode = vistaErrorCode;
180
+ this.requestId = requestId;
181
+ }
182
+ toJSON() {
183
+ return {
184
+ name: this.name,
185
+ message: this.message,
186
+ statusCode: this.statusCode,
187
+ vistaErrorCode: this.vistaErrorCode,
188
+ requestId: this.requestId
189
+ };
190
+ }
191
+ };
192
+ var AuthenticationError = class extends TheatricalError {
193
+ constructor(message = "Authentication failed", vistaErrorCode, requestId) {
194
+ super(message, 401, vistaErrorCode, requestId);
195
+ this.name = "AuthenticationError";
196
+ }
197
+ };
198
+ var RateLimitError = class extends TheatricalError {
199
+ retryAfter;
200
+ constructor(retryAfter, requestId) {
201
+ super(`Rate limit exceeded. Retry after ${retryAfter}s`, 429, void 0, requestId);
202
+ this.name = "RateLimitError";
203
+ this.retryAfter = retryAfter;
204
+ }
205
+ };
206
+ var ValidationError = class extends TheatricalError {
207
+ fields;
208
+ constructor(message, fields = {}, requestId) {
209
+ super(message, 400, void 0, requestId);
210
+ this.name = "ValidationError";
211
+ this.fields = fields;
212
+ }
213
+ };
214
+ var NotFoundError = class extends TheatricalError {
215
+ resource;
216
+ resourceId;
217
+ constructor(resource, resourceId, requestId) {
218
+ super(`${resource} '${resourceId}' not found`, 404, void 0, requestId);
219
+ this.name = "NotFoundError";
220
+ this.resource = resource;
221
+ this.resourceId = resourceId;
222
+ }
223
+ };
224
+ var ServerError = class extends TheatricalError {
225
+ constructor(message = "Internal server error", requestId) {
226
+ super(message, 500, void 0, requestId);
227
+ this.name = "ServerError";
228
+ }
229
+ };
230
+
231
+ // src/utils/validate-config.ts
232
+ function validateConfig(config) {
233
+ try {
234
+ return theatricalConfigSchema.parse(config);
235
+ } catch (err) {
236
+ if (err instanceof ZodError) {
237
+ const fields = {};
238
+ for (const issue of err.issues) {
239
+ const path = issue.path.join(".");
240
+ fields[path || "unknown"] = issue.message;
241
+ }
242
+ const firstField = Object.keys(fields)[0] ?? "config";
243
+ const firstMessage = fields[firstField] ?? "Invalid configuration";
244
+ throw new ValidationError(
245
+ `Invalid TheatricalConfig: ${firstField} \u2014 ${firstMessage}`,
246
+ fields
247
+ );
248
+ }
249
+ throw err;
250
+ }
251
+ }
252
+
253
+ // src/types/session.ts
254
+ import { z as z2 } from "zod";
255
+ var sessionFormatSchema = z2.enum([
256
+ "2D",
257
+ "3D",
258
+ "IMAX",
259
+ "IMAX3D",
260
+ "4DX",
261
+ "DOLBY_CINEMA",
262
+ "SCREENX",
263
+ "STANDARD"
264
+ ]);
265
+ var seatStatusSchema = z2.enum([
266
+ "available",
267
+ "taken",
268
+ "reserved",
269
+ "wheelchair",
270
+ "companion",
271
+ "blocked"
272
+ ]);
273
+ var sessionSchema = z2.object({
274
+ id: z2.string(),
275
+ filmId: z2.string(),
276
+ filmTitle: z2.string(),
277
+ siteId: z2.string(),
278
+ screenId: z2.string(),
279
+ screenName: z2.string(),
280
+ startTime: z2.string(),
281
+ endTime: z2.string(),
282
+ format: sessionFormatSchema,
283
+ isBookable: z2.boolean(),
284
+ isSoldOut: z2.boolean(),
285
+ seatsAvailable: z2.number().int().nonnegative(),
286
+ seatsTotal: z2.number().int().positive(),
287
+ priceFrom: z2.number().nonnegative().optional(),
288
+ currency: z2.string().length(3).optional(),
289
+ attributes: z2.record(z2.string(), z2.string())
290
+ });
291
+ var sessionFilterSchema = z2.object({
292
+ siteId: z2.string().optional(),
293
+ filmId: z2.string().optional(),
294
+ date: z2.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
295
+ dateFrom: z2.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
296
+ dateTo: z2.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
297
+ format: sessionFormatSchema.optional(),
298
+ bookableOnly: z2.boolean().optional(),
299
+ limit: z2.number().int().positive().max(500).optional(),
300
+ offset: z2.number().int().nonnegative().optional(),
301
+ cursor: z2.string().optional()
302
+ });
303
+ var sessionListResponseSchema = z2.object({
304
+ sessions: z2.array(sessionSchema),
305
+ total: z2.number().int().nonnegative(),
306
+ hasMore: z2.boolean(),
307
+ nextOffset: z2.number().int().nonnegative().optional(),
308
+ nextCursor: z2.string().optional()
309
+ });
310
+ var seatSchema = z2.object({
311
+ id: z2.string(),
312
+ row: z2.string(),
313
+ number: z2.number().int().positive(),
314
+ status: seatStatusSchema,
315
+ x: z2.number(),
316
+ y: z2.number(),
317
+ type: z2.string().optional(),
318
+ isAccessible: z2.boolean()
319
+ });
320
+ var seatAvailabilitySchema = z2.object({
321
+ sessionId: z2.string(),
322
+ screenName: z2.string(),
323
+ seats: z2.array(seatSchema),
324
+ rowCount: z2.number().int().positive(),
325
+ screenPosition: z2.enum(["top", "bottom"]),
326
+ availableCount: z2.number().int().nonnegative(),
327
+ totalCount: z2.number().int().positive()
328
+ });
329
+
330
+ // src/resources/sessions.ts
331
+ var DEFAULT_PAGE_SIZE = 50;
332
+ var SessionsResource = class {
333
+ constructor(http) {
334
+ this.http = http;
335
+ }
336
+ http;
337
+ /**
338
+ * List sessions (showtimes) with optional filters.
339
+ * Response is validated at runtime using Zod — malformed API responses throw a parse error.
340
+ * @throws {TheatricalError} When the API returns an error response
341
+ * @throws {z.ZodError} When the response fails schema validation
342
+ * @see https://developer.vista.co/digital-platform/sessions/
343
+ */
344
+ async list(filters) {
345
+ const raw = await this.http.get("/ocapi/v1/sessions", {
346
+ params: filters
347
+ });
348
+ return sessionListResponseSchema.parse(raw);
349
+ }
350
+ /**
351
+ * List sessions with explicit pagination control.
352
+ *
353
+ * Returns a normalised `PaginatedResponse<Session>` envelope that includes
354
+ * the pagination strategy and cursor/offset for the next page.
355
+ *
356
+ * @param filters - Session filters (site, film, date range, etc.)
357
+ * @param pagination - Page size and cursor/offset position
358
+ * @returns A single page of sessions with pagination metadata
359
+ *
360
+ * @example
361
+ * ```typescript
362
+ * const page = await client.sessions.listPaginated(
363
+ * { siteId: 'roxy-wellington' },
364
+ * { limit: 25 },
365
+ * );
366
+ * console.log(page.data.length, page.hasMore);
367
+ * ```
368
+ */
369
+ async listPaginated(filters, pagination) {
370
+ const limit = pagination?.limit ?? DEFAULT_PAGE_SIZE;
371
+ const useCursor = pagination?.cursor !== void 0;
372
+ const response = await this.list({
373
+ ...filters,
374
+ limit,
375
+ ...useCursor ? { cursor: pagination.cursor } : { offset: pagination?.offset ?? 0 }
376
+ });
377
+ return {
378
+ data: response.sessions,
379
+ total: response.total,
380
+ hasMore: response.hasMore,
381
+ nextCursor: response.nextCursor,
382
+ nextOffset: response.nextOffset,
383
+ strategy: useCursor ? "cursor" : "offset"
384
+ };
385
+ }
386
+ /**
387
+ * Async generator that automatically paginates through all matching sessions.
388
+ *
389
+ * Yields individual `Session` objects, fetching the next page transparently
390
+ * when the current page is exhausted. Uses offset-based pagination internally
391
+ * since Vista's session endpoints support numeric offsets.
392
+ *
393
+ * @param filters - Session filters (site, film, date range, etc.)
394
+ * @param pageSize - Number of sessions to fetch per page (default: 50, max: 500)
395
+ *
396
+ * @example
397
+ * ```typescript
398
+ * const allSessions: Session[] = [];
399
+ * for await (const session of client.sessions.listAll({ siteId: 'roxy-wellington' })) {
400
+ * allSessions.push(session);
401
+ * }
402
+ * ```
403
+ *
404
+ * @example Collect with early termination
405
+ * ```typescript
406
+ * for await (const session of client.sessions.listAll({ date: '2026-04-10' })) {
407
+ * if (session.isSoldOut) continue;
408
+ * console.log(`${session.filmTitle} — ${session.screenName} @ ${session.startTime}`);
409
+ * if (!session.isBookable) break; // stop on first unbookable
410
+ * }
411
+ * ```
412
+ */
413
+ async *listAll(filters, pageSize = DEFAULT_PAGE_SIZE) {
414
+ let offset = 0;
415
+ let hasMore = true;
416
+ while (hasMore) {
417
+ const response = await this.list({
418
+ ...filters,
419
+ limit: pageSize,
420
+ offset
421
+ });
422
+ for (const session of response.sessions) {
423
+ yield session;
424
+ }
425
+ hasMore = response.hasMore;
426
+ offset = response.nextOffset ?? offset + response.sessions.length;
427
+ }
428
+ }
429
+ /**
430
+ * Get a single session by ID.
431
+ * Response is validated at runtime using Zod.
432
+ */
433
+ async get(sessionId) {
434
+ const raw = await this.http.get(`/ocapi/v1/sessions/${sessionId}`);
435
+ return sessionSchema.parse(raw);
436
+ }
437
+ /**
438
+ * Get seat availability for a session.
439
+ * Returns the complete auditorium seat map with status for each seat.
440
+ * Response is validated at runtime using Zod.
441
+ */
442
+ async availability(sessionId) {
443
+ const raw = await this.http.get(`/ocapi/v1/sessions/${sessionId}/seat-plan`);
444
+ return seatAvailabilitySchema.parse(raw);
445
+ }
446
+ };
447
+
448
+ // src/types/site.ts
449
+ import { z as z3 } from "zod";
450
+ var geoLocationSchema = z3.object({
451
+ latitude: z3.number().min(-90).max(90),
452
+ longitude: z3.number().min(-180).max(180)
453
+ });
454
+ var addressSchema = z3.object({
455
+ line1: z3.string(),
456
+ line2: z3.string().optional(),
457
+ city: z3.string(),
458
+ state: z3.string().optional(),
459
+ postalCode: z3.string(),
460
+ country: z3.string().length(2)
461
+ });
462
+ var screenSchema = z3.object({
463
+ id: z3.string(),
464
+ name: z3.string(),
465
+ seatCount: z3.number().int().positive(),
466
+ formats: z3.array(z3.string()).min(1),
467
+ isAccessible: z3.boolean()
468
+ });
469
+ var amenitySchema = z3.object({
470
+ /** Amenity identifier (e.g. 'parking', 'bar', 'vip_lounge', 'imax') */
471
+ id: z3.string(),
472
+ /** Human-readable label */
473
+ label: z3.string(),
474
+ /** Optional icon key for rendering */
475
+ icon: z3.string().optional()
476
+ });
477
+ var siteConfigSchema = z3.object({
478
+ bookingLeadTime: z3.number().int().nonnegative(),
479
+ maxTicketsPerOrder: z3.number().int().positive(),
480
+ loyaltyEnabled: z3.boolean(),
481
+ fnbEnabled: z3.boolean()
482
+ });
483
+ var siteSchema = z3.object({
484
+ id: z3.string(),
485
+ name: z3.string(),
486
+ address: addressSchema,
487
+ location: geoLocationSchema,
488
+ screens: z3.array(screenSchema),
489
+ config: siteConfigSchema,
490
+ timezone: z3.string(),
491
+ currency: z3.string().length(3),
492
+ isActive: z3.boolean(),
493
+ amenities: z3.array(amenitySchema).optional()
494
+ });
495
+ var siteListResponseSchema = z3.array(siteSchema);
496
+
497
+ // src/resources/sites.ts
498
+ var SitesResource = class {
499
+ constructor(http) {
500
+ this.http = http;
501
+ }
502
+ http;
503
+ /**
504
+ * List all cinema sites, with optional search query or geographic filter.
505
+ * Response is validated at runtime using Zod.
506
+ *
507
+ * @param filters - Optional filters: `query` (text search), `latitude`/`longitude`/`radius`
508
+ * @returns Array of cinema sites
509
+ * @throws {TheatricalError} When the API returns an error response
510
+ * @throws {z.ZodError} When the response fails schema validation
511
+ *
512
+ * @example
513
+ * ```typescript
514
+ * const sites = await client.sites.list({ query: 'Embassy' });
515
+ * ```
516
+ */
517
+ async list(filters) {
518
+ const params = {};
519
+ if (filters?.query) params.query = filters.query;
520
+ if (filters?.latitude !== void 0) params.latitude = filters.latitude;
521
+ if (filters?.longitude !== void 0) params.longitude = filters.longitude;
522
+ if (filters?.radius !== void 0) params.radius = filters.radius;
523
+ const raw = await this.http.get("/ocapi/v1/sites", { params });
524
+ return siteListResponseSchema.parse(raw);
525
+ }
526
+ /**
527
+ * Get a single cinema site by ID.
528
+ * Response is validated at runtime using Zod.
529
+ *
530
+ * @param siteId - Vista site identifier
531
+ * @returns The site record
532
+ *
533
+ * @example
534
+ * ```typescript
535
+ * const roxy = await client.sites.get('site_roxy_wellington');
536
+ * console.log(roxy.screens.length); // number of auditoriums
537
+ * ```
538
+ */
539
+ async get(siteId) {
540
+ const raw = await this.http.get(`/ocapi/v1/sites/${siteId}`);
541
+ return siteSchema.parse(raw);
542
+ }
543
+ /**
544
+ * Get the screen (auditorium) configurations for a site.
545
+ * Returns each screen's capacity, supported formats, and accessibility status.
546
+ * Response is validated at runtime using Zod.
547
+ *
548
+ * @param siteId - Vista site identifier
549
+ * @returns Array of screen configurations
550
+ *
551
+ * @example
552
+ * ```typescript
553
+ * const screens = await client.sites.screens('site_embassy_wellington');
554
+ * const imax = screens.find(s => s.formats.includes('IMAX'));
555
+ * ```
556
+ */
557
+ async screens(siteId) {
558
+ const raw = await this.http.get(`/ocapi/v1/sites/${siteId}/screens`);
559
+ return screenSchema.array().parse(raw);
560
+ }
561
+ /**
562
+ * Find cinema sites within a geographic radius.
563
+ *
564
+ * Returns sites sorted by distance from the given coordinates.
565
+ * Uses Vista's OCAPI geographic search which accepts decimal lat/lng
566
+ * and a radius in kilometres.
567
+ *
568
+ * @param latitude - Decimal latitude of the search centre (−90 to 90)
569
+ * @param longitude - Decimal longitude of the search centre (−180 to 180)
570
+ * @param radiusKm - Search radius in kilometres
571
+ * @returns Sites within the radius, sorted nearest-first
572
+ *
573
+ * @example
574
+ * ```typescript
575
+ * // Find cinemas within 5 km of Roxy Cinema Wellington
576
+ * const nearby = await client.sites.nearby(-41.3007, 174.7766, 5);
577
+ * console.log(nearby.map(s => s.name));
578
+ * ```
579
+ *
580
+ * @example Discover cinemas near central Auckland
581
+ * ```typescript
582
+ * const auckland = await client.sites.nearby(-36.8509, 174.7645, 10);
583
+ * ```
584
+ */
585
+ async nearby(latitude, longitude, radiusKm) {
586
+ const raw = await this.http.get("/ocapi/v1/sites", {
587
+ params: { latitude, longitude, radius: radiusKm }
588
+ });
589
+ return siteListResponseSchema.parse(raw);
590
+ }
591
+ };
592
+
593
+ // src/types/film.ts
594
+ import { z as z4 } from "zod";
595
+ var GENRES = [
596
+ "action",
597
+ "adventure",
598
+ "animation",
599
+ "comedy",
600
+ "crime",
601
+ "documentary",
602
+ "drama",
603
+ "family",
604
+ "fantasy",
605
+ "horror",
606
+ "musical",
607
+ "mystery",
608
+ "romance",
609
+ "sci-fi",
610
+ "thriller",
611
+ "war",
612
+ "western"
613
+ ];
614
+ var FILM_FORMATS = ["2D", "3D", "IMAX", "IMAX 3D", "4DX", "Dolby Atmos", "ScreenX"];
615
+ var FILM_LANGUAGES = ["en", "es", "fr", "de", "ja", "ko", "zh", "hi", "te", "mi"];
616
+ var genreSchema = z4.enum(GENRES);
617
+ var filmFormatSchema = z4.enum(FILM_FORMATS);
618
+ var filmLanguageSchema = z4.enum(FILM_LANGUAGES);
619
+ var ratingSchema = z4.object({
620
+ classification: z4.string(),
621
+ description: z4.string().optional()
622
+ });
623
+ var castMemberSchema = z4.object({
624
+ name: z4.string(),
625
+ role: z4.string().optional()
626
+ });
627
+ var crewMemberSchema = z4.object({
628
+ name: z4.string(),
629
+ department: z4.string(),
630
+ job: z4.string()
631
+ });
632
+ var filmRatingSchema = z4.object({
633
+ source: z4.string(),
634
+ score: z4.string(),
635
+ outOf: z4.string().optional()
636
+ });
637
+ var filmSchema = z4.object({
638
+ id: z4.string(),
639
+ title: z4.string(),
640
+ synopsis: z4.string(),
641
+ genres: z4.array(genreSchema),
642
+ runtime: z4.number().nonnegative(),
643
+ rating: ratingSchema,
644
+ releaseDate: z4.string(),
645
+ posterUrl: z4.string().optional(),
646
+ trailerUrl: z4.string().optional(),
647
+ cast: z4.array(castMemberSchema),
648
+ director: z4.string(),
649
+ distributor: z4.string().optional(),
650
+ isNowShowing: z4.boolean(),
651
+ isComingSoon: z4.boolean()
652
+ });
653
+ var filmDetailSchema = filmSchema.extend({
654
+ crew: z4.array(crewMemberSchema),
655
+ ratings: z4.array(filmRatingSchema),
656
+ formats: z4.array(filmFormatSchema),
657
+ languages: z4.array(filmLanguageSchema),
658
+ originalTitle: z4.string().optional(),
659
+ productionCountries: z4.array(z4.string()).optional(),
660
+ budget: z4.number().optional(),
661
+ boxOffice: z4.number().optional(),
662
+ website: z4.string().optional()
663
+ });
664
+ var filmSearchFilterSchema = z4.object({
665
+ siteId: z4.string().optional(),
666
+ genre: genreSchema.optional(),
667
+ query: z4.string().optional(),
668
+ nowShowing: z4.boolean().optional(),
669
+ comingSoon: z4.boolean().optional(),
670
+ limit: z4.number().int().positive().optional(),
671
+ offset: z4.number().int().nonnegative().optional(),
672
+ ratingClassification: z4.string().optional(),
673
+ format: filmFormatSchema.optional(),
674
+ language: filmLanguageSchema.optional(),
675
+ releaseDateFrom: z4.string().optional(),
676
+ releaseDateTo: z4.string().optional(),
677
+ minRuntime: z4.number().nonnegative().optional(),
678
+ maxRuntime: z4.number().nonnegative().optional(),
679
+ sortBy: z4.enum(["title", "releaseDate", "runtime", "popularity"]).optional(),
680
+ sortOrder: z4.enum(["asc", "desc"]).optional()
681
+ });
682
+
683
+ // src/resources/films.ts
684
+ import { z as z5 } from "zod";
685
+ var FilmsResource = class {
686
+ constructor(http) {
687
+ this.http = http;
688
+ }
689
+ http;
690
+ /**
691
+ * Parse and validate a raw film response from the API.
692
+ * Throws a ZodError if the response doesn't match the expected shape.
693
+ */
694
+ parseFilm(data) {
695
+ return filmSchema.parse(data);
696
+ }
697
+ /**
698
+ * Parse and validate an array of films from the API.
699
+ */
700
+ parseFilms(data) {
701
+ return z5.array(filmSchema).parse(data);
702
+ }
703
+ /**
704
+ * Parse and validate a full film detail response.
705
+ */
706
+ parseFilmDetail(data) {
707
+ return filmDetailSchema.parse(data);
708
+ }
709
+ /**
710
+ * List films currently showing at a given site (or across all sites).
711
+ *
712
+ * @param filters - Optional site filter
713
+ * @returns Array of films currently in cinemas
714
+ */
715
+ async nowShowing(filters) {
716
+ const params = filters?.siteId ? { siteId: filters.siteId } : void 0;
717
+ const data = await this.http.get("/ocapi/v1/films/now-showing", { params });
718
+ return this.parseFilms(data);
719
+ }
720
+ /**
721
+ * List films coming soon — announced but not yet in cinemas.
722
+ *
723
+ * @param filters - Optional site filter
724
+ * @returns Array of upcoming films
725
+ */
726
+ async comingSoon(filters) {
727
+ const params = filters?.siteId ? { siteId: filters.siteId } : void 0;
728
+ const data = await this.http.get("/ocapi/v1/films/coming-soon", { params });
729
+ return this.parseFilms(data);
730
+ }
731
+ /**
732
+ * Retrieve a film by its unique identifier.
733
+ *
734
+ * @param filmId - The UUID of the film
735
+ * @returns The base film record
736
+ */
737
+ async get(filmId) {
738
+ const data = await this.http.get(`/ocapi/v1/films/${filmId}`);
739
+ return this.parseFilm(data);
740
+ }
741
+ /**
742
+ * Retrieve full film detail including cast, crew, ratings, formats, and languages.
743
+ *
744
+ * This returns the extended `FilmDetail` type with nested crew arrays,
745
+ * multiple rating sources (IMDB, Rotten Tomatoes, Metacritic), available
746
+ * screening formats, and language options.
747
+ *
748
+ * @param filmId - The UUID of the film
749
+ * @returns Full film detail with cast, crew, and ratings
750
+ */
751
+ async getDetail(filmId) {
752
+ const data = await this.http.get(`/ocapi/v1/films/${filmId}/detail`);
753
+ return this.parseFilmDetail(data);
754
+ }
755
+ /**
756
+ * Search films with basic filters.
757
+ *
758
+ * @throws {TheatricalError} When the API returns an error response
759
+ * @throws {z.ZodError} When the response fails schema validation
760
+ * @param filters - Genre, query string, site, and showing status filters
761
+ * @returns Array of matching films
762
+ */
763
+ async search(filters) {
764
+ const params = {};
765
+ if (filters.siteId) params.siteId = filters.siteId;
766
+ if (filters.genre) params.genre = filters.genre;
767
+ if (filters.query) params.query = filters.query;
768
+ if (filters.nowShowing !== void 0) params.nowShowing = filters.nowShowing;
769
+ if (filters.comingSoon !== void 0) params.comingSoon = filters.comingSoon;
770
+ const data = await this.http.get("/ocapi/v1/films", { params });
771
+ return this.parseFilms(data);
772
+ }
773
+ /**
774
+ * Advanced film search with extended filters.
775
+ *
776
+ * Supports filtering by format (IMAX, 3D, Dolby Atmos), language,
777
+ * rating classification, runtime range, release date range, and sorting.
778
+ *
779
+ * @param filters - Extended search filters including format, language, runtime range, sorting
780
+ * @returns Array of matching films
781
+ */
782
+ async advancedSearch(filters) {
783
+ const params = {};
784
+ if (filters.siteId) params.siteId = filters.siteId;
785
+ if (filters.genre) params.genre = filters.genre;
786
+ if (filters.query) params.query = filters.query;
787
+ if (filters.nowShowing !== void 0) params.nowShowing = String(filters.nowShowing);
788
+ if (filters.comingSoon !== void 0) params.comingSoon = String(filters.comingSoon);
789
+ if (filters.limit !== void 0) params.limit = String(filters.limit);
790
+ if (filters.offset !== void 0) params.offset = String(filters.offset);
791
+ if (filters.ratingClassification) params.ratingClassification = filters.ratingClassification;
792
+ if (filters.format) params.format = filters.format;
793
+ if (filters.language) params.language = filters.language;
794
+ if (filters.releaseDateFrom) params.releaseDateFrom = filters.releaseDateFrom;
795
+ if (filters.releaseDateTo) params.releaseDateTo = filters.releaseDateTo;
796
+ if (filters.minRuntime !== void 0) params.minRuntime = String(filters.minRuntime);
797
+ if (filters.maxRuntime !== void 0) params.maxRuntime = String(filters.maxRuntime);
798
+ if (filters.sortBy) params.sortBy = filters.sortBy;
799
+ if (filters.sortOrder) params.sortOrder = filters.sortOrder;
800
+ const data = await this.http.get("/ocapi/v1/films/search", { params });
801
+ return this.parseFilms(data);
802
+ }
803
+ };
804
+
805
+ // src/types/order.ts
806
+ import { z as z6 } from "zod";
807
+ var orderStatusSchema = z6.enum([
808
+ "draft",
809
+ "pending",
810
+ "held",
811
+ "confirmed",
812
+ "completed",
813
+ "cancelled",
814
+ "refunded"
815
+ ]);
816
+ var ticketSchema = z6.object({
817
+ id: z6.string(),
818
+ type: z6.string(),
819
+ seatId: z6.string(),
820
+ seatLabel: z6.string(),
821
+ price: z6.number().nonnegative(),
822
+ discount: z6.number().nonnegative().optional()
823
+ });
824
+ var orderItemSchema = z6.object({
825
+ id: z6.string(),
826
+ name: z6.string(),
827
+ category: z6.string(),
828
+ quantity: z6.number().int().positive(),
829
+ unitPrice: z6.number().nonnegative(),
830
+ totalPrice: z6.number().nonnegative()
831
+ });
832
+ var orderSchema = z6.object({
833
+ id: z6.string(),
834
+ sessionId: z6.string(),
835
+ status: orderStatusSchema,
836
+ tickets: z6.array(ticketSchema),
837
+ items: z6.array(orderItemSchema),
838
+ subtotal: z6.number().nonnegative(),
839
+ tax: z6.number().nonnegative(),
840
+ discount: z6.number().nonnegative(),
841
+ total: z6.number().nonnegative(),
842
+ currency: z6.string().length(3),
843
+ loyaltyMemberId: z6.string().optional(),
844
+ loyaltyPointsEarned: z6.number().int().nonnegative().optional(),
845
+ loyaltyPointsRedeemed: z6.number().int().nonnegative().optional(),
846
+ createdAt: z6.string(),
847
+ updatedAt: z6.string().optional(),
848
+ heldAt: z6.string().optional(),
849
+ heldUntil: z6.string().optional(),
850
+ confirmedAt: z6.string().optional(),
851
+ completedAt: z6.string().optional(),
852
+ cancelledAt: z6.string().optional(),
853
+ refundedAt: z6.string().optional()
854
+ });
855
+ var orderHistoryFilterSchema = z6.object({
856
+ status: orderStatusSchema.optional(),
857
+ since: z6.string().optional(),
858
+ until: z6.string().optional(),
859
+ limit: z6.number().int().positive().max(100).optional(),
860
+ cursor: z6.string().optional()
861
+ });
862
+
863
+ // src/types/pagination.ts
864
+ import { z as z7 } from "zod";
865
+ var paginationParamsSchema = z7.object({
866
+ limit: z7.number().int().positive().max(500).optional(),
867
+ cursor: z7.string().optional(),
868
+ offset: z7.number().int().nonnegative().optional()
869
+ }).refine(
870
+ (data) => !(data.cursor && data.offset !== void 0),
871
+ { message: "Cannot specify both cursor and offset \u2014 choose one pagination strategy" }
872
+ );
873
+ var paginatedResponseSchema = z7.object({
874
+ data: z7.array(z7.unknown()),
875
+ total: z7.number().int().nonnegative(),
876
+ hasMore: z7.boolean(),
877
+ nextCursor: z7.string().optional(),
878
+ nextOffset: z7.number().int().nonnegative().optional(),
879
+ strategy: z7.enum(["cursor", "offset"])
880
+ });
881
+
882
+ // src/resources/orders.ts
883
+ import { z as z8 } from "zod";
884
+ var OrdersResource = class {
885
+ constructor(http) {
886
+ this.http = http;
887
+ }
888
+ http;
889
+ /**
890
+ * Parse and validate a raw order response from the API.
891
+ * Throws a ZodError if the response doesn't match the expected shape.
892
+ */
893
+ parseOrder(data) {
894
+ return orderSchema.parse(data);
895
+ }
896
+ /**
897
+ * Parse and validate a paginated order response from the API.
898
+ */
899
+ parsePaginatedOrders(data) {
900
+ const envelopeSchema = paginatedResponseSchema.extend({
901
+ data: z8.array(orderSchema)
902
+ });
903
+ const parsed = envelopeSchema.parse(data);
904
+ return parsed;
905
+ }
906
+ /**
907
+ * Create a new draft order for a session.
908
+ *
909
+ * @throws {TheatricalError} When the API returns an error response
910
+ * @throws {z.ZodError} When the response fails schema validation
911
+ * @param input - Session ID, initial tickets, optional F * @param input - Session ID, initial tickets, optional F&B items, optional loyalty memberB items, optional loyalty member
912
+ * @returns The newly created order in 'draft' status
913
+ */
914
+ async create(input) {
915
+ const data = await this.http.post("/ocapi/v1/orders", { body: input });
916
+ return this.parseOrder(data);
917
+ }
918
+ /**
919
+ * Retrieve an order by its unique identifier.
920
+ *
921
+ * @param orderId - The UUID of the order to retrieve
922
+ */
923
+ async get(orderId) {
924
+ const data = await this.http.get(`/ocapi/v1/orders/${orderId}`);
925
+ return this.parseOrder(data);
926
+ }
927
+ /**
928
+ * Set tickets on a draft order. Each ticket specifies a seat and ticket type.
929
+ * The Vista OCAPI replaces all existing tickets — pass the full desired set in one call.
930
+ *
931
+ * @param orderId - The UUID of the draft order
932
+ * @param input - Tickets to set (replaces any previously assigned tickets)
933
+ * @returns The updated order with the new ticket set
934
+ */
935
+ async addTickets(orderId, input) {
936
+ const data = await this.http.post(`/ocapi/v1/orders/${orderId}/tickets`, { body: input });
937
+ return this.parseOrder(data);
938
+ }
939
+ /**
940
+ * Add concession or merchandise items to an order.
941
+ * F&B items can be added to orders in 'draft' or 'pending' status.
942
+ *
943
+ * @param orderId - The UUID of the order
944
+ * @param input - Items to add (menuItemId + quantity pairs)
945
+ * @returns The updated order with new items included
946
+ */
947
+ async addItems(orderId, input) {
948
+ const data = await this.http.post(`/ocapi/v1/orders/${orderId}/items`, { body: input });
949
+ return this.parseOrder(data);
950
+ }
951
+ /**
952
+ * Confirm an order, transitioning it from 'pending' to 'confirmed'.
953
+ * This locks in the seat selection and pricing.
954
+ *
955
+ * @param orderId - The UUID of the order to confirm
956
+ */
957
+ async confirm(orderId) {
958
+ const data = await this.http.post(`/ocapi/v1/orders/${orderId}/confirm`);
959
+ return this.parseOrder(data);
960
+ }
961
+ /**
962
+ * Cancel an order. Can be applied to 'pending' or 'confirmed' orders.
963
+ * Releases any held seats back to the pool.
964
+ *
965
+ * @param orderId - The UUID of the order to cancel
966
+ */
967
+ async cancel(orderId) {
968
+ const data = await this.http.post(`/ocapi/v1/orders/${orderId}/cancel`);
969
+ return this.parseOrder(data);
970
+ }
971
+ /**
972
+ * Apply a loyalty membership discount to an order.
973
+ * Links the member to the order and optionally redeems loyalty points
974
+ * for a discount on the total.
975
+ *
976
+ * @param orderId - The UUID of the order
977
+ * @param input - Loyalty member ID and optional points to redeem
978
+ * @returns The updated order with loyalty discount applied
979
+ */
980
+ async applyLoyalty(orderId, input) {
981
+ const data = await this.http.post(`/ocapi/v1/orders/${orderId}/loyalty`, { body: input });
982
+ return this.parseOrder(data);
983
+ }
984
+ /**
985
+ * Request a refund for a confirmed or completed order.
986
+ * Triggers the refund workflow in Vista's payment system.
987
+ *
988
+ * @param orderId - The UUID of the order to refund
989
+ */
990
+ async refund(orderId) {
991
+ const data = await this.http.post(`/ocapi/v1/orders/${orderId}/refund`);
992
+ return this.parseOrder(data);
993
+ }
994
+ /**
995
+ * Mark a confirmed order as completed (e.g., after the screening).
996
+ *
997
+ * @param orderId - The UUID of the confirmed order
998
+ */
999
+ async complete(orderId) {
1000
+ const data = await this.http.post(`/ocapi/v1/orders/${orderId}/complete`);
1001
+ return this.parseOrder(data);
1002
+ }
1003
+ /**
1004
+ * Retrieve order history for a loyalty member.
1005
+ * Returns a paginated list of orders, newest first.
1006
+ *
1007
+ * @param memberId - The loyalty member's unique identifier
1008
+ * @param filter - Optional filters for status, date range, and pagination
1009
+ */
1010
+ async history(memberId, filter) {
1011
+ const params = {};
1012
+ if (filter?.status) params.status = filter.status;
1013
+ if (filter?.since) params.since = filter.since;
1014
+ if (filter?.until) params.until = filter.until;
1015
+ if (filter?.limit) params.limit = String(filter.limit);
1016
+ if (filter?.cursor) params.cursor = filter.cursor;
1017
+ const data = await this.http.get(`/ocapi/v1/members/${memberId}/orders`, { params });
1018
+ return this.parsePaginatedOrders(data);
1019
+ }
1020
+ };
1021
+
1022
+ // src/resources/loyalty.ts
1023
+ var LoyaltyResource = class {
1024
+ constructor(http) {
1025
+ this.http = http;
1026
+ }
1027
+ http;
1028
+ /**
1029
+ * Fetch a loyalty member by their unique ID.
1030
+ *
1031
+ * @param memberId - Vista loyalty member identifier
1032
+ * @throws {TheatricalError} When the API returns an error response
1033
+ */
1034
+ async getMember(memberId) {
1035
+ return this.http.get(`/ocapi/v1/loyalty/members/${memberId}`);
1036
+ }
1037
+ /**
1038
+ * Authenticate a member using their email address and password (cookie-based
1039
+ * session). Returns the authenticated member record on success.
1040
+ *
1041
+ * @param email - Member's registered email
1042
+ * @param password - Account password (transmitted over TLS)
1043
+ */
1044
+ async authenticate(email, password) {
1045
+ return this.http.post("/ocapi/v1/loyalty/authenticate", {
1046
+ body: { email, password }
1047
+ });
1048
+ }
1049
+ /**
1050
+ * Return the current redeemable points balance for a member.
1051
+ *
1052
+ * @param memberId - Vista loyalty member identifier
1053
+ */
1054
+ async getPointsBalance(memberId) {
1055
+ return this.http.get(
1056
+ `/ocapi/v1/loyalty/members/${memberId}/points`
1057
+ );
1058
+ }
1059
+ /**
1060
+ * Retrieve a paginated transaction history for a member.
1061
+ *
1062
+ * @param memberId - Vista loyalty member identifier
1063
+ * @param filter - Optional date range, type, and pagination filters
1064
+ */
1065
+ async getHistory(memberId, filter) {
1066
+ return this.http.get(
1067
+ `/ocapi/v1/loyalty/members/${memberId}/history`,
1068
+ { params: filter }
1069
+ );
1070
+ }
1071
+ /**
1072
+ * List available redemption options for the loyalty program.
1073
+ * Options are filtered to those the member is eligible for based on their
1074
+ * tier and current points balance.
1075
+ *
1076
+ * @param memberId - Vista loyalty member identifier
1077
+ */
1078
+ async listRedemptionOptions(memberId) {
1079
+ return this.http.get(
1080
+ `/ocapi/v1/loyalty/members/${memberId}/redemptions`
1081
+ );
1082
+ }
1083
+ /**
1084
+ * Redeem points against a catalog option for a member.
1085
+ * Deducts points from the member's balance and applies the benefit.
1086
+ *
1087
+ * @param memberId - Vista loyalty member identifier
1088
+ * @param input - Redemption option, optional order linkage, and quantity
1089
+ */
1090
+ async redeemPoints(memberId, input) {
1091
+ return this.http.post(
1092
+ `/ocapi/v1/loyalty/members/${memberId}/redeem`,
1093
+ { body: input }
1094
+ );
1095
+ }
1096
+ };
1097
+
1098
+ // src/resources/subscriptions.ts
1099
+ var SubscriptionsResource = class {
1100
+ constructor(http) {
1101
+ this.http = http;
1102
+ }
1103
+ http;
1104
+ /**
1105
+ * List all available subscription plans for a Vista site.
1106
+ *
1107
+ * Returns only plans that are available for new subscriptions unless
1108
+ * {@link includeUnavailable} is set to true (useful for admin UIs).
1109
+ *
1110
+ * @param siteId - Vista site identifier to scope plans to
1111
+ * @param includeUnavailable - When true, include discontinued plans
1112
+ */
1113
+ async listPlans(siteId, includeUnavailable) {
1114
+ return this.http.get("/ocapi/v1/subscriptions/plans", {
1115
+ params: {
1116
+ ...siteId ? { siteId } : {},
1117
+ ...includeUnavailable ? { includeUnavailable: true } : {}
1118
+ }
1119
+ });
1120
+ }
1121
+ /**
1122
+ * Retrieve the active (or most recent) subscription for a member.
1123
+ *
1124
+ * @param memberId - Vista loyalty member identifier
1125
+ */
1126
+ async getMemberSubscription(memberId) {
1127
+ return this.http.get(`/ocapi/v1/subscriptions/members/${memberId}`);
1128
+ }
1129
+ /**
1130
+ * Get usage statistics for a member's subscription in the current billing period.
1131
+ *
1132
+ * Returns booking counts, remaining allowance, and per-benefit usage.
1133
+ * Use this before allowing a booking to check whether the member has
1134
+ * bookings remaining under their plan.
1135
+ *
1136
+ * @param memberId - Vista loyalty member identifier
1137
+ */
1138
+ async getUsage(memberId) {
1139
+ return this.http.get(
1140
+ `/ocapi/v1/subscriptions/members/${memberId}/usage`
1141
+ );
1142
+ }
1143
+ /**
1144
+ * Check whether a member is eligible to use a specific benefit.
1145
+ *
1146
+ * Returns true if the benefit is included in their plan, is currently active,
1147
+ * and the member has remaining uses for the current period.
1148
+ *
1149
+ * @param memberId - Vista loyalty member identifier
1150
+ * @param benefitId - Benefit ID from the plan's benefits array
1151
+ */
1152
+ async checkBenefitEligibility(memberId, benefitId) {
1153
+ return this.http.get(
1154
+ `/ocapi/v1/subscriptions/members/${memberId}/benefits/${benefitId}/eligibility`
1155
+ );
1156
+ }
1157
+ /**
1158
+ * Suspend a member's subscription temporarily.
1159
+ *
1160
+ * The subscription status transitions to `paused`. Billing is paused for the
1161
+ * suspension period. If {@link SuspendSubscriptionInput.resumeDate} is
1162
+ * provided, the subscription will auto-resume on that date; otherwise it must
1163
+ * be manually resumed.
1164
+ *
1165
+ * @param memberId - Vista loyalty member identifier
1166
+ * @param input - Optional resume date and reason
1167
+ */
1168
+ async suspend(memberId, input) {
1169
+ return this.http.post(
1170
+ `/ocapi/v1/subscriptions/members/${memberId}/suspend`,
1171
+ { body: input ?? {} }
1172
+ );
1173
+ }
1174
+ /**
1175
+ * Cancel a member's subscription.
1176
+ *
1177
+ * By default cancels at the end of the current billing period. Set
1178
+ * {@link CancelSubscriptionInput.immediate} to `true` for an immediate
1179
+ * cancellation with no refund.
1180
+ *
1181
+ * @param memberId - Vista loyalty member identifier
1182
+ * @param input - Whether to cancel immediately and optional reason
1183
+ */
1184
+ async cancel(memberId, input) {
1185
+ return this.http.post(
1186
+ `/ocapi/v1/subscriptions/members/${memberId}/cancel`,
1187
+ { body: input ?? {} }
1188
+ );
1189
+ }
1190
+ };
1191
+
1192
+ // src/resources/pricing.ts
1193
+ var PricingResource = class {
1194
+ constructor(http) {
1195
+ this.http = http;
1196
+ }
1197
+ http;
1198
+ /**
1199
+ * List available ticket types for a session.
1200
+ *
1201
+ * @param sessionId - The Vista session ID.
1202
+ * @param filter - Optional filter params (category, availableOnly).
1203
+ */
1204
+ async ticketTypes(sessionId, filter) {
1205
+ return this.http.get(`/ocapi/v1/sessions/${sessionId}/ticket-types`, {
1206
+ params: { ...filter }
1207
+ });
1208
+ }
1209
+ /**
1210
+ * Calculate the price for a ticket type and quantity.
1211
+ *
1212
+ * Returns an itemised {@link PriceBreakdown} including base price, discounts,
1213
+ * surcharges, and tax. The breakdown reflects the caller's Vista site tax
1214
+ * configuration (inclusive vs exclusive).
1215
+ *
1216
+ * @param sessionId - The Vista session ID.
1217
+ * @param ticketTypeId - Ticket type to price.
1218
+ * @param quantity - Number of tickets (default 1).
1219
+ */
1220
+ async calculate(sessionId, ticketTypeId, quantity = 1) {
1221
+ return this.http.get(`/ocapi/v1/pricing/calculate`, {
1222
+ params: { sessionId, ticketTypeId, quantity }
1223
+ });
1224
+ }
1225
+ /**
1226
+ * Apply coupon codes and resolve loyalty-tier discounts.
1227
+ *
1228
+ * Validates each coupon code, stacks applicable discounts, and returns
1229
+ * an updated {@link PriceBreakdown}. Invalid or expired codes are listed
1230
+ * in the `rejected` array rather than throwing.
1231
+ *
1232
+ * @param input - Session, ticket type, quantity, coupon codes, and optional member ID.
1233
+ */
1234
+ async applyCoupons(input) {
1235
+ return this.http.post(`/ocapi/v1/pricing/apply-coupons`, { body: input });
1236
+ }
1237
+ };
1238
+
1239
+ // src/resources/food-and-beverage.ts
1240
+ var FoodAndBeverageResource = class {
1241
+ constructor(http) {
1242
+ this.http = http;
1243
+ }
1244
+ http;
1245
+ /**
1246
+ * List menu items for a site, with optional filtering.
1247
+ *
1248
+ * Results include dietary tags and customisation options. Use `filter.dietary`
1249
+ * to surface only vegan / gluten-free items. Use `filter.preOrderOnly` when
1250
+ * building a pre-order flow tied to a session.
1251
+ *
1252
+ * @param siteId - The Vista site ID.
1253
+ * @param filter - Optional filter (dietary, category, availability).
1254
+ */
1255
+ async menu(siteId, filter) {
1256
+ return this.http.get(`/ocapi/v1/sites/${siteId}/menu`, {
1257
+ params: { ...filter, dietary: filter?.dietary?.join(",") }
1258
+ });
1259
+ }
1260
+ /**
1261
+ * List available menu categories for a site.
1262
+ *
1263
+ * Categories define the candy-bar section groupings (popcorn, drinks,
1264
+ * combos, snacks). Ordered by `displayOrder` ascending.
1265
+ *
1266
+ * @param siteId - The Vista site ID.
1267
+ */
1268
+ async categories(siteId) {
1269
+ return this.http.get(`/ocapi/v1/sites/${siteId}/menu/categories`);
1270
+ }
1271
+ /**
1272
+ * Fetch full detail for a single menu item.
1273
+ *
1274
+ * Includes customisation options (size, flavour) not always returned in
1275
+ * the full menu list. Use this before presenting an add-to-order form.
1276
+ *
1277
+ * @param siteId - The Vista site ID.
1278
+ * @param itemId - The menu item ID.
1279
+ */
1280
+ async itemDetail(siteId, itemId) {
1281
+ return this.http.get(`/ocapi/v1/sites/${siteId}/menu/items/${itemId}`);
1282
+ }
1283
+ /**
1284
+ * List combo deals available at a site.
1285
+ *
1286
+ * Combos are pre-configured bundles (e.g. "Large Popcorn + Large Drink").
1287
+ * The `savings` field on each {@link ComboOffer} indicates the discount
1288
+ * vs buying items individually — useful for upsell copy.
1289
+ *
1290
+ * @param siteId - The Vista site ID.
1291
+ */
1292
+ async combos(siteId) {
1293
+ return this.http.get(`/ocapi/v1/sites/${siteId}/menu/combos`);
1294
+ }
1295
+ /**
1296
+ * Add F&B items to an existing Vista order.
1297
+ *
1298
+ * Attaches concession items to a booking in progress. When adding
1299
+ * pre-order items tied to a specific session, pass `input.sessionId`
1300
+ * to enable collection-slot logic on the Vista side.
1301
+ *
1302
+ * Returns a confirmation with updated F&B subtotal for display.
1303
+ *
1304
+ * @param input - Order ID, items to add, optional session ID.
1305
+ */
1306
+ async addToOrder(input) {
1307
+ return this.http.post(
1308
+ `/ocapi/v1/orders/${input.orderId}/fnb`,
1309
+ { body: input }
1310
+ );
1311
+ }
1312
+ };
1313
+
1314
+ // src/http/retry.ts
1315
+ var DEFAULT_RETRY_CONFIG = {
1316
+ maxRetries: 3,
1317
+ baseDelay: 1e3,
1318
+ maxDelay: 3e4,
1319
+ jitter: true
1320
+ };
1321
+ function computeBackoffDelay(attempt, config) {
1322
+ const exponential = Math.min(
1323
+ config.baseDelay * Math.pow(2, attempt - 1),
1324
+ config.maxDelay
1325
+ );
1326
+ if (!config.jitter) return exponential;
1327
+ return Math.floor(exponential * (0.5 + Math.random() * 0.5));
1328
+ }
1329
+
1330
+ // src/http/interceptors.ts
1331
+ async function runInterceptors(value, interceptors) {
1332
+ let current = value;
1333
+ for (const interceptor of interceptors) {
1334
+ current = await interceptor(current);
1335
+ }
1336
+ return current;
1337
+ }
1338
+
1339
+ // src/http/client.ts
1340
+ var TheatricalHTTPClient = class {
1341
+ config;
1342
+ constructor(config) {
1343
+ this.config = config;
1344
+ }
1345
+ async get(path, options) {
1346
+ return this.request({ ...options, method: "GET" }, path);
1347
+ }
1348
+ async post(path, options) {
1349
+ return this.request({ ...options, method: "POST" }, path);
1350
+ }
1351
+ async put(path, options) {
1352
+ return this.request({ ...options, method: "PUT" }, path);
1353
+ }
1354
+ async delete(path, options) {
1355
+ return this.request({ ...options, method: "DELETE" }, path);
1356
+ }
1357
+ async request(options, path, attempt = 1) {
1358
+ if (this.config.rateLimiter) {
1359
+ await this.config.rateLimiter.waitForSlot();
1360
+ }
1361
+ const token = await this.config.tokenManager.getToken();
1362
+ const url = this.buildUrl(path, options.params);
1363
+ const requestId = this.generateRequestId();
1364
+ if (this.config.debug) {
1365
+ console.log(`[theatrical] ${options.method} ${url} (${requestId})`);
1366
+ }
1367
+ const serialisedBody = options.body ? JSON.stringify(options.body) : void 0;
1368
+ let requestConfig = {
1369
+ url,
1370
+ method: options.method ?? "GET",
1371
+ headers: {
1372
+ "Authorization": `Bearer ${token}`,
1373
+ "Content-Type": "application/json",
1374
+ "X-Request-ID": requestId,
1375
+ ...options.headers
1376
+ },
1377
+ body: serialisedBody
1378
+ };
1379
+ if (this.config.onRequest?.length) {
1380
+ requestConfig = await runInterceptors(requestConfig, this.config.onRequest);
1381
+ }
1382
+ const controller = new AbortController();
1383
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
1384
+ try {
1385
+ let response = await fetch(requestConfig.url, {
1386
+ method: requestConfig.method,
1387
+ headers: requestConfig.headers,
1388
+ body: requestConfig.body,
1389
+ signal: controller.signal
1390
+ });
1391
+ clearTimeout(timeoutId);
1392
+ if (response.ok) {
1393
+ if (this.config.onResponse?.length) {
1394
+ response = await runInterceptors(response, this.config.onResponse);
1395
+ }
1396
+ return await response.json();
1397
+ }
1398
+ if (response.status === 401) {
1399
+ this.config.tokenManager.invalidate();
1400
+ if (attempt <= 1) {
1401
+ return this.request(options, path, attempt + 1);
1402
+ }
1403
+ }
1404
+ const error = await parseErrorResponse(response, requestConfig.url);
1405
+ if ((error instanceof RateLimitError || error instanceof ServerError) && attempt <= this.config.maxRetries) {
1406
+ const retryConfig = this.config.retry ?? { ...DEFAULT_RETRY_CONFIG, maxRetries: this.config.maxRetries };
1407
+ const delayMs = error instanceof RateLimitError ? error.retryAfter * 1e3 : computeBackoffDelay(attempt, retryConfig);
1408
+ await this.delay(delayMs);
1409
+ return this.request(options, path, attempt + 1);
1410
+ }
1411
+ throw error;
1412
+ } catch (error) {
1413
+ clearTimeout(timeoutId);
1414
+ if (error instanceof TheatricalError) {
1415
+ throw error;
1416
+ }
1417
+ if (error instanceof DOMException && error.name === "AbortError") {
1418
+ throw new TheatricalError("Request timed out", 408, void 0, requestId);
1419
+ }
1420
+ throw new TheatricalError(
1421
+ `Network error: ${error.message}`,
1422
+ 0,
1423
+ void 0,
1424
+ requestId
1425
+ );
1426
+ }
1427
+ }
1428
+ buildUrl(path, params) {
1429
+ const url = new URL(path, this.config.baseUrl);
1430
+ if (params) {
1431
+ Object.entries(params).forEach(([key, value]) => {
1432
+ if (value !== void 0) {
1433
+ url.searchParams.set(key, String(value));
1434
+ }
1435
+ });
1436
+ }
1437
+ return url.toString();
1438
+ }
1439
+ generateRequestId() {
1440
+ return `th_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1441
+ }
1442
+ delay(ms) {
1443
+ return new Promise((resolve) => setTimeout(resolve, ms));
1444
+ }
1445
+ };
1446
+
1447
+ // src/auth/gas-client.ts
1448
+ var GASClient = class {
1449
+ config;
1450
+ constructor(config) {
1451
+ this.config = config;
1452
+ }
1453
+ /**
1454
+ * Request a new JWT token from the Global Authentication Service
1455
+ */
1456
+ async requestToken() {
1457
+ const response = await fetch(`${this.config.authUrl}/oauth/token`, {
1458
+ method: "POST",
1459
+ headers: {
1460
+ "Content-Type": "application/json"
1461
+ },
1462
+ body: JSON.stringify({
1463
+ grant_type: "client_credentials",
1464
+ api_key: this.config.apiKey
1465
+ })
1466
+ });
1467
+ if (!response.ok) {
1468
+ throw new Error(`GAS authentication failed: ${response.status} ${response.statusText}`);
1469
+ }
1470
+ const data = await response.json();
1471
+ return {
1472
+ accessToken: data.access_token,
1473
+ tokenType: data.token_type ?? "Bearer",
1474
+ expiresIn: data.expires_in ?? 3600,
1475
+ issuedAt: Date.now()
1476
+ };
1477
+ }
1478
+ };
1479
+
1480
+ // src/auth/token-manager.ts
1481
+ var TokenManager = class {
1482
+ gasClient;
1483
+ currentToken = null;
1484
+ refreshPromise = null;
1485
+ /** Buffer before expiry to trigger refresh (5 minutes) */
1486
+ expiryBuffer = 5 * 60 * 1e3;
1487
+ constructor(gasClient) {
1488
+ this.gasClient = gasClient;
1489
+ }
1490
+ /**
1491
+ * Get a valid access token, refreshing if necessary.
1492
+ * Deduplicates concurrent refresh requests.
1493
+ */
1494
+ async getToken() {
1495
+ if (this.currentToken && !this.isExpired(this.currentToken)) {
1496
+ return this.currentToken.accessToken;
1497
+ }
1498
+ if (!this.refreshPromise) {
1499
+ this.refreshPromise = this.refresh();
1500
+ }
1501
+ try {
1502
+ const token = await this.refreshPromise;
1503
+ return token.accessToken;
1504
+ } finally {
1505
+ this.refreshPromise = null;
1506
+ }
1507
+ }
1508
+ /**
1509
+ * Force a token refresh regardless of current token state
1510
+ */
1511
+ async refresh() {
1512
+ const token = await this.gasClient.requestToken();
1513
+ this.currentToken = token;
1514
+ return token;
1515
+ }
1516
+ /**
1517
+ * Check if a token is expired or within the expiry buffer
1518
+ */
1519
+ isExpired(token) {
1520
+ const expiresAt = token.issuedAt + token.expiresIn * 1e3;
1521
+ return Date.now() >= expiresAt - this.expiryBuffer;
1522
+ }
1523
+ /**
1524
+ * Clear the current token (e.g., on 401 response)
1525
+ */
1526
+ invalidate() {
1527
+ this.currentToken = null;
1528
+ }
1529
+ };
1530
+
1531
+ // src/http/rate-limiter.ts
1532
+ var DEFAULT_RATE_LIMITER_CONFIG = {
1533
+ maxRequests: 60,
1534
+ windowMs: 6e4
1535
+ };
1536
+ var RateLimiter = class {
1537
+ config;
1538
+ timestamps = [];
1539
+ /**
1540
+ * Creates a new RateLimiter.
1541
+ * @param config - Limits and window size. Defaults to {@link DEFAULT_RATE_LIMITER_CONFIG}.
1542
+ */
1543
+ constructor(config = DEFAULT_RATE_LIMITER_CONFIG) {
1544
+ this.config = config;
1545
+ }
1546
+ /**
1547
+ * Waits until a request slot is available within the current window,
1548
+ * records the request timestamp, and resolves.
1549
+ *
1550
+ * Callers `await` this before making an HTTP request. If the limit has been
1551
+ * reached, the call suspends until the oldest in-window request expires.
1552
+ *
1553
+ * @returns Promise that resolves when it is safe to proceed with a request
1554
+ */
1555
+ async waitForSlot() {
1556
+ for (; ; ) {
1557
+ const now = Date.now();
1558
+ const windowStart = now - this.config.windowMs;
1559
+ while (this.timestamps.length > 0 && this.timestamps[0] < windowStart) {
1560
+ this.timestamps.shift();
1561
+ }
1562
+ if (this.timestamps.length < this.config.maxRequests) {
1563
+ this.timestamps.push(now);
1564
+ return;
1565
+ }
1566
+ const oldestTs = this.timestamps[0];
1567
+ const waitMs = oldestTs + this.config.windowMs - now + 1;
1568
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
1569
+ }
1570
+ }
1571
+ /**
1572
+ * Returns the number of requests recorded within the current rolling window.
1573
+ * Useful for diagnostics and integration tests.
1574
+ */
1575
+ get activeCount() {
1576
+ const windowStart = Date.now() - this.config.windowMs;
1577
+ return this.timestamps.filter((ts) => ts >= windowStart).length;
1578
+ }
1579
+ /**
1580
+ * Clears all recorded timestamps, resetting the limiter to a clean state.
1581
+ * Primarily intended for use in tests.
1582
+ */
1583
+ reset() {
1584
+ this.timestamps.length = 0;
1585
+ }
1586
+ };
1587
+
1588
+ // src/mock/fixtures.ts
1589
+ var DEFAULT_MOCK_RESPONSES = {
1590
+ // Films
1591
+ "/ocapi/v1/films/now-showing": [
1592
+ {
1593
+ id: "film_holdovers_2023",
1594
+ title: "The Holdovers",
1595
+ synopsis: "A cranky history teacher is forced to stay on campus over the holidays with a troubled student.",
1596
+ genres: ["drama", "comedy"],
1597
+ runtime: 133,
1598
+ rating: { classification: "M", description: "Offensive language" },
1599
+ releaseDate: "2026-03-14",
1600
+ posterUrl: "https://image.tmdb.org/t/p/w500/VHSEkBNbAoEGfBmiGGOhEHaGMsD.jpg",
1601
+ cast: [{ name: "Paul Giamatti", role: "Paul Hunham" }, { name: "Dominic Sessa", role: "Angus Tully" }],
1602
+ director: "Alexander Payne",
1603
+ isNowShowing: true,
1604
+ isComingSoon: false
1605
+ },
1606
+ {
1607
+ id: "film_poor_things_2023",
1608
+ title: "Poor Things",
1609
+ synopsis: "The incredible tale about the fantastical evolution of Bella Baxter.",
1610
+ genres: ["comedy", "drama", "sci-fi"],
1611
+ runtime: 141,
1612
+ rating: { classification: "R18", description: "Graphic sexual content, offensive language" },
1613
+ releaseDate: "2026-02-01",
1614
+ posterUrl: "https://image.tmdb.org/t/p/w500/kCGlIMHnOm8JPXIhMLGUMEFbV4B.jpg",
1615
+ cast: [{ name: "Emma Stone", role: "Bella Baxter" }, { name: "Mark Ruffalo", role: "Duncan Wedderburn" }],
1616
+ director: "Yorgos Lanthimos",
1617
+ isNowShowing: true,
1618
+ isComingSoon: false
1619
+ }
1620
+ ],
1621
+ "/ocapi/v1/films/coming-soon": [
1622
+ {
1623
+ id: "film_dune2_2024",
1624
+ title: "Dune: Part Two",
1625
+ synopsis: "Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators.",
1626
+ genres: ["sci-fi", "adventure"],
1627
+ runtime: 166,
1628
+ rating: { classification: "M", description: "Violence" },
1629
+ releaseDate: "2026-05-01",
1630
+ posterUrl: "https://image.tmdb.org/t/p/w500/czembW0Rk1Ke7lCJGahbOhdCuhx.jpg",
1631
+ cast: [{ name: "Timoth\xE9e Chalamet", role: "Paul Atreides" }, { name: "Zendaya", role: "Chani" }],
1632
+ director: "Denis Villeneuve",
1633
+ isNowShowing: false,
1634
+ isComingSoon: true
1635
+ }
1636
+ ],
1637
+ "/ocapi/v1/films/:id": {
1638
+ id: "film_holdovers_2023",
1639
+ title: "The Holdovers",
1640
+ synopsis: "A cranky history teacher is forced to stay on campus over the holidays with a troubled student.",
1641
+ genres: ["drama", "comedy"],
1642
+ runtime: 133,
1643
+ rating: { classification: "M", description: "Offensive language" },
1644
+ releaseDate: "2026-03-14",
1645
+ posterUrl: "https://image.tmdb.org/t/p/w500/VHSEkBNbAoEGfBmiGGOhEHaGMsD.jpg",
1646
+ cast: [{ name: "Paul Giamatti", role: "Paul Hunham" }],
1647
+ director: "Alexander Payne",
1648
+ crew: [{ name: "Alexander Payne", department: "Directing", job: "Director" }],
1649
+ ratings: [{ source: "TMDB", score: "7.9", outOf: "10" }],
1650
+ formats: ["2D"],
1651
+ languages: ["en"],
1652
+ isNowShowing: true,
1653
+ isComingSoon: false
1654
+ },
1655
+ // Sessions
1656
+ "/ocapi/v1/sessions": {
1657
+ sessions: [
1658
+ {
1659
+ id: "ses_roxy_holdovers_20260427_1915",
1660
+ filmId: "film_holdovers_2023",
1661
+ filmTitle: "The Holdovers",
1662
+ siteId: "site_roxy_wellington",
1663
+ screenId: "scr_roxy_3",
1664
+ screenName: "Screen 3",
1665
+ startTime: "2026-04-27T19:15:00+12:00",
1666
+ endTime: "2026-04-27T21:28:00+12:00",
1667
+ format: "2D",
1668
+ isBookable: true,
1669
+ isSoldOut: false,
1670
+ seatsAvailable: 74,
1671
+ seatsTotal: 120,
1672
+ priceFrom: 19.5,
1673
+ attributes: {}
1674
+ },
1675
+ {
1676
+ id: "ses_roxy_poor_things_20260427_2000",
1677
+ filmId: "film_poor_things_2023",
1678
+ filmTitle: "Poor Things",
1679
+ siteId: "site_roxy_wellington",
1680
+ screenId: "scr_roxy_2",
1681
+ screenName: "Screen 2 \u2014 IMAX",
1682
+ startTime: "2026-04-27T20:00:00+12:00",
1683
+ endTime: "2026-04-27T22:21:00+12:00",
1684
+ format: "IMAX",
1685
+ isBookable: true,
1686
+ isSoldOut: false,
1687
+ seatsAvailable: 28,
1688
+ seatsTotal: 140,
1689
+ priceFrom: 25,
1690
+ attributes: {}
1691
+ }
1692
+ ],
1693
+ total: 2,
1694
+ hasMore: false
1695
+ },
1696
+ "/ocapi/v1/sessions/:id": {
1697
+ id: "ses_roxy_holdovers_20260427_1915",
1698
+ filmId: "film_holdovers_2023",
1699
+ filmTitle: "The Holdovers",
1700
+ siteId: "site_roxy_wellington",
1701
+ screenId: "scr_roxy_3",
1702
+ screenName: "Screen 3",
1703
+ startTime: "2026-04-27T19:15:00+12:00",
1704
+ endTime: "2026-04-27T21:28:00+12:00",
1705
+ format: "2D",
1706
+ isBookable: true,
1707
+ isSoldOut: false,
1708
+ seatsAvailable: 74,
1709
+ seatsTotal: 120,
1710
+ priceFrom: 19.5,
1711
+ attributes: {}
1712
+ },
1713
+ "/ocapi/v1/sessions/:id/seat-plan": {
1714
+ sessionId: "ses_roxy_holdovers_20260427_1915",
1715
+ screenName: "Screen 3",
1716
+ seats: Array.from(
1717
+ { length: 10 },
1718
+ (_, row) => Array.from({ length: 12 }, (_2, col) => ({
1719
+ id: `seat_${String.fromCharCode(65 + row)}${col + 1}`,
1720
+ row: String.fromCharCode(65 + row),
1721
+ number: col + 1,
1722
+ status: row < 3 && col > 8 ? "taken" : "available",
1723
+ x: col,
1724
+ y: row,
1725
+ type: row < 2 ? "premium" : "standard",
1726
+ isAccessible: false
1727
+ }))
1728
+ ).flat(),
1729
+ rowCount: 10,
1730
+ screenPosition: "top",
1731
+ availableCount: 74,
1732
+ totalCount: 120
1733
+ },
1734
+ // Sites
1735
+ "/ocapi/v1/sites": [
1736
+ {
1737
+ id: "site_roxy_wellington",
1738
+ name: "Roxy Cinema",
1739
+ address: { line1: "5 Park Road", city: "Wellington", postalCode: "6022", country: "NZ" },
1740
+ location: { latitude: -41.319, longitude: 174.8127 },
1741
+ screens: [
1742
+ { id: "scr_roxy_1", name: "Screen 1", seatCount: 80, formats: ["2D"], isAccessible: true },
1743
+ { id: "scr_roxy_2", name: "Screen 2 \u2014 IMAX", seatCount: 140, formats: ["IMAX", "2D"], isAccessible: true },
1744
+ { id: "scr_roxy_3", name: "Screen 3", seatCount: 120, formats: ["2D", "3D"], isAccessible: true }
1745
+ ],
1746
+ config: { bookingLeadTime: 15, maxTicketsPerOrder: 8, loyaltyEnabled: true, fnbEnabled: true },
1747
+ timezone: "Pacific/Auckland",
1748
+ currency: "NZD",
1749
+ isActive: true,
1750
+ amenities: [
1751
+ { id: "bar", label: "Bar" },
1752
+ { id: "wheelchair", label: "Wheelchair Access" },
1753
+ { id: "parking", label: "Free Parking" }
1754
+ ]
1755
+ }
1756
+ ],
1757
+ "/ocapi/v1/sites/:id": {
1758
+ id: "site_roxy_wellington",
1759
+ name: "Roxy Cinema",
1760
+ address: { line1: "5 Park Road", city: "Wellington", postalCode: "6022", country: "NZ" },
1761
+ location: { latitude: -41.319, longitude: 174.8127 },
1762
+ screens: [
1763
+ { id: "scr_roxy_1", name: "Screen 1", seatCount: 80, formats: ["2D"], isAccessible: true }
1764
+ ],
1765
+ config: { bookingLeadTime: 15, maxTicketsPerOrder: 8, loyaltyEnabled: true, fnbEnabled: true },
1766
+ timezone: "Pacific/Auckland",
1767
+ currency: "NZD",
1768
+ isActive: true
1769
+ },
1770
+ // Orders
1771
+ "/ocapi/v1/orders": {
1772
+ id: "ord_mock_001",
1773
+ status: "draft",
1774
+ siteId: "site_roxy_wellington",
1775
+ sessionId: "ses_roxy_holdovers_20260427_1915",
1776
+ tickets: [],
1777
+ items: [],
1778
+ pricing: { subtotal: 0, tax: 0, discounts: 0, total: 0 },
1779
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1780
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1781
+ },
1782
+ // Loyalty
1783
+ "/ocapi/v1/loyalty/members/:id": {
1784
+ id: "mem_hemi_walker_5528",
1785
+ email: "hemi.walker@example.co.nz",
1786
+ firstName: "Hemi",
1787
+ lastName: "Walker",
1788
+ tier: {
1789
+ id: "tier_gold",
1790
+ name: "Gold",
1791
+ level: 3,
1792
+ benefits: ["10% off all tickets", "Free large popcorn monthly", "Priority booking"],
1793
+ pointsThreshold: 5e3
1794
+ },
1795
+ points: 2840,
1796
+ lifetimePoints: 8640,
1797
+ memberSince: "2024-01-15",
1798
+ active: true
1799
+ }
1800
+ };
1801
+
1802
+ // src/mock/mock-http-adapter.ts
1803
+ var MockHTTPAdapter = class {
1804
+ responses;
1805
+ constructor(overrides) {
1806
+ this.responses = { ...DEFAULT_MOCK_RESPONSES, ...overrides };
1807
+ }
1808
+ /** Normalise a concrete path to a pattern key by replacing segments that look like IDs. */
1809
+ matchPattern(path) {
1810
+ const cleanPath = path.split("?")[0];
1811
+ return cleanPath.replace(/\/([a-z_]+-[a-z_\d-]+|\d+)(?=\/|$)/gi, "/:id");
1812
+ }
1813
+ lookup(path) {
1814
+ if (this.responses[path] !== void 0) return this.responses[path];
1815
+ const pattern = this.matchPattern(path);
1816
+ if (this.responses[pattern] !== void 0) return this.responses[pattern];
1817
+ return void 0;
1818
+ }
1819
+ async get(path) {
1820
+ const data = this.lookup(path);
1821
+ if (data === void 0) {
1822
+ throw new NotFoundError(`Mock: no fixture for GET ${path}`, path.split("/").pop() ?? path);
1823
+ }
1824
+ return data;
1825
+ }
1826
+ async post(path, options) {
1827
+ const data = this.lookup(path);
1828
+ if (data !== void 0) return data;
1829
+ if (path.includes("/orders")) {
1830
+ return {
1831
+ id: `ord_mock_${Date.now()}`,
1832
+ status: "draft",
1833
+ tickets: [],
1834
+ items: [],
1835
+ pricing: { subtotal: 0, tax: 0, discounts: 0, total: 0 },
1836
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1837
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1838
+ ...options?.body && typeof options.body === "object" ? options.body : {}
1839
+ };
1840
+ }
1841
+ return {};
1842
+ }
1843
+ async put(path, options) {
1844
+ const data = this.lookup(path);
1845
+ if (data !== void 0) return data;
1846
+ return { ...options?.body && typeof options.body === "object" ? options.body : {} };
1847
+ }
1848
+ async delete(path) {
1849
+ return {};
1850
+ }
1851
+ };
1852
+
1853
+ // src/client.ts
1854
+ var ENVIRONMENT_URLS = {
1855
+ sandbox: "https://api-sandbox.vista.co",
1856
+ staging: "https://api-staging.vista.co",
1857
+ production: "https://api.vista.co"
1858
+ };
1859
+ var _globalInstance;
1860
+ var TheatricalClient = class _TheatricalClient {
1861
+ config;
1862
+ httpClient;
1863
+ tokenManager;
1864
+ // Lazily initialised resource modules
1865
+ _sessions;
1866
+ _sites;
1867
+ _films;
1868
+ _orders;
1869
+ _loyalty;
1870
+ _subscriptions;
1871
+ _pricing;
1872
+ _fnb;
1873
+ /**
1874
+ * Constructs a TheatricalClient with validated configuration.
1875
+ *
1876
+ * Throws `ValidationError` immediately if config is invalid.
1877
+ *
1878
+ * @param config - SDK configuration
1879
+ * @throws {ValidationError} if config fails schema validation
1880
+ */
1881
+ constructor(config) {
1882
+ this.config = validateConfig(config);
1883
+ const baseUrl = this.config.baseUrl ?? ENVIRONMENT_URLS[this.config.environment];
1884
+ const gasClient = new GASClient({
1885
+ apiKey: this.config.apiKey,
1886
+ authUrl: "https://auth.moviexchange.com"
1887
+ });
1888
+ this.tokenManager = new TokenManager(gasClient);
1889
+ this.httpClient = new TheatricalHTTPClient({
1890
+ baseUrl,
1891
+ timeout: this.config.timeout,
1892
+ maxRetries: this.config.maxRetries,
1893
+ tokenManager: this.tokenManager,
1894
+ debug: this.config.debug,
1895
+ rateLimiter: new RateLimiter(DEFAULT_RATE_LIMITER_CONFIG)
1896
+ });
1897
+ }
1898
+ /**
1899
+ * Creates a new TheatricalClient instance.
1900
+ *
1901
+ * Equivalent to `new TheatricalClient(config)` but provided as a static
1902
+ * factory for clarity in configuration-heavy setups.
1903
+ *
1904
+ * @param config - SDK configuration
1905
+ * @returns New TheatricalClient instance
1906
+ * @throws {ValidationError} if config fails schema validation
1907
+ *
1908
+ * @example
1909
+ * ```typescript
1910
+ * const client = TheatricalClient.create({
1911
+ * apiKey: process.env.THEATRICAL_API_KEY!,
1912
+ * environment: 'production',
1913
+ * timeout: 15_000,
1914
+ * });
1915
+ * ```
1916
+ */
1917
+ static create(config) {
1918
+ return new _TheatricalClient(config);
1919
+ }
1920
+ /**
1921
+ * Returns the global singleton TheatricalClient instance.
1922
+ *
1923
+ * Throws if no global instance has been configured via `setGlobal()`.
1924
+ * Useful for applications that initialise the client once at startup and
1925
+ * access it throughout without passing the instance around.
1926
+ *
1927
+ * @returns The global TheatricalClient instance
1928
+ * @throws {Error} if setGlobal() has not been called
1929
+ *
1930
+ * @example
1931
+ * ```typescript
1932
+ * // At startup:
1933
+ * TheatricalClient.setGlobal({ apiKey: 'key', environment: 'production' });
1934
+ *
1935
+ * // Anywhere in your app:
1936
+ * const client = TheatricalClient.global();
1937
+ * const films = await client.films.nowShowing({ siteId: 'roxy-wellington' });
1938
+ * ```
1939
+ */
1940
+ static global() {
1941
+ if (!_globalInstance) {
1942
+ throw new Error(
1943
+ "No global TheatricalClient configured. Call TheatricalClient.setGlobal(config) first."
1944
+ );
1945
+ }
1946
+ return _globalInstance;
1947
+ }
1948
+ /**
1949
+ * Configures the global singleton TheatricalClient instance.
1950
+ *
1951
+ * Replaces any existing global instance. Subsequent calls to
1952
+ * `TheatricalClient.global()` will return this instance.
1953
+ *
1954
+ * @param config - SDK configuration
1955
+ * @throws {ValidationError} if config fails schema validation
1956
+ *
1957
+ * @example
1958
+ * ```typescript
1959
+ * TheatricalClient.setGlobal({
1960
+ * apiKey: process.env.THEATRICAL_API_KEY!,
1961
+ * environment: 'production',
1962
+ * });
1963
+ * ```
1964
+ */
1965
+ static setGlobal(config) {
1966
+ _globalInstance = new _TheatricalClient(config);
1967
+ }
1968
+ /**
1969
+ * Resets the global singleton instance.
1970
+ *
1971
+ * Primarily intended for testing — allows tests to start with a clean slate.
1972
+ *
1973
+ * @example
1974
+ * ```typescript
1975
+ * afterEach(() => {
1976
+ * TheatricalClient.resetGlobal();
1977
+ * });
1978
+ * ```
1979
+ */
1980
+ static resetGlobal() {
1981
+ _globalInstance = void 0;
1982
+ }
1983
+ /**
1984
+ * Creates an offline mock client that returns pre-defined NZ cinema fixture data.
1985
+ *
1986
+ * Ideal for demos, prototypes, and evaluators without a Vista API key.
1987
+ * The mock covers films, sessions, sites, orders, and loyalty endpoints.
1988
+ *
1989
+ * @param fixtureOverrides - Optional URL-keyed fixture map to replace specific responses
1990
+ *
1991
+ * @example
1992
+ * ```typescript
1993
+ * const client = import.meta.env.VITE_THEATRICAL_MOCK
1994
+ * ? TheatricalClient.createMock()
1995
+ * : TheatricalClient.create({ apiKey: process.env.THEATRICAL_API_KEY! });
1996
+ * ```
1997
+ *
1998
+ * @see https://developer.vista.co
1999
+ */
2000
+ static createMock(fixtureOverrides) {
2001
+ const instance = Object.create(_TheatricalClient.prototype);
2002
+ instance.httpClient = new MockHTTPAdapter(fixtureOverrides);
2003
+ instance.config = { apiKey: "mock", environment: "sandbox", timeout: 1e4, maxRetries: 0, debug: false };
2004
+ instance.tokenManager = { getToken: async () => "mock-token", invalidate: () => {
2005
+ } };
2006
+ return instance;
2007
+ }
2008
+ /** Sessions — showtimes, availability, seat maps */
2009
+ get sessions() {
2010
+ if (!this._sessions) {
2011
+ this._sessions = new SessionsResource(this.httpClient);
2012
+ }
2013
+ return this._sessions;
2014
+ }
2015
+ /** Sites — cinema locations, screens, configurations */
2016
+ get sites() {
2017
+ if (!this._sites) {
2018
+ this._sites = new SitesResource(this.httpClient);
2019
+ }
2020
+ return this._sites;
2021
+ }
2022
+ /** Films — now showing, coming soon, search */
2023
+ get films() {
2024
+ if (!this._films) {
2025
+ this._films = new FilmsResource(this.httpClient);
2026
+ }
2027
+ return this._films;
2028
+ }
2029
+ /** Orders — booking lifecycle: create, confirm, cancel */
2030
+ get orders() {
2031
+ if (!this._orders) {
2032
+ this._orders = new OrdersResource(this.httpClient);
2033
+ }
2034
+ return this._orders;
2035
+ }
2036
+ /** Loyalty — member management, points, tiers */
2037
+ get loyalty() {
2038
+ if (!this._loyalty) {
2039
+ this._loyalty = new LoyaltyResource(this.httpClient);
2040
+ }
2041
+ return this._loyalty;
2042
+ }
2043
+ /** Subscriptions — plans, member subscriptions */
2044
+ get subscriptions() {
2045
+ if (!this._subscriptions) {
2046
+ this._subscriptions = new SubscriptionsResource(this.httpClient);
2047
+ }
2048
+ return this._subscriptions;
2049
+ }
2050
+ /** Pricing — ticket types, price calculations, tax handling */
2051
+ get pricing() {
2052
+ if (!this._pricing) {
2053
+ this._pricing = new PricingResource(this.httpClient);
2054
+ }
2055
+ return this._pricing;
2056
+ }
2057
+ /** Food & Beverage — menus, ordering, dietary information */
2058
+ get fnb() {
2059
+ if (!this._fnb) {
2060
+ this._fnb = new FoodAndBeverageResource(this.httpClient);
2061
+ }
2062
+ return this._fnb;
2063
+ }
2064
+ };
2065
+
2066
+ // src/version.ts
2067
+ var SDK_VERSION = "0.1.0";
2068
+ var SDK_USER_AGENT = `theatrical-sdk/${SDK_VERSION} (Node.js)`;
2069
+ export {
2070
+ AuthenticationError,
2071
+ MockHTTPAdapter,
2072
+ NotFoundError,
2073
+ RateLimitError,
2074
+ SDK_USER_AGENT,
2075
+ SDK_VERSION,
2076
+ ServerError,
2077
+ TheatricalClient,
2078
+ TheatricalError,
2079
+ ValidationError
2080
+ };