@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/LICENSE +21 -0
- package/dist/index.d.mts +2243 -0
- package/dist/index.d.ts +2243 -0
- package/dist/index.js +2116 -0
- package/dist/index.mjs +2080 -0
- package/package.json +56 -0
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
|
+
};
|