@owask/tapace-templates 0.0.0-stage → 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.
Files changed (38) hide show
  1. package/README.md +13 -2
  2. package/dist/compiler/event-ticket.d.ts +38 -0
  3. package/dist/compiler/event-ticket.js +131 -0
  4. package/dist/compiler/loyalty-card.d.ts +28 -0
  5. package/dist/compiler/loyalty-card.js +70 -0
  6. package/dist/compiler/passmint-compiler.d.ts +118 -0
  7. package/dist/compiler/passmint-compiler.js +507 -0
  8. package/dist/compiler/preview-model.d.ts +123 -0
  9. package/dist/compiler/preview-model.js +402 -0
  10. package/dist/compiler/stamp-card.d.ts +23 -0
  11. package/dist/compiler/stamp-card.js +19 -0
  12. package/dist/compiler/template-preview.d.ts +34 -0
  13. package/dist/compiler/template-preview.js +125 -0
  14. package/dist/contract/definition.d.ts +1316 -0
  15. package/dist/contract/definition.js +854 -0
  16. package/dist/contract/event.d.ts +53 -0
  17. package/dist/contract/event.js +91 -0
  18. package/dist/contract/featured-actions.d.ts +45 -0
  19. package/dist/contract/featured-actions.js +81 -0
  20. package/dist/contract/fields.d.ts +153 -0
  21. package/dist/contract/fields.js +141 -0
  22. package/dist/contract/ids.d.ts +2 -0
  23. package/dist/contract/ids.js +6 -0
  24. package/dist/contract/index.d.ts +9 -0
  25. package/dist/contract/index.js +9 -0
  26. package/dist/contract/points.d.ts +41 -0
  27. package/dist/contract/points.js +86 -0
  28. package/dist/contract/primitives.d.ts +26 -0
  29. package/dist/contract/primitives.js +14 -0
  30. package/dist/contract/relevance.d.ts +54 -0
  31. package/dist/contract/relevance.js +52 -0
  32. package/dist/contract/stamps.d.ts +91 -0
  33. package/dist/contract/stamps.js +89 -0
  34. package/dist/contract/upgrade.d.ts +7 -0
  35. package/dist/contract/upgrade.js +39 -0
  36. package/dist/public-api.d.ts +4 -0
  37. package/dist/public-api.js +2 -0
  38. package/package.json +20 -4
