@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,507 @@
1
+ import { Pass, PassmintError, PassmintSchemaError, renderApplePass, renderGooglePayload, } from 'passmint';
2
+ import { POSTER_SLOTS, STANDARD_SLOTS, featuredActions, imageAssetId, imageSlotsOf, posterLayoutFields, posterLogoAssetId, posterEnabled, resolveRelevance, standardLayoutFields, } from '../contract/index.js';
3
+ import { appleDateStyle, eventFieldValue, eventSemantics, googleEvent, googleLocations, googleShowsNatively, } from './event-ticket.js';
4
+ import { stampRenderFor } from './stamp-card.js';
5
+ import { googleAccountId, googleLoyalty, googleProgramNames, isLoyaltyCard, loyaltyBalanceText, loyaltyFieldTexts, loyaltyShowsNatively, } from './loyalty-card.js';
6
+ export const PREVIEW_ISSUANCE = {
7
+ passTypeIdentifier: 'pass.tapace.preview',
8
+ teamIdentifier: 'TAPACEPREV',
9
+ serialNumber: 'preview',
10
+ };
11
+ export const WALLET_MESSAGE_FIELDS = [
12
+ {
13
+ kind: 'announcement',
14
+ key: 'tapace_announcement',
15
+ label: 'Announcements',
16
+ placeholder: 'No announcements yet',
17
+ },
18
+ {
19
+ kind: 'personal',
20
+ key: 'tapace_message',
21
+ label: 'Message for you',
22
+ placeholder: 'No messages yet',
23
+ },
24
+ ];
25
+ const PREVIEW_ISSUER_ID = '0000000000000000';
26
+ /** Google keeps at most 10 merchant locations on a class, and 10 on an object. */
27
+ const MAX_GOOGLE_LOCATIONS = 10;
28
+ export class AssetResolutionError extends Error {
29
+ }
30
+ /**
31
+ * Stand-in assets for structural validation (field counts, colors, required
32
+ * icon, barcode) without reading real bytes. Ownership + image validity are
33
+ * checked separately at upload/reference time; real bytes/URLs are needed only
34
+ * at signing (a later feature).
35
+ */
36
+ export function placeholderAssets(ids) {
37
+ const resolved = {};
38
+ for (const id of ids) {
39
+ resolved[id] = {
40
+ bytes: new Uint8Array([0]),
41
+ url: `https://assets.tapace.example/${id}`,
42
+ };
43
+ }
44
+ return resolved;
45
+ }
46
+ export function resolveFieldValue(field, values, mode) {
47
+ if (field.binding === 'perPass') {
48
+ const supplied = field.id !== undefined ? values[field.id] : undefined;
49
+ if (supplied !== undefined &&
50
+ !(mode === 'issue' && isBlankValue(supplied))) {
51
+ return supplied;
52
+ }
53
+ if (field.defaultValue !== undefined) {
54
+ if (mode === 'issue' && isBlankValue(field.defaultValue))
55
+ return undefined;
56
+ return field.defaultValue;
57
+ }
58
+ if (mode === 'preview')
59
+ return field.sampleValue !== undefined ? field.sampleValue : '';
60
+ return undefined;
61
+ }
62
+ if (field.defaultValue !== undefined)
63
+ return field.defaultValue;
64
+ if (mode === 'preview' && field.sampleValue !== undefined)
65
+ return field.sampleValue;
66
+ return '';
67
+ }
68
+ function isBlankValue(value) {
69
+ return typeof value === 'string' && value.trim() === '';
70
+ }
71
+ function resolveBarcodeMessage(barcode, options) {
72
+ switch (barcode.source.kind) {
73
+ case 'static':
74
+ return barcode.source.value;
75
+ case 'serialNumber':
76
+ return options.issuance.serialNumber;
77
+ case 'passValue': {
78
+ if (options.mode === 'preview')
79
+ return barcode.source.sampleValue ?? '';
80
+ // Issued/deployed passes: use the stored per-pass value, or fall back to the
81
+ // pass's serial number when none was provided (barcode transition rules).
82
+ const provided = options.barcodeValue?.trim();
83
+ return provided ? provided : options.issuance.serialNumber;
84
+ }
85
+ }
86
+ }
87
+ /**
88
+ * The barcode message an already-issued pass renders, honoring the `passValue`
89
+ * serial-number fallback. Shared by the sign path and the deployment engine so the
90
+ * fallback lives in one place.
91
+ */
92
+ export function resolveIssuedBarcodeMessage(barcode, barcodeValue, serialNumber) {
93
+ return resolveBarcodeMessage(barcode, {
94
+ mode: 'issue',
95
+ issuance: { ...PREVIEW_ISSUANCE, serialNumber },
96
+ barcodeValue: barcodeValue ?? undefined,
97
+ });
98
+ }
99
+ /**
100
+ * Whether passmint accepts a barcode payload for a format/encoding — used by the
101
+ * deployment engine to decide when a format/encoding change makes an existing
102
+ * payload unrenderable (omit until corrected). Everything but the barcode is a
103
+ * valid placeholder, so a schema error isolates the barcode.
104
+ */
105
+ export function barcodeRenderable(format, message, messageEncoding) {
106
+ try {
107
+ const barcode = { format, message };
108
+ if (messageEncoding)
109
+ barcode.messageEncoding = messageEncoding;
110
+ Pass.generic({
111
+ passTypeIdentifier: PREVIEW_ISSUANCE.passTypeIdentifier,
112
+ teamIdentifier: PREVIEW_ISSUANCE.teamIdentifier,
113
+ serialNumber: PREVIEW_ISSUANCE.serialNumber,
114
+ organizationName: 'preview',
115
+ description: 'preview',
116
+ images: { icon: { x2: { bytes: new Uint8Array([0]) } } },
117
+ colors: { background: '#000000', foreground: '#FFFFFF' },
118
+ })
119
+ .barcode(barcode)
120
+ .build();
121
+ return true;
122
+ }
123
+ catch (err) {
124
+ if (err instanceof PassmintSchemaError)
125
+ return false;
126
+ throw err;
127
+ }
128
+ }
129
+ function imageValue(asset, platform, slot) {
130
+ if (platform === 'apple') {
131
+ if (!asset.bytes) {
132
+ throw new AssetResolutionError(`Apple requires image bytes for slot "${slot}".`);
133
+ }
134
+ return { x2: { bytes: asset.bytes } };
135
+ }
136
+ if (!asset.url) {
137
+ throw new AssetResolutionError(`Google requires an https URL for slot "${slot}".`);
138
+ }
139
+ return { x2: { url: asset.url } };
140
+ }
141
+ function buildImages(def, options) {
142
+ const images = {};
143
+ for (const slot of imageSlotsOf(def)) {
144
+ if (slot === 'artwork' || slot === 'primaryLogo')
145
+ continue;
146
+ const id = options.platform === 'google' && slot === 'logo'
147
+ ? def.google.logoAssetId
148
+ : imageAssetId(def, slot);
149
+ if (!id)
150
+ continue;
151
+ const asset = options.assets[id];
152
+ if (!asset)
153
+ throw new AssetResolutionError(`Missing resolved asset for slot "${slot}".`);
154
+ images[slot] = imageValue(asset, options.platform, slot);
155
+ }
156
+ const heroId = def.google.heroAssetId;
157
+ if (options.platform === 'google' && heroId) {
158
+ const asset = options.assets[heroId];
159
+ if (!asset?.url)
160
+ throw new AssetResolutionError('Google hero image requires an https URL.');
161
+ images.heroImage = { url: asset.url };
162
+ }
163
+ if (options.platform === 'apple' && posterEnabled(def)) {
164
+ // A stamp card's poster art is drawn per pass, with its stamps.
165
+ if (def.type !== 'stamp_card') {
166
+ const artworkId = imageAssetId(def, 'artwork');
167
+ const artwork = artworkId ? options.assets[artworkId] : undefined;
168
+ if (!artwork)
169
+ throw new AssetResolutionError('Missing resolved asset for slot "artwork".');
170
+ // A generic pass's art stays in background.png, as first device-tested; the
171
+ // other styles have their own use for that file or none.
172
+ images[def.type === 'generic' ? 'background' : 'artwork'] = imageValue(artwork, 'apple', 'artwork');
173
+ }
174
+ const logoId = posterLogoAssetId(def);
175
+ if (logoId) {
176
+ const logo = options.assets[logoId];
177
+ if (!logo)
178
+ throw new AssetResolutionError('Missing resolved asset for slot "primaryLogo".');
179
+ images.primaryLogo = imageValue({ bytes: logo.posterLogoBytes ?? logo.bytes }, 'apple', 'primaryLogo');
180
+ }
181
+ }
182
+ return images;
183
+ }
184
+ function fieldValueFor(def, field, values, mode, loyaltyTexts) {
185
+ if (field.binding === 'event')
186
+ return eventFieldValue(def, field, mode);
187
+ if (field.binding === 'shared' || field.binding === 'perPass')
188
+ return resolveFieldValue(field, values, mode);
189
+ return loyaltyTexts[field.binding];
190
+ }
191
+ function mapField(def, field, values, mode, platform, loyaltyTexts, changeMessage) {
192
+ if (platform === 'google' && (googleShowsNatively(def, field) || loyaltyShowsNatively(def, field)))
193
+ return undefined;
194
+ const value = fieldValueFor(def, field, values, mode, loyaltyTexts);
195
+ if (value === undefined)
196
+ return undefined;
197
+ const mapped = { key: field.key, label: field.label, value };
198
+ if (field.textAlignment)
199
+ mapped.textAlignment = field.textAlignment;
200
+ if (changeMessage)
201
+ mapped.changeMessage = changeMessage;
202
+ const dateStyle = platform === 'apple' && value !== '' ? appleDateStyle(field) : undefined;
203
+ if (dateStyle)
204
+ Object.assign(mapped, dateStyle);
205
+ return mapped;
206
+ }
207
+ function actionUrl(action, links, mode) {
208
+ const supplied = action.id !== undefined ? links[action.id] : undefined;
209
+ switch (action.binding) {
210
+ case 'fixed':
211
+ return action.url;
212
+ case 'overridable':
213
+ return supplied ?? action.url;
214
+ case 'perPass':
215
+ return mode === 'preview' ? (supplied ?? action.sampleUrl) : supplied;
216
+ }
217
+ }
218
+ function mapBarcode(def, options) {
219
+ if (!def.barcode)
220
+ return undefined;
221
+ const barcode = {
222
+ format: def.barcode.format,
223
+ message: resolveBarcodeMessage(def.barcode, options),
224
+ };
225
+ // Wallets render the message when altText is absent or empty; a single space
226
+ // is what blanks the line on both Apple and Google.
227
+ if (def.barcode.showText === false)
228
+ barcode.altText = ' ';
229
+ else if (def.barcode.altText)
230
+ barcode.altText = def.barcode.altText;
231
+ if (def.barcode.messageEncoding)
232
+ barcode.messageEncoding = def.barcode.messageEncoding;
233
+ return barcode;
234
+ }
235
+ /** Build the validated passmint pass for one platform. Throws PassmintSchemaError on invalid mappings. */
236
+ export function compile(def, options) {
237
+ const mode = options.mode ?? 'preview';
238
+ const values = options.values ?? {};
239
+ const issuance = options.issuance ?? PREVIEW_ISSUANCE;
240
+ const base = {
241
+ passTypeIdentifier: issuance.passTypeIdentifier,
242
+ teamIdentifier: issuance.teamIdentifier,
243
+ serialNumber: issuance.serialNumber,
244
+ organizationName: def.organizationName,
245
+ description: def.description,
246
+ images: buildImages(def, options),
247
+ colors: {
248
+ background: def.colors.background,
249
+ foreground: def.colors.foreground,
250
+ ...(def.colors.label ? { label: def.colors.label } : {}),
251
+ },
252
+ };
253
+ const poster = options.platform === 'apple' && posterEnabled(def);
254
+ // logoText is one key for both Apple layouts, so the poster's choice decides it
255
+ // on older iOS too, over hideLogoText.
256
+ const posterBrand = poster ? def.apple.poster?.brand : undefined;
257
+ const hideAppleLogoText = posterBrand
258
+ ? posterBrand === 'logo'
259
+ : def.apple.hideLogoText;
260
+ if (def.logoText && !(options.platform === 'apple' && hideAppleLogoText))
261
+ base.logoText = def.logoText;
262
+ if (options.platform === 'apple' &&
263
+ def.apple.sharingProhibited !== undefined) {
264
+ base.sharingProhibited = def.apple.sharingProhibited;
265
+ }
266
+ if (options.platform === 'apple' && options.webService) {
267
+ base.webService = options.webService;
268
+ }
269
+ if (options.platform === 'apple' && options.voided)
270
+ base.voided = true;
271
+ if (options.platform === 'apple') {
272
+ const { locations, dates } = resolveRelevance(def, options.relevance);
273
+ if (locations.length > 0) {
274
+ base.locations = locations.map(({ latitude, longitude, message }) => ({
275
+ latitude,
276
+ longitude,
277
+ ...(message ? { relevantText: message } : {}),
278
+ }));
279
+ }
280
+ if (dates.length > 0) {
281
+ // Wallet follows the first window that hasn't ended and ignores the rest
282
+ // (verified on iOS 27), so the windows go in time order.
283
+ base.relevantDates = [...dates]
284
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
285
+ .map(({ start, end }) => ({ startDate: start, endDate: end }));
286
+ }
287
+ }
288
+ if (options.platform === 'google') {
289
+ // A generic class carries no design, so its object takes the template's
290
+ // locations; loyalty and event classes carry them, and the object only the
291
+ // pass's own (device test GN1: Google's nearby notice showed with class locations).
292
+ const own = def.type === 'generic'
293
+ ? resolveRelevance(def, options.relevance).locations
294
+ : (options.relevance?.locations ?? []);
295
+ const merchantLocations = googleLocations(def, own);
296
+ // Passmint has no Google merchantLocations; applyRaw sets them on the object.
297
+ if (merchantLocations.length > 0)
298
+ base.applyRaw = { google: { merchantLocations } };
299
+ }
300
+ const stamps = def.type === 'stamp_card' ? stampRenderFor(def, options.stamps, mode) : undefined;
301
+ const balanceText = loyaltyBalanceText(def, stamps?.count ?? 0, options.points);
302
+ const loyaltyTexts = loyaltyFieldTexts(def, stamps?.count ?? 0, options.points);
303
+ if (stamps && options.platform === 'apple') {
304
+ base.images.strip = {
305
+ x2: { bytes: stamps.appleStrip.x2 },
306
+ x3: { bytes: stamps.appleStrip.x3 },
307
+ };
308
+ if (poster)
309
+ base.images.artwork = { x2: { bytes: stamps.appleArtwork } };
310
+ }
311
+ if (stamps && options.platform === 'google')
312
+ base.images.googleObject = { heroImage: { url: stamps.googleHeroUrl } };
313
+ const valueOf = (field) => fieldValueFor(def, field, values, mode, loyaltyTexts);
314
+ if (isLoyaltyCard(def) && options.platform === 'google') {
315
+ const accountId = googleAccountId(def, valueOf);
316
+ // Passmint uses the serial number as the loyalty account id; applyRaw replaces it.
317
+ if (accountId)
318
+ base.applyRaw = { google: { ...base.applyRaw?.google, accountId } };
319
+ }
320
+ const semantics = eventSemantics(def, valueOf);
321
+ if (semantics)
322
+ base.semantics = semantics;
323
+ if (options.platform === 'google') {
324
+ const event = googleEvent(def, valueOf, options.holderName ?? undefined);
325
+ if (event)
326
+ base.google = { event };
327
+ }
328
+ const builder = def.type === 'event_ticket'
329
+ ? Pass.eventTicket(poster ? { ...base, poster: true } : base)
330
+ : isLoyaltyCard(def)
331
+ ? Pass.storeCard({
332
+ ...base,
333
+ ...(poster ? { poster: true } : {}),
334
+ google: options.platform === 'google'
335
+ ? { loyalty: googleLoyalty(def, balanceText ?? '', valueOf) }
336
+ : undefined,
337
+ })
338
+ : Pass.generic(poster ? { ...base, poster: true } : base);
339
+ const adders = {
340
+ header: (f) => builder.headerField(f),
341
+ primary: (f) => builder.primaryField(f),
342
+ secondary: (f) => builder.secondaryField(f),
343
+ auxiliary: (f) => builder.auxiliaryField(f),
344
+ back: (f) => builder.backField(f),
345
+ };
346
+ const changeMessages = options.platform === 'apple' ? options.changeMessages : undefined;
347
+ const map = (field) => mapField(def, field, values, mode, options.platform, loyaltyTexts, field.id !== undefined ? changeMessages?.[field.id] : undefined);
348
+ // Wallet ignores the generic block's back fields on a poster, so the reserved
349
+ // notification fields lead the back of both blocks.
350
+ const walletMessageFields = options.platform === 'apple' && options.walletMessages
351
+ ? WALLET_MESSAGE_FIELDS.map((field) => ({
352
+ key: field.key,
353
+ label: field.label,
354
+ value: options.walletMessages?.[field.kind] ?? field.placeholder,
355
+ changeMessage: '%@',
356
+ }))
357
+ : [];
358
+ for (const field of walletMessageFields)
359
+ builder.backField(field);
360
+ const standard = standardLayoutFields(def);
361
+ for (const slot of STANDARD_SLOTS) {
362
+ for (const field of standard[slot]) {
363
+ const mapped = map(field);
364
+ if (mapped)
365
+ adders[slot](mapped);
366
+ }
367
+ }
368
+ if (poster) {
369
+ for (const field of walletMessageFields)
370
+ builder.posterField('back', field);
371
+ const posterFields = posterLayoutFields(def);
372
+ for (const slot of POSTER_SLOTS) {
373
+ for (const field of posterFields?.[slot] ?? []) {
374
+ const mapped = map(field);
375
+ if (mapped)
376
+ builder.posterField(slot, mapped);
377
+ }
378
+ }
379
+ }
380
+ if (options.platform === 'apple') {
381
+ for (const action of featuredActions(def)) {
382
+ const url = actionUrl(action, options.actionLinks ?? {}, mode);
383
+ if (url)
384
+ builder.featuredAction({ identifier: action.key, type: action.type, url });
385
+ }
386
+ }
387
+ const barcode = mapBarcode(def, {
388
+ values,
389
+ mode,
390
+ issuance,
391
+ barcodeValue: options.barcodeValue,
392
+ });
393
+ if (barcode)
394
+ builder.barcode(barcode);
395
+ return builder.build().toObject();
396
+ }
397
+ /** The resolved barcode payload for preview/display, or undefined if no barcode. */
398
+ export function previewBarcodeMessage(def) {
399
+ if (!def.barcode)
400
+ return undefined;
401
+ return resolveBarcodeMessage(def.barcode, {
402
+ mode: 'preview',
403
+ issuance: PREVIEW_ISSUANCE,
404
+ });
405
+ }
406
+ export function renderApple(def, options) {
407
+ return renderApplePass(compile(def, { ...options, platform: 'apple' }));
408
+ }
409
+ export function renderGoogle(def, options) {
410
+ const payload = renderGooglePayload(compile(def, { ...options, platform: 'google' }), {
411
+ issuerId: options.issuerId ?? PREVIEW_ISSUER_ID,
412
+ ...(options.classSuffix ? { classSuffix: options.classSuffix } : {}),
413
+ ...(options.objectSuffix ? { objectSuffix: options.objectSuffix } : {}),
414
+ });
415
+ // Passmint gives every class the shared fields (issuerName, reviewStatus,
416
+ // hexBackgroundColor), none of which GenericClass defines: a generic card
417
+ // comes entirely from its object, so the class keeps only its id (and the
418
+ // holder lock below).
419
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
420
+ if (payload.genericClasses)
421
+ payload.genericClasses = payload.genericClasses
422
+ .filter(isRecord)
423
+ .map(({ id }) => ({ id }));
424
+ if (def.google.singleHolder) {
425
+ for (const [key, entries] of Object.entries(payload)) {
426
+ if (!key.endsWith('Classes') || !Array.isArray(entries))
427
+ continue;
428
+ for (const cls of entries.filter(isRecord))
429
+ cls.multipleDevicesAndHoldersAllowedStatus = 'ONE_USER_ALL_DEVICES';
430
+ }
431
+ }
432
+ const loyaltyClasses = (payload.loyaltyClasses ?? []).filter(isRecord);
433
+ if (isLoyaltyCard(def)) {
434
+ for (const cls of loyaltyClasses)
435
+ Object.assign(cls, googleProgramNames(def));
436
+ }
437
+ const classLocations = def.type === 'generic' ? [] : googleLocations(def, def.relevance?.locations ?? []);
438
+ if (classLocations.length > 0) {
439
+ // An event class already lists its venue (from passmint); the template's places follow it.
440
+ for (const cls of [...loyaltyClasses, ...(payload.eventTicketClasses ?? []).filter(isRecord)]) {
441
+ const current = Array.isArray(cls.merchantLocations) ? cls.merchantLocations : [];
442
+ cls.merchantLocations = [...current, ...classLocations].slice(0, MAX_GOOGLE_LOCATIONS);
443
+ }
444
+ }
445
+ const wideLogoId = def.google.wideLogoAssetId;
446
+ if (wideLogoId) {
447
+ // Passmint has no wide logo; an event ticket's and a loyalty program's live on the class.
448
+ const uri = options.assets[wideLogoId]?.url;
449
+ if (!uri)
450
+ throw new AssetResolutionError('Google wide logo requires an https URL.');
451
+ const holders = [
452
+ ...(payload.genericObjects ?? []),
453
+ ...(payload.eventTicketClasses ?? []),
454
+ ].filter(isRecord);
455
+ for (const holder of holders)
456
+ holder.wideLogo = { sourceUri: { uri } };
457
+ for (const cls of loyaltyClasses)
458
+ cls.wideProgramLogo = { sourceUri: { uri } };
459
+ }
460
+ if (options.voided) {
461
+ // Passmint always emits `state: "ACTIVE"`.
462
+ for (const [key, entries] of Object.entries(payload)) {
463
+ if (!key.endsWith('Objects') || !Array.isArray(entries))
464
+ continue;
465
+ for (const object of entries.filter(isRecord))
466
+ object.state = 'INACTIVE';
467
+ }
468
+ }
469
+ return payload;
470
+ }
471
+ /**
472
+ * Compile each enabled platform and collect passmint's own schema issues.
473
+ * Used by the preview/validate endpoint so the designer surfaces native-limit
474
+ * violations (field counts, missing icon, bad color) the way it surfaces ours.
475
+ */
476
+ export function collectCompileIssues(def, options) {
477
+ const issues = [];
478
+ const platforms = [];
479
+ if (def.platforms.apple)
480
+ platforms.push('apple');
481
+ if (def.platforms.google)
482
+ platforms.push('google');
483
+ for (const platform of platforms) {
484
+ try {
485
+ compile(def, { ...options, platform });
486
+ }
487
+ catch (err) {
488
+ issues.push(...toCompileIssues(platform, err));
489
+ }
490
+ }
491
+ return issues;
492
+ }
493
+ export function toCompileIssues(platform, err) {
494
+ if (err instanceof PassmintSchemaError) {
495
+ return err.issues.map((issue) => ({
496
+ platform,
497
+ path: (issue.path ?? [])
498
+ .map((p) => typeof p === 'object' && p !== null ? p.key : p)
499
+ .join('.'),
500
+ message: issue.message ?? 'Invalid value.',
501
+ }));
502
+ }
503
+ if (err instanceof AssetResolutionError || err instanceof PassmintError) {
504
+ return [{ platform, path: '', message: err.message }];
505
+ }
506
+ throw err;
507
+ }
@@ -0,0 +1,123 @@
1
+ import { type TemplateDefinition } from '../contract/index.js';
2
+ import { type CompileOptions } from './passmint-compiler.js';
3
+ export interface FieldPreview {
4
+ key: string;
5
+ label: string;
6
+ value: string;
7
+ textAlignment?: 'left' | 'center' | 'right' | 'start';
8
+ }
9
+ export interface BarcodePreview {
10
+ format: string;
11
+ message: string;
12
+ altText?: string;
13
+ }
14
+ export type PreviewStyle = 'generic' | 'eventTicket' | 'storeCard';
15
+ export interface ApplePreview {
16
+ platform: 'apple';
17
+ style: PreviewStyle;
18
+ organizationName: string;
19
+ description: string;
20
+ logoText?: string;
21
+ colors: {
22
+ background: string;
23
+ foreground: string;
24
+ label: string;
25
+ };
26
+ images: {
27
+ logo?: string;
28
+ thumbnail?: string;
29
+ strip?: string;
30
+ background?: string;
31
+ /** A stamp card's drawn stamps, as a PNG data URL. */
32
+ stamps?: string;
33
+ };
34
+ fields: Record<'header' | 'primary' | 'secondary' | 'auxiliary' | 'back', FieldPreview[]>;
35
+ barcode?: BarcodePreview;
36
+ /** The iOS 27 poster, when it is on. The fields above are what older iOS shows. */
37
+ poster?: PosterPreview;
38
+ featuredActions: {
39
+ key: string;
40
+ type: string;
41
+ url: string;
42
+ }[];
43
+ }
44
+ export interface PosterPreview {
45
+ brand: 'logo' | 'text' | 'logoAndText';
46
+ /** `stamps` is a stamp card's poster art with its stamps drawn in, as a PNG data URL. */
47
+ images: {
48
+ artwork?: string;
49
+ logo?: string;
50
+ stamps?: string;
51
+ };
52
+ fields: Record<'header' | 'primary' | 'footer' | 'additionalInfo' | 'back', FieldPreview[]>;
53
+ }
54
+ export interface GoogleCardItemPreview {
55
+ label: string;
56
+ value: string;
57
+ /** An event time: `value` is a timestamp to format in `GoogleEventPreview.timezone`. */
58
+ time?: 'dateTime' | 'date' | 'time';
59
+ }
60
+ /** A Google event ticket's native parts; its own fields are `textModules`. */
61
+ export interface GoogleEventPreview {
62
+ issuerName: string;
63
+ eventName: string;
64
+ venue?: {
65
+ name: string;
66
+ address: string;
67
+ };
68
+ dateTime: {
69
+ doorsOpen?: string;
70
+ start?: string;
71
+ end?: string;
72
+ };
73
+ doorsOpenLabel: 'doorsOpen' | 'gatesOpen';
74
+ timezone?: string;
75
+ seat: {
76
+ seat?: string;
77
+ row?: string;
78
+ section?: string;
79
+ gate?: string;
80
+ };
81
+ ticketHolderName?: string;
82
+ ticketType?: string;
83
+ ticketNumber: string;
84
+ /** The template's card-front rows; Google's default rows when absent. */
85
+ cardRows?: GoogleCardItemPreview[][];
86
+ }
87
+ /** A Google loyalty card's (stamp or points) native parts; its own fields are `textModules`. */
88
+ export interface GoogleLoyaltyPreview {
89
+ issuerName: string;
90
+ programName: string;
91
+ balance: {
92
+ label: string;
93
+ value: string;
94
+ };
95
+ accountName?: string;
96
+ accountId: string;
97
+ }
98
+ /** A generic card, or the native event ticket (`event`) or loyalty card (`loyalty`). */
99
+ export interface GooglePreview {
100
+ platform: 'google';
101
+ style: 'generic' | 'eventTicket' | 'loyalty';
102
+ event?: GoogleEventPreview;
103
+ loyalty?: GoogleLoyaltyPreview;
104
+ cardTitle: string;
105
+ header: string;
106
+ subheader?: string;
107
+ backgroundColor: string;
108
+ foregroundColor: string;
109
+ labelColor: string;
110
+ /** `stamps` is a stamp card's drawn stamps (Google's hero), as a PNG data URL. */
111
+ images: {
112
+ logo?: string;
113
+ wideLogo?: string;
114
+ hero?: string;
115
+ stamps?: string;
116
+ };
117
+ textModules: FieldPreview[];
118
+ barcode?: BarcodePreview;
119
+ }
120
+ type Options = Omit<CompileOptions, 'platform'>;
121
+ export declare function applePreview(def: TemplateDefinition, options: Options): ApplePreview;
122
+ export declare function googlePreview(def: TemplateDefinition, options: Options): GooglePreview;
123
+ export {};