@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
package/README.md
CHANGED
|
@@ -33,6 +33,15 @@ export const exampleCore = defineElement({
|
|
|
33
33
|
state: () => ({ loading: true, error: null, items: [] }),
|
|
34
34
|
status: ({ state }) => (state.loading ? 'loading' : state.error ? 'error' : 'ready'),
|
|
35
35
|
derive: ({ state, strings }) => ({ caption: state.items.length ? '' : strings.empty }),
|
|
36
|
+
mount: (ctx) => {
|
|
37
|
+
void (async () => {
|
|
38
|
+
const client = await ctx.whenClient();
|
|
39
|
+
if (!ctx.active()) return;
|
|
40
|
+
await ctx.actions.load();
|
|
41
|
+
const off = client.on?.('cart:updated', () => ctx.actions.load());
|
|
42
|
+
if (off) ctx.onTeardown(off);
|
|
43
|
+
})();
|
|
44
|
+
},
|
|
36
45
|
actions: (ctx) => ({
|
|
37
46
|
async load() {
|
|
38
47
|
const items = await ctx.client().getEventsManager().getTicketTypes(ctx.props.eventId, '');
|
|
@@ -47,6 +56,7 @@ A harness instantiates it, renders `view`, and forwards:
|
|
|
47
56
|
```ts
|
|
48
57
|
const element = createElement(exampleCore, {
|
|
49
58
|
client: () => getSDK(),
|
|
59
|
+
clientReady: () => whenSDK(),
|
|
50
60
|
emit: (event, detail) => this.forward(event, detail),
|
|
51
61
|
});
|
|
52
62
|
element.subscribe((view) => (this.view = view));
|
|
@@ -55,6 +65,76 @@ element.subscribe((view) => (this.view = view));
|
|
|
55
65
|
`view` is `{ status, props, state, strings, derived }`, recomputed after every
|
|
56
66
|
change. The first three lines of a harness's render are a switch on `status`.
|
|
57
67
|
|
|
68
|
+
## Mount and teardown
|
|
69
|
+
|
|
70
|
+
`mount` is where a core says what happens when a harness puts the element on
|
|
71
|
+
screen, and hands back what undoes it (TKT-197). Loading and subscribing belong
|
|
72
|
+
here and not in a harness: a harness that decided when to load, or who owns a
|
|
73
|
+
subscription, would have to decide it again for every runtime, and the two
|
|
74
|
+
would drift. The web harness calls `mount()` from `connectedCallback` and its
|
|
75
|
+
teardown from `disconnectedCallback`; a native harness calls the same two from
|
|
76
|
+
whatever its runtime calls them.
|
|
77
|
+
|
|
78
|
+
The rules, held by `test/mount.test.mjs` rather than by convention:
|
|
79
|
+
|
|
80
|
+
| | |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `instance.mount()` | Runs the core's `mount` and returns that mount's teardown. A core with no `mount` still mounts, and still returns a teardown that does nothing. |
|
|
83
|
+
| The teardown | Runs everything registered with `ctx.onTeardown`, in reverse order, plus whatever `mount` returned. Idempotent: calling it a second time does nothing. A teardown from an earlier mount does nothing at all. |
|
|
84
|
+
| Mounting while mounted | Tears the live mount down first. At most one mount is live, so a remount cannot leave two subscriptions behind. |
|
|
85
|
+
| `destroy()` | Terminal. Runs the live mount's teardown, clears the timers and the listeners; mounting afterwards does nothing. A harness that only ever destroys still leaks nothing. |
|
|
86
|
+
| `ctx.active()` | False once this mount has been torn down or the instance destroyed. A mount that awaits anything checks it before touching the state. |
|
|
87
|
+
| `ctx.onTeardown(fn)` after teardown | Runs `fn` immediately, so a subscription made after an `await` cannot outlive the mount that asked for it. |
|
|
88
|
+
| `ctx.whenClient()` | Resolves with the host's client once `clientReady` says it is up, and rejects when the host never produces one. On an action too, not only a mount: an action a buyer triggers can be the first thing that needs the client (`tl-promo-code` redeems a code without reading anything first), and `client()` would throw on a page that is still starting up. |
|
|
89
|
+
|
|
90
|
+
On the web, `destroy()` is deliberately not called from `disconnectedCallback`:
|
|
91
|
+
Stencil calls that for a move as well as a removal, and destroying there would
|
|
92
|
+
leave a re-inserted element frozen. The teardown is what pairs with the mount.
|
|
93
|
+
|
|
94
|
+
## What a core is handed besides the client
|
|
95
|
+
|
|
96
|
+
`ctx.storage` is the runtime's key/value store: strings in, strings out,
|
|
97
|
+
synchronous, and allowed to fail silently. It exists because a core is
|
|
98
|
+
occasionally allowed to remember something between visits - `tl-cart-badge`
|
|
99
|
+
keeps the last item count so the badge does not flash empty on the next page -
|
|
100
|
+
and where that is written down is per runtime. The web harness passes a
|
|
101
|
+
`localStorage` wrapper; a harness that passes nothing gets `NO_STORAGE`, whose
|
|
102
|
+
reads come back null and whose writes go nowhere, so a core never has to ask
|
|
103
|
+
whether it has somewhere to write.
|
|
104
|
+
|
|
105
|
+
The rule is the same as for the client: the core says **what** it remembers and
|
|
106
|
+
**when**, the harness says **where**.
|
|
107
|
+
|
|
108
|
+
`ctx.query` is the second seam, and the same bargain. Two elements are the far
|
|
109
|
+
end of a link somebody follows: `tl-login` exchanges the `?code=` a magic-link
|
|
110
|
+
email lands on, and `tl-order-confirmation` finds its order in `?orderId=` and
|
|
111
|
+
reads `?redirect_status=` to learn whether a payment provider sent the buyer
|
|
112
|
+
back on a success. Where those parameters live is per runtime - the web has
|
|
113
|
+
`window.location.search`, a native harness has its deep-link router - so the
|
|
114
|
+
core says which parameter it reads and when, and the harness says where they
|
|
115
|
+
come from. It reads one name at a time, synchronously, null when it is not
|
|
116
|
+
there, and it is read-only: a core reports what a link said and does not
|
|
117
|
+
rewrite the address bar, because navigation is the host's. The web harness
|
|
118
|
+
passes `webQuery`; a harness that passes nothing gets `NO_QUERY`, whose reads
|
|
119
|
+
come back null, and both elements fall through to what they do on a page with
|
|
120
|
+
no link in it.
|
|
121
|
+
|
|
122
|
+
## Props a type cannot say in one word
|
|
123
|
+
|
|
124
|
+
`PropSpec.type` is the primitive a prop carries across every runtime. When the
|
|
125
|
+
harness's type is narrower - a literal union, or a prop that may be undefined -
|
|
126
|
+
the core writes it out as `tsType`:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
size: { type: 'string', tsType: '"lg" | "md" | "sm"', attr: 'size', default: 'md', docs: 'Size variant' },
|
|
130
|
+
icon: { type: 'string', tsType: 'string | undefined', attr: 'icon', docs: 'Custom icon' },
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Written out for the same reason `EventSpec.detail` is: the catalogue is JSON and
|
|
134
|
+
a type is not. The catalogue emitter compares `tsType ?? type` against what
|
|
135
|
+
Stencil built, so a core and its harness cannot disagree about what a prop
|
|
136
|
+
accepts.
|
|
137
|
+
|
|
58
138
|
## Tokens
|
|
59
139
|
|
|
60
140
|
`tokens` names `@ticketlayer/theme` tokens by their canonical name: `primary`,
|
|
@@ -90,7 +170,64 @@ emitter take the CommonJS, because Jest will not load ESM from `node_modules`.
|
|
|
90
170
|
|
|
91
171
|
## What is here today
|
|
92
172
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
173
|
+
Fifteen of the eighteen elements are split (TKT-11):
|
|
174
|
+
|
|
175
|
+
| Core | What it proves |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `tl-ticket-selector` | The reference element: the first split, and the one the shape was proved against (TKT-68, TKT-197). |
|
|
178
|
+
| `tl-cart` | The first core to live on a subscription. It binds `cart:updated` in its own `mount` and hands the unsubscribe to `ctx.onTeardown`, and its lifecycle spec is the first end-to-end proof that a remount does not double subscribe (TKT-69). |
|
|
179
|
+
| `tl-cart-badge` | The same subscription, plus `ctx.storage`: the one thing a core is allowed to remember. |
|
|
180
|
+
| `tl-cart-drawer` | Two subscriptions on the client's own bus, and a state a host can drive from outside without hearing its own writes back. |
|
|
181
|
+
| `tl-event-card` | A core filled either by a prop or by a read it starts itself, and an adapter that is behaviour rather than plumbing. |
|
|
182
|
+
| `tl-promo-code` | A core with **no** `mount` hook at all: a harness still calls `mount()` and still gets a teardown. `whenClient()` on an action is what it needs instead. |
|
|
183
|
+
| `tl-login` | The first core to read `ctx.query`, and the first whose whole shape is a phase machine. A magic-link code is single use, so the query is read once per instance and a remount cannot spend it again. |
|
|
184
|
+
| `tl-my-orders` | Two subscriptions on the same bus, and a core whose emptiness is a question about the session rather than about the data: a 401 from the listing is not an error, it is the signed-out view. |
|
|
185
|
+
| `tl-order-confirmation` | Three routes to one subject - a prop, the query, and a `checkout:completed` that arrives after the element is already on screen - and the last element of the eighteen to live on a subscription. |
|
|
186
|
+
| `tl-event-list` | The smallest core there is: one read, one re-read when the question changes, and no events at all bar the click it passes on. |
|
|
187
|
+
| `tl-order-tickets` | The one core that waits. Its mount owns a widening poll schedule and its teardown is what stops it, so no harness has to remember to. |
|
|
188
|
+
| `tl-buy-tickets-wrapper` | The smallest core of the fifteen: no state at all, one action, and three events that exist only because the modal reports back to whoever opened it. Split once its events were renamed to `tlComplete`, `tlError` and `tlClose` (TKT-203). |
|
|
189
|
+
| `tl-checkout-wrapper` | The same core pointed at the checkout, and the only one with no subject: the cart is the client's. |
|
|
190
|
+
| `tl-buy-tickets-button` | The one whose look depends on its state. It derives the CLASS its sale state renders under and leaves the stylesheet to say which token family that class paints with, because a core cannot map a status onto one - the same bargain `tl-event-card`'s status badge makes. |
|
|
191
|
+
| `tl-occurrence-selector` | The biggest, at 883 lines with no spec of its own, and the one split back to front: the characterisation spec was written and committed against the unsplit component FIRST, then passed unchanged against the harness. That is what makes it a split rather than a rewrite, bugs included - it still opens its calendar on the alphabetically first date, because `toDateString()` strings are what the component sorted (TKT-202). It also shows where the line falls: the core decides that `auto` turns horizontal at 768px, and the harness is what owns the `ResizeObserver` that measures one. |
|
|
192
|
+
|
|
193
|
+
The other three still hold their behaviour in their Stencil component and their
|
|
194
|
+
facts in the hand-kept `packages/elements/src/catalogue.manifest.json`; they move
|
|
195
|
+
across one at a time.
|
|
196
|
+
|
|
197
|
+
## What a core still cannot say
|
|
198
|
+
|
|
199
|
+
Kept here because it is the useful half of a split, and because each one is a
|
|
200
|
+
decision somebody will have to make rather than a gap to fill in quietly:
|
|
201
|
+
|
|
202
|
+
- **Copy with something emphasised in the middle of it.** `strings` is a flat
|
|
203
|
+
map of finished sentences, so `tl-login`'s "We've sent a sign-in link to
|
|
204
|
+
**you@example.com**. Open it on this device to continue." is carried as
|
|
205
|
+
`sentLeadBefore` and `sentLeadAfter` with the address between them. That works
|
|
206
|
+
and it does not translate: a language that puts the address first has nowhere
|
|
207
|
+
to say so. What is missing is a placeholder in a string and a harness that
|
|
208
|
+
fills it.
|
|
209
|
+
- **Which token family a status paints with.** `tokens` names the theme tokens
|
|
210
|
+
a harness renders with, and nothing in a core may hold a value. It is a flat
|
|
211
|
+
list, so there is no shape for saying that `soldOut` paints from `error` and
|
|
212
|
+
`waitlist` from `info`: a core can name the families its harnesses use, and
|
|
213
|
+
not which one belongs to which state. That is not a gap the split elements
|
|
214
|
+
wait on, because there is an answer already - the core derives the CLASS NAME
|
|
215
|
+
the status renders under and the stylesheet says which family that class
|
|
216
|
+
reaches for. `tl-event-card`'s status badge has done it since it was split,
|
|
217
|
+
and `tl-buy-tickets-button`, whose four states used to paint from literal hex
|
|
218
|
+
in the component and now read the `primary`, `error`, `info` and `warning`
|
|
219
|
+
families (TKT-203), was split on the same bargain. What is missing is only the
|
|
220
|
+
ability to state the mapping in the manifest, so a native harness with no
|
|
221
|
+
stylesheet could read it rather than write it out again.
|
|
222
|
+
- **A file the buyer takes away.** `tl-ticket` downloads a PDF by making an
|
|
223
|
+
object URL and clicking an anchor. A core can say "the buyer asked for this
|
|
224
|
+
ticket"; it has no seam for handing a runtime a blob to save, the way
|
|
225
|
+
`ctx.storage` and `ctx.query` are seams for the other two runtime facts.
|
|
226
|
+
- **A timer you can cancel.** `ctx.after(ms, fn)` schedules and hands nothing
|
|
227
|
+
back, and the instance only clears its timers on `destroy()`, which a web
|
|
228
|
+
harness never calls. `tl-order-tickets` polls, so it cancels by moving a
|
|
229
|
+
generation counter on and letting the stale timer fire into a no-op. That is
|
|
230
|
+
correct and it is not obvious, and every core that ever polls will have to
|
|
231
|
+
write it again until `after` returns its own cancel.
|
|
232
|
+
- **Anything whose subject is another document.** `tl-seat-map` is an iframe and
|
|
233
|
+
a `postMessage` protocol. There is no runtime-neutral half to lift out of it.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buyTicketsButtonCore = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* `tl-buy-tickets-button`: the pre-styled button that opens the purchase
|
|
6
|
+
* modal for one event.
|
|
7
|
+
*
|
|
8
|
+
* The sibling of `tl-buy-tickets-wrapper`. The wrapper turns whatever the host
|
|
9
|
+
* already has into the thing that opens the modal; this one IS the thing, so
|
|
10
|
+
* on top of the wrapper's single action it carries the four sale states a
|
|
11
|
+
* seller puts a button in, the label each one defaults to, and the price the
|
|
12
|
+
* button may show beside it.
|
|
13
|
+
*
|
|
14
|
+
* Until now it could not be split for a different reason from the wrapper's.
|
|
15
|
+
* Its four states painted themselves from literal hex in the component, and a
|
|
16
|
+
* core may hold no value at all, so there was nothing to lift. TKT-203 moved
|
|
17
|
+
* them onto token families - `available` to primary, `soldOut` to error,
|
|
18
|
+
* `comingSoon` to warning, `waitlist` to info, each deriving its hover with
|
|
19
|
+
* `color-mix` - and that is what unblocked this.
|
|
20
|
+
*
|
|
21
|
+
* What a core still cannot say, and this one does not try to: WHICH family a
|
|
22
|
+
* state paints with. `tokens` is a flat list and there is no shape for mapping
|
|
23
|
+
* one onto a status. So this core does what `tl-event-card`'s status badge
|
|
24
|
+
* does - it derives the CLASS NAME the state is rendered under
|
|
25
|
+
* (`derived.stateClass`) and leaves the stylesheet to say which family that
|
|
26
|
+
* class reaches for. The core names the state; the harness names the paint.
|
|
27
|
+
*
|
|
28
|
+
* Behaviour is what the Stencil component did, to the letter:
|
|
29
|
+
*
|
|
30
|
+
* - `disabled`, `soldOut` and `comingSoon` are not pressable, and a press on
|
|
31
|
+
* one of them does nothing whatever: no event, no modal.
|
|
32
|
+
* - a press announces `tlClick` FIRST and opens the modal after, so a host
|
|
33
|
+
* hears the intent even on a page that never started Live.
|
|
34
|
+
* - with no client, or a connector too old to have the modal verbs, the press
|
|
35
|
+
* still announces `tlClick` and then quietly opens nothing. Unlike the
|
|
36
|
+
* wrapper this element has no error event to say so with, and inventing one
|
|
37
|
+
* would be a new published surface rather than a split.
|
|
38
|
+
* - the label is the `label` prop when the host set one, else the state's own.
|
|
39
|
+
* - the price is formatted `en-GB`, in the button's currency, with the pence
|
|
40
|
+
* shown only when there are any.
|
|
41
|
+
*
|
|
42
|
+
* What did not come across is the `console.error` the component wrote when the
|
|
43
|
+
* modal threw, for the reason `tl-cart` and `tl-promo-code` dropped theirs: a
|
|
44
|
+
* library should not write to its host's console.
|
|
45
|
+
*
|
|
46
|
+
* The other thing a core refuses to know is which key press counts as
|
|
47
|
+
* activation. `activate()` is the whole input surface; the harness decides what
|
|
48
|
+
* reaches it - a click and Enter or Space on the web, a press on a phone.
|
|
49
|
+
*/
|
|
50
|
+
const define_js_1 = require("../define.js");
|
|
51
|
+
const STRINGS = {
|
|
52
|
+
available: 'Buy Tickets',
|
|
53
|
+
soldOut: 'Sold Out',
|
|
54
|
+
waitlist: 'Join Waitlist',
|
|
55
|
+
comingSoon: 'On Sale Soon',
|
|
56
|
+
};
|
|
57
|
+
/** Sale state -> the class it renders under. */
|
|
58
|
+
const STATE_CLASSES = {
|
|
59
|
+
available: 'state-available',
|
|
60
|
+
soldOut: 'state-sold-out',
|
|
61
|
+
waitlist: 'state-waitlist',
|
|
62
|
+
comingSoon: 'state-coming-soon',
|
|
63
|
+
};
|
|
64
|
+
/** The states a press does nothing in, whatever `disabled` says. */
|
|
65
|
+
const NOT_PRESSABLE = ['soldOut', 'comingSoon'];
|
|
66
|
+
const pressable = (props) => !props.disabled && !NOT_PRESSABLE.includes(props.state);
|
|
67
|
+
/**
|
|
68
|
+
* As the component formatted it: `en-GB`, minor units, and the pence only when
|
|
69
|
+
* there are any. The locale is written down here rather than taken from
|
|
70
|
+
* `ctx.formatMoney` because that is what the element did before the split and
|
|
71
|
+
* this is a split, not a change (see the note on the card core's date).
|
|
72
|
+
*/
|
|
73
|
+
function formatPrice(priceInMinorUnits, currency) {
|
|
74
|
+
const amount = priceInMinorUnits / 100;
|
|
75
|
+
const hasDecimals = amount % 1 !== 0;
|
|
76
|
+
return new Intl.NumberFormat('en-GB', {
|
|
77
|
+
style: 'currency',
|
|
78
|
+
currency,
|
|
79
|
+
minimumFractionDigits: hasDecimals ? 2 : 0,
|
|
80
|
+
maximumFractionDigits: hasDecimals ? 2 : 0,
|
|
81
|
+
}).format(amount);
|
|
82
|
+
}
|
|
83
|
+
exports.buyTicketsButtonCore = (0, define_js_1.defineElement)({
|
|
84
|
+
tag: 'tl-buy-tickets-button',
|
|
85
|
+
docs: 'A buy-tickets button in one of four sale states, which opens the purchase modal.',
|
|
86
|
+
category: 'purchase',
|
|
87
|
+
runtimes: ['web'],
|
|
88
|
+
tokens: [
|
|
89
|
+
'error',
|
|
90
|
+
'error-foreground',
|
|
91
|
+
'font-family',
|
|
92
|
+
'info',
|
|
93
|
+
'info-foreground',
|
|
94
|
+
'primary',
|
|
95
|
+
'primary-foreground',
|
|
96
|
+
'radius-lg',
|
|
97
|
+
'radius-md',
|
|
98
|
+
'radius-sm',
|
|
99
|
+
'warning',
|
|
100
|
+
'warning-foreground',
|
|
101
|
+
],
|
|
102
|
+
sdkMethods: ['event.openModal'],
|
|
103
|
+
props: {
|
|
104
|
+
eventId: {
|
|
105
|
+
type: 'string',
|
|
106
|
+
attr: 'event-id',
|
|
107
|
+
required: true,
|
|
108
|
+
docs: 'Event ID to purchase tickets for',
|
|
109
|
+
},
|
|
110
|
+
occurrenceId: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
tsType: 'string | undefined',
|
|
113
|
+
attr: 'occurrence-id',
|
|
114
|
+
docs: 'Optional pre-selected occurrence ID',
|
|
115
|
+
},
|
|
116
|
+
state: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
tsType: '"available" | "comingSoon" | "soldOut" | "waitlist"',
|
|
119
|
+
attr: 'state',
|
|
120
|
+
default: 'available',
|
|
121
|
+
docs: 'Button state - determines colour, label, and icon',
|
|
122
|
+
},
|
|
123
|
+
label: {
|
|
124
|
+
type: 'string',
|
|
125
|
+
tsType: 'string | undefined',
|
|
126
|
+
attr: 'label',
|
|
127
|
+
docs: 'Button label override (default depends on state)',
|
|
128
|
+
},
|
|
129
|
+
variant: {
|
|
130
|
+
type: 'string',
|
|
131
|
+
tsType: '"outline" | "primary" | "secondary"',
|
|
132
|
+
attr: 'variant',
|
|
133
|
+
default: 'primary',
|
|
134
|
+
docs: 'Button variant',
|
|
135
|
+
},
|
|
136
|
+
size: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
tsType: '"lg" | "md" | "sm"',
|
|
139
|
+
attr: 'size',
|
|
140
|
+
default: 'md',
|
|
141
|
+
docs: 'Button size',
|
|
142
|
+
},
|
|
143
|
+
fullWidth: {
|
|
144
|
+
type: 'boolean',
|
|
145
|
+
attr: 'full-width',
|
|
146
|
+
default: false,
|
|
147
|
+
docs: 'Full width button',
|
|
148
|
+
},
|
|
149
|
+
price: {
|
|
150
|
+
type: 'number',
|
|
151
|
+
tsType: 'number | undefined',
|
|
152
|
+
attr: 'price',
|
|
153
|
+
docs: 'Price to display (in minor units e.g. pence)',
|
|
154
|
+
},
|
|
155
|
+
currency: {
|
|
156
|
+
type: 'string',
|
|
157
|
+
attr: 'currency',
|
|
158
|
+
default: 'GBP',
|
|
159
|
+
docs: 'Currency code for price formatting',
|
|
160
|
+
},
|
|
161
|
+
pricePrefix: {
|
|
162
|
+
type: 'string',
|
|
163
|
+
attr: 'price-prefix',
|
|
164
|
+
default: 'From',
|
|
165
|
+
docs: 'Show price prefix text',
|
|
166
|
+
},
|
|
167
|
+
disabled: {
|
|
168
|
+
type: 'boolean',
|
|
169
|
+
attr: 'disabled',
|
|
170
|
+
default: false,
|
|
171
|
+
docs: 'Disabled state',
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
events: {
|
|
175
|
+
tlClick: {
|
|
176
|
+
detail: '{ eventId: string; occurrenceId?: string | undefined; }',
|
|
177
|
+
docs: 'Emitted when button is clicked (before modal opens)',
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
strings: STRINGS,
|
|
181
|
+
statuses: ['ready', 'disabled'],
|
|
182
|
+
state: () => ({}),
|
|
183
|
+
status: ({ props }) => (pressable(props) ? 'ready' : 'disabled'),
|
|
184
|
+
derive: ({ props, strings }) => ({
|
|
185
|
+
interactive: pressable(props),
|
|
186
|
+
buttonLabel: props.label || strings[props.state],
|
|
187
|
+
stateClass: STATE_CLASSES[props.state],
|
|
188
|
+
priceLabel: props.price === undefined ? null : formatPrice(props.price, props.currency),
|
|
189
|
+
}),
|
|
190
|
+
actions: (ctx) => ({
|
|
191
|
+
/** A click, a tap, Enter or Space: whatever the harness calls a press. */
|
|
192
|
+
activate() {
|
|
193
|
+
if (!pressable(ctx.props))
|
|
194
|
+
return;
|
|
195
|
+
// Announced first, and whatever happens next: a host that tracks buying
|
|
196
|
+
// intent hears it even on a page that never started Live.
|
|
197
|
+
ctx.emit('tlClick', { eventId: ctx.props.eventId, occurrenceId: ctx.props.occurrenceId });
|
|
198
|
+
const client = ctx.optionalClient();
|
|
199
|
+
// Tolerated rather than assumed, like every other accessor a core reads:
|
|
200
|
+
// the same core runs against whatever connector version the host has.
|
|
201
|
+
if (!client || !client.event)
|
|
202
|
+
return;
|
|
203
|
+
try {
|
|
204
|
+
client.event(ctx.props.eventId).openModal({ occurrenceId: ctx.props.occurrenceId });
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// The component swallowed this too (it logged and carried on). This
|
|
208
|
+
// element has no error event to announce it with, and giving it one
|
|
209
|
+
// would be a new published surface rather than a split.
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buyTicketsWrapperCore = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* `tl-buy-tickets-wrapper`: whatever the page already has, turned into the
|
|
6
|
+
* thing that opens the purchase modal.
|
|
7
|
+
*
|
|
8
|
+
* The element renders nothing of its own - the host's own button, link or card
|
|
9
|
+
* is slotted into it - so this is the smallest core in the package: no state,
|
|
10
|
+
* one action, and three events that exist only because the modal reports back
|
|
11
|
+
* to whoever opened it.
|
|
12
|
+
*
|
|
13
|
+
* Until now it could not be split at all. Its events were `tl-complete`,
|
|
14
|
+
* `tl-error` and `tl-close`, and `defineElement` requires `tlSomething`; the
|
|
15
|
+
* names were renamed to `tlComplete`, `tlError` and `tlClose` (TKT-203), which
|
|
16
|
+
* is what made this possible. The rename is a breaking change to a published
|
|
17
|
+
* element and is deliberate.
|
|
18
|
+
*
|
|
19
|
+
* Behaviour is what the Stencil component did, to the letter:
|
|
20
|
+
*
|
|
21
|
+
* - a disabled wrapper does nothing at all, and announces nothing.
|
|
22
|
+
* - with no client on the page the click announces `tlError` rather than
|
|
23
|
+
* throwing, because the host asked for a modal and deserves to hear that it
|
|
24
|
+
* did not open.
|
|
25
|
+
* - the modal is the client's, not the element's. This core asks for it and
|
|
26
|
+
* then only relays what comes back.
|
|
27
|
+
*
|
|
28
|
+
* What did not come across is the `console.error` tracing, for the reason
|
|
29
|
+
* `tl-cart` and `tl-promo-code` dropped theirs: a library should not write to
|
|
30
|
+
* its host's console when it has an event for saying the same thing.
|
|
31
|
+
*
|
|
32
|
+
* What a core still cannot say, and this one does not try to: which key press
|
|
33
|
+
* counts as activation. `activate()` is the whole input surface, and each
|
|
34
|
+
* harness decides what reaches it - a click and Enter or Space on the web, a
|
|
35
|
+
* press on a phone.
|
|
36
|
+
*/
|
|
37
|
+
const define_js_1 = require("../define.js");
|
|
38
|
+
const STRINGS = {
|
|
39
|
+
noClient: 'SDK not initialized',
|
|
40
|
+
notAvailable: 'Tickets are not available',
|
|
41
|
+
};
|
|
42
|
+
exports.buyTicketsWrapperCore = (0, define_js_1.defineElement)({
|
|
43
|
+
tag: 'tl-buy-tickets-wrapper',
|
|
44
|
+
docs: 'Wrap any content and open the purchase modal when it is activated.',
|
|
45
|
+
category: 'purchase',
|
|
46
|
+
runtimes: ['web'],
|
|
47
|
+
tokens: ['radius-sm', 'ring'],
|
|
48
|
+
sdkMethods: ['event.openModal'],
|
|
49
|
+
props: {
|
|
50
|
+
eventId: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
attr: 'event-id',
|
|
53
|
+
required: true,
|
|
54
|
+
docs: 'The event ID to purchase tickets for',
|
|
55
|
+
},
|
|
56
|
+
occurrenceId: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
tsType: 'string | undefined',
|
|
59
|
+
attr: 'occurrence-id',
|
|
60
|
+
docs: 'Optional specific occurrence ID (skips occurrence selector)',
|
|
61
|
+
},
|
|
62
|
+
disabled: {
|
|
63
|
+
type: 'boolean',
|
|
64
|
+
attr: 'disabled',
|
|
65
|
+
default: false,
|
|
66
|
+
docs: 'Whether the wrapper is disabled',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
events: {
|
|
70
|
+
// `OrderResult` because that is the name the web harness writes, and the
|
|
71
|
+
// catalogue compares the two: it is the connector's name for the shape
|
|
72
|
+
// `ElementOrderResult` describes here.
|
|
73
|
+
tlComplete: { detail: 'OrderResult', docs: 'Emitted when purchase completes successfully' },
|
|
74
|
+
tlError: { detail: '{ message: string; }', docs: 'Emitted when an error occurs' },
|
|
75
|
+
tlClose: { detail: 'void', docs: 'Emitted when the modal closes' },
|
|
76
|
+
},
|
|
77
|
+
strings: STRINGS,
|
|
78
|
+
statuses: ['ready', 'disabled'],
|
|
79
|
+
state: () => ({}),
|
|
80
|
+
status: ({ props }) => (props.disabled ? 'disabled' : 'ready'),
|
|
81
|
+
derive: ({ props }) => ({ interactive: !props.disabled }),
|
|
82
|
+
actions: (ctx) => ({
|
|
83
|
+
/** A click, a tap, Enter or Space: whatever the harness calls a press. */
|
|
84
|
+
activate() {
|
|
85
|
+
if (ctx.props.disabled)
|
|
86
|
+
return;
|
|
87
|
+
const client = ctx.optionalClient();
|
|
88
|
+
if (!client) {
|
|
89
|
+
ctx.emit('tlError', { message: ctx.strings.noClient });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Tolerated rather than assumed, like every other accessor a core reads:
|
|
93
|
+
// the same core runs against whatever connector version the host has.
|
|
94
|
+
if (!client.event) {
|
|
95
|
+
ctx.emit('tlError', { message: ctx.strings.notAvailable });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
client.event(ctx.props.eventId).openModal({
|
|
99
|
+
occurrenceId: ctx.props.occurrenceId,
|
|
100
|
+
onComplete: (order) => ctx.emit('tlComplete', order),
|
|
101
|
+
onError: (err) => ctx.emit('tlError', { message: err.message }),
|
|
102
|
+
onClose: () => ctx.emit('tlClose', undefined),
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cartBadgeCore = exports.CART_COUNT_KEY = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* `tl-cart-badge`: the cart icon with the item count on it.
|
|
6
|
+
*
|
|
7
|
+
* Small, and the second element that lives on a subscription: it binds
|
|
8
|
+
* `cart:updated` inside its own `mount` and hands the unsubscribe to
|
|
9
|
+
* `ctx.onTeardown` (TKT-197), so a badge that is moved in the DOM, or rendered
|
|
10
|
+
* twice by a host that mounts and unmounts it, never ends up counting twice.
|
|
11
|
+
*
|
|
12
|
+
* The one thing it remembers is the last count, so the badge does not flash
|
|
13
|
+
* empty on the next page before the cart has been read. That is behaviour, so
|
|
14
|
+
* it is here; where the count is written down is per runtime, so that is
|
|
15
|
+
* `ctx.storage`, which the harness supplies.
|
|
16
|
+
*/
|
|
17
|
+
const define_js_1 = require("../define.js");
|
|
18
|
+
/** Where the last known count is kept. The key the component used. */
|
|
19
|
+
exports.CART_COUNT_KEY = 'tl_cart_count';
|
|
20
|
+
const STRINGS = {
|
|
21
|
+
/** Read as "Cart with 2 items". The count is appended. */
|
|
22
|
+
ariaPrefix: 'Cart with ',
|
|
23
|
+
ariaSuffix: ' items',
|
|
24
|
+
overflow: '99+',
|
|
25
|
+
};
|
|
26
|
+
/** Above this the bubble reads "99+" rather than a number nothing can fit. */
|
|
27
|
+
const OVERFLOW_ABOVE = 99;
|
|
28
|
+
const countOf = (cart) => {
|
|
29
|
+
const c = cart;
|
|
30
|
+
return typeof c?.itemCount === 'number' ? c.itemCount : 0;
|
|
31
|
+
};
|
|
32
|
+
exports.cartBadgeCore = (0, define_js_1.defineElement)({
|
|
33
|
+
tag: 'tl-cart-badge',
|
|
34
|
+
docs: 'A cart icon carrying the number of items in the cart, kept current from the cart bus.',
|
|
35
|
+
category: 'cart',
|
|
36
|
+
runtimes: ['web'],
|
|
37
|
+
tokens: [
|
|
38
|
+
'font-family',
|
|
39
|
+
'foreground',
|
|
40
|
+
'muted',
|
|
41
|
+
'primary',
|
|
42
|
+
'primary-foreground',
|
|
43
|
+
'radius-md',
|
|
44
|
+
'spacing-1',
|
|
45
|
+
'spacing-2',
|
|
46
|
+
'spacing-3',
|
|
47
|
+
],
|
|
48
|
+
sdkMethods: ['cart.get', 'cart.on'],
|
|
49
|
+
props: {
|
|
50
|
+
showEmpty: {
|
|
51
|
+
type: 'boolean',
|
|
52
|
+
attr: 'show-empty',
|
|
53
|
+
default: false,
|
|
54
|
+
docs: 'Show the badge even when count is 0',
|
|
55
|
+
},
|
|
56
|
+
icon: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
tsType: 'string | undefined',
|
|
59
|
+
attr: 'icon',
|
|
60
|
+
docs: 'Custom icon (emoji or text). If not provided, uses cart SVG',
|
|
61
|
+
},
|
|
62
|
+
size: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
tsType: '"lg" | "md" | "sm"',
|
|
65
|
+
attr: 'size',
|
|
66
|
+
default: 'md',
|
|
67
|
+
docs: 'Size variant',
|
|
68
|
+
},
|
|
69
|
+
badgeOnly: {
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
attr: 'badge-only',
|
|
72
|
+
default: false,
|
|
73
|
+
docs: 'Badge only mode - only shows the count bubble, no icon or button',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
events: {
|
|
77
|
+
tlClick: { detail: '{ itemCount: number; }', docs: 'Emitted when the badge is clicked' },
|
|
78
|
+
},
|
|
79
|
+
strings: STRINGS,
|
|
80
|
+
statuses: ['ready'],
|
|
81
|
+
state: () => ({ itemCount: 0 }),
|
|
82
|
+
/**
|
|
83
|
+
* On screen: show the remembered count straight away, then wait for the
|
|
84
|
+
* client, subscribe, and read the cart.
|
|
85
|
+
*
|
|
86
|
+
* Hydrating first is what keeps the badge from flashing empty; the component
|
|
87
|
+
* did it in `componentWillLoad` and this runs earlier still, in the harness's
|
|
88
|
+
* `connectedCallback`. A host with no store simply starts at zero.
|
|
89
|
+
*/
|
|
90
|
+
mount: (ctx) => {
|
|
91
|
+
ctx.actions.hydrate();
|
|
92
|
+
void (async () => {
|
|
93
|
+
let client;
|
|
94
|
+
try {
|
|
95
|
+
client = await ctx.whenClient();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// A page that never started Live keeps the remembered count and says
|
|
99
|
+
// nothing, which is what the component did.
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!ctx.active())
|
|
103
|
+
return;
|
|
104
|
+
const manager = client.getCartManager?.();
|
|
105
|
+
if (!manager)
|
|
106
|
+
return;
|
|
107
|
+
const off = manager.on?.('cart:updated', (cart) => {
|
|
108
|
+
ctx.actions.countChanged(countOf(cart));
|
|
109
|
+
});
|
|
110
|
+
if (typeof off === 'function')
|
|
111
|
+
ctx.onTeardown(off);
|
|
112
|
+
try {
|
|
113
|
+
const read = manager.get?.bind(manager);
|
|
114
|
+
if (!read)
|
|
115
|
+
return;
|
|
116
|
+
const cart = await read();
|
|
117
|
+
if (!ctx.active())
|
|
118
|
+
return;
|
|
119
|
+
ctx.actions.countChanged(countOf(cart));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// The cart may not exist yet. Keep the remembered value.
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
},
|
|
126
|
+
status: () => 'ready',
|
|
127
|
+
derive: ({ state, props, strings }) => ({
|
|
128
|
+
displayCount: state.itemCount > OVERFLOW_ABOVE ? strings.overflow : state.itemCount.toString(),
|
|
129
|
+
showBadge: state.itemCount > 0,
|
|
130
|
+
renderNothing: props.badgeOnly && state.itemCount === 0,
|
|
131
|
+
ariaLabel: `${strings.ariaPrefix}${state.itemCount}${strings.ariaSuffix}`,
|
|
132
|
+
sizeClass: `size-${props.size}`,
|
|
133
|
+
}),
|
|
134
|
+
actions: (ctx) => ({
|
|
135
|
+
/** A new count from the cart: show it, and remember it for next time. */
|
|
136
|
+
countChanged(count) {
|
|
137
|
+
ctx.setState({ itemCount: count });
|
|
138
|
+
ctx.storage.set(exports.CART_COUNT_KEY, String(count));
|
|
139
|
+
},
|
|
140
|
+
/** Start from whatever the last visit left behind, or zero. */
|
|
141
|
+
hydrate() {
|
|
142
|
+
const stored = ctx.storage.get(exports.CART_COUNT_KEY);
|
|
143
|
+
ctx.setState({ itemCount: (stored ? parseInt(stored, 10) : 0) || 0 });
|
|
144
|
+
},
|
|
145
|
+
/** The buyer asked for the cart. What that opens is the host's business. */
|
|
146
|
+
click() {
|
|
147
|
+
ctx.emit('tlClick', { itemCount: ctx.state.itemCount });
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
});
|