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