@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,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tl-login`: magic-link sign-in. No password is handled anywhere in here.
|
|
3
|
+
*
|
|
4
|
+
* The customer enters an email, the client is asked to send a link, and the
|
|
5
|
+
* code that comes back is exchanged for a session either because the link
|
|
6
|
+
* landed on this page with `?code=` or because the customer typed the six
|
|
7
|
+
* digits out of the email. Both go through the same `exchange` action.
|
|
8
|
+
*
|
|
9
|
+
* The first core to read {@link ActionContext.query}, the seam this split
|
|
10
|
+
* added. An element that is the far end of an emailed link has to be able to
|
|
11
|
+
* see what the link said, and where that is written down is per runtime: the
|
|
12
|
+
* web has `window.location.search`, a native harness has its deep-link router.
|
|
13
|
+
* So the core says it reads `code`, once, on mount, and the harness says where
|
|
14
|
+
* from. A harness that supplies no query reads null and the element simply
|
|
15
|
+
* starts on the email step, which is what a page with no code in its URL does
|
|
16
|
+
* anyway.
|
|
17
|
+
*
|
|
18
|
+
* Behaviour is what the Stencil component did, to the letter, including:
|
|
19
|
+
*
|
|
20
|
+
* - the URL code is single use. It is taken once per instance and never read
|
|
21
|
+
* again, so a remount cannot spend a code that was already exchanged.
|
|
22
|
+
* - a failed exchange goes back to where it came from, and the two ways in
|
|
23
|
+
* are told apart: a typed code returns to the code step, a URL code returns
|
|
24
|
+
* to the email step.
|
|
25
|
+
* - a failed sign out is swallowed. The customer is shown as signed out and
|
|
26
|
+
* `tlAuthChanged` says so, because the session is gone locally either way,
|
|
27
|
+
* and that is what the component did.
|
|
28
|
+
*
|
|
29
|
+
* What did not come across is the `console.error` tracing, which a library
|
|
30
|
+
* should not do for its host (`tl-cart` dropped its own for the same reason).
|
|
31
|
+
*/
|
|
32
|
+
import { defineElement } from '../define.js';
|
|
33
|
+
import { errorMessage } from '../orders.js';
|
|
34
|
+
const STRINGS = {
|
|
35
|
+
signedInPrefix: 'Signed in as ',
|
|
36
|
+
fallbackAccount: 'your account',
|
|
37
|
+
signOut: 'Sign out',
|
|
38
|
+
verifyingTitle: 'Signing you in…',
|
|
39
|
+
verifyingLead: 'Just a moment.',
|
|
40
|
+
sentTitle: 'Check your inbox',
|
|
41
|
+
// The lead reads "We've sent a sign-in link to <strong>you@…</strong>. Open
|
|
42
|
+
// it on this device to continue." Split in two because the address is
|
|
43
|
+
// emphasised in the middle of it, and the shape has no way to say "this copy
|
|
44
|
+
// has a slot in it" - see the README's note on what a core still cannot
|
|
45
|
+
// express.
|
|
46
|
+
sentLeadBefore: "We've sent a sign-in link to ",
|
|
47
|
+
sentLeadAfter: '. Open it on this device to continue.',
|
|
48
|
+
sentAgain: 'Sent again. Check your inbox.',
|
|
49
|
+
resendLink: 'Resend link',
|
|
50
|
+
resending: 'Sending…',
|
|
51
|
+
enterCodeInstead: 'Enter the code instead',
|
|
52
|
+
differentEmail: 'Use a different email',
|
|
53
|
+
codeTitle: 'Enter your code',
|
|
54
|
+
codeLeadBefore: 'Type the 6-digit code from the email we sent to ',
|
|
55
|
+
codeLeadAfter: '.',
|
|
56
|
+
codeLabel: 'Code',
|
|
57
|
+
codePlaceholder: '123456',
|
|
58
|
+
codeSubmit: 'Sign in',
|
|
59
|
+
checking: 'Checking…',
|
|
60
|
+
back: 'Back',
|
|
61
|
+
emailLead: "Enter your email and we'll send you a secure sign-in link. No password needed.",
|
|
62
|
+
emailLabel: 'Email address',
|
|
63
|
+
emailPlaceholder: 'you@example.com',
|
|
64
|
+
emailSubmit: 'Email me a sign-in link',
|
|
65
|
+
sendingLink: 'Sending link…',
|
|
66
|
+
enterEmail: 'Enter your email address',
|
|
67
|
+
enterCode: 'Enter the code from your email',
|
|
68
|
+
codeRejected: 'That code did not work. Check it and try again, or request a new link.',
|
|
69
|
+
requestFailed: 'Could not send your sign-in link',
|
|
70
|
+
unavailable: 'Sign-in is not available right now',
|
|
71
|
+
unsupported: 'Sign-in is not available with this SDK',
|
|
72
|
+
};
|
|
73
|
+
export const loginCore = defineElement({
|
|
74
|
+
tag: 'tl-login',
|
|
75
|
+
docs: 'Magic-link sign-in: an email in, a link or a six-digit code back, and a session. No password is ever handled.',
|
|
76
|
+
category: 'account',
|
|
77
|
+
runtimes: ['web', 'native'],
|
|
78
|
+
tokens: [
|
|
79
|
+
'background',
|
|
80
|
+
'border',
|
|
81
|
+
'error',
|
|
82
|
+
'font-family',
|
|
83
|
+
'font-family-mono',
|
|
84
|
+
'foreground',
|
|
85
|
+
'muted-foreground',
|
|
86
|
+
'primary',
|
|
87
|
+
'primary-foreground',
|
|
88
|
+
'radius-lg',
|
|
89
|
+
'radius-md',
|
|
90
|
+
'secondary',
|
|
91
|
+
'spacing-3',
|
|
92
|
+
'spacing-4',
|
|
93
|
+
'spacing-6',
|
|
94
|
+
'success',
|
|
95
|
+
],
|
|
96
|
+
sdkMethods: ['auth.requestMagicLink', 'auth.exchange', 'auth.logout', 'on'],
|
|
97
|
+
props: {
|
|
98
|
+
heading: { type: 'string', attr: 'heading', default: 'Sign in', docs: 'Heading for the email step.' },
|
|
99
|
+
allowCodeEntry: {
|
|
100
|
+
type: 'boolean',
|
|
101
|
+
attr: 'allow-code-entry',
|
|
102
|
+
default: true,
|
|
103
|
+
docs: 'Offer a "enter the code" path alongside the emailed link (default true).',
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
events: {
|
|
107
|
+
tlAuthChanged: { detail: 'TlAuthChanged', docs: 'signed in or out ({ customer, authenticated })' },
|
|
108
|
+
tlMagicLinkSent: { detail: '{ email: string; }', docs: 'a link was requested for an email' },
|
|
109
|
+
tlError: { detail: '{ message: string; }', docs: 'request or code exchange failed' },
|
|
110
|
+
},
|
|
111
|
+
strings: STRINGS,
|
|
112
|
+
statuses: ['email', 'sent', 'code', 'verifying', 'signed-in'],
|
|
113
|
+
state: () => ({
|
|
114
|
+
phase: 'email',
|
|
115
|
+
email: '',
|
|
116
|
+
code: '',
|
|
117
|
+
busy: false,
|
|
118
|
+
error: null,
|
|
119
|
+
notice: null,
|
|
120
|
+
customer: null,
|
|
121
|
+
authAvailable: false,
|
|
122
|
+
urlCodeUsed: false,
|
|
123
|
+
}),
|
|
124
|
+
/**
|
|
125
|
+
* On screen: wait for the client, read who is signed in, follow both auth
|
|
126
|
+
* signals, then spend the code the link arrived with, if it did.
|
|
127
|
+
*
|
|
128
|
+
* The code is taken from the query whether or not it gets used, which is
|
|
129
|
+
* what makes it single use: arriving already signed in burns it, exactly as
|
|
130
|
+
* the component did.
|
|
131
|
+
*/
|
|
132
|
+
mount: (ctx) => {
|
|
133
|
+
void (async () => {
|
|
134
|
+
let client;
|
|
135
|
+
try {
|
|
136
|
+
client = await ctx.whenClient();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
if (!ctx.active())
|
|
140
|
+
return;
|
|
141
|
+
ctx.actions.connectFailed();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!ctx.active())
|
|
145
|
+
return;
|
|
146
|
+
const auth = client.getAuthManager?.();
|
|
147
|
+
if (!auth) {
|
|
148
|
+
ctx.actions.unsupported();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
ctx.setState({ authAvailable: true });
|
|
152
|
+
ctx.actions.syncFromClient();
|
|
153
|
+
// The session hydrates after the client publishes; follow both signals.
|
|
154
|
+
for (const event of ['auth:changed', 'session:changed']) {
|
|
155
|
+
const off = client.on?.(event, () => ctx.actions.syncFromClient());
|
|
156
|
+
if (typeof off === 'function')
|
|
157
|
+
ctx.onTeardown(off);
|
|
158
|
+
}
|
|
159
|
+
const urlCode = ctx.state.urlCodeUsed ? null : ctx.query.get('code');
|
|
160
|
+
if (urlCode) {
|
|
161
|
+
ctx.setState({ urlCodeUsed: true });
|
|
162
|
+
if (!auth.authenticated)
|
|
163
|
+
await ctx.actions.exchange(urlCode);
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
},
|
|
167
|
+
status: ({ state }) => state.phase,
|
|
168
|
+
derive: ({ state, strings, props }) => ({
|
|
169
|
+
signedInEmail: state.customer?.email ?? strings.fallbackAccount,
|
|
170
|
+
inputsDisabled: state.busy || !state.authAvailable,
|
|
171
|
+
emailSubmitLabel: state.busy ? strings.sendingLink : strings.emailSubmit,
|
|
172
|
+
resendLabel: state.busy ? strings.resending : strings.resendLink,
|
|
173
|
+
codeSubmitLabel: state.busy ? strings.checking : strings.codeSubmit,
|
|
174
|
+
showCodeEntry: props.allowCodeEntry,
|
|
175
|
+
}),
|
|
176
|
+
// Named so `submitCode` can call `exchange`: the action context does not
|
|
177
|
+
// carry the actions, only a mount's does.
|
|
178
|
+
actions: (ctx) => {
|
|
179
|
+
/** The auth manager as it stands, or null on a client that has none. */
|
|
180
|
+
const authManager = () => ctx.optionalClient()?.getAuthManager?.() ?? null;
|
|
181
|
+
const self = {
|
|
182
|
+
/** Typing clears whatever the last attempt complained about. */
|
|
183
|
+
setEmail(value) {
|
|
184
|
+
ctx.setState({ email: value, error: null });
|
|
185
|
+
},
|
|
186
|
+
setCode(value) {
|
|
187
|
+
ctx.setState({ code: value, error: null });
|
|
188
|
+
},
|
|
189
|
+
/**
|
|
190
|
+
* Ask for a link. The response always reports success, so nothing here
|
|
191
|
+
* can reveal whether an account exists.
|
|
192
|
+
*/
|
|
193
|
+
async requestLink(resend = false) {
|
|
194
|
+
const email = ctx.state.email.trim();
|
|
195
|
+
if (!email) {
|
|
196
|
+
ctx.setState({ error: ctx.strings.enterEmail });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const auth = authManager();
|
|
200
|
+
if (!auth)
|
|
201
|
+
return;
|
|
202
|
+
ctx.setState({ busy: true, error: null, notice: null });
|
|
203
|
+
try {
|
|
204
|
+
await auth.requestMagicLink({ email });
|
|
205
|
+
ctx.setState({ phase: 'sent', notice: resend ? ctx.strings.sentAgain : null });
|
|
206
|
+
ctx.emit('tlMagicLinkSent', { email });
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
const message = errorMessage(err, ctx.strings.requestFailed);
|
|
210
|
+
ctx.setState({ error: message });
|
|
211
|
+
ctx.emit('tlError', { message });
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
ctx.setState({ busy: false });
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
/** The code the customer typed. */
|
|
218
|
+
async submitCode() {
|
|
219
|
+
await self.exchange(ctx.state.code.trim());
|
|
220
|
+
},
|
|
221
|
+
/**
|
|
222
|
+
* Trade a code for a session. On failure the step it came from decides
|
|
223
|
+
* where it goes back to: a typed code returns to the code step, a URL
|
|
224
|
+
* code (which arrives while the phase is still `email`) returns to the
|
|
225
|
+
* email step.
|
|
226
|
+
*/
|
|
227
|
+
async exchange(code) {
|
|
228
|
+
if (!code) {
|
|
229
|
+
ctx.setState({ error: ctx.strings.enterCode });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const auth = authManager();
|
|
233
|
+
if (!auth)
|
|
234
|
+
return;
|
|
235
|
+
const previous = ctx.state.phase;
|
|
236
|
+
ctx.setState({ busy: true, error: null, phase: 'verifying' });
|
|
237
|
+
try {
|
|
238
|
+
const res = (await auth.exchange(code));
|
|
239
|
+
const customer = auth.customer ?? res?.customer ?? null;
|
|
240
|
+
ctx.setState({ customer, phase: 'signed-in', code: '' });
|
|
241
|
+
ctx.emit('tlAuthChanged', { customer, authenticated: true });
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
ctx.setState({
|
|
245
|
+
phase: previous === 'verifying' || previous === 'email' ? 'email' : 'code',
|
|
246
|
+
error: ctx.strings.codeRejected,
|
|
247
|
+
});
|
|
248
|
+
ctx.emit('tlError', { message: ctx.strings.codeRejected });
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
ctx.setState({ busy: false });
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
/**
|
|
255
|
+
* Sign out. A failure is swallowed on purpose: the session is gone from
|
|
256
|
+
* this page either way, so the customer is shown as signed out and the
|
|
257
|
+
* host is told so.
|
|
258
|
+
*/
|
|
259
|
+
async signOut() {
|
|
260
|
+
const auth = authManager();
|
|
261
|
+
if (!auth)
|
|
262
|
+
return;
|
|
263
|
+
ctx.setState({ busy: true });
|
|
264
|
+
try {
|
|
265
|
+
await auth.logout();
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Shown as signed out regardless, which is what the component did.
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
ctx.setState({ busy: false, customer: null, phase: 'email' });
|
|
272
|
+
ctx.emit('tlAuthChanged', { customer: null, authenticated: false });
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
/** The steps a buyer can walk between by hand. */
|
|
276
|
+
goTo(phase) {
|
|
277
|
+
ctx.setState({ phase });
|
|
278
|
+
},
|
|
279
|
+
/**
|
|
280
|
+
* The client says the session changed. Signed in wins outright; signed
|
|
281
|
+
* out only pulls the element back when it was showing the signed-in
|
|
282
|
+
* step, so a customer half way through typing a code is left alone.
|
|
283
|
+
*/
|
|
284
|
+
syncFromClient() {
|
|
285
|
+
const auth = authManager();
|
|
286
|
+
if (!auth)
|
|
287
|
+
return;
|
|
288
|
+
if (auth.authenticated) {
|
|
289
|
+
ctx.setState({ customer: auth.customer ?? null, phase: 'signed-in' });
|
|
290
|
+
}
|
|
291
|
+
else if (ctx.state.phase === 'signed-in') {
|
|
292
|
+
ctx.setState({ customer: null, phase: 'email' });
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
/** The host never started Live. Said on screen, and nowhere else. */
|
|
296
|
+
connectFailed() {
|
|
297
|
+
ctx.setState({ error: ctx.strings.unavailable });
|
|
298
|
+
},
|
|
299
|
+
/** A connector with no auth accessor cannot sign anybody in. */
|
|
300
|
+
unsupported() {
|
|
301
|
+
ctx.setState({ error: ctx.strings.unsupported });
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
return self;
|
|
305
|
+
},
|
|
306
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type StatusTone, type TlOrderData } from '../orders.js';
|
|
2
|
+
export interface MyOrdersProps {
|
|
3
|
+
emptyMessage: string;
|
|
4
|
+
columns: number;
|
|
5
|
+
}
|
|
6
|
+
export interface MyOrdersState {
|
|
7
|
+
orders: TlOrderData[];
|
|
8
|
+
authenticated: boolean;
|
|
9
|
+
loading: boolean;
|
|
10
|
+
error: string | null;
|
|
11
|
+
expandedId: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface MyOrdersRow {
|
|
14
|
+
id: string;
|
|
15
|
+
/** "Order ORD-AAA", as the row's own heading. */
|
|
16
|
+
numberLabel: string;
|
|
17
|
+
eventName: string;
|
|
18
|
+
/** The order date, formatted, or null when the order carries none. */
|
|
19
|
+
dateLabel: string | null;
|
|
20
|
+
/** "2 tickets", pluralised. */
|
|
21
|
+
countLabel: string;
|
|
22
|
+
totalLabel: string;
|
|
23
|
+
statusLabel: string;
|
|
24
|
+
statusTone: StatusTone;
|
|
25
|
+
expanded: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface MyOrdersDerived {
|
|
28
|
+
rows: MyOrdersRow[];
|
|
29
|
+
}
|
|
30
|
+
export type MyOrdersStatus = 'loading' | 'signed-out' | 'error' | 'empty' | 'ready';
|
|
31
|
+
declare const STRINGS: {
|
|
32
|
+
signInTitle: string;
|
|
33
|
+
signInText: string;
|
|
34
|
+
errorTitle: string;
|
|
35
|
+
emptyTitle: string;
|
|
36
|
+
orderPrefix: string;
|
|
37
|
+
unnamedEvent: string;
|
|
38
|
+
ticketSingular: string;
|
|
39
|
+
ticketPlural: string;
|
|
40
|
+
unavailable: string;
|
|
41
|
+
noOrdersManager: string;
|
|
42
|
+
loadFailed: string;
|
|
43
|
+
};
|
|
44
|
+
export type MyOrdersStrings = typeof STRINGS;
|
|
45
|
+
export declare const myOrdersCore: import("../define.js").ElementCore<MyOrdersProps, MyOrdersState, MyOrdersDerived, {
|
|
46
|
+
load(): Promise<void>;
|
|
47
|
+
syncAuth(): void;
|
|
48
|
+
toggle(orderId: string): void;
|
|
49
|
+
connectFailed(): void;
|
|
50
|
+
}, MyOrdersStatus, {
|
|
51
|
+
signInTitle: string;
|
|
52
|
+
signInText: string;
|
|
53
|
+
errorTitle: string;
|
|
54
|
+
emptyTitle: string;
|
|
55
|
+
orderPrefix: string;
|
|
56
|
+
unnamedEvent: string;
|
|
57
|
+
ticketSingular: string;
|
|
58
|
+
ticketPlural: string;
|
|
59
|
+
unavailable: string;
|
|
60
|
+
noOrdersManager: string;
|
|
61
|
+
loadFailed: string;
|
|
62
|
+
}>;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tl-my-orders`: the signed-in customer's orders, one expandable row each.
|
|
3
|
+
*
|
|
4
|
+
* The first core whose whole shape is decided by whether somebody is signed
|
|
5
|
+
* in. It reads the client's auth manager once, follows `auth:changed` and
|
|
6
|
+
* `session:changed` from there, and lists the orders whenever the answer turns
|
|
7
|
+
* from signed out to signed in. Both subscriptions are bound in its own
|
|
8
|
+
* `mount` and handed to `ctx.onTeardown` (TKT-197), so a remount cannot leave
|
|
9
|
+
* four listeners on the bus.
|
|
10
|
+
*
|
|
11
|
+
* Behaviour is what the Stencil component did, to the letter, including the
|
|
12
|
+
* three things that read like accidents and are not:
|
|
13
|
+
*
|
|
14
|
+
* - a connector with no auth accessor is treated as SIGNED IN, and a 401 from
|
|
15
|
+
* the listing is what decides otherwise. That is deliberate: an older
|
|
16
|
+
* connector has no way to answer the question, and showing the sign-in form
|
|
17
|
+
* to someone who is signed in is the worse failure.
|
|
18
|
+
* - a 401 or 403 from the listing is not an error. It drops back to the
|
|
19
|
+
* signed-out view, which renders the inline sign-in, and emits nothing.
|
|
20
|
+
* - the element does NOT announce that the host never started Live. It shows
|
|
21
|
+
* "Orders are not available right now" and stays quiet, where `tl-cart`
|
|
22
|
+
* emits `tlError` for the same condition, because the component's wait and
|
|
23
|
+
* its read were in different places. Kept rather than made consistent.
|
|
24
|
+
*
|
|
25
|
+
* What did not come across is the `console.error` tracing, which a library
|
|
26
|
+
* should not do for its host (`tl-cart` dropped its own for the same reason).
|
|
27
|
+
*/
|
|
28
|
+
import { defineElement } from '../define.js';
|
|
29
|
+
import { errorMessage, formatDate, formatPrice, orderEventName, statusLabel, statusTone, ticketCount, } from '../orders.js';
|
|
30
|
+
import { isAuthError } from './promo-code.js';
|
|
31
|
+
const STRINGS = {
|
|
32
|
+
signInTitle: 'Sign in to see your orders',
|
|
33
|
+
signInText: 'Your tickets and order history live in your account.',
|
|
34
|
+
errorTitle: 'Could not load your orders',
|
|
35
|
+
emptyTitle: 'No orders yet',
|
|
36
|
+
orderPrefix: 'Order ',
|
|
37
|
+
unnamedEvent: 'Order',
|
|
38
|
+
ticketSingular: 'ticket',
|
|
39
|
+
ticketPlural: 'tickets',
|
|
40
|
+
unavailable: 'Orders are not available right now',
|
|
41
|
+
noOrdersManager: 'Orders are not available',
|
|
42
|
+
loadFailed: 'Failed to load your orders',
|
|
43
|
+
};
|
|
44
|
+
export const myOrdersCore = defineElement({
|
|
45
|
+
tag: 'tl-my-orders',
|
|
46
|
+
docs: "The signed-in customer's orders: event, date, ticket count, total and status, each row expanding to its tickets.",
|
|
47
|
+
category: 'account',
|
|
48
|
+
runtimes: ['web', 'native'],
|
|
49
|
+
tokens: [
|
|
50
|
+
'background',
|
|
51
|
+
'border',
|
|
52
|
+
'error',
|
|
53
|
+
'error-foreground',
|
|
54
|
+
'font-family',
|
|
55
|
+
'foreground',
|
|
56
|
+
'muted-foreground',
|
|
57
|
+
'primary',
|
|
58
|
+
'primary-foreground',
|
|
59
|
+
'radius-lg',
|
|
60
|
+
'secondary',
|
|
61
|
+
'secondary-foreground',
|
|
62
|
+
'spacing-1',
|
|
63
|
+
'spacing-3',
|
|
64
|
+
'spacing-4',
|
|
65
|
+
'spacing-8',
|
|
66
|
+
'success',
|
|
67
|
+
'success-foreground',
|
|
68
|
+
'warning',
|
|
69
|
+
'warning-foreground',
|
|
70
|
+
],
|
|
71
|
+
sdkMethods: ['orders.mine', 'auth.authenticated', 'on'],
|
|
72
|
+
props: {
|
|
73
|
+
emptyMessage: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
attr: 'empty-message',
|
|
76
|
+
default: "When you buy tickets they'll show up here, ready to view and download.",
|
|
77
|
+
docs: 'Copy for the empty state.',
|
|
78
|
+
},
|
|
79
|
+
columns: { type: 'number', attr: 'columns', default: 2, docs: 'Columns for the expanded ticket grid.' },
|
|
80
|
+
},
|
|
81
|
+
events: {
|
|
82
|
+
tlOrderSelect: { detail: '{ orderId: string; }', docs: 'a row was expanded ({ orderId })' },
|
|
83
|
+
tlError: { detail: '{ message: string; }', docs: 'the orders could not be loaded' },
|
|
84
|
+
},
|
|
85
|
+
strings: STRINGS,
|
|
86
|
+
statuses: ['loading', 'signed-out', 'error', 'empty', 'ready'],
|
|
87
|
+
state: () => ({ orders: [], authenticated: false, loading: true, error: null, expandedId: null }),
|
|
88
|
+
/**
|
|
89
|
+
* On screen: wait for the client, read who is signed in, follow both auth
|
|
90
|
+
* signals, then list the orders if there is somebody to list them for.
|
|
91
|
+
*
|
|
92
|
+
* The session hydrates after the client publishes, so `auth:changed` and
|
|
93
|
+
* `session:changed` are both followed: the connector raises one or the other
|
|
94
|
+
* depending on whether the customer signed in here or arrived with a session
|
|
95
|
+
* already warm.
|
|
96
|
+
*/
|
|
97
|
+
mount: (ctx) => {
|
|
98
|
+
void (async () => {
|
|
99
|
+
let client;
|
|
100
|
+
try {
|
|
101
|
+
client = await ctx.whenClient();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
if (!ctx.active())
|
|
105
|
+
return;
|
|
106
|
+
ctx.actions.connectFailed();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!ctx.active())
|
|
110
|
+
return;
|
|
111
|
+
// An older connector has no auth accessor and cannot answer, so assume
|
|
112
|
+
// signed in and let a 401 from the listing decide.
|
|
113
|
+
const auth = client.getAuthManager?.();
|
|
114
|
+
ctx.setState({ authenticated: auth ? auth.authenticated : true });
|
|
115
|
+
for (const event of ['auth:changed', 'session:changed']) {
|
|
116
|
+
const off = client.on?.(event, () => ctx.actions.syncAuth());
|
|
117
|
+
if (typeof off === 'function')
|
|
118
|
+
ctx.onTeardown(off);
|
|
119
|
+
}
|
|
120
|
+
if (ctx.state.authenticated)
|
|
121
|
+
await ctx.actions.load();
|
|
122
|
+
else
|
|
123
|
+
ctx.setState({ loading: false });
|
|
124
|
+
})();
|
|
125
|
+
},
|
|
126
|
+
status: ({ state }) => state.loading
|
|
127
|
+
? 'loading'
|
|
128
|
+
: !state.authenticated
|
|
129
|
+
? 'signed-out'
|
|
130
|
+
: state.error
|
|
131
|
+
? 'error'
|
|
132
|
+
: state.orders.length === 0
|
|
133
|
+
? 'empty'
|
|
134
|
+
: 'ready',
|
|
135
|
+
derive: ({ state, strings }) => ({
|
|
136
|
+
rows: state.orders.map((order) => {
|
|
137
|
+
const count = ticketCount(order);
|
|
138
|
+
return {
|
|
139
|
+
id: order.id,
|
|
140
|
+
numberLabel: `${strings.orderPrefix}${order.orderNumber}`,
|
|
141
|
+
eventName: orderEventName(order) ?? strings.unnamedEvent,
|
|
142
|
+
dateLabel: order.createdAt ? formatDate(order.createdAt) : null,
|
|
143
|
+
countLabel: `${count} ${count === 1 ? strings.ticketSingular : strings.ticketPlural}`,
|
|
144
|
+
totalLabel: formatPrice(order.total, order.currency),
|
|
145
|
+
statusLabel: statusLabel(order.status),
|
|
146
|
+
statusTone: statusTone(order.status),
|
|
147
|
+
expanded: state.expandedId === order.id,
|
|
148
|
+
};
|
|
149
|
+
}),
|
|
150
|
+
}),
|
|
151
|
+
// The action map is named so `syncAuth` can call `load`: the action context
|
|
152
|
+
// does not carry the actions (only a mount's does), and listing the orders
|
|
153
|
+
// when the session turns warm is the same work the mount does.
|
|
154
|
+
actions: (ctx) => {
|
|
155
|
+
const self = {
|
|
156
|
+
/** List the orders. A 401 means signed out, which is not an error. */
|
|
157
|
+
async load() {
|
|
158
|
+
const client = ctx.optionalClient();
|
|
159
|
+
if (!client)
|
|
160
|
+
return;
|
|
161
|
+
try {
|
|
162
|
+
ctx.setState({ loading: true, error: null });
|
|
163
|
+
const orders = client.getOrdersManager?.();
|
|
164
|
+
if (!orders)
|
|
165
|
+
throw new Error(ctx.strings.noOrdersManager);
|
|
166
|
+
const list = (await orders.mine());
|
|
167
|
+
ctx.setState({ orders: list ?? [], authenticated: true });
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
if (isAuthError(err)) {
|
|
171
|
+
// Not signed in: fall through to the inline sign-in rather than an error.
|
|
172
|
+
ctx.setState({ authenticated: false, orders: [] });
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
const message = errorMessage(err, ctx.strings.loadFailed);
|
|
176
|
+
ctx.setState({ error: message });
|
|
177
|
+
ctx.emit('tlError', { message });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
ctx.setState({ loading: false });
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
/**
|
|
185
|
+
* The client says the session changed. Listing on the way in and clearing
|
|
186
|
+
* on the way out; an unchanged answer does nothing, which is what keeps a
|
|
187
|
+
* burst of `session:changed` from listing the orders four times.
|
|
188
|
+
*/
|
|
189
|
+
syncAuth() {
|
|
190
|
+
const auth = ctx.optionalClient()?.getAuthManager?.();
|
|
191
|
+
if (!auth)
|
|
192
|
+
return;
|
|
193
|
+
const was = ctx.state.authenticated;
|
|
194
|
+
ctx.setState({ authenticated: auth.authenticated });
|
|
195
|
+
if (auth.authenticated && !was)
|
|
196
|
+
void self.load();
|
|
197
|
+
else if (!auth.authenticated && was)
|
|
198
|
+
ctx.setState({ orders: [], expandedId: null });
|
|
199
|
+
},
|
|
200
|
+
/** Open a row, or close the open one. Only opening is announced. */
|
|
201
|
+
toggle(orderId) {
|
|
202
|
+
const opening = ctx.state.expandedId !== orderId;
|
|
203
|
+
ctx.setState({ expandedId: opening ? orderId : null });
|
|
204
|
+
if (opening)
|
|
205
|
+
ctx.emit('tlOrderSelect', { orderId });
|
|
206
|
+
},
|
|
207
|
+
/**
|
|
208
|
+
* The host never produced a client. Said on screen and nowhere else: the
|
|
209
|
+
* component did not emit `tlError` here, because its wait for the client
|
|
210
|
+
* was outside the try block that announces a failure.
|
|
211
|
+
*/
|
|
212
|
+
connectFailed() {
|
|
213
|
+
ctx.setState({ loading: false, error: ctx.strings.unavailable });
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
return self;
|
|
217
|
+
},
|
|
218
|
+
});
|