@ticketlayer/elements-core 0.1.0 → 0.2.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 (62) hide show
  1. package/README.md +141 -4
  2. package/dist/cjs/cores/buy-tickets-button.js +213 -0
  3. package/dist/cjs/cores/buy-tickets-wrapper.js +106 -0
  4. package/dist/cjs/cores/cart-badge.js +150 -0
  5. package/dist/cjs/cores/cart-drawer.js +122 -0
  6. package/dist/cjs/cores/cart.js +247 -0
  7. package/dist/cjs/cores/checkout-wrapper.js +75 -0
  8. package/dist/cjs/cores/event-card.js +209 -0
  9. package/dist/cjs/cores/event-list.js +109 -0
  10. package/dist/cjs/cores/index.js +64 -1
  11. package/dist/cjs/cores/login.js +309 -0
  12. package/dist/cjs/cores/my-orders.js +221 -0
  13. package/dist/cjs/cores/occurrence-selector.js +528 -0
  14. package/dist/cjs/cores/order-confirmation.js +301 -0
  15. package/dist/cjs/cores/order-tickets.js +172 -0
  16. package/dist/cjs/cores/promo-code.js +136 -0
  17. package/dist/cjs/cores/ticket-selector.js +30 -0
  18. package/dist/cjs/define.js +76 -0
  19. package/dist/cjs/index.js +17 -1
  20. package/dist/cjs/manifest.js +5 -0
  21. package/dist/cjs/orders.js +169 -0
  22. package/dist/esm/client.d.ts +106 -0
  23. package/dist/esm/cores/buy-tickets-button.d.ts +55 -0
  24. package/dist/esm/cores/buy-tickets-button.js +210 -0
  25. package/dist/esm/cores/buy-tickets-wrapper.d.ts +27 -0
  26. package/dist/esm/cores/buy-tickets-wrapper.js +103 -0
  27. package/dist/esm/cores/cart-badge.d.ts +48 -0
  28. package/dist/esm/cores/cart-badge.js +147 -0
  29. package/dist/esm/cores/cart-drawer.d.ts +44 -0
  30. package/dist/esm/cores/cart-drawer.js +119 -0
  31. package/dist/esm/cores/cart.d.ts +105 -0
  32. package/dist/esm/cores/cart.js +244 -0
  33. package/dist/esm/cores/checkout-wrapper.d.ts +22 -0
  34. package/dist/esm/cores/checkout-wrapper.js +72 -0
  35. package/dist/esm/cores/event-card.d.ts +80 -0
  36. package/dist/esm/cores/event-card.js +205 -0
  37. package/dist/esm/cores/event-list.d.ts +35 -0
  38. package/dist/esm/cores/event-list.js +106 -0
  39. package/dist/esm/cores/index.d.ts +28 -0
  40. package/dist/esm/cores/index.js +42 -0
  41. package/dist/esm/cores/login.d.ts +125 -0
  42. package/dist/esm/cores/login.js +306 -0
  43. package/dist/esm/cores/my-orders.d.ts +63 -0
  44. package/dist/esm/cores/my-orders.js +218 -0
  45. package/dist/esm/cores/occurrence-selector.d.ts +170 -0
  46. package/dist/esm/cores/occurrence-selector.js +525 -0
  47. package/dist/esm/cores/order-confirmation.d.ts +99 -0
  48. package/dist/esm/cores/order-confirmation.js +298 -0
  49. package/dist/esm/cores/order-tickets.d.ts +46 -0
  50. package/dist/esm/cores/order-tickets.js +169 -0
  51. package/dist/esm/cores/promo-code.d.ts +56 -0
  52. package/dist/esm/cores/promo-code.js +132 -0
  53. package/dist/esm/cores/ticket-selector.js +30 -0
  54. package/dist/esm/define.d.ts +146 -0
  55. package/dist/esm/define.js +75 -0
  56. package/dist/esm/index.d.ts +5 -3
  57. package/dist/esm/index.js +2 -1
  58. package/dist/esm/manifest.d.ts +12 -0
  59. package/dist/esm/manifest.js +5 -0
  60. package/dist/esm/orders.d.ts +110 -0
  61. package/dist/esm/orders.js +154 -0
  62. package/package.json +2 -2