@@ -0,0 +1,854 @@
1
+ import { z } from 'zod';
2
+ import { AssetRefSchema, BarcodeEncodingSchema, BarcodeFormatSchema, HexColorSchema, } from './primitives.js';
3
+ import { FieldKeySchema, FieldListSchema, POSTER_SLOTS, PosterLayoutSchema, STANDARD_SLOTS, StandardLayoutSchema, } from './fields.js';
4
+ import { FeaturedActionDefSchema, MAX_FEATURED_ACTIONS, } from './featured-actions.js';
5
+ import { CardRowsSchema, cardItemRef, resolveCardItem } from './event.js';
6
+ import { TemplateRelevanceSchema, } from './relevance.js';
7
+ import { StampNotificationsSchema, StampSettingsSchema, stampAssetIds, } from './stamps.js';
8
+ import { PointsNotificationsSchema, PointsSettingsSchema } from './points.js';
9
+ import { upgradeDefinition } from './upgrade.js';
10
+ import { newFieldId } from './ids.js';
11
+ export const TEMPLATE_SCHEMA_VERSION = 2;
12
+ /** Where a barcode's payload comes from. Resolved to a concrete message at issuance. */
13
+ export const BarcodeSourceSchema = z.discriminatedUnion('kind', [
14
+ z.strictObject({ kind: z.literal('static'), value: z.string().min(1).max(2048) }),
15
+ z.strictObject({ kind: z.literal('passValue'), sampleValue: z.string().max(2048).optional() }),
16
+ z.strictObject({ kind: z.literal('serialNumber') }),
17
+ ]);
18
+ export const BarcodeConfigSchema = z.strictObject({
19
+ format: BarcodeFormatSchema,
20
+ source: BarcodeSourceSchema,
21
+ altText: z.string().max(256).optional(),
22
+ showText: z.boolean().optional(),
23
+ messageEncoding: BarcodeEncodingSchema.optional(),
24
+ });
25
+ /**
26
+ * Each type accepts only the slots its Apple style renders; strip, background
27
+ * and footer break Apple issuance for `generic` (see TEMPLATE_MAPPING.md "Image
28
+ * slots by pass type"). `artwork` and `primaryLogo` are poster-only.
29
+ */
30
+ export const GENERIC_IMAGE_SLOTS = ['icon', 'logo', 'thumbnail', 'artwork', 'primaryLogo'];
31
+ export const EVENT_TICKET_IMAGE_SLOTS = [
32
+ 'icon',
33
+ 'logo',
34
+ 'strip',
35
+ 'thumbnail',
36
+ 'background',
37
+ 'artwork',
38
+ 'primaryLogo',
39
+ ];
40
+ /** A stamp card's strip is drawn from its stamp settings, never uploaded. */
41
+ export const STAMP_CARD_IMAGE_SLOTS = ['icon', 'logo', 'artwork', 'primaryLogo'];
42
+ export const POINTS_CARD_IMAGE_SLOTS = ['icon', 'logo', 'strip', 'artwork', 'primaryLogo'];
43
+ export const ImageSlotsSchema = z.strictObject({
44
+ icon: AssetRefSchema,
45
+ logo: AssetRefSchema.optional(),
46
+ thumbnail: AssetRefSchema.optional(),
47
+ artwork: AssetRefSchema.optional(),
48
+ primaryLogo: AssetRefSchema.optional(),
49
+ });
50
+ export const EventTicketImageSlotsSchema = z.strictObject({
51
+ icon: AssetRefSchema,
52
+ logo: AssetRefSchema.optional(),
53
+ strip: AssetRefSchema.optional(),
54
+ thumbnail: AssetRefSchema.optional(),
55
+ background: AssetRefSchema.optional(),
56
+ artwork: AssetRefSchema.optional(),
57
+ primaryLogo: AssetRefSchema.optional(),
58
+ });
59
+ export const StampCardImageSlotsSchema = z.strictObject({
60
+ icon: AssetRefSchema,
61
+ logo: AssetRefSchema.optional(),
62
+ artwork: AssetRefSchema.optional(),
63
+ primaryLogo: AssetRefSchema.optional(),
64
+ });
65
+ /** A points card's strip is an uploaded image, unrelated to the balance. */
66
+ export const PointsCardImageSlotsSchema = z.strictObject({
67
+ icon: AssetRefSchema,
68
+ logo: AssetRefSchema.optional(),
69
+ strip: AssetRefSchema.optional(),
70
+ artwork: AssetRefSchema.optional(),
71
+ primaryLogo: AssetRefSchema.optional(),
72
+ });
73
+ /**
74
+ * The wallets whose design this revision must keep valid: those offered on new
75
+ * passes or used by an active pass. The server sets it on save. A wallet left
76
+ * out may still hold design that is saved but not required.
77
+ */
78
+ export const PlatformsSchema = z.strictObject({ apple: z.boolean(), google: z.boolean() });
79
+ /**
80
+ * The iOS 27 poster. It stays stored while switched off. `brand` picks a logo,
81
+ * logo text, or both; with both, Wallet cuts the text to fit beside the logo.
82
+ */
83
+ export const PosterSettingsSchema = z.strictObject({
84
+ enabled: z.boolean(),
85
+ brand: z.enum(['logo', 'text', 'logoAndText']),
86
+ });
87
+ /**
88
+ * Apple-only controls. `sharingProhibited` hides Wallet's Share button; Google's
89
+ * closest control is `google.singleHolder`.
90
+ * `hideLogoText` drops `logoText` from the Apple pass only, for a wide logo that
91
+ * already shows the name; Google keeps it as the card title.
92
+ */
93
+ export const AppleSettingsSchema = z.strictObject({
94
+ sharingProhibited: z.boolean().optional(),
95
+ hideLogoText: z.boolean().optional(),
96
+ poster: PosterSettingsSchema.optional(),
97
+ featuredActions: z.array(FeaturedActionDefSchema).max(MAX_FEATURED_ACTIONS).optional(),
98
+ });
99
+ /**
100
+ * Google-only controls. The hero image is Google-only (Apple has no analogue).
101
+ * `logoAssetId` is Google's logo, required when Google is on: Google crops it
102
+ * into a circle, while `images.logo` is Apple's wide 160×50 pt header logo.
103
+ * `wideLogoAssetId` is an optional wide header logo; Google still shows the
104
+ * square logo in the pass list and details.
105
+ * `singleHolder` locks each pass to the first Google account that saves it. It
106
+ * lives on the class, so a save applies it to every issued pass at once; Google
107
+ * refuses to turn it on while anyone holds a pass of the class.
108
+ */
109
+ export const GoogleSettingsSchema = z.strictObject({
110
+ heroAssetId: AssetRefSchema.optional(),
111
+ logoAssetId: AssetRefSchema.optional(),
112
+ wideLogoAssetId: AssetRefSchema.optional(),
113
+ singleHolder: z.boolean().optional(),
114
+ });
115
+ /** An event ticket's Google class also carries its card-front rows (`cardRows`). */
116
+ export const EventTicketGoogleSettingsSchema = GoogleSettingsSchema.extend({
117
+ cardRows: CardRowsSchema.optional(),
118
+ });
119
+ /**
120
+ * A loyalty card's (stamp or points) Google class names (each falls back to logo
121
+ * text) and the fields shown as Member name and Member ID. Google requires the ID,
122
+ * so its field is a required per-pass field. On a stamp card the drawn stamps are
123
+ * the hero, so `heroAssetId` is rejected there.
124
+ */
125
+ export const LoyaltyGoogleSettingsSchema = GoogleSettingsSchema.extend({
126
+ issuerName: z.string().trim().min(1).max(100).optional(),
127
+ programName: z.string().trim().min(1).max(100).optional(),
128
+ memberNameField: FieldKeySchema.optional(),
129
+ memberIdField: FieldKeySchema.optional(),
130
+ });
131
+ /** Design fields shared by every template type. `name` lives on the row, not here. */
132
+ const sharedShape = {
133
+ schemaVersion: z.literal(TEMPLATE_SCHEMA_VERSION),
134
+ description: z.string().min(1).max(200),
135
+ organizationName: z.string().min(1).max(100),
136
+ logoText: z.string().max(100).optional(),
137
+ colors: z.strictObject({
138
+ background: HexColorSchema,
139
+ foreground: HexColorSchema,
140
+ label: HexColorSchema.optional(),
141
+ }),
142
+ barcode: BarcodeConfigSchema.optional(),
143
+ platforms: PlatformsSchema,
144
+ apple: AppleSettingsSchema.default({}),
145
+ /** "When and where": locations reach both wallets, date windows only Apple. */
146
+ relevance: TemplateRelevanceSchema.optional(),
147
+ };
148
+ export const GenericConfigSchema = z.strictObject({
149
+ fields: FieldListSchema,
150
+ layouts: z.strictObject({
151
+ standard: StandardLayoutSchema,
152
+ poster: PosterLayoutSchema.optional(),
153
+ }),
154
+ });
155
+ export const EventTicketConfigSchema = z.strictObject({
156
+ fields: FieldListSchema,
157
+ layouts: z.strictObject({
158
+ standard: StandardLayoutSchema,
159
+ poster: PosterLayoutSchema.optional(),
160
+ }),
161
+ });
162
+ /** Apple draws the primary field over the strip, so stamp cards' standard layout has none. */
163
+ export const StampCardConfigSchema = z.strictObject({
164
+ fields: FieldListSchema,
165
+ layouts: z.strictObject({
166
+ standard: StandardLayoutSchema,
167
+ poster: PosterLayoutSchema.optional(),
168
+ }),
169
+ stamps: StampSettingsSchema,
170
+ notifications: StampNotificationsSchema.default({}),
171
+ });
172
+ export const PointsCardConfigSchema = z.strictObject({
173
+ fields: FieldListSchema,
174
+ layouts: z.strictObject({
175
+ standard: StandardLayoutSchema,
176
+ poster: PosterLayoutSchema.optional(),
177
+ }),
178
+ points: PointsSettingsSchema,
179
+ notifications: PointsNotificationsSchema.default({}),
180
+ });
181
+ export const RESERVED_FIELD_KEY_PREFIX = 'tapace_';
182
+ /**
183
+ * The template definition: shared design fields plus a `type`-discriminated
184
+ * `config`. A new type is added by appending a member here (and its config
185
+ * schema) — never a separate table or `any`.
186
+ */
187
+ export const TemplateDefinitionSchema = z
188
+ .discriminatedUnion('type', [
189
+ z.strictObject({
190
+ ...sharedShape,
191
+ type: z.literal('generic'),
192
+ images: ImageSlotsSchema,
193
+ google: GoogleSettingsSchema.default({}),
194
+ config: GenericConfigSchema,
195
+ }),
196
+ z.strictObject({
197
+ ...sharedShape,
198
+ type: z.literal('event_ticket'),
199
+ images: EventTicketImageSlotsSchema,
200
+ google: EventTicketGoogleSettingsSchema.default({}),
201
+ config: EventTicketConfigSchema,
202
+ }),
203
+ z.strictObject({
204
+ ...sharedShape,
205
+ type: z.literal('stamp_card'),
206
+ images: StampCardImageSlotsSchema,
207
+ google: LoyaltyGoogleSettingsSchema.default({}),
208
+ config: StampCardConfigSchema,
209
+ }),
210
+ z.strictObject({
211
+ ...sharedShape,
212
+ type: z.literal('points_card'),
213
+ images: PointsCardImageSlotsSchema,
214
+ google: LoyaltyGoogleSettingsSchema.default({}),
215
+ config: PointsCardConfigSchema,
216
+ }),
217
+ ])
218
+ .superRefine((def, ctx) => {
219
+ const fields = def.config.fields;
220
+ const seenKeys = new Set();
221
+ const seenIds = new Set();
222
+ fields.forEach((f, index) => {
223
+ if (seenKeys.has(f.key)) {
224
+ ctx.addIssue({
225
+ code: 'custom',
226
+ message: `Duplicate field key "${f.key}".`,
227
+ path: ['config', 'fields', index, 'key'],
228
+ });
229
+ }
230
+ seenKeys.add(f.key);
231
+ if (f.key.toLowerCase().startsWith(RESERVED_FIELD_KEY_PREFIX)) {
232
+ ctx.addIssue({
233
+ code: 'custom',
234
+ message: `Field keys starting with "${RESERVED_FIELD_KEY_PREFIX}" are reserved for TapAce notifications.`,
235
+ path: ['config', 'fields', index, 'key'],
236
+ });
237
+ }
238
+ if (f.id !== undefined) {
239
+ if (seenIds.has(f.id)) {
240
+ ctx.addIssue({
241
+ code: 'custom',
242
+ message: `Duplicate field id "${f.id}".`,
243
+ path: ['config', 'fields', index, 'id'],
244
+ });
245
+ }
246
+ seenIds.add(f.id);
247
+ }
248
+ });
249
+ const roles = new Set();
250
+ fields.forEach((f, index) => {
251
+ if (def.type !== 'event_ticket' && (f.binding === 'event' || f.googleRole)) {
252
+ ctx.addIssue({
253
+ code: 'custom',
254
+ message: 'Event fields and Google roles are for event ticket templates.',
255
+ path: ['config', 'fields', index, f.binding === 'event' ? 'binding' : 'googleRole'],
256
+ });
257
+ }
258
+ if (f.googleRole) {
259
+ if (roles.has(f.googleRole)) {
260
+ ctx.addIssue({
261
+ code: 'custom',
262
+ message: `Another field already has the Google role "${f.googleRole}".`,
263
+ path: ['config', 'fields', index, 'googleRole'],
264
+ });
265
+ }
266
+ roles.add(f.googleRole);
267
+ }
268
+ });
269
+ if (def.type === 'event_ticket') {
270
+ const byKey = new Map(fields.map((f) => [f.key, f]));
271
+ const inRows = new Set();
272
+ def.google.cardRows?.forEach((row, rowIndex) => {
273
+ row.forEach((ref, index) => {
274
+ const path = ['google', 'cardRows', rowIndex, index];
275
+ const item = resolveCardItem(ref, fields);
276
+ const field = byKey.get(ref);
277
+ if (!item && !field) {
278
+ ctx.addIssue({ code: 'custom', message: `No field has the key "${ref}".`, path });
279
+ return;
280
+ }
281
+ if (!item) {
282
+ ctx.addIssue({
283
+ code: 'custom',
284
+ message: `"${field?.label}" already shows on the Google ticket; it can't be a card row item.`,
285
+ path,
286
+ });
287
+ return;
288
+ }
289
+ if (item.kind === 'seat' && !fields.some((f) => f.googleRole === item.role)) {
290
+ ctx.addIssue({
291
+ code: 'custom',
292
+ message: `"${ref}" needs a per-pass field with the Google role "${item.role}".`,
293
+ path,
294
+ });
295
+ }
296
+ const identity = cardItemRef(item);
297
+ if (inRows.has(identity)) {
298
+ ctx.addIssue({ code: 'custom', message: `"${ref}" is on the Google card twice.`, path });
299
+ }
300
+ inRows.add(identity);
301
+ });
302
+ });
303
+ }
304
+ fields.forEach((f, index) => {
305
+ if ((f.binding === 'stamps' || f.binding === 'stampsLeft') && def.type !== 'stamp_card') {
306
+ ctx.addIssue({
307
+ code: 'custom',
308
+ message: f.binding === 'stamps'
309
+ ? 'The stamp count field is for stamp card templates.'
310
+ : 'The stamps left field is for stamp card templates.',
311
+ path: ['config', 'fields', index, 'binding'],
312
+ });
313
+ }
314
+ if ((f.binding === 'points' || f.binding === 'pointsToGoal') && def.type !== 'points_card') {
315
+ ctx.addIssue({
316
+ code: 'custom',
317
+ message: f.binding === 'points'
318
+ ? 'The points field is for points card templates.'
319
+ : 'The points to goal field is for points card templates.',
320
+ path: ['config', 'fields', index, 'binding'],
321
+ });
322
+ }
323
+ if (f.binding === 'pointsToGoal' &&
324
+ def.type === 'points_card' &&
325
+ def.config.points.goal === undefined) {
326
+ ctx.addIssue({
327
+ code: 'custom',
328
+ message: 'Set a goal to use the points to goal field.',
329
+ path: ['config', 'fields', index, 'binding'],
330
+ });
331
+ }
332
+ });
333
+ const ONE_FIELD = [
334
+ ['stamps', 'A stamp card has one stamp count field.'],
335
+ ['points', 'A points card has one points field.'],
336
+ ['stampsLeft', 'A stamp card has one stamps left field.'],
337
+ ['pointsToGoal', 'A points card has one points to goal field.'],
338
+ ];
339
+ for (const [binding, message] of ONE_FIELD) {
340
+ if (fields.filter((f) => f.binding === binding).length > 1) {
341
+ ctx.addIssue({ code: 'custom', message, path: ['config', 'fields'] });
342
+ }
343
+ }
344
+ const notificationTexts = (notifications, required, missing) => {
345
+ for (const [key, enabled] of required) {
346
+ if (enabled && notifications[key] === undefined) {
347
+ ctx.addIssue({ code: 'custom', message: missing, path: ['config', 'notifications', key] });
348
+ }
349
+ }
350
+ };
351
+ if (def.type === 'stamp_card' || def.type === 'points_card') {
352
+ const byKey = new Map(fields.map((f) => [f.key, f]));
353
+ const memberField = (key, name, required) => {
354
+ if (key === undefined)
355
+ return;
356
+ const path = ['google', name];
357
+ const field = byKey.get(key);
358
+ if (!field) {
359
+ ctx.addIssue({ code: 'custom', message: `No field has the key "${key}".`, path });
360
+ }
361
+ else if (field.binding !== 'perPass' || (required && !field.required)) {
362
+ ctx.addIssue({
363
+ code: 'custom',
364
+ message: required
365
+ ? `Member ID must be a required per-pass field; "${field.label}" is not.`
366
+ : `Member name must be a per-pass field; "${field.label}" is not.`,
367
+ path,
368
+ });
369
+ }
370
+ };
371
+ memberField(def.google.memberNameField, 'memberNameField', false);
372
+ memberField(def.google.memberIdField, 'memberIdField', true);
373
+ if (def.platforms.google && def.google.memberIdField === undefined) {
374
+ ctx.addIssue({
375
+ code: 'custom',
376
+ message: 'Google Wallet needs a Member ID. Pick the field that holds it.',
377
+ path: ['google', 'memberIdField'],
378
+ });
379
+ }
380
+ }
381
+ if (def.type === 'points_card') {
382
+ const notifications = def.config.notifications;
383
+ notificationTexts(notifications, [
384
+ ['apple', def.platforms.apple],
385
+ ['googleTitle', def.platforms.google],
386
+ ['googleBody', def.platforms.google],
387
+ ], 'Write the notification text for points changes.');
388
+ if (def.config.points.goal === undefined) {
389
+ for (const key of ['goalApple', 'goalGoogleTitle', 'goalGoogleBody']) {
390
+ if (notifications[key] !== undefined) {
391
+ ctx.addIssue({
392
+ code: 'custom',
393
+ message: 'Set a goal to use goal texts.',
394
+ path: ['config', 'notifications', key],
395
+ });
396
+ }
397
+ }
398
+ }
399
+ }
400
+ if (def.type === 'stamp_card') {
401
+ if (def.config.layouts.standard.primary.length > 0) {
402
+ ctx.addIssue({
403
+ code: 'custom',
404
+ message: 'Stamp cards have no primary field: Apple draws it over the stamps.',
405
+ path: ['config', 'layouts', 'standard', 'primary'],
406
+ });
407
+ }
408
+ if (def.google.heroAssetId) {
409
+ ctx.addIssue({
410
+ code: 'custom',
411
+ message: 'The stamps are the Google hero image on a stamp card.',
412
+ path: ['google', 'heroAssetId'],
413
+ });
414
+ }
415
+ notificationTexts(def.config.notifications, [
416
+ ['apple', def.platforms.apple],
417
+ ['googleTitle', def.platforms.google],
418
+ ['googleBody', def.platforms.google],
419
+ ], 'Write the notification text for stamp changes.');
420
+ }
421
+ const placed = new Set();
422
+ for (const [layout, slots] of layoutEntries(def)) {
423
+ const inLayout = new Set();
424
+ for (const [slot, keys] of slots) {
425
+ keys.forEach((key, index) => {
426
+ const path = ['config', 'layouts', layout, slot, index];
427
+ if (!seenKeys.has(key)) {
428
+ ctx.addIssue({ code: 'custom', message: `No field has the key "${key}".`, path });
429
+ }
430
+ else if (inLayout.has(key)) {
431
+ ctx.addIssue({
432
+ code: 'custom',
433
+ message: `Field "${key}" is placed twice in the ${layout} layout.`,
434
+ path,
435
+ });
436
+ }
437
+ inLayout.add(key);
438
+ placed.add(key);
439
+ });
440
+ }
441
+ }
442
+ fields.forEach((f, index) => {
443
+ if (!placed.has(f.key)) {
444
+ ctx.addIssue({
445
+ code: 'custom',
446
+ message: `Field "${f.label}" is not placed in any layout.`,
447
+ path: ['config', 'fields', index],
448
+ });
449
+ }
450
+ });
451
+ const poster = def.apple.poster;
452
+ if (poster?.enabled) {
453
+ if (!def.config.layouts.poster) {
454
+ ctx.addIssue({
455
+ code: 'custom',
456
+ message: 'Arrange the poster layout before turning the poster on.',
457
+ path: ['config', 'layouts', 'poster'],
458
+ });
459
+ }
460
+ if (def.type === 'stamp_card' && !def.config.stamps.poster) {
461
+ ctx.addIssue({
462
+ code: 'custom',
463
+ message: 'Choose how the stamps look on the poster.',
464
+ path: ['config', 'stamps', 'poster'],
465
+ });
466
+ }
467
+ const onColor = def.type === 'stamp_card' && def.config.stamps.poster?.background.kind === 'color';
468
+ if (!onColor && !def.images.artwork) {
469
+ ctx.addIssue({
470
+ code: 'custom',
471
+ message: 'The poster needs artwork. Upload a poster artwork image.',
472
+ path: ['images', 'artwork'],
473
+ });
474
+ }
475
+ if (poster.brand !== 'text' && !def.images.primaryLogo && !def.images.logo) {
476
+ ctx.addIssue({
477
+ code: 'custom',
478
+ message: 'The poster shows a logo. Upload a logo or show logo text instead.',
479
+ path: ['apple', 'poster', 'brand'],
480
+ });
481
+ }
482
+ if (poster.brand !== 'logo' && !def.logoText?.trim()) {
483
+ ctx.addIssue({
484
+ code: 'custom',
485
+ message: 'The poster shows logo text. Add logo text or show a logo instead.',
486
+ path: ['logoText'],
487
+ });
488
+ }
489
+ }
490
+ const actions = def.apple.featuredActions ?? [];
491
+ const actionKeys = new Set();
492
+ const actionIds = new Set();
493
+ actions.forEach((action, index) => {
494
+ if (actionKeys.has(action.key)) {
495
+ ctx.addIssue({
496
+ code: 'custom',
497
+ message: `Duplicate action key "${action.key}".`,
498
+ path: ['apple', 'featuredActions', index, 'key'],
499
+ });
500
+ }
501
+ actionKeys.add(action.key);
502
+ if (action.id !== undefined) {
503
+ if (actionIds.has(action.id)) {
504
+ ctx.addIssue({
505
+ code: 'custom',
506
+ message: `Duplicate action id "${action.id}".`,
507
+ path: ['apple', 'featuredActions', index, 'id'],
508
+ });
509
+ }
510
+ actionIds.add(action.id);
511
+ }
512
+ });
513
+ if (def.platforms.google && !def.google.logoAssetId) {
514
+ ctx.addIssue({
515
+ code: 'custom',
516
+ message: 'Google Wallet needs its own logo. Upload a Google logo.',
517
+ path: ['google', 'logoAssetId'],
518
+ });
519
+ }
520
+ // Apple's event ticket shows either a strip or a background with an optional
521
+ // thumbnail; a pass carrying both is not laid out the way the designer shows.
522
+ if (def.type === 'event_ticket' && def.images.strip) {
523
+ for (const slot of ['background', 'thumbnail']) {
524
+ if (def.images[slot]) {
525
+ ctx.addIssue({
526
+ code: 'custom',
527
+ message: `A strip can't be combined with a ${slot}. Choose either Strip or Background + thumbnail.`,
528
+ path: ['images', slot],
529
+ });
530
+ }
531
+ }
532
+ }
533
+ });
534
+ export class TemplateValidationError extends Error {
535
+ issues;
536
+ constructor(issues) {
537
+ super('Template definition is invalid.');
538
+ this.issues = issues;
539
+ this.name = 'TemplateValidationError';
540
+ }
541
+ }
542
+ /**
543
+ * Parse an untrusted definition through the versioned schema. Version 1 bodies
544
+ * are converted first. `type` is the caller's declared type (from the row /
545
+ * request) and must match the body's discriminator — a mismatched type/config
546
+ * pair is rejected, never coerced.
547
+ */
548
+ export function parseTemplateDefinition(declaredType, input) {
549
+ const result = TemplateDefinitionSchema.safeParse(upgradeDefinition(input));
550
+ if (!result.success)
551
+ throw new TemplateValidationError(result.error.issues);
552
+ if (result.data.type !== declaredType) {
553
+ throw new TemplateValidationError([
554
+ {
555
+ code: 'custom',
556
+ message: `Definition type "${result.data.type}" does not match template type "${declaredType}".`,
557
+ path: ['type'],
558
+ input,
559
+ },
560
+ ]);
561
+ }
562
+ return result.data;
563
+ }
564
+ /**
565
+ * A saved revision's definition. Saved definitions were validated on save;
566
+ * version 1 ones are converted, and ones saved before the platform blocks
567
+ * existed get empty ones.
568
+ */
569
+ export function storedDefinition(raw) {
570
+ const def = upgradeDefinition(raw);
571
+ if (def.apple && def.google)
572
+ return def;
573
+ return { ...def, apple: def.apple ?? {}, google: def.google ?? {} };
574
+ }
575
+ function layoutEntries(def) {
576
+ const entries = [
577
+ ['standard', STANDARD_SLOTS.map((slot) => [slot, def.config.layouts.standard[slot]])],
578
+ ];
579
+ const poster = posterLayout(def);
580
+ if (poster)
581
+ entries.push(['poster', POSTER_SLOTS.map((slot) => [slot, poster[slot]])]);
582
+ return entries;
583
+ }
584
+ function posterLayout(def) {
585
+ return 'poster' in def.config.layouts ? def.config.layouts.poster : undefined;
586
+ }
587
+ /** True when the pass renders the poster: switched on, with a stored poster layout. */
588
+ export function posterEnabled(def) {
589
+ return def.apple.poster?.enabled === true && !!posterLayout(def);
590
+ }
591
+ function fieldsByKey(def) {
592
+ return new Map(def.config.fields.map((f) => [f.key, f]));
593
+ }
594
+ /** The standard layout's fields per slot, in placement order. */
595
+ export function standardLayoutFields(def) {
596
+ const byKey = fieldsByKey(def);
597
+ const out = {};
598
+ for (const slot of STANDARD_SLOTS) {
599
+ out[slot] = def.config.layouts.standard[slot]
600
+ .map((key) => byKey.get(key))
601
+ .filter((f) => !!f);
602
+ }
603
+ return out;
604
+ }
605
+ /** The poster layout's fields per slot, or undefined when the poster is not rendered. */
606
+ export function posterLayoutFields(def) {
607
+ const poster = posterLayout(def);
608
+ if (!poster || !posterEnabled(def))
609
+ return undefined;
610
+ const byKey = fieldsByKey(def);
611
+ const out = {};
612
+ for (const slot of POSTER_SLOTS) {
613
+ out[slot] = poster[slot].map((key) => byKey.get(key)).filter((f) => !!f);
614
+ }
615
+ return out;
616
+ }
617
+ /**
618
+ * Fields placed in a rendered layout: the standard layout, plus the poster while
619
+ * it is on. Ordered by placement (standard slots, then poster slots).
620
+ */
621
+ export function activeFields(def) {
622
+ const out = [];
623
+ const seen = new Set();
624
+ const add = (fields) => {
625
+ for (const f of fields) {
626
+ if (seen.has(f.key))
627
+ continue;
628
+ seen.add(f.key);
629
+ out.push(f);
630
+ }
631
+ };
632
+ const standard = standardLayoutFields(def);
633
+ for (const slot of STANDARD_SLOTS)
634
+ add(standard[slot]);
635
+ const poster = def.platforms.apple ? posterLayoutFields(def) : undefined;
636
+ if (poster)
637
+ for (const slot of POSTER_SLOTS)
638
+ add(poster[slot]);
639
+ return out;
640
+ }
641
+ /** Where a field shows: its standard slot, or its poster slot for a poster-only field. */
642
+ export function fieldSlot(def, key) {
643
+ for (const slot of STANDARD_SLOTS) {
644
+ if (def.config.layouts.standard[slot].includes(key))
645
+ return slot;
646
+ }
647
+ const poster = posterLayout(def);
648
+ if (poster) {
649
+ for (const slot of POSTER_SLOTS)
650
+ if (poster[slot].includes(key))
651
+ return slot;
652
+ }
653
+ return undefined;
654
+ }
655
+ export function featuredActions(def) {
656
+ return def.platforms.apple ? (def.apple.featuredActions ?? []) : [];
657
+ }
658
+ /**
659
+ * The Lock Screen relevance a pass carries: its own lists, else the template's,
660
+ * else, on an event ticket, the venue and the doors-open (or start) to end window.
661
+ * Date windows need Apple; locations also reach Google.
662
+ */
663
+ export function resolveRelevance(def, own) {
664
+ const event = def.type === 'event_ticket' ? def.event : undefined;
665
+ const opens = event?.doorsOpen ?? event?.start;
666
+ const eventLocations = event?.venue?.location ? [event.venue.location] : [];
667
+ const eventDates = opens && event?.end ? [{ start: opens, end: event.end }] : [];
668
+ return {
669
+ locations: own?.locations ?? def.relevance?.locations ?? eventLocations,
670
+ dates: def.platforms.apple ? (own?.dates ?? def.relevance?.dates ?? eventDates) : [],
671
+ };
672
+ }
673
+ export function imageSlotsOf(def) {
674
+ switch (def.type) {
675
+ case 'event_ticket':
676
+ return EVENT_TICKET_IMAGE_SLOTS;
677
+ case 'stamp_card':
678
+ return STAMP_CARD_IMAGE_SLOTS;
679
+ case 'points_card':
680
+ return POINTS_CARD_IMAGE_SLOTS;
681
+ case 'generic':
682
+ return GENERIC_IMAGE_SLOTS;
683
+ }
684
+ }
685
+ export function imageAssetId(def, slot) {
686
+ return def.images[slot];
687
+ }
688
+ /**
689
+ * The asset the poster shows as its logo: the poster logo override, else the
690
+ * Apple logo. Undefined when the poster is off or shows logo text only.
691
+ */
692
+ export function posterLogoAssetId(def) {
693
+ if (!posterEnabled(def) || def.apple.poster?.brand === 'text')
694
+ return undefined;
695
+ return imageAssetId(def, 'primaryLogo') ?? imageAssetId(def, 'logo');
696
+ }
697
+ /** Asset ids a definition references, so they can be ownership-checked. */
698
+ export function referencedAssetIds(def) {
699
+ const ids = imageSlotsOf(def)
700
+ .map((slot) => imageAssetId(def, slot))
701
+ .filter((id) => !!id);
702
+ if (def.google.heroAssetId)
703
+ ids.push(def.google.heroAssetId);
704
+ if (def.google.logoAssetId)
705
+ ids.push(def.google.logoAssetId);
706
+ if (def.google.wideLogoAssetId)
707
+ ids.push(def.google.wideLogoAssetId);
708
+ if (def.type === 'stamp_card')
709
+ ids.push(...stampAssetIds(def.config.stamps));
710
+ return [...new Set(ids)];
711
+ }
712
+ /** Keys the issue-pass form must collect: per-pass fields flagged required, in a rendered layout. */
713
+ export function requiredFieldKeys(def) {
714
+ return activeFields(def)
715
+ .filter((f) => f.binding === 'perPass' && f.required)
716
+ .map((f) => f.key);
717
+ }
718
+ /** Featured actions every issued pass must supply a link for. */
719
+ export function requiredActionKeys(def) {
720
+ return featuredActions(def)
721
+ .filter((a) => a.binding === 'perPass')
722
+ .map((a) => a.key);
723
+ }
724
+ function idIssue(message, path) {
725
+ return { code: 'custom', message, path };
726
+ }
727
+ /**
728
+ * Assign stable ids to new fields and featured actions, and reject unknown or
729
+ * duplicate ids. Within a layout a saved field keeps its slot: moving it means
730
+ * deleting it and creating a new field.
731
+ */
732
+ export function normalizeFieldIds(def, previousDef) {
733
+ const issues = [];
734
+ const previousFieldIds = new Set((previousDef?.config.fields ?? []).flatMap((f) => (f.id ? [f.id] : [])));
735
+ const usedFieldIds = new Set();
736
+ const fields = def.config.fields.map((field, index) => {
737
+ if (field.id === undefined) {
738
+ return { ...field, id: mintId('fld', previousFieldIds, usedFieldIds) };
739
+ }
740
+ const path = ['config', 'fields', index, 'id'];
741
+ if (!previousFieldIds.has(field.id)) {
742
+ issues.push(idIssue(`Unknown field id "${field.id}".`, path));
743
+ }
744
+ else if (usedFieldIds.has(field.id)) {
745
+ issues.push(idIssue(`Duplicate field id "${field.id}".`, path));
746
+ }
747
+ usedFieldIds.add(field.id);
748
+ return field;
749
+ });
750
+ if (previousDef) {
751
+ const next = { ...def, config: { ...def.config, fields } };
752
+ const idOf = new Map(fields.map((f) => [f.key, f.id]));
753
+ const previousIdOf = new Map(previousDef.config.fields.map((f) => [f.key, f.id]));
754
+ const previousSlots = new Map();
755
+ for (const [layout, slots] of layoutEntries(previousDef)) {
756
+ const byId = new Map();
757
+ for (const [slot, keys] of slots) {
758
+ for (const key of keys) {
759
+ const id = previousIdOf.get(key);
760
+ if (id)
761
+ byId.set(id, slot);
762
+ }
763
+ }
764
+ previousSlots.set(layout, byId);
765
+ }
766
+ for (const [layout, slots] of layoutEntries(next)) {
767
+ const before = previousSlots.get(layout);
768
+ if (!before)
769
+ continue;
770
+ for (const [slot, keys] of slots) {
771
+ keys.forEach((key, index) => {
772
+ const id = idOf.get(key);
773
+ const previousSlot = id ? before.get(id) : undefined;
774
+ if (previousSlot !== undefined && previousSlot !== slot) {
775
+ issues.push(idIssue(`Field "${key}" cannot move from "${previousSlot}" to "${slot}" in the ${layout} layout. Delete it and create a new field instead.`, ['config', 'layouts', layout, slot, index]));
776
+ }
777
+ });
778
+ }
779
+ }
780
+ }
781
+ const previousActionIds = new Set((previousDef?.apple?.featuredActions ?? []).flatMap((a) => (a.id ? [a.id] : [])));
782
+ const usedActionIds = new Set();
783
+ const actions = def.apple.featuredActions?.map((action, index) => {
784
+ if (action.id === undefined) {
785
+ return { ...action, id: mintId('fa', previousActionIds, usedActionIds) };
786
+ }
787
+ const path = ['apple', 'featuredActions', index, 'id'];
788
+ if (!previousActionIds.has(action.id)) {
789
+ issues.push(idIssue(`Unknown action id "${action.id}".`, path));
790
+ }
791
+ else if (usedActionIds.has(action.id)) {
792
+ issues.push(idIssue(`Duplicate action id "${action.id}".`, path));
793
+ }
794
+ usedActionIds.add(action.id);
795
+ return action;
796
+ });
797
+ if (issues.length > 0)
798
+ throw new TemplateValidationError(issues);
799
+ return {
800
+ ...def,
801
+ apple: actions ? { ...def.apple, featuredActions: actions } : def.apple,
802
+ config: { ...def.config, fields },
803
+ };
804
+ }
805
+ function mintId(prefix, previousIds, usedIds) {
806
+ let id = newFieldId(prefix);
807
+ while (previousIds.has(id) || usedIds.has(id))
808
+ id = newFieldId(prefix);
809
+ usedIds.add(id);
810
+ return id;
811
+ }
812
+ /**
813
+ * Preview adapter: callers pass public key-based sample values, but the compiler
814
+ * resolves per-pass values by field id. This maps keys to ids and assigns
815
+ * ephemeral in-memory ids to unsaved (id-less) fields and actions so their
816
+ * sample values still resolve. The returned definition is for rendering only,
817
+ * never persisted; keys that match no field are dropped.
818
+ */
819
+ export function previewFieldValues(def, sampleValues) {
820
+ const idByKey = new Map();
821
+ let ephemeral = 0;
822
+ const fields = def.config.fields.map((field) => {
823
+ const id = field.id ?? `preview_${ephemeral++}`;
824
+ idByKey.set(field.key, id);
825
+ return field.id === undefined ? { ...field, id } : field;
826
+ });
827
+ const actions = def.apple.featuredActions?.map((action) => action.id === undefined ? { ...action, id: `preview_${ephemeral++}` } : action);
828
+ const values = {};
829
+ for (const [key, value] of Object.entries(sampleValues)) {
830
+ const id = idByKey.get(key);
831
+ if (id !== undefined)
832
+ values[id] = value;
833
+ }
834
+ return {
835
+ def: {
836
+ ...def,
837
+ apple: actions ? { ...def.apple, featuredActions: actions } : def.apple,
838
+ config: { ...def.config, fields },
839
+ },
840
+ values,
841
+ };
842
+ }
843
+ export function fieldValuesByKey(def, byId) {
844
+ const keyOf = new Map(def.config.fields
845
+ .filter((f) => f.id !== undefined)
846
+ .map((f) => [f.id, f.key]));
847
+ const out = {};
848
+ for (const [id, value] of Object.entries(byId)) {
849
+ const key = keyOf.get(id);
850
+ if (key !== undefined)
851
+ out[key] = value;
852
+ }
853
+ return out;
854
+ }