@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,99 @@
|
|
|
1
|
+
import { type TlOrderData, type TlOrderTotals, type TlResolvedOccurrence } from '../orders.js';
|
|
2
|
+
export interface OrderConfirmationProps {
|
|
3
|
+
orderId: string | undefined;
|
|
4
|
+
customerEmail: string | undefined;
|
|
5
|
+
showTickets: boolean;
|
|
6
|
+
columns: number;
|
|
7
|
+
}
|
|
8
|
+
export interface OrderConfirmationState {
|
|
9
|
+
order: TlOrderData | null;
|
|
10
|
+
resolvedOrderId: string | null;
|
|
11
|
+
loading: boolean;
|
|
12
|
+
error: string | null;
|
|
13
|
+
/** The payment provider sent the buyer back on something other than success. */
|
|
14
|
+
paymentFailed: boolean;
|
|
15
|
+
occurrence: TlResolvedOccurrence | null;
|
|
16
|
+
signedInCustomer: unknown | null;
|
|
17
|
+
}
|
|
18
|
+
export interface OrderConfirmationRow {
|
|
19
|
+
label: string;
|
|
20
|
+
value: string;
|
|
21
|
+
/** The reference is set in the monospace face the stylesheet reserves. */
|
|
22
|
+
reference: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface OrderConfirmationLine {
|
|
25
|
+
label: string;
|
|
26
|
+
priceLabel: string;
|
|
27
|
+
}
|
|
28
|
+
export interface OrderConfirmationDerived {
|
|
29
|
+
/** "Order ORD-1 is confirmed...", or the version with no reference in it. */
|
|
30
|
+
leadText: string;
|
|
31
|
+
rows: OrderConfirmationRow[];
|
|
32
|
+
lines: OrderConfirmationLine[];
|
|
33
|
+
totals: TlOrderTotals;
|
|
34
|
+
subtotalLabel: string;
|
|
35
|
+
feeAmountLabel: string;
|
|
36
|
+
taxLabel: string;
|
|
37
|
+
discountLabel: string;
|
|
38
|
+
totalLabel: string;
|
|
39
|
+
showTaxRow: boolean;
|
|
40
|
+
showDiscountRow: boolean;
|
|
41
|
+
/** The order arrived and carries lines, so there is a summary to draw. */
|
|
42
|
+
showSummary: boolean;
|
|
43
|
+
/** A read failed and there is nothing on screen to keep instead. */
|
|
44
|
+
showError: boolean;
|
|
45
|
+
showTickets: boolean;
|
|
46
|
+
/** The order the ticket grid is for, which is known before the order is. */
|
|
47
|
+
ticketsOrderId: string;
|
|
48
|
+
}
|
|
49
|
+
export type OrderConfirmationStatus = 'payment-failed' | 'idle' | 'loading' | 'ready';
|
|
50
|
+
declare const STRINGS: {
|
|
51
|
+
paymentFailedTitle: string;
|
|
52
|
+
paymentFailedLead: string;
|
|
53
|
+
title: string;
|
|
54
|
+
leadPrefix: string;
|
|
55
|
+
leadSuffix: string;
|
|
56
|
+
leadGeneric: string;
|
|
57
|
+
errorPrefix: string;
|
|
58
|
+
rowReference: string;
|
|
59
|
+
rowEmail: string;
|
|
60
|
+
rowEvent: string;
|
|
61
|
+
rowDate: string;
|
|
62
|
+
subtotalRow: string;
|
|
63
|
+
taxRow: string;
|
|
64
|
+
discountRow: string;
|
|
65
|
+
totalRow: string;
|
|
66
|
+
ticketsTitle: string;
|
|
67
|
+
ticketsHint: string;
|
|
68
|
+
noOrdersManager: string;
|
|
69
|
+
notFound: string;
|
|
70
|
+
loadFailed: string;
|
|
71
|
+
};
|
|
72
|
+
export type OrderConfirmationStrings = typeof STRINGS;
|
|
73
|
+
export declare const orderConfirmationCore: import("../define.js").ElementCore<OrderConfirmationProps, OrderConfirmationState, OrderConfirmationDerived, {
|
|
74
|
+
load(): Promise<void>;
|
|
75
|
+
orderIdChanged(): void;
|
|
76
|
+
checkoutCompleted(order: TlOrderData | null): void;
|
|
77
|
+
}, OrderConfirmationStatus, {
|
|
78
|
+
paymentFailedTitle: string;
|
|
79
|
+
paymentFailedLead: string;
|
|
80
|
+
title: string;
|
|
81
|
+
leadPrefix: string;
|
|
82
|
+
leadSuffix: string;
|
|
83
|
+
leadGeneric: string;
|
|
84
|
+
errorPrefix: string;
|
|
85
|
+
rowReference: string;
|
|
86
|
+
rowEmail: string;
|
|
87
|
+
rowEvent: string;
|
|
88
|
+
rowDate: string;
|
|
89
|
+
subtotalRow: string;
|
|
90
|
+
taxRow: string;
|
|
91
|
+
discountRow: string;
|
|
92
|
+
totalRow: string;
|
|
93
|
+
ticketsTitle: string;
|
|
94
|
+
ticketsHint: string;
|
|
95
|
+
noOrdersManager: string;
|
|
96
|
+
notFound: string;
|
|
97
|
+
loadFailed: string;
|
|
98
|
+
}>;
|
|
99
|
+
export {};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tl-order-confirmation`: the thank-you block - reference, email, event and
|
|
3
|
+
* date, the ticket lines, the money rows, and the tickets themselves.
|
|
4
|
+
*
|
|
5
|
+
* It finds its order three ways, in this order, and that priority is the whole
|
|
6
|
+
* point of the element:
|
|
7
|
+
*
|
|
8
|
+
* 1. the `order-id` prop, for a host that already knows;
|
|
9
|
+
* 2. the query the page was opened with (`?orderId=`), which is the contract
|
|
10
|
+
* the hosted box office confirmation page uses, plus `?redirect_status=`,
|
|
11
|
+
* which is how a payment provider says on the way back whether the money
|
|
12
|
+
* actually moved;
|
|
13
|
+
* 3. the client's `checkout:completed`, for a page that mounts this before
|
|
14
|
+
* the customer has paid.
|
|
15
|
+
*
|
|
16
|
+
* (2) is why this core reads {@link ActionContext.query} and (3) is why it
|
|
17
|
+
* holds a subscription, bound in its own `mount` and handed to
|
|
18
|
+
* `ctx.onTeardown` (TKT-197). It is the last of the eighteen elements to live
|
|
19
|
+
* on a subscription.
|
|
20
|
+
*
|
|
21
|
+
* Behaviour is what the Stencil component did, to the letter, including:
|
|
22
|
+
*
|
|
23
|
+
* - a redirect that is not `succeeded` shows "Payment not completed" and does
|
|
24
|
+
* NOT read the order, even when there is an order id to read.
|
|
25
|
+
* - a re-read while an order is already on screen does not blank it: the
|
|
26
|
+
* skeleton is only for the first read.
|
|
27
|
+
* - a failed read keeps the thank-you header and puts the message underneath,
|
|
28
|
+
* because someone who has just paid should not be told the purchase failed.
|
|
29
|
+
* - `checkout:completed` shows what it carries at once and THEN re-reads,
|
|
30
|
+
* because an embed's payload is only the id and the order number until the
|
|
31
|
+
* tickets have been issued.
|
|
32
|
+
* - the event name and date are best effort. The order view carries neither,
|
|
33
|
+
* so they are walked out of the channel's listing, and a failure there
|
|
34
|
+
* leaves the line at whatever the order itself said.
|
|
35
|
+
*
|
|
36
|
+
* What did not come across is the `console.error` tracing, which a library
|
|
37
|
+
* should not do for its host (`tl-cart` dropped its own for the same reason).
|
|
38
|
+
*/
|
|
39
|
+
import { defineElement } from '../define.js';
|
|
40
|
+
import { errorMessage, formatDateTime, formatPrice, orderCustomerEmail, orderEventName, orderTotals, resolveOccurrence, ticketItems, } from '../orders.js';
|
|
41
|
+
const STRINGS = {
|
|
42
|
+
paymentFailedTitle: 'Payment not completed',
|
|
43
|
+
paymentFailedLead: "Your payment wasn't completed. Your cart is still saved, so you can try again.",
|
|
44
|
+
title: "You're going!",
|
|
45
|
+
leadPrefix: 'Order ',
|
|
46
|
+
leadSuffix: ' is confirmed. Your tickets are on their way to your email.',
|
|
47
|
+
leadGeneric: 'Your order is confirmed. Your tickets are on their way to your email.',
|
|
48
|
+
errorPrefix: "We couldn't load your order details yet. ",
|
|
49
|
+
rowReference: 'Order reference',
|
|
50
|
+
rowEmail: 'Email',
|
|
51
|
+
rowEvent: 'Event',
|
|
52
|
+
rowDate: 'Date',
|
|
53
|
+
subtotalRow: 'Subtotal',
|
|
54
|
+
taxRow: 'Tax',
|
|
55
|
+
discountRow: 'Discount',
|
|
56
|
+
totalRow: 'Total',
|
|
57
|
+
ticketsTitle: 'Your tickets',
|
|
58
|
+
ticketsHint: 'Show these QR codes at the door for entry. Use Download PDF on a ticket to save it.',
|
|
59
|
+
noOrdersManager: 'Orders are not available',
|
|
60
|
+
notFound: 'Order not found',
|
|
61
|
+
loadFailed: 'Failed to load your order',
|
|
62
|
+
};
|
|
63
|
+
/** The query parameter a payment provider comes back on, and the one value that means paid. */
|
|
64
|
+
const REDIRECT_STATUS = 'redirect_status';
|
|
65
|
+
const REDIRECT_SUCCEEDED = 'succeeded';
|
|
66
|
+
export const orderConfirmationCore = defineElement({
|
|
67
|
+
tag: 'tl-order-confirmation',
|
|
68
|
+
docs: 'The thank-you block: order reference, email, event and date, the ticket lines, the money rows and the tickets.',
|
|
69
|
+
category: 'checkout',
|
|
70
|
+
runtimes: ['web', 'native'],
|
|
71
|
+
tokens: [
|
|
72
|
+
'border',
|
|
73
|
+
'error',
|
|
74
|
+
'font-family',
|
|
75
|
+
'font-family-mono',
|
|
76
|
+
'foreground',
|
|
77
|
+
'muted',
|
|
78
|
+
'muted-foreground',
|
|
79
|
+
'radius-lg',
|
|
80
|
+
'radius-md',
|
|
81
|
+
'secondary',
|
|
82
|
+
'spacing-1',
|
|
83
|
+
'spacing-2',
|
|
84
|
+
'spacing-3',
|
|
85
|
+
'spacing-4',
|
|
86
|
+
'spacing-6',
|
|
87
|
+
'success',
|
|
88
|
+
'success-foreground',
|
|
89
|
+
],
|
|
90
|
+
sdkMethods: ['orders.get', 'events.list', 'events.get', 'auth.customer', 'on'],
|
|
91
|
+
props: {
|
|
92
|
+
orderId: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
tsType: 'string | undefined',
|
|
95
|
+
attr: 'order-id',
|
|
96
|
+
docs: 'The order to show. Falls back to `?orderId=` in the page URL.',
|
|
97
|
+
},
|
|
98
|
+
customerEmail: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
tsType: 'string | undefined',
|
|
101
|
+
attr: 'customer-email',
|
|
102
|
+
docs: 'Email to show when the order view carries none (a host that collected it at checkout).',
|
|
103
|
+
},
|
|
104
|
+
showTickets: {
|
|
105
|
+
type: 'boolean',
|
|
106
|
+
attr: 'show-tickets',
|
|
107
|
+
default: true,
|
|
108
|
+
docs: 'Mount tl-order-tickets under the summary (default true).',
|
|
109
|
+
},
|
|
110
|
+
columns: { type: 'number', attr: 'columns', default: 2, docs: 'Columns for the ticket grid.' },
|
|
111
|
+
},
|
|
112
|
+
events: {
|
|
113
|
+
tlOrderLoaded: { detail: '{ order: TlOrderData; }', docs: 'the order was loaded' },
|
|
114
|
+
tlError: { detail: '{ message: string; }', docs: 'the order could not be loaded' },
|
|
115
|
+
},
|
|
116
|
+
strings: STRINGS,
|
|
117
|
+
statuses: ['payment-failed', 'idle', 'loading', 'ready'],
|
|
118
|
+
state: () => ({
|
|
119
|
+
order: null,
|
|
120
|
+
resolvedOrderId: null,
|
|
121
|
+
loading: false,
|
|
122
|
+
error: null,
|
|
123
|
+
paymentFailed: false,
|
|
124
|
+
occurrence: null,
|
|
125
|
+
signedInCustomer: null,
|
|
126
|
+
}),
|
|
127
|
+
/**
|
|
128
|
+
* On screen: read the link, start the read, subscribe.
|
|
129
|
+
*
|
|
130
|
+
* The query goes first because it decides whether there is anything to read
|
|
131
|
+
* at all: a payment that did not go through is not an order to fetch. The
|
|
132
|
+
* read is started and not awaited before subscribing, so a
|
|
133
|
+
* `checkout:completed` that lands mid-read is not missed - the component
|
|
134
|
+
* read in `componentWillLoad` and subscribed in `componentDidLoad`, and had
|
|
135
|
+
* exactly that window.
|
|
136
|
+
*
|
|
137
|
+
* The subscription needs the client and the read does not: `load` waits for
|
|
138
|
+
* the client itself, inside the try block that announces a failure, which is
|
|
139
|
+
* why a page that never starts Live still emits `tlError` here when it was
|
|
140
|
+
* given an order to show.
|
|
141
|
+
*/
|
|
142
|
+
mount: (ctx) => {
|
|
143
|
+
void (async () => {
|
|
144
|
+
// The link, before anything else: a failed redirect means no read.
|
|
145
|
+
const redirect = ctx.query.get(REDIRECT_STATUS);
|
|
146
|
+
ctx.setState({
|
|
147
|
+
paymentFailed: !!redirect && redirect !== REDIRECT_SUCCEEDED,
|
|
148
|
+
resolvedOrderId: ctx.props.orderId || ctx.query.get('orderId') || null,
|
|
149
|
+
});
|
|
150
|
+
const reading = ctx.state.resolvedOrderId && !ctx.state.paymentFailed
|
|
151
|
+
? ctx.actions.load()
|
|
152
|
+
: Promise.resolve();
|
|
153
|
+
let client = null;
|
|
154
|
+
try {
|
|
155
|
+
client = await ctx.whenClient();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
client = null;
|
|
159
|
+
}
|
|
160
|
+
if (client && ctx.active()) {
|
|
161
|
+
const off = client.on?.('checkout:completed', (payload) => {
|
|
162
|
+
ctx.actions.checkoutCompleted(payload);
|
|
163
|
+
});
|
|
164
|
+
if (typeof off === 'function')
|
|
165
|
+
ctx.onTeardown(off);
|
|
166
|
+
}
|
|
167
|
+
await reading;
|
|
168
|
+
})();
|
|
169
|
+
},
|
|
170
|
+
status: ({ state }) => state.paymentFailed
|
|
171
|
+
? 'payment-failed'
|
|
172
|
+
: !state.resolvedOrderId && !state.order
|
|
173
|
+
? 'idle'
|
|
174
|
+
: state.loading
|
|
175
|
+
? 'loading'
|
|
176
|
+
: 'ready',
|
|
177
|
+
derive: ({ state, props, strings }) => {
|
|
178
|
+
const order = state.order;
|
|
179
|
+
const totals = orderTotals(order);
|
|
180
|
+
const currency = totals.currency;
|
|
181
|
+
const email = props.customerEmail || orderCustomerEmail(order, state.signedInCustomer);
|
|
182
|
+
const eventName = state.occurrence?.eventName || (order ? orderEventName(order) : null);
|
|
183
|
+
const startsAt = state.occurrence?.startsAt;
|
|
184
|
+
const rows = [];
|
|
185
|
+
if (order)
|
|
186
|
+
rows.push({ label: strings.rowReference, value: order.orderNumber, reference: true });
|
|
187
|
+
if (email)
|
|
188
|
+
rows.push({ label: strings.rowEmail, value: email, reference: false });
|
|
189
|
+
if (eventName)
|
|
190
|
+
rows.push({ label: strings.rowEvent, value: eventName, reference: false });
|
|
191
|
+
if (startsAt)
|
|
192
|
+
rows.push({ label: strings.rowDate, value: formatDateTime(startsAt), reference: false });
|
|
193
|
+
return {
|
|
194
|
+
leadText: order?.orderNumber
|
|
195
|
+
? `${strings.leadPrefix}${order.orderNumber}${strings.leadSuffix}`
|
|
196
|
+
: strings.leadGeneric,
|
|
197
|
+
rows,
|
|
198
|
+
lines: order
|
|
199
|
+
? ticketItems(order).map((item) => ({
|
|
200
|
+
label: `${item.quantity} x ${item.name}`,
|
|
201
|
+
priceLabel: formatPrice(item.subtotal, order.currency),
|
|
202
|
+
}))
|
|
203
|
+
: [],
|
|
204
|
+
totals,
|
|
205
|
+
subtotalLabel: formatPrice(totals.subtotal, currency),
|
|
206
|
+
feeAmountLabel: formatPrice(totals.fees, currency),
|
|
207
|
+
taxLabel: formatPrice(totals.tax, currency),
|
|
208
|
+
discountLabel: formatPrice(totals.discount, currency),
|
|
209
|
+
totalLabel: formatPrice(totals.total, currency),
|
|
210
|
+
showTaxRow: totals.tax > 0,
|
|
211
|
+
showDiscountRow: totals.discount > 0,
|
|
212
|
+
// An embed's checkout:completed carries only id and order number until
|
|
213
|
+
// the refetch lands, so there is nothing to summarise yet.
|
|
214
|
+
showSummary: !!order && Array.isArray(order.items),
|
|
215
|
+
showError: !!state.error && !order,
|
|
216
|
+
showTickets: props.showTickets && !!order,
|
|
217
|
+
ticketsOrderId: order?.id || state.resolvedOrderId || '',
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
actions: (ctx) => {
|
|
221
|
+
/**
|
|
222
|
+
* Put an event name and a start time on the summary. The order view has
|
|
223
|
+
* neither, so the occurrence is walked out of the channel's listing. Best
|
|
224
|
+
* effort and never blocking: a failure leaves the line at what the order
|
|
225
|
+
* said, and a result that arrives after the order changed underneath is
|
|
226
|
+
* dropped.
|
|
227
|
+
*/
|
|
228
|
+
const resolveEvent = async (client, order) => {
|
|
229
|
+
const occurrenceId = order.tickets?.find((t) => t.eventOccurrenceId)?.eventOccurrenceId;
|
|
230
|
+
if (!occurrenceId)
|
|
231
|
+
return;
|
|
232
|
+
try {
|
|
233
|
+
const resolved = await resolveOccurrence(client, occurrenceId);
|
|
234
|
+
if (resolved && ctx.state.order?.id === order.id)
|
|
235
|
+
ctx.setState({ occurrence: resolved });
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Leave the event line at what the order gives us.
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const self = {
|
|
242
|
+
/** Read the order. The skeleton is only for the first read. */
|
|
243
|
+
async load() {
|
|
244
|
+
const id = ctx.state.resolvedOrderId;
|
|
245
|
+
if (!id)
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
ctx.setState({ loading: !ctx.state.order, error: null });
|
|
249
|
+
const client = await ctx.whenClient();
|
|
250
|
+
const orders = client.getOrdersManager?.();
|
|
251
|
+
if (!orders)
|
|
252
|
+
throw new Error(ctx.strings.noOrdersManager);
|
|
253
|
+
const order = (await orders.get(id));
|
|
254
|
+
if (!order)
|
|
255
|
+
throw new Error(ctx.strings.notFound);
|
|
256
|
+
ctx.setState({
|
|
257
|
+
order,
|
|
258
|
+
signedInCustomer: client.getAuthManager?.()?.customer ?? null,
|
|
259
|
+
});
|
|
260
|
+
ctx.emit('tlOrderLoaded', { order });
|
|
261
|
+
void resolveEvent(client, order);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
const message = errorMessage(err, ctx.strings.loadFailed);
|
|
265
|
+
ctx.setState({ error: message });
|
|
266
|
+
ctx.emit('tlError', { message });
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
ctx.setState({ loading: false });
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
/**
|
|
273
|
+
* The host pointed the element at a different order. It resolves again
|
|
274
|
+
* the way the mount did - the prop first, the link second - and reads.
|
|
275
|
+
* Not a remount: the `checkout:completed` subscription belongs to the
|
|
276
|
+
* element, not to the order it happens to be showing.
|
|
277
|
+
*/
|
|
278
|
+
orderIdChanged() {
|
|
279
|
+
ctx.setState({ resolvedOrderId: ctx.props.orderId || ctx.query.get('orderId') || null });
|
|
280
|
+
void self.load();
|
|
281
|
+
},
|
|
282
|
+
/**
|
|
283
|
+
* The client finished a checkout. Show what it carries at once, because
|
|
284
|
+
* an order number on screen beats a skeleton, then read the order again:
|
|
285
|
+
* the tickets are issued after the order exists, so the payload almost
|
|
286
|
+
* never has them.
|
|
287
|
+
*/
|
|
288
|
+
checkoutCompleted(order) {
|
|
289
|
+
if (!order?.id)
|
|
290
|
+
return;
|
|
291
|
+
ctx.setState({ paymentFailed: false, order, resolvedOrderId: order.id });
|
|
292
|
+
ctx.emit('tlOrderLoaded', { order });
|
|
293
|
+
void self.load();
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
return self;
|
|
297
|
+
},
|
|
298
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { TlTicketData } from '../orders.js';
|
|
2
|
+
export interface OrderTicketsProps {
|
|
3
|
+
orderId: string;
|
|
4
|
+
columns: number;
|
|
5
|
+
}
|
|
6
|
+
export interface OrderTicketsState {
|
|
7
|
+
tickets: TlTicketData[];
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
/** The order loaded, has no tickets yet, and there is still waiting to do. */
|
|
11
|
+
issuing: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface OrderTicketsDerived {
|
|
14
|
+
tickets: TlTicketData[];
|
|
15
|
+
columns: number;
|
|
16
|
+
/** The order every rendered ticket belongs to, for its PDF link. */
|
|
17
|
+
orderId: string;
|
|
18
|
+
}
|
|
19
|
+
export type OrderTicketsStatus = 'loading' | 'error' | 'issuing' | 'delayed' | 'ready';
|
|
20
|
+
/**
|
|
21
|
+
* Gaps between polls, in milliseconds. Front-loaded because issuance usually
|
|
22
|
+
* lands in the first few seconds, then widening so a genuinely stuck order is
|
|
23
|
+
* not hammered. Sums to roughly one minute, and running out is what turns
|
|
24
|
+
* `issuing` into `delayed`.
|
|
25
|
+
*/
|
|
26
|
+
export declare const POLL_DELAYS_MS: readonly number[];
|
|
27
|
+
declare const STRINGS: {
|
|
28
|
+
loading: string;
|
|
29
|
+
preparing: string;
|
|
30
|
+
delayed: string;
|
|
31
|
+
notAvailable: string;
|
|
32
|
+
loadFailed: string;
|
|
33
|
+
};
|
|
34
|
+
export type OrderTicketsStrings = typeof STRINGS;
|
|
35
|
+
export declare const orderTicketsCore: import("../define.js").ElementCore<OrderTicketsProps, OrderTicketsState, OrderTicketsDerived, {
|
|
36
|
+
load(): Promise<void>;
|
|
37
|
+
orderIdChanged(): void;
|
|
38
|
+
stopPolling(): void;
|
|
39
|
+
}, OrderTicketsStatus, {
|
|
40
|
+
loading: string;
|
|
41
|
+
preparing: string;
|
|
42
|
+
delayed: string;
|
|
43
|
+
notAvailable: string;
|
|
44
|
+
loadFailed: string;
|
|
45
|
+
}>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tl-order-tickets`: every ticket in an order, as scannable cards.
|
|
3
|
+
*
|
|
4
|
+
* The one core that waits rather than concluding. Tickets are issued
|
|
5
|
+
* asynchronously - the payment confirms, the order is marked paid, and
|
|
6
|
+
* entitlement issuance follows a moment later - so a confirmation page renders
|
|
7
|
+
* BEFORE the passes exist, reliably rather than occasionally. The element used
|
|
8
|
+
* to answer that with "no tickets for this order yet", which is true and reads
|
|
9
|
+
* as "something went wrong" to somebody who has just been charged. Refreshing
|
|
10
|
+
* showed them, so the only thing missing was the waiting.
|
|
11
|
+
*
|
|
12
|
+
* So an empty order is treated as issuance in flight and polled, with the gaps
|
|
13
|
+
* widening, for about a minute. Then it stops and says the tickets are coming
|
|
14
|
+
* by email, because at that point something probably is wrong and an element
|
|
15
|
+
* that polls forever is worse than one that admits it.
|
|
16
|
+
*
|
|
17
|
+
* The polling belongs to the mount, which is what the split adds: the
|
|
18
|
+
* component cancelled its timer in `disconnectedCallback`, and here the mount's
|
|
19
|
+
* teardown does it, so every runtime gets the same guarantee rather than each
|
|
20
|
+
* harness remembering. A scheduled poll is cancelled by moving the generation
|
|
21
|
+
* on rather than by clearing a handle - `ctx.after` has no handle to clear -
|
|
22
|
+
* so a stale timer still fires and does nothing, which is the same thing from
|
|
23
|
+
* outside: no request is made.
|
|
24
|
+
*
|
|
25
|
+
* Behaviour is what the Stencil component did, to the letter, including:
|
|
26
|
+
*
|
|
27
|
+
* - a failed read STOPS the polling. Retrying a 403 a dozen times helps
|
|
28
|
+
* nobody, and a failure is a different thing from an order with no tickets.
|
|
29
|
+
* - `loading` is only ever true before the first read finishes. Every later
|
|
30
|
+
* read, including a poll and a new order id, leaves whatever is on screen.
|
|
31
|
+
* - pointing it at another order clears the tickets without putting the
|
|
32
|
+
* loading state back, so there is a beat where the element reads as "taking
|
|
33
|
+
* longer than usual" before the new read lands. Kept, not tidied.
|
|
34
|
+
*
|
|
35
|
+
* There is no `tlError` here, and no event of any kind: nothing this element
|
|
36
|
+
* does is announced to the host. Kept as found.
|
|
37
|
+
*/
|
|
38
|
+
import { defineElement } from '../define.js';
|
|
39
|
+
/**
|
|
40
|
+
* Gaps between polls, in milliseconds. Front-loaded because issuance usually
|
|
41
|
+
* lands in the first few seconds, then widening so a genuinely stuck order is
|
|
42
|
+
* not hammered. Sums to roughly one minute, and running out is what turns
|
|
43
|
+
* `issuing` into `delayed`.
|
|
44
|
+
*/
|
|
45
|
+
export const POLL_DELAYS_MS = [
|
|
46
|
+
1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000, 12000,
|
|
47
|
+
];
|
|
48
|
+
const STRINGS = {
|
|
49
|
+
loading: 'Loading tickets…',
|
|
50
|
+
preparing: 'Preparing your tickets…',
|
|
51
|
+
delayed: 'Your tickets are taking longer than usual. They will be emailed to you as soon as they are ready.',
|
|
52
|
+
notAvailable: 'Tickets are not available',
|
|
53
|
+
loadFailed: 'Failed to load tickets',
|
|
54
|
+
};
|
|
55
|
+
export const orderTicketsCore = defineElement({
|
|
56
|
+
tag: 'tl-order-tickets',
|
|
57
|
+
docs: "Every ticket in an order as a scannable card, waiting out issuance rather than reporting an empty order as empty.",
|
|
58
|
+
category: 'tickets',
|
|
59
|
+
runtimes: ['web', 'native'],
|
|
60
|
+
tokens: ['border', 'error', 'font-family', 'muted-foreground', 'primary', 'spacing-2', 'spacing-4', 'spacing-6'],
|
|
61
|
+
sdkMethods: ['orders.get'],
|
|
62
|
+
props: {
|
|
63
|
+
orderId: { type: 'string', attr: 'order-id', required: true, docs: 'The order whose tickets to show.' },
|
|
64
|
+
columns: { type: 'number', attr: 'columns', default: 2, docs: 'Columns in the grid (default 2).' },
|
|
65
|
+
},
|
|
66
|
+
events: {},
|
|
67
|
+
strings: STRINGS,
|
|
68
|
+
statuses: ['loading', 'error', 'issuing', 'delayed', 'ready'],
|
|
69
|
+
state: () => ({ tickets: [], loading: true, error: null, issuing: false }),
|
|
70
|
+
/**
|
|
71
|
+
* On screen: read, and keep reading while issuance is in flight. Off screen:
|
|
72
|
+
* stop, which is the whole reason the polling lives in the mount.
|
|
73
|
+
*/
|
|
74
|
+
mount: (ctx) => {
|
|
75
|
+
ctx.onTeardown(() => ctx.actions.stopPolling());
|
|
76
|
+
void ctx.actions.load();
|
|
77
|
+
},
|
|
78
|
+
status: ({ state }) => state.loading
|
|
79
|
+
? 'loading'
|
|
80
|
+
: state.error
|
|
81
|
+
? 'error'
|
|
82
|
+
: state.tickets.length > 0
|
|
83
|
+
? 'ready'
|
|
84
|
+
: state.issuing
|
|
85
|
+
? 'issuing'
|
|
86
|
+
: 'delayed',
|
|
87
|
+
derive: ({ state, props }) => ({
|
|
88
|
+
tickets: state.tickets,
|
|
89
|
+
columns: props.columns,
|
|
90
|
+
orderId: props.orderId,
|
|
91
|
+
}),
|
|
92
|
+
actions: (ctx) => {
|
|
93
|
+
// Neither of these is state: nothing renders from them, and the component
|
|
94
|
+
// held both as instance fields for the same reason. `generation` is how a
|
|
95
|
+
// scheduled poll is cancelled, since `ctx.after` hands back no handle.
|
|
96
|
+
let generation = 0;
|
|
97
|
+
let poll = 0;
|
|
98
|
+
const self = {
|
|
99
|
+
/**
|
|
100
|
+
* Read the order. Tickets end the waiting; no tickets and a gap left in
|
|
101
|
+
* the schedule extends it; no tickets and no gap left gives up.
|
|
102
|
+
*/
|
|
103
|
+
async load() {
|
|
104
|
+
try {
|
|
105
|
+
ctx.setState({ error: null });
|
|
106
|
+
const client = await ctx.whenClient();
|
|
107
|
+
const orders = client.getOrdersManager?.();
|
|
108
|
+
if (!orders)
|
|
109
|
+
throw new Error(ctx.strings.notAvailable);
|
|
110
|
+
const order = (await orders.get(ctx.props.orderId));
|
|
111
|
+
const tickets = order?.tickets ?? [];
|
|
112
|
+
ctx.setState({ tickets });
|
|
113
|
+
if (tickets.length > 0) {
|
|
114
|
+
// Arrived. Stop asking.
|
|
115
|
+
ctx.setState({ issuing: false });
|
|
116
|
+
self.stopPolling();
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const delay = POLL_DELAYS_MS[poll];
|
|
120
|
+
// `undefined` once the schedule runs out: give up waiting, and say so.
|
|
121
|
+
ctx.setState({ issuing: delay !== undefined });
|
|
122
|
+
if (delay !== undefined) {
|
|
123
|
+
poll += 1;
|
|
124
|
+
self.stopPolling();
|
|
125
|
+
const mine = generation;
|
|
126
|
+
ctx.after(delay, () => {
|
|
127
|
+
if (mine === generation)
|
|
128
|
+
void self.load();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
// A failed request is a different thing from an order with no
|
|
135
|
+
// tickets, and stops the polling.
|
|
136
|
+
//
|
|
137
|
+
// Defensive rather than load bearing, as it was in the component:
|
|
138
|
+
// a poll is scheduled only on the success path, and the timer that
|
|
139
|
+
// triggered this read has already fired, so there is normally
|
|
140
|
+
// nothing left to cancel. Mutation testing says so out loud -
|
|
141
|
+
// removing this line kills no test - and it is kept because the
|
|
142
|
+
// component had it and because it is what makes the intent legible.
|
|
143
|
+
self.stopPolling();
|
|
144
|
+
ctx.setState({ issuing: false, error: err instanceof Error ? err.message : ctx.strings.loadFailed });
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
ctx.setState({ loading: false });
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
/**
|
|
151
|
+
* A different order: forget this one, put the schedule back to the top
|
|
152
|
+
* and read. The loading state is deliberately NOT restored, so the
|
|
153
|
+
* element reads as "taking longer than usual" for a beat, exactly as the
|
|
154
|
+
* component did.
|
|
155
|
+
*/
|
|
156
|
+
orderIdChanged() {
|
|
157
|
+
self.stopPolling();
|
|
158
|
+
poll = 0;
|
|
159
|
+
ctx.setState({ tickets: [], issuing: false });
|
|
160
|
+
void self.load();
|
|
161
|
+
},
|
|
162
|
+
/** Cancel whatever poll is scheduled. Also what the teardown runs. */
|
|
163
|
+
stopPolling() {
|
|
164
|
+
generation += 1;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
return self;
|
|
168
|
+
},
|
|
169
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ElementPresaleRedemption } from '../client.js';
|
|
2
|
+
export interface TlPromoApplied extends ElementPresaleRedemption {
|
|
3
|
+
code: string;
|
|
4
|
+
cart: unknown | null;
|
|
5
|
+
}
|
|
6
|
+
export interface PromoCodeProps {
|
|
7
|
+
placeholder: string;
|
|
8
|
+
buttonLabel: string;
|
|
9
|
+
}
|
|
10
|
+
export interface PromoCodeState {
|
|
11
|
+
code: string;
|
|
12
|
+
applying: boolean;
|
|
13
|
+
applied: TlPromoApplied | null;
|
|
14
|
+
error: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface PromoCodeDerived {
|
|
17
|
+
/** What the Apply button reads while a redemption is in flight, or otherwise. */
|
|
18
|
+
actionLabel: string;
|
|
19
|
+
/** The input and the button are both dead while a redemption is in flight. */
|
|
20
|
+
disabled: boolean;
|
|
21
|
+
/** The code, as the receipt shows it, or null when nothing is applied. */
|
|
22
|
+
appliedCode: string | null;
|
|
23
|
+
/** " applied", or " was already applied" for a code the customer already held. */
|
|
24
|
+
appliedSuffix: string;
|
|
25
|
+
}
|
|
26
|
+
export type PromoCodeStatus = 'idle' | 'applied';
|
|
27
|
+
declare const STRINGS: {
|
|
28
|
+
applying: string;
|
|
29
|
+
codePrefix: string;
|
|
30
|
+
appliedSuffix: string;
|
|
31
|
+
alreadyHeldSuffix: string;
|
|
32
|
+
useDifferent: string;
|
|
33
|
+
enterCode: string;
|
|
34
|
+
notAvailable: string;
|
|
35
|
+
signIn: string;
|
|
36
|
+
rejected: string;
|
|
37
|
+
};
|
|
38
|
+
export type PromoCodeStrings = typeof STRINGS;
|
|
39
|
+
/** True for the "not signed in" family of API errors (401/403, NO_SESSION, NOT_AUTHENTICATED). */
|
|
40
|
+
export declare function isAuthError(err: unknown): boolean;
|
|
41
|
+
export declare const promoCodeCore: import("../define.js").ElementCore<PromoCodeProps, PromoCodeState, PromoCodeDerived, {
|
|
42
|
+
setCode(code: string): void;
|
|
43
|
+
apply(): Promise<void>;
|
|
44
|
+
reset(): void;
|
|
45
|
+
}, PromoCodeStatus, {
|
|
46
|
+
applying: string;
|
|
47
|
+
codePrefix: string;
|
|
48
|
+
appliedSuffix: string;
|
|
49
|
+
alreadyHeldSuffix: string;
|
|
50
|
+
useDifferent: string;
|
|
51
|
+
enterCode: string;
|
|
52
|
+
notAvailable: string;
|
|
53
|
+
signIn: string;
|
|
54
|
+
rejected: string;
|
|
55
|
+
}>;
|
|
56
|
+
export {};
|