@@ -0,0 +1,528 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.occurrenceSelectorCore = exports.HORIZONTAL_MIN_WIDTH = void 0;
4
+ /**
5
+ * `tl-occurrence-selector`: every date and time one event is on, and the one
6
+ * the buyer picks.
7
+ *
8
+ * The largest of the eighteen, and the last big one: 883 lines of Stencil with
9
+ * no spec at all, which is why TKT-202 wrote the characterisation spec first
10
+ * and split it on its own. Everything below is what that component did, ported
11
+ * rather than redesigned, and the spec
12
+ * (`packages/elements/src/components/tl-occurrence-selector/test/`) is the
13
+ * proof - it passed against the component before this file existed and passes
14
+ * against the harness now, unchanged.
15
+ *
16
+ * What it decides, in the order it decides it:
17
+ *
18
+ * - Which of three views to render. A host can force one; otherwise five
19
+ * occurrences or fewer is a LIST, more than five all on one calendar date
20
+ * is a grid of SLOTS, and more than five over several dates is a CALENDAR
21
+ * with the picked date's times beside it.
22
+ * - Where the calendar opens. Ported with its bug intact: the dates are sorted
23
+ * as `Date.toDateString()` STRINGS, so "Fri Jul 05 2030" sorts before
24
+ * "Fri Jun 14 2030" and a weekday name decides the order before a month
25
+ * does. Fixing that is a decision about the product, not part of moving the
26
+ * code, and a split that quietly corrected it would be exactly the kind of
27
+ * change the characterisation spec exists to catch.
28
+ * - Which days a buyer may press, which are sold out, and how far the month
29
+ * navigation may walk in each direction.
30
+ * - Every label: the `en-GB` dates and times, the capacity line, the weekday
31
+ * headings and the two month-navigation labels a screen reader reads.
32
+ *
33
+ * What is NOT here, and why:
34
+ *
35
+ * - The `theme` prop. It is declared, because it is part of the element's
36
+ * published surface, and it is the harness that acts on it: the prop is a
37
+ * set of CSS CUSTOM PROPERTIES written onto the element, and a custom
38
+ * property is not a fact every runtime has. A core holds no values at all,
39
+ * so there is nothing here for it to hold.
40
+ * - How wide the element is. `containerWidth` is state the harness measures
41
+ * and pushes in through `resized()`; the core decides what a width MEANS
42
+ * (`auto` becomes horizontal at 768px and not before), which is the same
43
+ * bargain as `ctx.storage` - the core says what, the harness says where.
44
+ * - Which token family a state paints with. The core derives the CLASS a
45
+ * calendar day renders under and the stylesheet says what that class paints,
46
+ * because a core cannot map a status onto a token family
47
+ * (`tl-event-card`'s status badge and `tl-buy-tickets-button` make the same
48
+ * bargain).
49
+ *
50
+ * What did not come across is the `console.error` on a failed read and the
51
+ * `console.log` on every selection, for the reason `tl-cart` and
52
+ * `tl-promo-code` dropped theirs: a library should not write to its host's
53
+ * console.
54
+ */
55
+ const define_js_1 = require("../define.js");
56
+ const STRINGS = {
57
+ noEventId: 'Event ID is required',
58
+ noClient: 'SDK not initialized.',
59
+ notFound: 'Event not found',
60
+ loadFailed: 'Failed to load occurrences',
61
+ empty: 'No dates available for this event.',
62
+ retry: 'Retry',
63
+ soldOut: 'Sold Out',
64
+ /** Follows the figure: "120 available". */
65
+ available: 'available',
66
+ selectADate: 'Select a date to see available times',
67
+ previousMonth: 'Previous month',
68
+ nextMonth: 'Next month',
69
+ weekdayMon: 'Mon',
70
+ weekdayTue: 'Tue',
71
+ weekdayWed: 'Wed',
72
+ weekdayThu: 'Thu',
73
+ weekdayFri: 'Fri',
74
+ weekdaySat: 'Sat',
75
+ weekdaySun: 'Sun',
76
+ };
77
+ /** Above this many pixels an `auto` layout puts the times beside the calendar. */
78
+ exports.HORIZONTAL_MIN_WIDTH = 768;
79
+ /** More than this many occurrences and the list view gives way. */
80
+ const LIST_LIMIT = 5;
81
+ // ---------------------------------------------------------------------------
82
+ // Dates. Every one of these is the component's own arithmetic, moved.
83
+ // ---------------------------------------------------------------------------
84
+ /** Local midnight on the day `date` falls on. */
85
+ function startOfDay(date) {
86
+ const start = new Date(date);
87
+ start.setHours(0, 0, 0, 0);
88
+ return start;
89
+ }
90
+ /** The key a day is held under, which is also what a harness hands back. */
91
+ function dayKey(date) {
92
+ return date.toDateString();
93
+ }
94
+ /** Monday of the week `date` falls in. */
95
+ function mondayOf(date) {
96
+ const monday = new Date(date);
97
+ const dayOfWeek = monday.getDay();
98
+ monday.setDate(monday.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
99
+ monday.setHours(0, 0, 0, 0);
100
+ return monday;
101
+ }
102
+ /** The first of the month `date` falls in. */
103
+ function firstOfMonth(date) {
104
+ return new Date(date.getFullYear(), date.getMonth(), 1);
105
+ }
106
+ const startTime = (occurrence) => new Date(occurrence.startsAt).getTime();
107
+ const byStart = (a, b) => startTime(a) - startTime(b);
108
+ /** As the component decided it: an explicit zero counts as gone. */
109
+ const isSoldOut = (occurrence) => !!occurrence.soldOut || occurrence.availableCapacity === 0;
110
+ /** Day key -> the occurrences on it, in the order the event gave them. */
111
+ function occurrencesByDate(occurrences) {
112
+ const map = new Map();
113
+ for (const occurrence of occurrences) {
114
+ const key = dayKey(startOfDay(new Date(occurrence.startsAt)));
115
+ const on = map.get(key);
116
+ if (on)
117
+ on.push(occurrence);
118
+ else
119
+ map.set(key, [occurrence]);
120
+ }
121
+ return map;
122
+ }
123
+ /** The earliest and latest occurrence, or null when there are none. */
124
+ function dateRangeOf(occurrences) {
125
+ if (!occurrences.length)
126
+ return null;
127
+ let first = new Date(occurrences[0].startsAt);
128
+ let last = new Date(occurrences[0].startsAt);
129
+ for (const occurrence of occurrences) {
130
+ const date = new Date(occurrence.startsAt);
131
+ if (date < first)
132
+ first = date;
133
+ if (date > last)
134
+ last = date;
135
+ }
136
+ return { first, last };
137
+ }
138
+ /** Whether everything fits between one Monday and the Sunday after it. */
139
+ function isSingleWeek(occurrences) {
140
+ const range = dateRangeOf(occurrences);
141
+ if (!range)
142
+ return false;
143
+ const sunday = new Date(mondayOf(range.first));
144
+ sunday.setDate(sunday.getDate() + 6);
145
+ sunday.setHours(23, 59, 59, 999);
146
+ return range.last <= sunday;
147
+ }
148
+ /** Whether everything falls in one calendar month. */
149
+ function isSingleMonth(occurrences) {
150
+ const range = dateRangeOf(occurrences);
151
+ if (!range)
152
+ return false;
153
+ return (range.first.getMonth() === range.last.getMonth() &&
154
+ range.first.getFullYear() === range.last.getFullYear());
155
+ }
156
+ /**
157
+ * Where the calendar opens.
158
+ *
159
+ * The sort is on `toDateString()` STRINGS and that is not an oversight here:
160
+ * it is what the component did, so "Fri Jul 05 2030" comes before
161
+ * "Fri Jun 14 2030" and the element opens on July. See the note at the top.
162
+ */
163
+ function initialSelection(occurrences) {
164
+ if (!occurrences.length)
165
+ return null;
166
+ const today = startOfDay(new Date());
167
+ const keys = Array.from(occurrencesByDate(occurrences).keys()).sort();
168
+ let selected = null;
169
+ for (const key of keys) {
170
+ const date = new Date(key);
171
+ if (date >= today) {
172
+ selected = date;
173
+ break;
174
+ }
175
+ }
176
+ if (!selected && keys.length)
177
+ selected = new Date(keys[keys.length - 1]);
178
+ if (!selected)
179
+ return null;
180
+ return { selectedDate: selected, currentMonth: firstOfMonth(selected) };
181
+ }
182
+ /**
183
+ * The grid: one week when everything fits in one, else the whole month padded
184
+ * out to whole Monday-to-Sunday weeks.
185
+ */
186
+ function calendarDays(occurrences, currentMonth) {
187
+ const days = [];
188
+ if (isSingleWeek(occurrences)) {
189
+ const range = dateRangeOf(occurrences);
190
+ if (!range)
191
+ return days;
192
+ const cursor = mondayOf(range.first);
193
+ for (let i = 0; i < 7; i += 1) {
194
+ days.push(new Date(cursor));
195
+ cursor.setDate(cursor.getDate() + 1);
196
+ }
197
+ return days;
198
+ }
199
+ const year = currentMonth.getFullYear();
200
+ const month = currentMonth.getMonth();
201
+ const monthStart = new Date(year, month, 1);
202
+ const monthEnd = new Date(year, month + 1, 0);
203
+ const startDay = monthStart.getDay();
204
+ const calendarStart = new Date(monthStart);
205
+ calendarStart.setDate(calendarStart.getDate() - (startDay === 0 ? 6 : startDay - 1));
206
+ const endDay = monthEnd.getDay();
207
+ const calendarEnd = new Date(monthEnd);
208
+ calendarEnd.setDate(calendarEnd.getDate() + (endDay === 0 ? 0 : 7 - endDay));
209
+ const cursor = new Date(calendarStart);
210
+ while (cursor <= calendarEnd) {
211
+ days.push(new Date(cursor));
212
+ cursor.setDate(cursor.getDate() + 1);
213
+ }
214
+ return days;
215
+ }
216
+ /**
217
+ * As the component formatted them: `en-GB`, written down here rather than taken
218
+ * from the host's locale because that is what the element did before the split
219
+ * and this is a split, not a change (see the note on the card core's date).
220
+ */
221
+ function formatDate(date) {
222
+ return date.toLocaleDateString('en-GB', {
223
+ weekday: 'long',
224
+ day: 'numeric',
225
+ month: 'long',
226
+ year: 'numeric',
227
+ });
228
+ }
229
+ function formatTime(dateString) {
230
+ return new Date(dateString).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
231
+ }
232
+ function formatMonth(date) {
233
+ return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
234
+ }
235
+ /** The event detail's occurrences, as this element holds them. */
236
+ function toOccurrence(occurrence) {
237
+ return {
238
+ id: occurrence.id,
239
+ startsAt: occurrence.startsAt,
240
+ endsAt: occurrence.endsAt,
241
+ status: occurrence.availabilityStatus,
242
+ availableCapacity: occurrence.availableCapacity,
243
+ soldOut: occurrence.availabilityStatus === 'sold_out',
244
+ };
245
+ }
246
+ exports.occurrenceSelectorCore = (0, define_js_1.defineElement)({
247
+ tag: 'tl-occurrence-selector',
248
+ docs: 'A smart occurrence selector component that fetches and displays event occurrences from the Ticketlayer API.',
249
+ category: 'discovery',
250
+ // Not `native`: the hand-kept manifest said `web` and there is no React
251
+ // Native harness for a calendar yet. Splitting it does not make that claim
252
+ // true, and the catalogue is read as a promise about where an element runs.
253
+ runtimes: ['web'],
254
+ tokens: [
255
+ 'background',
256
+ 'border',
257
+ 'font-family',
258
+ 'foreground',
259
+ 'muted',
260
+ 'muted-foreground',
261
+ 'primary',
262
+ 'primary-foreground',
263
+ 'radius-md',
264
+ ],
265
+ sdkMethods: ['events.get'],
266
+ props: {
267
+ eventId: {
268
+ type: 'string',
269
+ attr: 'event-id',
270
+ required: true,
271
+ docs: 'Event ID to fetch occurrences for',
272
+ },
273
+ displayMode: {
274
+ type: 'string',
275
+ tsType: '"calendar" | "list" | "slots" | undefined',
276
+ attr: 'display-mode',
277
+ docs: 'Force a specific display mode (auto-detected if not set)',
278
+ },
279
+ orientation: {
280
+ type: 'string',
281
+ tsType: '"auto" | "horizontal" | "vertical"',
282
+ attr: 'orientation',
283
+ default: 'auto',
284
+ docs: 'Layout orientation (auto = horizontal on desktop, vertical on mobile)',
285
+ },
286
+ theme: {
287
+ type: 'object',
288
+ tsType: 'TlOccurrenceSelectorTheme | undefined',
289
+ attr: null,
290
+ docs: 'Theme configuration',
291
+ },
292
+ },
293
+ events: {
294
+ tlSelect: {
295
+ detail: '{ occurrence: TlOccurrence; }',
296
+ docs: 'Emitted when an occurrence is selected',
297
+ },
298
+ },
299
+ strings: STRINGS,
300
+ statuses: ['loading', 'error', 'empty', 'ready'],
301
+ state: () => ({
302
+ loading: true,
303
+ error: null,
304
+ occurrences: [],
305
+ currentMonth: new Date(),
306
+ selectedDate: null,
307
+ containerWidth: 0,
308
+ }),
309
+ /**
310
+ * On screen: wait for the host's client, then read the event.
311
+ *
312
+ * The component did this in `componentWillLoad`, which held the first paint
313
+ * until the read came back. A mount hook cannot hold a paint, so the element
314
+ * shows its own skeleton first instead of a blank - the same change
315
+ * `tl-ticket-selector` and `tl-event-card` took when they moved onto the hook
316
+ * (TKT-197).
317
+ *
318
+ * There is nothing to unsubscribe: this element reads once and lives on what
319
+ * it read, so the mount registers no teardown and the harness still gets one.
320
+ */
321
+ mount: (ctx) => {
322
+ if (!ctx.props.eventId) {
323
+ ctx.actions.loadFailed(ctx.strings.noEventId);
324
+ return;
325
+ }
326
+ void (async () => {
327
+ try {
328
+ await ctx.whenClient();
329
+ }
330
+ catch (err) {
331
+ if (!ctx.active())
332
+ return;
333
+ ctx.actions.loadFailed(err instanceof Error ? err.message : ctx.strings.noClient);
334
+ return;
335
+ }
336
+ if (!ctx.active())
337
+ return;
338
+ await ctx.actions.load();
339
+ })();
340
+ },
341
+ status: ({ state }) => state.loading ? 'loading' : state.error ? 'error' : state.occurrences.length === 0 ? 'empty' : 'ready',
342
+ derive: ({ state, props, strings }) => {
343
+ const { occurrences, selectedDate } = state;
344
+ const byDate = occurrencesByDate(occurrences);
345
+ const range = dateRangeOf(occurrences);
346
+ // Which view. The component decided this once, when the read came back;
347
+ // deriving it means a host that changes `display-mode` afterwards is
348
+ // answered at once rather than at the next read (TKT-202, named in the
349
+ // pull request as the one behaviour this split does not preserve exactly).
350
+ const mode = props.displayMode
351
+ ? props.displayMode
352
+ : occurrences.length <= LIST_LIMIT
353
+ ? 'list'
354
+ : byDate.size === 1
355
+ ? 'slots'
356
+ : 'calendar';
357
+ const horizontal = props.orientation === 'horizontal'
358
+ ? true
359
+ : props.orientation === 'vertical'
360
+ ? false
361
+ : state.containerWidth >= exports.HORIZONTAL_MIN_WIDTH;
362
+ const row = (occurrence) => {
363
+ const soldOut = isSoldOut(occurrence);
364
+ const start = formatTime(occurrence.startsAt);
365
+ return {
366
+ id: occurrence.id,
367
+ dateLabel: formatDate(new Date(occurrence.startsAt)),
368
+ timeLabel: occurrence.endsAt ? `${start} - ${formatTime(occurrence.endsAt)}` : start,
369
+ startLabel: start,
370
+ soldOut,
371
+ capacityLabel: occurrence.availableCapacity
372
+ ? `${occurrence.availableCapacity} ${strings.available}`
373
+ : null,
374
+ statusKind: soldOut ? 'soldOut' : occurrence.availableCapacity ? 'available' : 'arrow',
375
+ };
376
+ };
377
+ const inTimeOrder = [...occurrences].sort(byStart);
378
+ const selectedKey = selectedDate ? dayKey(selectedDate) : null;
379
+ const selectedOn = selectedKey ? [...(byDate.get(selectedKey) ?? [])].sort(byStart) : [];
380
+ const today = startOfDay(new Date());
381
+ const firstMonth = range ? firstOfMonth(range.first) : null;
382
+ const lastMonth = range ? firstOfMonth(range.last) : null;
383
+ const days = calendarDays(occurrences, state.currentMonth).map((date) => {
384
+ const key = dayKey(date);
385
+ const on = byDate.get(key);
386
+ const hasOccurrences = !!on;
387
+ const soldOut = hasOccurrences && on.every(isSoldOut);
388
+ const otherMonth = date.getMonth() !== state.currentMonth.getMonth();
389
+ const selected = selectedKey === key;
390
+ const isToday = key === dayKey(today);
391
+ const past = date < today;
392
+ return {
393
+ key,
394
+ dayNumber: String(date.getDate()),
395
+ otherMonth,
396
+ hasOccurrences,
397
+ soldOut,
398
+ selected,
399
+ today: isToday,
400
+ past,
401
+ selectable: hasOccurrences,
402
+ className: [
403
+ 'calendar-day',
404
+ otherMonth ? 'other-month' : '',
405
+ hasOccurrences ? 'has-occurrences' : '',
406
+ soldOut ? 'sold-out' : '',
407
+ selected ? 'selected' : '',
408
+ isToday ? 'today' : '',
409
+ past ? 'past' : '',
410
+ ]
411
+ .filter(Boolean)
412
+ .join(' '),
413
+ };
414
+ });
415
+ return {
416
+ mode,
417
+ horizontal,
418
+ viewClass: `calendar-view ${horizontal ? 'horizontal' : 'vertical'}`,
419
+ rows: occurrences.map(row),
420
+ slots: inTimeOrder.map(row),
421
+ slotsDateLabel: inTimeOrder.length ? formatDate(new Date(inTimeOrder[0].startsAt)) : null,
422
+ monthLabel: formatMonth(state.currentMonth),
423
+ showMonthNav: !isSingleMonth(occurrences),
424
+ canGoBack: !!firstMonth && state.currentMonth > firstMonth,
425
+ canGoForward: !!lastMonth && state.currentMonth < lastMonth,
426
+ weekdayLabels: [
427
+ strings.weekdayMon,
428
+ strings.weekdayTue,
429
+ strings.weekdayWed,
430
+ strings.weekdayThu,
431
+ strings.weekdayFri,
432
+ strings.weekdaySat,
433
+ strings.weekdaySun,
434
+ ],
435
+ days,
436
+ selectedDateLabel: selectedDate && selectedOn.length ? formatDate(selectedDate) : null,
437
+ selectedSlots: selectedOn.map(row),
438
+ };
439
+ },
440
+ actions: (ctx) => ({
441
+ /**
442
+ * Read the event and take its occurrences. Also what Retry runs, which is
443
+ * why it sets `loading` and clears the error itself rather than relying on
444
+ * the state it started in.
445
+ */
446
+ async load() {
447
+ ctx.setState({ loading: true, error: null });
448
+ try {
449
+ const events = ctx.client().getEventsManager();
450
+ const read = events.get?.bind(events);
451
+ if (!read)
452
+ throw new TypeError('eventsManager.get is not a function');
453
+ const detail = (await read(ctx.props.eventId));
454
+ if (!detail)
455
+ throw new Error(ctx.strings.notFound);
456
+ const occurrences = (detail.occurrences ?? []).map(toOccurrence);
457
+ ctx.setState({ occurrences, ...(initialSelection(occurrences) ?? {}) });
458
+ }
459
+ catch (err) {
460
+ ctx.setState({
461
+ error: err instanceof Error ? err.message : ctx.strings.loadFailed,
462
+ occurrences: [],
463
+ });
464
+ }
465
+ finally {
466
+ ctx.setState({ loading: false });
467
+ }
468
+ },
469
+ /** Nothing to read: no event id, or the host never produced a client. */
470
+ loadFailed(message) {
471
+ ctx.setState({ error: message, loading: false });
472
+ },
473
+ /**
474
+ * The buyer picked an occurrence. A sold out one is not pickable, which the
475
+ * harness also renders as disabled: the core decides, the harness shows.
476
+ */
477
+ select(occurrenceId) {
478
+ const occurrence = ctx.state.occurrences.find((o) => o.id === occurrenceId);
479
+ if (!occurrence || isSoldOut(occurrence))
480
+ return;
481
+ ctx.emit('tlSelect', { occurrence });
482
+ },
483
+ /** The buyer picked a day. Only a day with something on it counts. */
484
+ selectDate(key) {
485
+ if (!occurrencesByDate(ctx.state.occurrences).has(key))
486
+ return;
487
+ const date = new Date(key);
488
+ if (Number.isNaN(date.getTime()))
489
+ return;
490
+ const { currentMonth } = ctx.state;
491
+ const movedMonth = date.getMonth() !== currentMonth.getMonth() || date.getFullYear() !== currentMonth.getFullYear();
492
+ ctx.setState({
493
+ selectedDate: date,
494
+ ...(movedMonth ? { currentMonth: firstOfMonth(date) } : {}),
495
+ });
496
+ },
497
+ /** Back a month, and never before the first month there is anything in. */
498
+ previousMonth() {
499
+ const range = dateRangeOf(ctx.state.occurrences);
500
+ if (!range)
501
+ return;
502
+ const next = new Date(ctx.state.currentMonth);
503
+ next.setMonth(next.getMonth() - 1);
504
+ if (next >= firstOfMonth(range.first))
505
+ ctx.setState({ currentMonth: next });
506
+ },
507
+ /** Forward a month, and never past the last month there is anything in. */
508
+ nextMonth() {
509
+ const range = dateRangeOf(ctx.state.occurrences);
510
+ if (!range)
511
+ return;
512
+ const next = new Date(ctx.state.currentMonth);
513
+ next.setMonth(next.getMonth() + 1);
514
+ if (next <= firstOfMonth(range.last))
515
+ ctx.setState({ currentMonth: next });
516
+ },
517
+ /**
518
+ * How wide the harness measures the element. What a width means is the
519
+ * core's (`auto` turns horizontal at 768px); measuring one is the
520
+ * runtime's, and on the web that is a `ResizeObserver`.
521
+ */
522
+ resized(width) {
523
+ if (width === ctx.state.containerWidth)
524
+ return;
525
+ ctx.setState({ containerWidth: width });
526
+ },
527
+ }),
528
+ });