@ticketlayer/elements-core 0.1.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/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/cjs/client.js +16 -0
- package/dist/cjs/cores/index.js +10 -0
- package/dist/cjs/cores/ticket-selector.js +216 -0
- package/dist/cjs/define.js +181 -0
- package/dist/cjs/index.js +44 -0
- package/dist/cjs/manifest.js +54 -0
- package/dist/cjs/money.js +117 -0
- package/dist/cjs/package.json +3 -0
- package/dist/esm/client.d.ts +25 -0
- package/dist/esm/client.js +15 -0
- package/dist/esm/cores/index.d.ts +15 -0
- package/dist/esm/cores/index.js +6 -0
- package/dist/esm/cores/ticket-selector.d.ts +104 -0
- package/dist/esm/cores/ticket-selector.js +213 -0
- package/dist/esm/define.d.ts +156 -0
- package/dist/esm/define.js +177 -0
- package/dist/esm/index.d.ts +23 -0
- package/dist/esm/index.js +19 -0
- package/dist/esm/manifest.d.ts +53 -0
- package/dist/esm/manifest.js +51 -0
- package/dist/esm/money.d.ts +102 -0
- package/dist/esm/money.js +110 -0
- package/dist/esm/package.json +3 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ticketlayer Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# @ticketlayer/elements-core
|
|
2
|
+
|
|
3
|
+
The headless cores the Ticketlayer Elements render.
|
|
4
|
+
|
|
5
|
+
One core per element. A core is plain TypeScript: props, a state store,
|
|
6
|
+
actions, derived values, a status, the copy it renders, the
|
|
7
|
+
`@ticketlayer/theme` tokens its harnesses use, the `tl*` events it emits, the
|
|
8
|
+
client methods it calls and the runtimes it is meant for. No DOM, no React, no
|
|
9
|
+
network, and no client of its own: a core is handed one.
|
|
10
|
+
|
|
11
|
+
A harness renders a core and forwards its events, and decides nothing.
|
|
12
|
+
`@ticketlayer/elements` is the web harness, built with Stencil; a React Native
|
|
13
|
+
harness will render the same cores in an app. That split is the platform plan's
|
|
14
|
+
section 2.7 and `AGENTS.md` at the root of this repository.
|
|
15
|
+
|
|
16
|
+
## The shape
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { defineElement } from '@ticketlayer/elements-core';
|
|
20
|
+
|
|
21
|
+
export const exampleCore = defineElement({
|
|
22
|
+
tag: 'tl-example',
|
|
23
|
+
docs: 'What a host gets from putting this on a page.',
|
|
24
|
+
category: 'purchase',
|
|
25
|
+
runtimes: ['web', 'native'],
|
|
26
|
+
tokens: ['primary', 'spacing-3'],
|
|
27
|
+
sdkMethods: ['events.getTicketTypes'],
|
|
28
|
+
props: { eventId: { type: 'string', attr: 'event-id', required: true, docs: 'Event ID' } },
|
|
29
|
+
events: { tlPicked: { detail: '{ id: string; }', docs: 'Emitted when one is picked' } },
|
|
30
|
+
strings: { empty: 'Nothing on sale' },
|
|
31
|
+
statuses: ['loading', 'error', 'ready'],
|
|
32
|
+
|
|
33
|
+
state: () => ({ loading: true, error: null, items: [] }),
|
|
34
|
+
status: ({ state }) => (state.loading ? 'loading' : state.error ? 'error' : 'ready'),
|
|
35
|
+
derive: ({ state, strings }) => ({ caption: state.items.length ? '' : strings.empty }),
|
|
36
|
+
actions: (ctx) => ({
|
|
37
|
+
async load() {
|
|
38
|
+
const items = await ctx.client().getEventsManager().getTicketTypes(ctx.props.eventId, '');
|
|
39
|
+
ctx.setState({ items, loading: false });
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A harness instantiates it, renders `view`, and forwards:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const element = createElement(exampleCore, {
|
|
49
|
+
client: () => getSDK(),
|
|
50
|
+
emit: (event, detail) => this.forward(event, detail),
|
|
51
|
+
});
|
|
52
|
+
element.subscribe((view) => (this.view = view));
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`view` is `{ status, props, state, strings, derived }`, recomputed after every
|
|
56
|
+
change. The first three lines of a harness's render are a switch on `status`.
|
|
57
|
+
|
|
58
|
+
## Tokens
|
|
59
|
+
|
|
60
|
+
`tokens` names `@ticketlayer/theme` tokens by their canonical name: `primary`,
|
|
61
|
+
not `--tl-primary`, not the `color-primary` alias, and never a value. The type
|
|
62
|
+
is generated from `packages/theme/tokens.json`, so a token renamed there stops
|
|
63
|
+
every core that named it compiling, and
|
|
64
|
+
`packages/elements/scripts/check-core-tokens.mjs` fails a web harness whose
|
|
65
|
+
stylesheet reaches for a token its core did not declare, or declares one the
|
|
66
|
+
stylesheet never uses.
|
|
67
|
+
|
|
68
|
+
## The manifest and the catalogue
|
|
69
|
+
|
|
70
|
+
`manifestOf(core)` projects the declarative half of a core to JSON.
|
|
71
|
+
`packages/elements/scripts/build-catalogue.mjs` emits
|
|
72
|
+
`elements.catalogue.json` from those manifests, so what the docs site, the CLI
|
|
73
|
+
and a native host read is a projection of the object that runs rather than a
|
|
74
|
+
second copy kept by hand. An element that has a core has no entry in the
|
|
75
|
+
hand-kept `packages/elements/src/catalogue.manifest.json`; the emitter refuses a
|
|
76
|
+
tag that is in both, and fails when a core claims the `web` runtime and no
|
|
77
|
+
Stencil component answers to its tag, or when the props and events a core
|
|
78
|
+
declares are not the ones its harness built.
|
|
79
|
+
|
|
80
|
+
## Build and test
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pnpm --filter @ticketlayer/elements-core build # tsc twice: dist/esm and dist/cjs
|
|
84
|
+
pnpm --filter @ticketlayer/elements-core test # builds, then node --test test/*.test.mjs
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Both module formats are published. Stencil's rollup build and a native bundler
|
|
88
|
+
take the ESM; the Jest run behind `stencil test --spec` and the catalogue
|
|
89
|
+
emitter take the CommonJS, because Jest will not load ESM from `node_modules`.
|
|
90
|
+
|
|
91
|
+
## What is here today
|
|
92
|
+
|
|
93
|
+
`tl-ticket-selector` is the reference element: the first split, and the one the
|
|
94
|
+
shape was proved against (TKT-68). The other seventeen still hold their
|
|
95
|
+
behaviour in their Stencil component and their facts in the hand-kept manifest;
|
|
96
|
+
they move across one at a time (TKT-11).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The client a core is handed.
|
|
4
|
+
*
|
|
5
|
+
* Cores receive a client and never construct one (TKT-11), and they never
|
|
6
|
+
* import `@ticketlayer/live`: this is the structural shape of the connector a
|
|
7
|
+
* host publishes, narrowed to what the cores in this package actually call.
|
|
8
|
+
* `@ticketlayer/elements`' `TicketlayerSDK` satisfies it, so a Stencil harness
|
|
9
|
+
* passes `getSDK()` straight through; a React Native harness will pass the
|
|
10
|
+
* connector `@ticketlayer/live/native` gives it.
|
|
11
|
+
*
|
|
12
|
+
* Everything the managers return is `unknown`. A core narrows what it reads and
|
|
13
|
+
* tolerates what it does not, because the same core runs against whatever
|
|
14
|
+
* client version the host installed.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ELEMENT_CORES = exports.ticketSelectorCore = void 0;
|
|
4
|
+
const ticket_selector_js_1 = require("./ticket-selector.js");
|
|
5
|
+
var ticket_selector_js_2 = require("./ticket-selector.js");
|
|
6
|
+
Object.defineProperty(exports, "ticketSelectorCore", { enumerable: true, get: function () { return ticket_selector_js_2.ticketSelectorCore; } });
|
|
7
|
+
/** Every core, by tag. */
|
|
8
|
+
exports.ELEMENT_CORES = {
|
|
9
|
+
[ticket_selector_js_1.ticketSelectorCore.tag]: ticket_selector_js_1.ticketSelectorCore,
|
|
10
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ticketSelectorCore = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* `tl-ticket-selector`: the reference core.
|
|
6
|
+
*
|
|
7
|
+
* This is the whole of what the element does. The Stencil harness in
|
|
8
|
+
* `packages/elements/src/components/tl-ticket-selector/` renders `view` and
|
|
9
|
+
* forwards the three `tl*` events; it decides nothing, holds no copy and does
|
|
10
|
+
* no arithmetic. A React Native harness will render the same view.
|
|
11
|
+
*
|
|
12
|
+
* Behaviour is the behaviour the Stencil component had before the split, to
|
|
13
|
+
* the letter: the same quantity clamping, the same reset after a successful
|
|
14
|
+
* add, the same three second success message, the same button text, and the
|
|
15
|
+
* same rule that no fee row is shown until the surface quotes a fee. The one
|
|
16
|
+
* thing that did not come across is the `console.log` tracing, which a library
|
|
17
|
+
* should not do for its host.
|
|
18
|
+
*/
|
|
19
|
+
const define_js_1 = require("../define.js");
|
|
20
|
+
const money_js_1 = require("../money.js");
|
|
21
|
+
const STRINGS = {
|
|
22
|
+
selectTickets: 'Select Tickets',
|
|
23
|
+
adding: 'Adding...',
|
|
24
|
+
added: 'Added to cart!',
|
|
25
|
+
limited: 'Limited',
|
|
26
|
+
soldOut: 'Sold Out',
|
|
27
|
+
ticketsRow: 'Tickets',
|
|
28
|
+
totalRow: 'Total',
|
|
29
|
+
decrease: 'Decrease quantity',
|
|
30
|
+
increase: 'Increase quantity',
|
|
31
|
+
loadFailed: 'Failed to load tickets',
|
|
32
|
+
addFailed: 'Failed to add to cart',
|
|
33
|
+
noClient: 'SDK not initialized.',
|
|
34
|
+
};
|
|
35
|
+
/** How long the "Added to cart!" message stays up. */
|
|
36
|
+
const SUCCESS_MS = 3000;
|
|
37
|
+
const totalsOf = (ctx) => (0, money_js_1.selectionTotals)(ctx.state.ticketTypes.map((tt) => ({ price: tt.price, quantity: ctx.state.quantities[tt.id] || 0 })), ctx.state.feeQuote);
|
|
38
|
+
const totalItemsOf = (state) => Object.values(state.quantities).reduce((sum, qty) => sum + qty, 0);
|
|
39
|
+
exports.ticketSelectorCore = (0, define_js_1.defineElement)({
|
|
40
|
+
tag: 'tl-ticket-selector',
|
|
41
|
+
docs: 'Pick quantities of the ticket types on sale for one occurrence, and add them to the cart.',
|
|
42
|
+
category: 'purchase',
|
|
43
|
+
runtimes: ['web', 'native'],
|
|
44
|
+
tokens: [
|
|
45
|
+
'background',
|
|
46
|
+
'border',
|
|
47
|
+
'border-hover',
|
|
48
|
+
'error',
|
|
49
|
+
'error-bg',
|
|
50
|
+
'font-family',
|
|
51
|
+
'foreground',
|
|
52
|
+
'muted',
|
|
53
|
+
'muted-foreground',
|
|
54
|
+
'primary',
|
|
55
|
+
'primary-foreground',
|
|
56
|
+
'radius-md',
|
|
57
|
+
'radius-sm',
|
|
58
|
+
'spacing-1',
|
|
59
|
+
'spacing-2',
|
|
60
|
+
'spacing-3',
|
|
61
|
+
'spacing-4',
|
|
62
|
+
'success',
|
|
63
|
+
'warning',
|
|
64
|
+
'warning-bg',
|
|
65
|
+
],
|
|
66
|
+
sdkMethods: ['cart.addItem', 'events.getTicketTypes'],
|
|
67
|
+
props: {
|
|
68
|
+
eventId: { type: 'string', attr: 'event-id', required: true, docs: 'Event ID' },
|
|
69
|
+
occurrenceId: { type: 'string', attr: 'occurrence-id', required: true, docs: 'Occurrence ID' },
|
|
70
|
+
showButton: { type: 'boolean', attr: 'show-button', default: true, docs: 'Show add to cart button' },
|
|
71
|
+
buttonLabel: { type: 'string', attr: 'button-label', default: 'Add to Cart', docs: 'Button label' },
|
|
72
|
+
},
|
|
73
|
+
events: {
|
|
74
|
+
tlAddToCart: {
|
|
75
|
+
detail: '{ items: AddToCartItem[]; }',
|
|
76
|
+
docs: 'Emitted when items are added to cart',
|
|
77
|
+
},
|
|
78
|
+
tlQuantityChange: {
|
|
79
|
+
detail: '{ ticketTypeId: string; quantity: number; total: number; }',
|
|
80
|
+
docs: 'Emitted when quantity changes',
|
|
81
|
+
},
|
|
82
|
+
tlError: {
|
|
83
|
+
detail: '{ message: string; }',
|
|
84
|
+
docs: 'Emitted on error',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
strings: STRINGS,
|
|
88
|
+
statuses: ['loading', 'error', 'ready'],
|
|
89
|
+
state: () => ({
|
|
90
|
+
ticketTypes: [],
|
|
91
|
+
quantities: {},
|
|
92
|
+
loading: true,
|
|
93
|
+
error: null,
|
|
94
|
+
adding: false,
|
|
95
|
+
addSuccess: false,
|
|
96
|
+
feeQuote: null,
|
|
97
|
+
}),
|
|
98
|
+
status: ({ state }) => (state.loading ? 'loading' : state.error ? 'error' : 'ready'),
|
|
99
|
+
derive: (ctx) => {
|
|
100
|
+
const { state, strings, props, formatMoney } = ctx;
|
|
101
|
+
const totals = totalsOf(ctx);
|
|
102
|
+
const totalItems = totalItemsOf(state);
|
|
103
|
+
const currency = state.ticketTypes[0]?.currency;
|
|
104
|
+
const rows = state.ticketTypes.map((tt) => {
|
|
105
|
+
const quantity = state.quantities[tt.id] || 0;
|
|
106
|
+
const soldOut = tt.availabilityStatus === 'sold_out';
|
|
107
|
+
return {
|
|
108
|
+
id: tt.id,
|
|
109
|
+
name: tt.name,
|
|
110
|
+
description: tt.description ?? null,
|
|
111
|
+
priceLabel: formatMoney(tt.price, tt.currency),
|
|
112
|
+
quantity,
|
|
113
|
+
soldOut,
|
|
114
|
+
limited: tt.availabilityStatus === 'limited',
|
|
115
|
+
canDecrease: !soldOut && quantity > 0,
|
|
116
|
+
canIncrease: !soldOut && quantity < tt.maxPerOrder,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
rows,
|
|
121
|
+
totals,
|
|
122
|
+
totalItems,
|
|
123
|
+
currency,
|
|
124
|
+
subtotalLabel: formatMoney(totals.subtotal, currency),
|
|
125
|
+
feeLabel: formatMoney(totals.fees, currency),
|
|
126
|
+
totalLabel: formatMoney(totals.total, currency),
|
|
127
|
+
showTotals: totals.showFee && totalItems > 0,
|
|
128
|
+
actionLabel: state.adding
|
|
129
|
+
? strings.adding
|
|
130
|
+
: totalItems === 0
|
|
131
|
+
? strings.selectTickets
|
|
132
|
+
: `${props.buttonLabel} - ${formatMoney(totals.total)}`,
|
|
133
|
+
actionDisabled: totalItems === 0 || state.adding,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
actions: (ctx) => ({
|
|
137
|
+
/** Read the ticket types for the current event and occurrence. */
|
|
138
|
+
async load() {
|
|
139
|
+
try {
|
|
140
|
+
ctx.setState({ loading: true, error: null });
|
|
141
|
+
const types = (await ctx.client().getEventsManager().getTicketTypes(ctx.props.eventId, ctx.props.occurrenceId));
|
|
142
|
+
const quantities = {};
|
|
143
|
+
for (const type of types)
|
|
144
|
+
quantities[type.id] = 0;
|
|
145
|
+
ctx.setState({ ticketTypes: types, quantities });
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const message = err instanceof Error ? err.message : ctx.strings.loadFailed;
|
|
149
|
+
ctx.setState({ error: message });
|
|
150
|
+
ctx.emit('tlError', { message });
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
ctx.setState({ loading: false });
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
/** Nudge one row's quantity, clamped to 0 and the type's maxPerOrder. */
|
|
157
|
+
changeQuantity(ticketTypeId, delta) {
|
|
158
|
+
const ticketType = ctx.state.ticketTypes.find((t) => t.id === ticketTypeId);
|
|
159
|
+
if (!ticketType)
|
|
160
|
+
return;
|
|
161
|
+
const current = ctx.state.quantities[ticketTypeId] || 0;
|
|
162
|
+
const quantity = Math.max(0, Math.min(ticketType.maxPerOrder, current + delta));
|
|
163
|
+
ctx.setState((state) => ({ quantities: { ...state.quantities, [ticketTypeId]: quantity } }));
|
|
164
|
+
// After setState, so the total is the one the buyer can now see.
|
|
165
|
+
ctx.emit('tlQuantityChange', { ticketTypeId, quantity, total: totalsOf(ctx).total });
|
|
166
|
+
},
|
|
167
|
+
/**
|
|
168
|
+
* Add every selected line to the cart, then clear the selection.
|
|
169
|
+
*
|
|
170
|
+
* With no client, or a client too old to carry a cart manager, the
|
|
171
|
+
* selection is still cleared and `tlAddToCart` still fires: a host that
|
|
172
|
+
* listens for the event and does its own cart keeps working.
|
|
173
|
+
*/
|
|
174
|
+
async addToCart() {
|
|
175
|
+
const items = Object.entries(ctx.state.quantities)
|
|
176
|
+
.filter(([, quantity]) => quantity > 0)
|
|
177
|
+
.map(([ticketTypeId, quantity]) => ({ ticketTypeId, quantity }));
|
|
178
|
+
if (items.length === 0)
|
|
179
|
+
return;
|
|
180
|
+
ctx.setState({ adding: true, addSuccess: false });
|
|
181
|
+
try {
|
|
182
|
+
const cart = ctx.optionalClient()?.getCartManager?.();
|
|
183
|
+
if (cart) {
|
|
184
|
+
for (const item of items) {
|
|
185
|
+
await cart.addItem(item.ticketTypeId, item.quantity, ctx.props.occurrenceId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const quantities = {};
|
|
189
|
+
for (const type of ctx.state.ticketTypes)
|
|
190
|
+
quantities[type.id] = 0;
|
|
191
|
+
ctx.setState({ quantities, addSuccess: true });
|
|
192
|
+
ctx.after(SUCCESS_MS, () => ctx.setState({ addSuccess: false }));
|
|
193
|
+
ctx.emit('tlAddToCart', { items });
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
const message = err instanceof Error ? err.message : ctx.strings.addFailed;
|
|
197
|
+
ctx.setState({ error: message });
|
|
198
|
+
ctx.emit('tlError', { message });
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
ctx.setState({ adding: false });
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
/** What the surface quoted for this selection, when it ever quotes one. */
|
|
205
|
+
setFeeQuote(quote) {
|
|
206
|
+
ctx.setState({ feeQuote: quote });
|
|
207
|
+
},
|
|
208
|
+
/**
|
|
209
|
+
* The host never produced a client. Shown, not emitted: the element has
|
|
210
|
+
* nothing to say to a page that has not started Live yet.
|
|
211
|
+
*/
|
|
212
|
+
connectFailed(message) {
|
|
213
|
+
ctx.setState({ error: message || ctx.strings.noClient, loading: false });
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defineElement = defineElement;
|
|
4
|
+
exports.createElement = createElement;
|
|
5
|
+
const CATEGORIES = [
|
|
6
|
+
'discovery',
|
|
7
|
+
'purchase',
|
|
8
|
+
'cart',
|
|
9
|
+
'checkout',
|
|
10
|
+
'account',
|
|
11
|
+
'tickets',
|
|
12
|
+
];
|
|
13
|
+
const RUNTIMES = ['web', 'native'];
|
|
14
|
+
/**
|
|
15
|
+
* Declare a core. The checks here are the ones a type cannot make: a tag that
|
|
16
|
+
* is not a `tl-` custom element name, a category or runtime outside the set the
|
|
17
|
+
* catalogue knows, a duplicated token, a status list that does not include what
|
|
18
|
+
* `status` can return. They run at import time, so a malformed core fails the
|
|
19
|
+
* build rather than a browser.
|
|
20
|
+
*/
|
|
21
|
+
function defineElement(core) {
|
|
22
|
+
const fail = (message) => {
|
|
23
|
+
throw new Error(`[elements-core] ${core.tag || '<no tag>'}: ${message}`);
|
|
24
|
+
};
|
|
25
|
+
if (!/^tl-[a-z0-9]+(-[a-z0-9]+)*$/.test(core.tag))
|
|
26
|
+
fail('tag must look like tl-something');
|
|
27
|
+
if (!CATEGORIES.includes(core.category))
|
|
28
|
+
fail(`category must be one of ${CATEGORIES.join(', ')}`);
|
|
29
|
+
if (!core.runtimes.length || core.runtimes.some((r) => !RUNTIMES.includes(r))) {
|
|
30
|
+
fail(`runtimes must be a non-empty subset of ${RUNTIMES.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
if (new Set(core.tokens).size !== core.tokens.length)
|
|
33
|
+
fail('tokens must not repeat');
|
|
34
|
+
if (!core.statuses.length)
|
|
35
|
+
fail('statuses must not be empty');
|
|
36
|
+
for (const [name, spec] of Object.entries(core.events)) {
|
|
37
|
+
if (!/^tl[A-Z]/.test(name))
|
|
38
|
+
fail(`event ${name} must be named tlSomething`);
|
|
39
|
+
if (!spec.detail)
|
|
40
|
+
fail(`event ${name} must state its detail type`);
|
|
41
|
+
}
|
|
42
|
+
for (const [name, spec] of Object.entries(core.props)) {
|
|
43
|
+
if (spec.required && spec.default !== undefined)
|
|
44
|
+
fail(`prop ${name} is required and has a default`);
|
|
45
|
+
}
|
|
46
|
+
return Object.freeze({ ...core });
|
|
47
|
+
}
|
|
48
|
+
const DEFAULT_LOCALE = 'en-GB';
|
|
49
|
+
const DEFAULT_CURRENCY = 'GBP';
|
|
50
|
+
/**
|
|
51
|
+
* Instantiate a core. The instance owns the state and the timers; the harness
|
|
52
|
+
* owns the pixels. `subscribe` fires after every change with the whole view, so
|
|
53
|
+
* a harness only has to assign it to whatever makes its runtime re-render.
|
|
54
|
+
*/
|
|
55
|
+
function createElement(core, options = {}) {
|
|
56
|
+
const defaults = {};
|
|
57
|
+
for (const [name, spec] of Object.entries(core.props)) {
|
|
58
|
+
if (spec.default !== undefined)
|
|
59
|
+
defaults[name] = spec.default;
|
|
60
|
+
}
|
|
61
|
+
let props = { ...defaults, ...(options.props ?? {}) };
|
|
62
|
+
const strings = { ...core.strings, ...(options.strings ?? {}) };
|
|
63
|
+
const locale = options.locale ?? DEFAULT_LOCALE;
|
|
64
|
+
const fallbackCurrency = options.currency ?? DEFAULT_CURRENCY;
|
|
65
|
+
let resolveClient = toResolver(options.client);
|
|
66
|
+
let state = core.state(props);
|
|
67
|
+
let destroyed = false;
|
|
68
|
+
const timers = new Set();
|
|
69
|
+
const listeners = new Set();
|
|
70
|
+
const formatMoney = (amountInMinorUnits, currency) => new Intl.NumberFormat(locale, {
|
|
71
|
+
style: 'currency',
|
|
72
|
+
currency: currency || fallbackCurrency,
|
|
73
|
+
}).format(amountInMinorUnits / 100);
|
|
74
|
+
const viewContext = () => ({ props, state, strings, formatMoney });
|
|
75
|
+
const buildView = () => {
|
|
76
|
+
const ctx = viewContext();
|
|
77
|
+
return {
|
|
78
|
+
status: core.status(ctx),
|
|
79
|
+
props,
|
|
80
|
+
state,
|
|
81
|
+
strings,
|
|
82
|
+
derived: core.derive(ctx),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const notify = () => {
|
|
86
|
+
if (destroyed)
|
|
87
|
+
return;
|
|
88
|
+
const view = buildView();
|
|
89
|
+
for (const listener of [...listeners])
|
|
90
|
+
listener(view);
|
|
91
|
+
};
|
|
92
|
+
const actionContext = {
|
|
93
|
+
get props() {
|
|
94
|
+
return props;
|
|
95
|
+
},
|
|
96
|
+
get state() {
|
|
97
|
+
return state;
|
|
98
|
+
},
|
|
99
|
+
strings,
|
|
100
|
+
formatMoney,
|
|
101
|
+
setState(patch) {
|
|
102
|
+
if (destroyed)
|
|
103
|
+
return;
|
|
104
|
+
const next = typeof patch === 'function' ? patch(state) : patch;
|
|
105
|
+
state = { ...state, ...next };
|
|
106
|
+
notify();
|
|
107
|
+
},
|
|
108
|
+
emit(event, detail) {
|
|
109
|
+
if (!core.events[event]) {
|
|
110
|
+
throw new Error(`[elements-core] ${core.tag}: ${event} is not a declared event`);
|
|
111
|
+
}
|
|
112
|
+
options.emit?.(event, detail);
|
|
113
|
+
},
|
|
114
|
+
client() {
|
|
115
|
+
const client = resolveClient();
|
|
116
|
+
if (!client) {
|
|
117
|
+
throw new Error(`[elements-core] ${core.tag}: no client. A harness hands the core the client the host published.`);
|
|
118
|
+
}
|
|
119
|
+
return client;
|
|
120
|
+
},
|
|
121
|
+
optionalClient() {
|
|
122
|
+
try {
|
|
123
|
+
return resolveClient();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
after(ms, run) {
|
|
130
|
+
if (destroyed)
|
|
131
|
+
return;
|
|
132
|
+
const handle = setTimeout(() => {
|
|
133
|
+
timers.delete(handle);
|
|
134
|
+
if (!destroyed)
|
|
135
|
+
run();
|
|
136
|
+
}, ms);
|
|
137
|
+
timers.add(handle);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
const actions = core.actions(actionContext);
|
|
141
|
+
return {
|
|
142
|
+
core,
|
|
143
|
+
get props() {
|
|
144
|
+
return props;
|
|
145
|
+
},
|
|
146
|
+
get state() {
|
|
147
|
+
return state;
|
|
148
|
+
},
|
|
149
|
+
get view() {
|
|
150
|
+
return buildView();
|
|
151
|
+
},
|
|
152
|
+
actions,
|
|
153
|
+
setProps(next) {
|
|
154
|
+
if (destroyed)
|
|
155
|
+
return;
|
|
156
|
+
props = { ...props, ...next };
|
|
157
|
+
notify();
|
|
158
|
+
},
|
|
159
|
+
setClient(client) {
|
|
160
|
+
resolveClient = toResolver(client);
|
|
161
|
+
},
|
|
162
|
+
subscribe(listener) {
|
|
163
|
+
listeners.add(listener);
|
|
164
|
+
return () => {
|
|
165
|
+
listeners.delete(listener);
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
destroy() {
|
|
169
|
+
destroyed = true;
|
|
170
|
+
for (const handle of timers)
|
|
171
|
+
clearTimeout(handle);
|
|
172
|
+
timers.clear();
|
|
173
|
+
listeners.clear();
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function toResolver(client) {
|
|
178
|
+
if (typeof client === 'function')
|
|
179
|
+
return client;
|
|
180
|
+
return () => client ?? null;
|
|
181
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.FEE_LABEL_FALLBACK = exports.selectionTotals = exports.minorUnits = exports.expressesFee = exports.cartTotals = exports.manifestOf = exports.defineElement = exports.createElement = void 0;
|
|
18
|
+
/**
|
|
19
|
+
* `@ticketlayer/elements-core`: the headless cores the Ticketlayer Elements
|
|
20
|
+
* render.
|
|
21
|
+
*
|
|
22
|
+
* One core per element, in `src/cores/`. A core is plain TypeScript: state,
|
|
23
|
+
* actions, derived values, a status, its copy, the theme tokens its harnesses
|
|
24
|
+
* use and the `tl*` events it emits, with no DOM, no React and no client of its
|
|
25
|
+
* own. The harnesses are thin: `packages/elements` renders a core with Stencil
|
|
26
|
+
* on the web, and a React Native harness will render the same core in an app
|
|
27
|
+
* (platform plan section 2.7, AGENTS.md, TKT-11).
|
|
28
|
+
*
|
|
29
|
+
* `elements.catalogue.json` is emitted from the manifests these cores carry, so
|
|
30
|
+
* what the docs site and a native host read is a projection of the object that
|
|
31
|
+
* runs, not a second copy kept by hand.
|
|
32
|
+
*/
|
|
33
|
+
var define_js_1 = require("./define.js");
|
|
34
|
+
Object.defineProperty(exports, "createElement", { enumerable: true, get: function () { return define_js_1.createElement; } });
|
|
35
|
+
Object.defineProperty(exports, "defineElement", { enumerable: true, get: function () { return define_js_1.defineElement; } });
|
|
36
|
+
var manifest_js_1 = require("./manifest.js");
|
|
37
|
+
Object.defineProperty(exports, "manifestOf", { enumerable: true, get: function () { return manifest_js_1.manifestOf; } });
|
|
38
|
+
var money_js_1 = require("./money.js");
|
|
39
|
+
Object.defineProperty(exports, "cartTotals", { enumerable: true, get: function () { return money_js_1.cartTotals; } });
|
|
40
|
+
Object.defineProperty(exports, "expressesFee", { enumerable: true, get: function () { return money_js_1.expressesFee; } });
|
|
41
|
+
Object.defineProperty(exports, "minorUnits", { enumerable: true, get: function () { return money_js_1.minorUnits; } });
|
|
42
|
+
Object.defineProperty(exports, "selectionTotals", { enumerable: true, get: function () { return money_js_1.selectionTotals; } });
|
|
43
|
+
Object.defineProperty(exports, "FEE_LABEL_FALLBACK", { enumerable: true, get: function () { return money_js_1.FEE_LABEL_FALLBACK; } });
|
|
44
|
+
__exportStar(require("./cores/index.js"), exports);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.manifestOf = manifestOf;
|
|
4
|
+
/** Project a core onto its manifest. Sorted, so the catalogue diff is stable. */
|
|
5
|
+
function manifestOf(core) {
|
|
6
|
+
const props = Object.entries(core.props)
|
|
7
|
+
.map(([name, spec]) => ({
|
|
8
|
+
name,
|
|
9
|
+
attr: spec.attr,
|
|
10
|
+
type: spec.type,
|
|
11
|
+
default: spec.default ?? null,
|
|
12
|
+
required: !!spec.required,
|
|
13
|
+
docs: spec.docs,
|
|
14
|
+
}))
|
|
15
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
16
|
+
const events = Object.entries(core.events)
|
|
17
|
+
.map(([name, spec]) => ({ name, detail: spec.detail, docs: spec.docs }))
|
|
18
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
19
|
+
return {
|
|
20
|
+
tag: core.tag,
|
|
21
|
+
docs: core.docs,
|
|
22
|
+
category: core.category,
|
|
23
|
+
runtimes: [...core.runtimes],
|
|
24
|
+
tokens: [...core.tokens].sort(),
|
|
25
|
+
sdkMethods: [...core.sdkMethods].sort(),
|
|
26
|
+
props,
|
|
27
|
+
events,
|
|
28
|
+
strings: { ...core.strings },
|
|
29
|
+
statuses: [...core.statuses],
|
|
30
|
+
actions: Object.keys(dryRunActions(core)).sort(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The action names, without running anything. `actions` is a factory, so the
|
|
35
|
+
* only way to learn the names is to build it once against a context whose
|
|
36
|
+
* every member throws: a core that called the client or set state while
|
|
37
|
+
* declaring its actions would fail here, loudly, which is the intent.
|
|
38
|
+
*/
|
|
39
|
+
function dryRunActions(core) {
|
|
40
|
+
const refuse = (what) => () => {
|
|
41
|
+
throw new Error(`[elements-core] ${core.tag}: ${what} cannot run while the actions are declared`);
|
|
42
|
+
};
|
|
43
|
+
return core.actions({
|
|
44
|
+
props: {},
|
|
45
|
+
state: {},
|
|
46
|
+
strings: core.strings,
|
|
47
|
+
formatMoney: refuse('formatMoney'),
|
|
48
|
+
setState: refuse('setState'),
|
|
49
|
+
emit: refuse('emit'),
|
|
50
|
+
client: refuse('client'),
|
|
51
|
+
optionalClient: refuse('optionalClient'),
|
|
52
|
+
after: refuse('after'),
|
|
53
|
+
});
|
|
54
|
+
}
|