@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.
- package/README.md +13 -2
- package/dist/compiler/event-ticket.d.ts +38 -0
- package/dist/compiler/event-ticket.js +131 -0
- package/dist/compiler/loyalty-card.d.ts +28 -0
- package/dist/compiler/loyalty-card.js +70 -0
- package/dist/compiler/passmint-compiler.d.ts +118 -0
- package/dist/compiler/passmint-compiler.js +507 -0
- package/dist/compiler/preview-model.d.ts +123 -0
- package/dist/compiler/preview-model.js +402 -0
- package/dist/compiler/stamp-card.d.ts +23 -0
- package/dist/compiler/stamp-card.js +19 -0
- package/dist/compiler/template-preview.d.ts +34 -0
- package/dist/compiler/template-preview.js +125 -0
- package/dist/contract/definition.d.ts +1316 -0
- package/dist/contract/definition.js +854 -0
- package/dist/contract/event.d.ts +53 -0
- package/dist/contract/event.js +91 -0
- package/dist/contract/featured-actions.d.ts +45 -0
- package/dist/contract/featured-actions.js +81 -0
- package/dist/contract/fields.d.ts +153 -0
- package/dist/contract/fields.js +141 -0
- package/dist/contract/ids.d.ts +2 -0
- package/dist/contract/ids.js +6 -0
- package/dist/contract/index.d.ts +9 -0
- package/dist/contract/index.js +9 -0
- package/dist/contract/points.d.ts +41 -0
- package/dist/contract/points.js +86 -0
- package/dist/contract/primitives.d.ts +26 -0
- package/dist/contract/primitives.js +14 -0
- package/dist/contract/relevance.d.ts +54 -0
- package/dist/contract/relevance.js +52 -0
- package/dist/contract/stamps.d.ts +91 -0
- package/dist/contract/stamps.js +89 -0
- package/dist/contract/upgrade.d.ts +7 -0
- package/dist/contract/upgrade.js +39 -0
- package/dist/public-api.d.ts +4 -0
- package/dist/public-api.js +2 -0
- package/package.json +20 -4
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { imageAssetId, posterLogoAssetId, resolveCardItem, } from '../contract/index.js';
|
|
3
|
+
import { renderApple, renderGoogle, } from './passmint-compiler.js';
|
|
4
|
+
const NativeField = z.object({
|
|
5
|
+
key: z.string(),
|
|
6
|
+
label: z.string().default(''),
|
|
7
|
+
value: z.union([z.string(), z.number()]).transform(String),
|
|
8
|
+
dateStyle: z.string().optional(),
|
|
9
|
+
timeStyle: z.string().optional(),
|
|
10
|
+
textAlignment: z
|
|
11
|
+
.enum([
|
|
12
|
+
'PKTextAlignmentLeft',
|
|
13
|
+
'PKTextAlignmentCenter',
|
|
14
|
+
'PKTextAlignmentRight',
|
|
15
|
+
'PKTextAlignmentNatural',
|
|
16
|
+
])
|
|
17
|
+
.optional(),
|
|
18
|
+
});
|
|
19
|
+
const NativeStyleFields = z.object({
|
|
20
|
+
headerFields: z.array(NativeField).default([]),
|
|
21
|
+
primaryFields: z.array(NativeField).default([]),
|
|
22
|
+
secondaryFields: z.array(NativeField).default([]),
|
|
23
|
+
auxiliaryFields: z.array(NativeField).default([]),
|
|
24
|
+
backFields: z.array(NativeField).default([]),
|
|
25
|
+
});
|
|
26
|
+
const NativePosterFields = z.object({
|
|
27
|
+
headerFields: z.array(NativeField).default([]),
|
|
28
|
+
primaryFields: z.array(NativeField).default([]),
|
|
29
|
+
footerFields: z.array(NativeField).default([]),
|
|
30
|
+
additionalInfoFields: z.array(NativeField).default([]),
|
|
31
|
+
backFields: z.array(NativeField).default([]),
|
|
32
|
+
});
|
|
33
|
+
const NativeApple = z.object({
|
|
34
|
+
organizationName: z.string(),
|
|
35
|
+
description: z.string(),
|
|
36
|
+
logoText: z.string().optional(),
|
|
37
|
+
backgroundColor: z.string(),
|
|
38
|
+
foregroundColor: z.string(),
|
|
39
|
+
labelColor: z.string().optional(),
|
|
40
|
+
generic: NativeStyleFields.optional(),
|
|
41
|
+
eventTicket: NativeStyleFields.optional(),
|
|
42
|
+
storeCard: NativeStyleFields.optional(),
|
|
43
|
+
posterGeneric: NativePosterFields.optional(),
|
|
44
|
+
featuredActions: z
|
|
45
|
+
.array(z.object({ identifier: z.string(), type: z.string(), url: z.string() }))
|
|
46
|
+
.default([]),
|
|
47
|
+
barcodes: z
|
|
48
|
+
.array(z.object({
|
|
49
|
+
format: z.string(),
|
|
50
|
+
message: z.string(),
|
|
51
|
+
altText: z.string().optional(),
|
|
52
|
+
}))
|
|
53
|
+
.default([]),
|
|
54
|
+
});
|
|
55
|
+
const LocalizedText = z.object({
|
|
56
|
+
defaultValue: z.object({ value: z.string() }),
|
|
57
|
+
});
|
|
58
|
+
const NativeImage = z.object({ sourceUri: z.object({ uri: z.string() }) });
|
|
59
|
+
const NativeTextModules = z
|
|
60
|
+
.array(z.object({
|
|
61
|
+
id: z.string(),
|
|
62
|
+
header: z.string().default(''),
|
|
63
|
+
body: z.string(),
|
|
64
|
+
}))
|
|
65
|
+
.default([]);
|
|
66
|
+
const NativeBarcode = z
|
|
67
|
+
.object({
|
|
68
|
+
type: z.string(),
|
|
69
|
+
value: z.string(),
|
|
70
|
+
alternateText: z.string().optional(),
|
|
71
|
+
})
|
|
72
|
+
.optional();
|
|
73
|
+
const NativeEventClass = z.object({
|
|
74
|
+
issuerName: z.string(),
|
|
75
|
+
eventName: LocalizedText,
|
|
76
|
+
venue: z.object({ name: LocalizedText, address: LocalizedText }).optional(),
|
|
77
|
+
dateTime: z
|
|
78
|
+
.object({
|
|
79
|
+
doorsOpen: z.string().optional(),
|
|
80
|
+
start: z.string().optional(),
|
|
81
|
+
end: z.string().optional(),
|
|
82
|
+
doorsOpenLabel: z.enum(['DOORS_OPEN', 'GATES_OPEN']).optional(),
|
|
83
|
+
})
|
|
84
|
+
.default({}),
|
|
85
|
+
logo: z.object({ sourceUri: z.object({ uri: z.string() }) }).optional(),
|
|
86
|
+
wideLogo: z.object({ sourceUri: z.object({ uri: z.string() }) }).optional(),
|
|
87
|
+
heroImage: z.object({ sourceUri: z.object({ uri: z.string() }) }).optional(),
|
|
88
|
+
});
|
|
89
|
+
const NativeEventObject = z.object({
|
|
90
|
+
hexBackgroundColor: z.string(),
|
|
91
|
+
ticketNumber: z.string(),
|
|
92
|
+
ticketHolderName: z.string().optional(),
|
|
93
|
+
ticketType: LocalizedText.optional(),
|
|
94
|
+
seatInfo: z
|
|
95
|
+
.object({
|
|
96
|
+
seat: LocalizedText.optional(),
|
|
97
|
+
row: LocalizedText.optional(),
|
|
98
|
+
section: LocalizedText.optional(),
|
|
99
|
+
gate: LocalizedText.optional(),
|
|
100
|
+
})
|
|
101
|
+
.default({}),
|
|
102
|
+
textModulesData: NativeTextModules,
|
|
103
|
+
barcode: NativeBarcode,
|
|
104
|
+
});
|
|
105
|
+
const NativeLoyaltyClass = z.object({
|
|
106
|
+
issuerName: z.string(),
|
|
107
|
+
programName: z.string(),
|
|
108
|
+
hexBackgroundColor: z.string(),
|
|
109
|
+
programLogo: NativeImage.optional(),
|
|
110
|
+
wideProgramLogo: NativeImage.optional(),
|
|
111
|
+
heroImage: NativeImage.optional(),
|
|
112
|
+
});
|
|
113
|
+
const NativeLoyaltyObject = z.object({
|
|
114
|
+
accountId: z.string(),
|
|
115
|
+
accountName: z.string().optional(),
|
|
116
|
+
loyaltyPoints: z.object({
|
|
117
|
+
label: z.string(),
|
|
118
|
+
balance: z.object({ string: z.string() }),
|
|
119
|
+
}),
|
|
120
|
+
textModulesData: NativeTextModules,
|
|
121
|
+
barcode: NativeBarcode,
|
|
122
|
+
});
|
|
123
|
+
const NativeGoogle = z.object({
|
|
124
|
+
cardTitle: LocalizedText,
|
|
125
|
+
header: LocalizedText,
|
|
126
|
+
subheader: LocalizedText.optional(),
|
|
127
|
+
hexBackgroundColor: z.string(),
|
|
128
|
+
logo: NativeImage.optional(),
|
|
129
|
+
wideLogo: NativeImage.optional(),
|
|
130
|
+
heroImage: NativeImage.optional(),
|
|
131
|
+
textModulesData: z
|
|
132
|
+
.array(z.object({
|
|
133
|
+
id: z.string(),
|
|
134
|
+
header: z.string().default(''),
|
|
135
|
+
body: z.string(),
|
|
136
|
+
}))
|
|
137
|
+
.default([]),
|
|
138
|
+
barcode: z
|
|
139
|
+
.object({
|
|
140
|
+
type: z.string(),
|
|
141
|
+
value: z.string(),
|
|
142
|
+
alternateText: z.string().optional(),
|
|
143
|
+
})
|
|
144
|
+
.optional(),
|
|
145
|
+
});
|
|
146
|
+
const ALIGNMENTS = {
|
|
147
|
+
PKTextAlignmentLeft: 'left',
|
|
148
|
+
PKTextAlignmentCenter: 'center',
|
|
149
|
+
PKTextAlignmentRight: 'right',
|
|
150
|
+
PKTextAlignmentNatural: 'start',
|
|
151
|
+
};
|
|
152
|
+
const WALL_CLOCK = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/;
|
|
153
|
+
/**
|
|
154
|
+
* Roughly how Wallet shows a dated field (en-US, medium date and short time) in
|
|
155
|
+
* the timestamp's own offset, as `ignoresTimeZone` does. Other values pass through.
|
|
156
|
+
*/
|
|
157
|
+
function appleDateText(value, dateStyle, timeStyle) {
|
|
158
|
+
const match = WALL_CLOCK.exec(value);
|
|
159
|
+
if (!match || (!dateStyle && !timeStyle))
|
|
160
|
+
return value;
|
|
161
|
+
const [, y, mo, d, h, mi] = match.map(Number);
|
|
162
|
+
const at = Date.UTC(y, mo - 1, d, h, mi);
|
|
163
|
+
const showDate = dateStyle !== undefined && dateStyle !== 'PKDateStyleNone';
|
|
164
|
+
const showTime = timeStyle !== undefined && timeStyle !== 'PKDateStyleNone';
|
|
165
|
+
const date = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', dateStyle: 'medium' }).format(at);
|
|
166
|
+
const time = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', timeStyle: 'short' }).format(at);
|
|
167
|
+
if (showDate && showTime)
|
|
168
|
+
return `${date} at ${time}`;
|
|
169
|
+
return showDate ? date : showTime ? time : value;
|
|
170
|
+
}
|
|
171
|
+
export function applePreview(def, options) {
|
|
172
|
+
const rendered = renderApple(def, options);
|
|
173
|
+
const native = NativeApple.parse(rendered.passJson);
|
|
174
|
+
const fields = (items) => items.map(({ dateStyle, timeStyle, ...field }) => ({
|
|
175
|
+
...field,
|
|
176
|
+
value: appleDateText(field.value, dateStyle, timeStyle),
|
|
177
|
+
textAlignment: field.textAlignment
|
|
178
|
+
? ALIGNMENTS[field.textAlignment]
|
|
179
|
+
: undefined,
|
|
180
|
+
}));
|
|
181
|
+
const image = (slot) => Object.keys(rendered.files).some((name) => name === `${slot}.png` ||
|
|
182
|
+
name === `${slot}@2x.png` ||
|
|
183
|
+
name === `${slot}@3x.png`)
|
|
184
|
+
? imageAssetId(def, slot)
|
|
185
|
+
: undefined;
|
|
186
|
+
const style = def.type === 'event_ticket'
|
|
187
|
+
? 'eventTicket'
|
|
188
|
+
: def.type === 'stamp_card' || def.type === 'points_card'
|
|
189
|
+
? 'storeCard'
|
|
190
|
+
: 'generic';
|
|
191
|
+
const layout = native[style];
|
|
192
|
+
if (!layout)
|
|
193
|
+
throw new Error(`Rendered Apple pass has no ${style} fields.`);
|
|
194
|
+
return {
|
|
195
|
+
platform: 'apple',
|
|
196
|
+
style,
|
|
197
|
+
organizationName: native.organizationName,
|
|
198
|
+
description: native.description,
|
|
199
|
+
logoText: native.logoText,
|
|
200
|
+
colors: {
|
|
201
|
+
background: native.backgroundColor,
|
|
202
|
+
foreground: native.foregroundColor,
|
|
203
|
+
label: native.labelColor ?? native.foregroundColor,
|
|
204
|
+
},
|
|
205
|
+
images: {
|
|
206
|
+
logo: image('logo'),
|
|
207
|
+
thumbnail: image('thumbnail'),
|
|
208
|
+
strip: image('strip'),
|
|
209
|
+
background: image('background'),
|
|
210
|
+
},
|
|
211
|
+
fields: {
|
|
212
|
+
header: fields(layout.headerFields),
|
|
213
|
+
primary: fields(layout.primaryFields),
|
|
214
|
+
secondary: fields(layout.secondaryFields),
|
|
215
|
+
auxiliary: fields(layout.auxiliaryFields),
|
|
216
|
+
back: fields(layout.backFields),
|
|
217
|
+
},
|
|
218
|
+
barcode: native.barcodes[0],
|
|
219
|
+
...(native.posterGeneric
|
|
220
|
+
? {
|
|
221
|
+
poster: {
|
|
222
|
+
brand: def.apple.poster?.brand ?? 'logo',
|
|
223
|
+
images: {
|
|
224
|
+
artwork: imageAssetId(def, 'artwork'),
|
|
225
|
+
logo: posterLogoAssetId(def),
|
|
226
|
+
},
|
|
227
|
+
fields: {
|
|
228
|
+
header: fields(native.posterGeneric.headerFields),
|
|
229
|
+
primary: fields(native.posterGeneric.primaryFields),
|
|
230
|
+
footer: fields(native.posterGeneric.footerFields),
|
|
231
|
+
additionalInfo: fields(native.posterGeneric.additionalInfoFields),
|
|
232
|
+
back: fields(native.posterGeneric.backFields),
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
: {}),
|
|
237
|
+
featuredActions: native.featuredActions.map((action) => ({
|
|
238
|
+
key: action.identifier,
|
|
239
|
+
type: action.type,
|
|
240
|
+
url: action.url,
|
|
241
|
+
})),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const SEAT_LABELS = { seat: 'Seat', row: 'Row', section: 'Section', gate: 'Gate' };
|
|
245
|
+
/** Google's own labels, seen on device (2026-10-22); only the start's depends on the format. */
|
|
246
|
+
function eventItemLabel(event, format, doorsOpenLabel) {
|
|
247
|
+
if (event === 'doorsOpen')
|
|
248
|
+
return doorsOpenLabel === 'GATES_OPEN' ? 'Gates open' : 'Doors open';
|
|
249
|
+
if (event === 'end')
|
|
250
|
+
return 'Event end time';
|
|
251
|
+
return format === 'date' ? 'Date' : format === 'time' ? 'Time' : 'Event start time';
|
|
252
|
+
}
|
|
253
|
+
function eventCardRows(def, cls, obj) {
|
|
254
|
+
if (def.type !== 'event_ticket' || !def.google.cardRows)
|
|
255
|
+
return undefined;
|
|
256
|
+
return def.google.cardRows.map((row) => row.flatMap((ref) => {
|
|
257
|
+
const item = resolveCardItem(ref, def.config.fields);
|
|
258
|
+
if (!item)
|
|
259
|
+
return [];
|
|
260
|
+
if (item.kind === 'seat') {
|
|
261
|
+
return [{ label: SEAT_LABELS[item.role], value: obj.seatInfo[item.role]?.defaultValue.value ?? '' }];
|
|
262
|
+
}
|
|
263
|
+
if (item.kind === 'event') {
|
|
264
|
+
return [
|
|
265
|
+
{
|
|
266
|
+
label: eventItemLabel(item.event, item.format, cls.dateTime.doorsOpenLabel),
|
|
267
|
+
value: cls.dateTime[item.event] ?? '',
|
|
268
|
+
time: item.format,
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
const module = obj.textModulesData.find((m) => m.id === ref);
|
|
273
|
+
return [{ label: module?.header ?? item.field.label, value: module?.body ?? '' }];
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
function googleEventPreview(def, options) {
|
|
277
|
+
const payload = renderGoogle(def, options);
|
|
278
|
+
const cls = NativeEventClass.parse(payload.eventTicketClasses?.[0]);
|
|
279
|
+
const obj = NativeEventObject.parse(payload.eventTicketObjects?.[0]);
|
|
280
|
+
const assetId = (image) => image
|
|
281
|
+
? Object.entries(options.assets).find(([, asset]) => asset.url === image.sourceUri.uri)?.[0]
|
|
282
|
+
: undefined;
|
|
283
|
+
const local = (text) => text?.defaultValue.value;
|
|
284
|
+
const seat = {
|
|
285
|
+
seat: local(obj.seatInfo.seat),
|
|
286
|
+
row: local(obj.seatInfo.row),
|
|
287
|
+
section: local(obj.seatInfo.section),
|
|
288
|
+
gate: local(obj.seatInfo.gate),
|
|
289
|
+
};
|
|
290
|
+
const textModules = obj.textModulesData.map((field) => ({
|
|
291
|
+
key: field.id,
|
|
292
|
+
label: field.header,
|
|
293
|
+
value: field.body,
|
|
294
|
+
}));
|
|
295
|
+
return {
|
|
296
|
+
platform: 'google',
|
|
297
|
+
style: 'eventTicket',
|
|
298
|
+
event: {
|
|
299
|
+
issuerName: cls.issuerName,
|
|
300
|
+
eventName: cls.eventName.defaultValue.value,
|
|
301
|
+
venue: cls.venue
|
|
302
|
+
? { name: cls.venue.name.defaultValue.value, address: cls.venue.address.defaultValue.value }
|
|
303
|
+
: undefined,
|
|
304
|
+
dateTime: { doorsOpen: cls.dateTime.doorsOpen, start: cls.dateTime.start, end: cls.dateTime.end },
|
|
305
|
+
doorsOpenLabel: cls.dateTime.doorsOpenLabel === 'GATES_OPEN' ? 'gatesOpen' : 'doorsOpen',
|
|
306
|
+
timezone: def.event?.timezone,
|
|
307
|
+
seat: Object.fromEntries(Object.entries(seat).filter(([, v]) => v !== undefined)),
|
|
308
|
+
ticketHolderName: obj.ticketHolderName,
|
|
309
|
+
ticketType: local(obj.ticketType),
|
|
310
|
+
ticketNumber: obj.ticketNumber,
|
|
311
|
+
cardRows: eventCardRows(def, cls, obj),
|
|
312
|
+
},
|
|
313
|
+
cardTitle: cls.issuerName,
|
|
314
|
+
header: cls.eventName.defaultValue.value,
|
|
315
|
+
backgroundColor: obj.hexBackgroundColor,
|
|
316
|
+
foregroundColor: def.colors.foreground,
|
|
317
|
+
labelColor: def.colors.label ?? def.colors.foreground,
|
|
318
|
+
images: {
|
|
319
|
+
logo: assetId(cls.logo),
|
|
320
|
+
wideLogo: assetId(cls.wideLogo),
|
|
321
|
+
hero: assetId(cls.heroImage),
|
|
322
|
+
},
|
|
323
|
+
textModules,
|
|
324
|
+
barcode: obj.barcode
|
|
325
|
+
? { format: obj.barcode.type, message: obj.barcode.value, altText: obj.barcode.alternateText }
|
|
326
|
+
: undefined,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function googleLoyaltyPreview(def, options) {
|
|
330
|
+
const payload = renderGoogle(def, options);
|
|
331
|
+
const cls = NativeLoyaltyClass.parse(payload.loyaltyClasses?.[0]);
|
|
332
|
+
const obj = NativeLoyaltyObject.parse(payload.loyaltyObjects?.[0]);
|
|
333
|
+
const assetId = (image) => image
|
|
334
|
+
? Object.entries(options.assets).find(([, asset]) => asset.url === image.sourceUri.uri)?.[0]
|
|
335
|
+
: undefined;
|
|
336
|
+
return {
|
|
337
|
+
platform: 'google',
|
|
338
|
+
style: 'loyalty',
|
|
339
|
+
loyalty: {
|
|
340
|
+
issuerName: cls.issuerName,
|
|
341
|
+
programName: cls.programName,
|
|
342
|
+
balance: { label: obj.loyaltyPoints.label, value: obj.loyaltyPoints.balance.string },
|
|
343
|
+
accountName: obj.accountName,
|
|
344
|
+
accountId: obj.accountId,
|
|
345
|
+
},
|
|
346
|
+
cardTitle: cls.issuerName,
|
|
347
|
+
header: cls.programName,
|
|
348
|
+
backgroundColor: cls.hexBackgroundColor,
|
|
349
|
+
foregroundColor: def.colors.foreground,
|
|
350
|
+
labelColor: def.colors.label ?? def.colors.foreground,
|
|
351
|
+
images: {
|
|
352
|
+
logo: assetId(cls.programLogo),
|
|
353
|
+
wideLogo: assetId(cls.wideProgramLogo),
|
|
354
|
+
hero: assetId(cls.heroImage),
|
|
355
|
+
},
|
|
356
|
+
textModules: obj.textModulesData.map((field) => ({
|
|
357
|
+
key: field.id,
|
|
358
|
+
label: field.header,
|
|
359
|
+
value: field.body,
|
|
360
|
+
})),
|
|
361
|
+
barcode: obj.barcode
|
|
362
|
+
? { format: obj.barcode.type, message: obj.barcode.value, altText: obj.barcode.alternateText }
|
|
363
|
+
: undefined,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
export function googlePreview(def, options) {
|
|
367
|
+
if (def.type === 'event_ticket')
|
|
368
|
+
return googleEventPreview(def, options);
|
|
369
|
+
if (def.type === 'stamp_card' || def.type === 'points_card')
|
|
370
|
+
return googleLoyaltyPreview(def, options);
|
|
371
|
+
const native = NativeGoogle.parse(renderGoogle(def, options).genericObjects?.[0]);
|
|
372
|
+
const assetId = (image) => image
|
|
373
|
+
? Object.entries(options.assets).find(([, asset]) => asset.url === image.sourceUri.uri)?.[0]
|
|
374
|
+
: undefined;
|
|
375
|
+
return {
|
|
376
|
+
platform: 'google',
|
|
377
|
+
style: 'generic',
|
|
378
|
+
cardTitle: native.cardTitle.defaultValue.value,
|
|
379
|
+
header: native.header.defaultValue.value,
|
|
380
|
+
subheader: native.subheader?.defaultValue.value,
|
|
381
|
+
backgroundColor: native.hexBackgroundColor,
|
|
382
|
+
foregroundColor: def.colors.foreground,
|
|
383
|
+
labelColor: def.colors.label ?? def.colors.foreground,
|
|
384
|
+
images: {
|
|
385
|
+
logo: assetId(native.logo),
|
|
386
|
+
wideLogo: assetId(native.wideLogo),
|
|
387
|
+
hero: assetId(native.heroImage),
|
|
388
|
+
},
|
|
389
|
+
textModules: native.textModulesData.map((field) => ({
|
|
390
|
+
key: field.id,
|
|
391
|
+
label: field.header,
|
|
392
|
+
value: field.body,
|
|
393
|
+
})),
|
|
394
|
+
barcode: native.barcode
|
|
395
|
+
? {
|
|
396
|
+
format: native.barcode.type,
|
|
397
|
+
message: native.barcode.value,
|
|
398
|
+
altText: native.barcode.alternateText,
|
|
399
|
+
}
|
|
400
|
+
: undefined,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TemplateDefinition } from '../contract/index.js';
|
|
2
|
+
type StampCardDefinition = Extract<TemplateDefinition, {
|
|
3
|
+
type: 'stamp_card';
|
|
4
|
+
}>;
|
|
5
|
+
/** A stamp card's count and its drawn stamps, rendered for the platform being compiled. */
|
|
6
|
+
export interface StampRender {
|
|
7
|
+
count: number;
|
|
8
|
+
appleStrip?: {
|
|
9
|
+
x2: Uint8Array;
|
|
10
|
+
x3: Uint8Array;
|
|
11
|
+
};
|
|
12
|
+
/** The poster art with the stamps drawn in, while the poster is on. */
|
|
13
|
+
appleArtwork?: Uint8Array;
|
|
14
|
+
googleHeroUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Previews show a card about half full. */
|
|
17
|
+
export declare function previewStampCount(total: number): number;
|
|
18
|
+
/**
|
|
19
|
+
* The stamps a compile uses. Validation compiles (previews, update checks) pass
|
|
20
|
+
* none and get stand-ins: the image bytes and URL only matter when signing.
|
|
21
|
+
*/
|
|
22
|
+
export declare function stampRenderFor(def: StampCardDefinition, stamps: StampRender | undefined, mode: 'preview' | 'issue'): Required<StampRender>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const PLACEHOLDER_STRIP = new Uint8Array([0]);
|
|
2
|
+
const PLACEHOLDER_ARTWORK = new Uint8Array([0]);
|
|
3
|
+
const PLACEHOLDER_HERO_URL = 'https://assets.tapace.example/stamps.png';
|
|
4
|
+
/** Previews show a card about half full. */
|
|
5
|
+
export function previewStampCount(total) {
|
|
6
|
+
return Math.ceil(total / 2);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The stamps a compile uses. Validation compiles (previews, update checks) pass
|
|
10
|
+
* none and get stand-ins: the image bytes and URL only matter when signing.
|
|
11
|
+
*/
|
|
12
|
+
export function stampRenderFor(def, stamps, mode) {
|
|
13
|
+
return {
|
|
14
|
+
count: stamps?.count ?? (mode === 'preview' ? previewStampCount(def.config.stamps.total) : 0),
|
|
15
|
+
appleStrip: stamps?.appleStrip ?? { x2: PLACEHOLDER_STRIP, x3: PLACEHOLDER_STRIP },
|
|
16
|
+
appleArtwork: stamps?.appleArtwork ?? PLACEHOLDER_ARTWORK,
|
|
17
|
+
googleHeroUrl: stamps?.googleHeroUrl ?? PLACEHOLDER_HERO_URL,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type CompileIssue } from './passmint-compiler.js';
|
|
2
|
+
import { type ApplePreview, type GooglePreview } from './preview-model.js';
|
|
3
|
+
import { type FieldValue, type TemplateDefinition, type TemplateTypeName } from '../contract/index.js';
|
|
4
|
+
export interface TemplatePreview {
|
|
5
|
+
requiredFields: string[];
|
|
6
|
+
issues: CompileIssue[];
|
|
7
|
+
apple?: ApplePreview;
|
|
8
|
+
google?: GooglePreview;
|
|
9
|
+
}
|
|
10
|
+
export interface AssetSize {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TemplatePreviewOptions {
|
|
15
|
+
sampleValues?: Record<string, FieldValue>;
|
|
16
|
+
mode?: 'preview' | 'issue';
|
|
17
|
+
barcodeValue?: string;
|
|
18
|
+
actionLinks?: Record<string, string>;
|
|
19
|
+
stampCount?: number;
|
|
20
|
+
pointsBalance?: number;
|
|
21
|
+
allWallets?: boolean;
|
|
22
|
+
/** Sizes of the referenced images; a missing size skips that image's size check. */
|
|
23
|
+
assetSizes?: ReadonlyMap<string, AssetSize>;
|
|
24
|
+
}
|
|
25
|
+
export type ParsedPreviewInput = {
|
|
26
|
+
definition: TemplateDefinition;
|
|
27
|
+
issues?: undefined;
|
|
28
|
+
} | {
|
|
29
|
+
definition?: undefined;
|
|
30
|
+
issues: CompileIssue[];
|
|
31
|
+
};
|
|
32
|
+
export declare function parsePreviewInput(declaredType: TemplateTypeName, input: unknown, organizationName: string, event?: unknown): ParsedPreviewInput;
|
|
33
|
+
/** Apple and Google previews of a parsed definition. Stamp cards' drawn stamps are added by the caller. */
|
|
34
|
+
export declare function buildTemplatePreview(def: TemplateDefinition, options?: TemplatePreviewOptions): TemplatePreview;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { ZodError } from 'zod';
|
|
2
|
+
import { placeholderAssets, toCompileIssues } from './passmint-compiler.js';
|
|
3
|
+
import { applePreview, googlePreview } from './preview-model.js';
|
|
4
|
+
import { EventDetailsSchema, TemplateValidationError, imageAssetId, parseTemplateDefinition, posterEnabled, previewFieldValues, referencedAssetIds, requiredFieldKeys, } from '../contract/index.js';
|
|
5
|
+
/** Google's minimum for the logo it crops into a circle (Wallet brand guidelines). */
|
|
6
|
+
const GOOGLE_LOGO_MIN_SIZE = 660;
|
|
7
|
+
const GOOGLE_WIDE_LOGO_MIN_HEIGHT = 400;
|
|
8
|
+
const GOOGLE_WIDE_LOGO_RATIO = 16 / 5;
|
|
9
|
+
const GOOGLE_WIDE_LOGO_RATIO_TOLERANCE = 0.1;
|
|
10
|
+
// Wallet fills the 5:7 (358×501 pt) poster card and crops other shapes from the
|
|
11
|
+
// centre (device tests AC1–AC3).
|
|
12
|
+
const POSTER_ARTWORK_RATIO = 5 / 7;
|
|
13
|
+
const POSTER_ARTWORK_RATIO_TOLERANCE = 0.02;
|
|
14
|
+
export function parsePreviewInput(declaredType, input, organizationName, event) {
|
|
15
|
+
// Signing overlays the live event details on the definition; they are never part of it.
|
|
16
|
+
const isObject = !!input && typeof input === 'object' && !Array.isArray(input);
|
|
17
|
+
const { event: overlaid, ...rest } = isObject ? input : { event: undefined };
|
|
18
|
+
const body = isObject ? { ...rest, organizationName } : input;
|
|
19
|
+
try {
|
|
20
|
+
let definition = parseTemplateDefinition(declaredType, body);
|
|
21
|
+
if (definition.type === 'event_ticket') {
|
|
22
|
+
const details = event ?? overlaid;
|
|
23
|
+
if (details !== undefined)
|
|
24
|
+
definition = { ...definition, event: EventDetailsSchema.parse(details) };
|
|
25
|
+
}
|
|
26
|
+
return { definition };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err instanceof ZodError)
|
|
30
|
+
return {
|
|
31
|
+
issues: err.issues.map((i) => ({
|
|
32
|
+
platform: 'template',
|
|
33
|
+
path: ['event', ...i.path].join('.'),
|
|
34
|
+
message: i.message,
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
if (err instanceof TemplateValidationError)
|
|
38
|
+
return {
|
|
39
|
+
issues: err.issues.map((i) => ({
|
|
40
|
+
platform: 'template',
|
|
41
|
+
path: i.path.join('.'),
|
|
42
|
+
message: i.message,
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Apple and Google previews of a parsed definition. Stamp cards' drawn stamps are added by the caller. */
|
|
49
|
+
export function buildTemplatePreview(def, options = {}) {
|
|
50
|
+
const { sampleValues = {}, mode = 'preview', allWallets = false, assetSizes = new Map() } = options;
|
|
51
|
+
const ids = referencedAssetIds(def);
|
|
52
|
+
// In preview mode callers pass public field keys; resolve them to field ids
|
|
53
|
+
// (assigning ephemeral ids to unsaved fields) for the id-keyed compiler.
|
|
54
|
+
const compileDef = mode === 'preview' ? previewFieldValues(def, sampleValues) : { def, values: sampleValues };
|
|
55
|
+
// Renderers need asset references; previewing never downloads or signs image bytes.
|
|
56
|
+
const compileOptions = {
|
|
57
|
+
assets: placeholderAssets(ids),
|
|
58
|
+
values: compileDef.values,
|
|
59
|
+
barcodeValue: options.barcodeValue,
|
|
60
|
+
actionLinks: options.actionLinks,
|
|
61
|
+
mode,
|
|
62
|
+
...(options.stampCount !== undefined ? { stamps: { count: options.stampCount } } : {}),
|
|
63
|
+
...(options.pointsBalance !== undefined ? { points: options.pointsBalance } : {}),
|
|
64
|
+
};
|
|
65
|
+
const result = { requiredFields: requiredFieldKeys(def), issues: [] };
|
|
66
|
+
if (def.platforms.apple || allWallets) {
|
|
67
|
+
try {
|
|
68
|
+
result.apple = applePreview(compileDef.def, compileOptions);
|
|
69
|
+
if (['PKBarcodeFormatQR', 'PKBarcodeFormatAztec'].includes(result.apple.barcode?.format ?? '') &&
|
|
70
|
+
result.apple.fields.secondary.length + result.apple.fields.auxiliary.length > 4) {
|
|
71
|
+
result.issues.push({
|
|
72
|
+
platform: 'apple',
|
|
73
|
+
path: 'config.layouts.standard',
|
|
74
|
+
message: 'With a square barcode, Apple passes display at most four secondary and auxiliary fields combined.',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const artworkId = posterEnabled(def) ? imageAssetId(def, 'artwork') : undefined;
|
|
78
|
+
const artwork = artworkId ? assetSizes.get(artworkId) : undefined;
|
|
79
|
+
if (artwork &&
|
|
80
|
+
Math.abs(artwork.width / artwork.height / POSTER_ARTWORK_RATIO - 1) > POSTER_ARTWORK_RATIO_TOLERANCE)
|
|
81
|
+
result.issues.push({
|
|
82
|
+
platform: 'apple',
|
|
83
|
+
path: 'images.artwork',
|
|
84
|
+
message: 'Wallet fills the 5:7 poster and crops the rest of the artwork from the centre. Use a 5:7 image, such as 1074×1504 px, to keep its edges.',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (def.platforms.apple)
|
|
89
|
+
result.issues.push(...toCompileIssues('apple', err));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (def.platforms.google || allWallets) {
|
|
93
|
+
try {
|
|
94
|
+
result.google = googlePreview(compileDef.def, compileOptions);
|
|
95
|
+
if (result.google.textModules.length > 10)
|
|
96
|
+
result.issues.push({
|
|
97
|
+
platform: 'google',
|
|
98
|
+
path: 'config.layouts.standard',
|
|
99
|
+
message: 'Google displays at most ten object text modules. Reduce the number of fields.',
|
|
100
|
+
});
|
|
101
|
+
const googleLogo = def.google.logoAssetId ? assetSizes.get(def.google.logoAssetId) : undefined;
|
|
102
|
+
if (googleLogo && (googleLogo.width !== googleLogo.height || googleLogo.width < GOOGLE_LOGO_MIN_SIZE))
|
|
103
|
+
result.issues.push({
|
|
104
|
+
platform: 'google',
|
|
105
|
+
path: 'google.logoAssetId',
|
|
106
|
+
message: `Google crops the logo into a circle. Use a square image of at least ${GOOGLE_LOGO_MIN_SIZE}×${GOOGLE_LOGO_MIN_SIZE} px with a full-bleed background.`,
|
|
107
|
+
});
|
|
108
|
+
const wideLogo = def.google.wideLogoAssetId ? assetSizes.get(def.google.wideLogoAssetId) : undefined;
|
|
109
|
+
if (wideLogo &&
|
|
110
|
+
(wideLogo.height < GOOGLE_WIDE_LOGO_MIN_HEIGHT ||
|
|
111
|
+
Math.abs(wideLogo.width / wideLogo.height / GOOGLE_WIDE_LOGO_RATIO - 1) > GOOGLE_WIDE_LOGO_RATIO_TOLERANCE))
|
|
112
|
+
result.issues.push({
|
|
113
|
+
platform: 'google',
|
|
114
|
+
path: 'google.wideLogoAssetId',
|
|
115
|
+
message: `Google fits the wide logo into its header. Use a transparent 16:5 image of at least ${GOOGLE_WIDE_LOGO_MIN_HEIGHT} px tall, such as 1280×400 px.`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (def.platforms.google)
|
|
120
|
+
result.issues.push(...toCompileIssues('google', err));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
result.issues = result.issues.filter((issue) => (issue.platform !== 'apple' && issue.platform !== 'google') || def.platforms[issue.platform]);
|
|
124
|
+
return result;
|
|
125
|
+
}
|