@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.
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ /**
3
+ * The money rows a cart-shaped view renders: subtotal, the booking fee, a
4
+ * discount and the total.
5
+ *
6
+ * Pure: no Stencil, no DOM, no SDK. A core derives these figures and a harness
7
+ * renders what it returns, so the rule for what is shown can be unit tested on
8
+ * its own (the platform plan's headless-core split, AGENTS.md). It lived in
9
+ * `packages/elements/src/utils/cart-totals.ts` until the core split (TKT-68)
10
+ * and is re-exported from there, so every existing import still resolves.
11
+ *
12
+ * The client (`@ticketlayer/live`) already derives these figures from the
13
+ * sales-surface response and publishes `subtotal`, `fees`, `discount`,
14
+ * `total`, `hasFees` and `feeLabel` on the cart it hands us. Cores never
15
+ * import the client, so the same rule is stated here for the case where a
16
+ * host binds an older connector, or a bare sales cart: read what is there,
17
+ * and never invent money that is not.
18
+ *
19
+ * Money is integer minor units (2500 = 25.00) everywhere on this platform.
20
+ * Nothing here divides or multiplies money; the only division is the one
21
+ * `formatPrice` does to render it.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.FEE_LABEL_FALLBACK = void 0;
25
+ exports.minorUnits = minorUnits;
26
+ exports.expressesFee = expressesFee;
27
+ exports.cartTotals = cartTotals;
28
+ exports.selectionTotals = selectionTotals;
29
+ /** Shown when the response names no fee of its own. */
30
+ exports.FEE_LABEL_FALLBACK = 'Booking fee';
31
+ /**
32
+ * Minor units, or null when the value is not minor units. Integers and
33
+ * all-digit strings only: a decimal is not minor units, and treating it as
34
+ * money here would put floating point into a total.
35
+ */
36
+ function minorUnits(value) {
37
+ if (typeof value === 'number')
38
+ return Number.isInteger(value) ? value : null;
39
+ if (typeof value === 'string' && /^-?\d+$/.test(value))
40
+ return Number(value);
41
+ return null;
42
+ }
43
+ /**
44
+ * THE RULE: a fee of zero is not the same as no fee.
45
+ *
46
+ * Render the fee row when the response EXPRESSES a fee - an amount above
47
+ * zero, or a fee the surface has NAMED (a `type: 'fee'` line or a fee label)
48
+ * even where it computes to zero. A response with no fee figure at all, or a
49
+ * bare zero nothing names, expresses no fee: a channel with no fee profile
50
+ * renders no row, and we never show a "Booking fee 0.00" the surface did not
51
+ * put there.
52
+ */
53
+ function expressesFee(fees, named) {
54
+ if (fees === null)
55
+ return named;
56
+ return fees > 0 || named;
57
+ }
58
+ const lineType = (line) => line.type || 'ticket';
59
+ function cartTotals(cart) {
60
+ const c = cart || {};
61
+ const lines = Array.isArray(c.items) ? c.items : [];
62
+ const feeLines = lines.filter((l) => lineType(l) === 'fee');
63
+ const discountLines = lines.filter((l) => lineType(l) === 'discount');
64
+ const subtotal = minorUnits(c.itemsSubtotal) ??
65
+ minorUnits(c.subtotal) ??
66
+ lines
67
+ .filter((l) => !['fee', 'tax', 'discount'].includes(lineType(l)))
68
+ .reduce((sum, l) => sum + (minorUnits(l.subtotal) ?? 0), 0);
69
+ const feeFromLines = feeLines.length
70
+ ? feeLines.reduce((sum, l) => sum + (minorUnits(l.subtotal) ?? 0), 0)
71
+ : null;
72
+ const fees = minorUnits(c.totalFees) ?? minorUnits(c.fees) ?? feeFromLines;
73
+ const discountFromLines = discountLines.length
74
+ ? discountLines.reduce((sum, l) => sum + Math.abs(minorUnits(l.subtotal) ?? 0), 0)
75
+ : null;
76
+ const discount = minorUnits(c.totalDiscounts) ?? minorUnits(c.discount) ?? discountFromLines;
77
+ const tax = minorUnits(c.totalTax) ?? 0;
78
+ const named = (typeof c.feeLabel === 'string' && !!c.feeLabel) ||
79
+ feeLines.some((l) => typeof l.name === 'string' && !!l.name);
80
+ const label = (typeof c.feeLabel === 'string' && c.feeLabel) ||
81
+ feeLines.map((l) => l.name).find((n) => typeof n === 'string' && !!n) ||
82
+ exports.FEE_LABEL_FALLBACK;
83
+ return {
84
+ currency: c.currency || 'GBP',
85
+ subtotal,
86
+ fees: fees ?? 0,
87
+ discount: discount ?? 0,
88
+ // The server's total is what the buyer is charged, so it wins over any sum
89
+ // we could do here. We only add up when the response carries no total.
90
+ total: minorUnits(c.total) ?? subtotal + (fees ?? 0) + tax - (discount ?? 0),
91
+ // The client already applied the rule; honour its answer when it sent one.
92
+ showFee: typeof c.hasFees === 'boolean' ? c.hasFees : expressesFee(fees, named),
93
+ feeLabel: label,
94
+ };
95
+ }
96
+ /**
97
+ * The totals for a selection that is not yet a cart (tl-ticket-selector).
98
+ *
99
+ * The buyer-facing surface quotes no fee before the cart exists - there is no
100
+ * quote or preview operation, and the channel's fee profile is not exposed to
101
+ * a publishable key - so `quote` is null on every call today and no fee row is
102
+ * rendered. It is a parameter rather than an assumption so that the row lights
103
+ * up from data the moment the surface quotes one, and so the rule stays
104
+ * testable now.
105
+ */
106
+ function selectionTotals(lines, quote = null) {
107
+ const subtotal = (lines || []).reduce((sum, l) => sum + (minorUnits(l.price) ?? 0) * (Math.max(0, Math.trunc(l.quantity || 0)) || 0), 0);
108
+ const fees = quote ? minorUnits(quote.amount) : null;
109
+ const named = !!quote?.label;
110
+ return {
111
+ subtotal,
112
+ fees: fees ?? 0,
113
+ total: subtotal + (fees ?? 0),
114
+ showFee: quote ? expressesFee(fees, named) : false,
115
+ feeLabel: quote?.label || exports.FEE_LABEL_FALLBACK,
116
+ };
117
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The client a core is handed.
3
+ *
4
+ * Cores receive a client and never construct one (TKT-11), and they never
5
+ * import `@ticketlayer/live`: this is the structural shape of the connector a
6
+ * host publishes, narrowed to what the cores in this package actually call.
7
+ * `@ticketlayer/elements`' `TicketlayerSDK` satisfies it, so a Stencil harness
8
+ * passes `getSDK()` straight through; a React Native harness will pass the
9
+ * connector `@ticketlayer/live/native` gives it.
10
+ *
11
+ * Everything the managers return is `unknown`. A core narrows what it reads and
12
+ * tolerates what it does not, because the same core runs against whatever
13
+ * client version the host installed.
14
+ */
15
+ export interface ElementEventsManager {
16
+ getTicketTypes(eventId: string, occurrenceId: string): Promise<unknown[]>;
17
+ }
18
+ export interface ElementCartManager {
19
+ addItem(ticketTypeId: string, quantity: number, occurrenceId: string): Promise<unknown>;
20
+ }
21
+ export interface ElementClient {
22
+ getEventsManager(): ElementEventsManager;
23
+ /** Absent on an older connector, so every call site checks. */
24
+ getCartManager?(): ElementCartManager | null | undefined;
25
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The client a core is handed.
3
+ *
4
+ * Cores receive a client and never construct one (TKT-11), and they never
5
+ * import `@ticketlayer/live`: this is the structural shape of the connector a
6
+ * host publishes, narrowed to what the cores in this package actually call.
7
+ * `@ticketlayer/elements`' `TicketlayerSDK` satisfies it, so a Stencil harness
8
+ * passes `getSDK()` straight through; a React Native harness will pass the
9
+ * connector `@ticketlayer/live/native` gives it.
10
+ *
11
+ * Everything the managers return is `unknown`. A core narrows what it reads and
12
+ * tolerates what it does not, because the same core runs against whatever
13
+ * client version the host installed.
14
+ */
15
+ export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The registry: every core this package defines.
3
+ *
4
+ * `packages/elements/scripts/build-catalogue.mjs` reads it to emit
5
+ * `elements.catalogue.json`, and fails when a core claims the `web` runtime and
6
+ * no Stencil component answers to its tag. Splitting an element means adding it
7
+ * here and deleting its entry from the hand-kept
8
+ * `packages/elements/src/catalogue.manifest.json`; the emitter refuses a tag
9
+ * that is in both, so the two cannot drift while the split is half done.
10
+ */
11
+ import type { AnyElementCore } from '../manifest.js';
12
+ export { ticketSelectorCore } from './ticket-selector.js';
13
+ export type { AddToCartItem, TicketSelectorDerived, TicketSelectorProps, TicketSelectorRow, TicketSelectorState, TicketSelectorStatus, TicketSelectorStrings, TlTicketType, } from './ticket-selector.js';
14
+ /** Every core, by tag. */
15
+ export declare const ELEMENT_CORES: Record<string, AnyElementCore>;
@@ -0,0 +1,6 @@
1
+ import { ticketSelectorCore } from './ticket-selector.js';
2
+ export { ticketSelectorCore } from './ticket-selector.js';
3
+ /** Every core, by tag. */
4
+ export const ELEMENT_CORES = {
5
+ [ticketSelectorCore.tag]: ticketSelectorCore,
6
+ };
@@ -0,0 +1,104 @@
1
+ import { type TlFeeQuote, type TlSelectionTotals } from '../money.js';
2
+ export interface TlTicketType {
3
+ id: string;
4
+ occurrenceId: string;
5
+ name: string;
6
+ description?: string;
7
+ price: number;
8
+ currency: string;
9
+ availableQuantity: number;
10
+ maxPerOrder: number;
11
+ minPerOrder: number;
12
+ availabilityStatus: 'available' | 'limited' | 'sold_out';
13
+ sortOrder: number;
14
+ }
15
+ export interface AddToCartItem {
16
+ ticketTypeId: string;
17
+ quantity: number;
18
+ }
19
+ export interface TicketSelectorProps {
20
+ eventId: string;
21
+ occurrenceId: string;
22
+ showButton: boolean;
23
+ buttonLabel: string;
24
+ }
25
+ export interface TicketSelectorState {
26
+ ticketTypes: TlTicketType[];
27
+ quantities: Record<string, number>;
28
+ loading: boolean;
29
+ error: string | null;
30
+ adding: boolean;
31
+ addSuccess: boolean;
32
+ /**
33
+ * The fee the surface has quoted for this selection.
34
+ *
35
+ * There is no quote today: the buyer-facing surface exposes no fee until a
36
+ * cart is checked out (no quote or preview operation, and the channel's fee
37
+ * profile is not readable with a publishable key), so this stays null and no
38
+ * fee row is rendered rather than a made-up one. It is state rather than an
39
+ * assumption so the row lights up from data the moment the surface quotes
40
+ * one, and so the rule stays testable now.
41
+ */
42
+ feeQuote: TlFeeQuote | null;
43
+ }
44
+ export interface TicketSelectorRow {
45
+ id: string;
46
+ name: string;
47
+ description: string | null;
48
+ priceLabel: string;
49
+ quantity: number;
50
+ soldOut: boolean;
51
+ limited: boolean;
52
+ canDecrease: boolean;
53
+ canIncrease: boolean;
54
+ }
55
+ export interface TicketSelectorDerived {
56
+ rows: TicketSelectorRow[];
57
+ totals: TlSelectionTotals;
58
+ totalItems: number;
59
+ currency: string | undefined;
60
+ subtotalLabel: string;
61
+ feeLabel: string;
62
+ totalLabel: string;
63
+ /** Whether the tickets / fee / total block is rendered at all. */
64
+ showTotals: boolean;
65
+ actionLabel: string;
66
+ actionDisabled: boolean;
67
+ }
68
+ export type TicketSelectorStatus = 'loading' | 'error' | 'ready';
69
+ declare const STRINGS: {
70
+ selectTickets: string;
71
+ adding: string;
72
+ added: string;
73
+ limited: string;
74
+ soldOut: string;
75
+ ticketsRow: string;
76
+ totalRow: string;
77
+ decrease: string;
78
+ increase: string;
79
+ loadFailed: string;
80
+ addFailed: string;
81
+ noClient: string;
82
+ };
83
+ export type TicketSelectorStrings = typeof STRINGS;
84
+ export declare const ticketSelectorCore: import("../define.js").ElementCore<TicketSelectorProps, TicketSelectorState, TicketSelectorDerived, {
85
+ load(): Promise<void>;
86
+ changeQuantity(ticketTypeId: string, delta: number): void;
87
+ addToCart(): Promise<void>;
88
+ setFeeQuote(quote: TlFeeQuote | null): void;
89
+ connectFailed(message: string): void;
90
+ }, TicketSelectorStatus, {
91
+ selectTickets: string;
92
+ adding: string;
93
+ added: string;
94
+ limited: string;
95
+ soldOut: string;
96
+ ticketsRow: string;
97
+ totalRow: string;
98
+ decrease: string;
99
+ increase: string;
100
+ loadFailed: string;
101
+ addFailed: string;
102
+ noClient: string;
103
+ }>;
104
+ export {};
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `tl-ticket-selector`: the reference core.
3
+ *
4
+ * This is the whole of what the element does. The Stencil harness in
5
+ * `packages/elements/src/components/tl-ticket-selector/` renders `view` and
6
+ * forwards the three `tl*` events; it decides nothing, holds no copy and does
7
+ * no arithmetic. A React Native harness will render the same view.
8
+ *
9
+ * Behaviour is the behaviour the Stencil component had before the split, to
10
+ * the letter: the same quantity clamping, the same reset after a successful
11
+ * add, the same three second success message, the same button text, and the
12
+ * same rule that no fee row is shown until the surface quotes a fee. The one
13
+ * thing that did not come across is the `console.log` tracing, which a library
14
+ * should not do for its host.
15
+ */
16
+ import { defineElement } from '../define.js';
17
+ import { selectionTotals } from '../money.js';
18
+ const STRINGS = {
19
+ selectTickets: 'Select Tickets',
20
+ adding: 'Adding...',
21
+ added: 'Added to cart!',
22
+ limited: 'Limited',
23
+ soldOut: 'Sold Out',
24
+ ticketsRow: 'Tickets',
25
+ totalRow: 'Total',
26
+ decrease: 'Decrease quantity',
27
+ increase: 'Increase quantity',
28
+ loadFailed: 'Failed to load tickets',
29
+ addFailed: 'Failed to add to cart',
30
+ noClient: 'SDK not initialized.',
31
+ };
32
+ /** How long the "Added to cart!" message stays up. */
33
+ const SUCCESS_MS = 3000;
34
+ const totalsOf = (ctx) => selectionTotals(ctx.state.ticketTypes.map((tt) => ({ price: tt.price, quantity: ctx.state.quantities[tt.id] || 0 })), ctx.state.feeQuote);
35
+ const totalItemsOf = (state) => Object.values(state.quantities).reduce((sum, qty) => sum + qty, 0);
36
+ export const ticketSelectorCore = defineElement({
37
+ tag: 'tl-ticket-selector',
38
+ docs: 'Pick quantities of the ticket types on sale for one occurrence, and add them to the cart.',
39
+ category: 'purchase',
40
+ runtimes: ['web', 'native'],
41
+ tokens: [
42
+ 'background',
43
+ 'border',
44
+ 'border-hover',
45
+ 'error',
46
+ 'error-bg',
47
+ 'font-family',
48
+ 'foreground',
49
+ 'muted',
50
+ 'muted-foreground',
51
+ 'primary',
52
+ 'primary-foreground',
53
+ 'radius-md',
54
+ 'radius-sm',
55
+ 'spacing-1',
56
+ 'spacing-2',
57
+ 'spacing-3',
58
+ 'spacing-4',
59
+ 'success',
60
+ 'warning',
61
+ 'warning-bg',
62
+ ],
63
+ sdkMethods: ['cart.addItem', 'events.getTicketTypes'],
64
+ props: {
65
+ eventId: { type: 'string', attr: 'event-id', required: true, docs: 'Event ID' },
66
+ occurrenceId: { type: 'string', attr: 'occurrence-id', required: true, docs: 'Occurrence ID' },
67
+ showButton: { type: 'boolean', attr: 'show-button', default: true, docs: 'Show add to cart button' },
68
+ buttonLabel: { type: 'string', attr: 'button-label', default: 'Add to Cart', docs: 'Button label' },
69
+ },
70
+ events: {
71
+ tlAddToCart: {
72
+ detail: '{ items: AddToCartItem[]; }',
73
+ docs: 'Emitted when items are added to cart',
74
+ },
75
+ tlQuantityChange: {
76
+ detail: '{ ticketTypeId: string; quantity: number; total: number; }',
77
+ docs: 'Emitted when quantity changes',
78
+ },
79
+ tlError: {
80
+ detail: '{ message: string; }',
81
+ docs: 'Emitted on error',
82
+ },
83
+ },
84
+ strings: STRINGS,
85
+ statuses: ['loading', 'error', 'ready'],
86
+ state: () => ({
87
+ ticketTypes: [],
88
+ quantities: {},
89
+ loading: true,
90
+ error: null,
91
+ adding: false,
92
+ addSuccess: false,
93
+ feeQuote: null,
94
+ }),
95
+ status: ({ state }) => (state.loading ? 'loading' : state.error ? 'error' : 'ready'),
96
+ derive: (ctx) => {
97
+ const { state, strings, props, formatMoney } = ctx;
98
+ const totals = totalsOf(ctx);
99
+ const totalItems = totalItemsOf(state);
100
+ const currency = state.ticketTypes[0]?.currency;
101
+ const rows = state.ticketTypes.map((tt) => {
102
+ const quantity = state.quantities[tt.id] || 0;
103
+ const soldOut = tt.availabilityStatus === 'sold_out';
104
+ return {
105
+ id: tt.id,
106
+ name: tt.name,
107
+ description: tt.description ?? null,
108
+ priceLabel: formatMoney(tt.price, tt.currency),
109
+ quantity,
110
+ soldOut,
111
+ limited: tt.availabilityStatus === 'limited',
112
+ canDecrease: !soldOut && quantity > 0,
113
+ canIncrease: !soldOut && quantity < tt.maxPerOrder,
114
+ };
115
+ });
116
+ return {
117
+ rows,
118
+ totals,
119
+ totalItems,
120
+ currency,
121
+ subtotalLabel: formatMoney(totals.subtotal, currency),
122
+ feeLabel: formatMoney(totals.fees, currency),
123
+ totalLabel: formatMoney(totals.total, currency),
124
+ showTotals: totals.showFee && totalItems > 0,
125
+ actionLabel: state.adding
126
+ ? strings.adding
127
+ : totalItems === 0
128
+ ? strings.selectTickets
129
+ : `${props.buttonLabel} - ${formatMoney(totals.total)}`,
130
+ actionDisabled: totalItems === 0 || state.adding,
131
+ };
132
+ },
133
+ actions: (ctx) => ({
134
+ /** Read the ticket types for the current event and occurrence. */
135
+ async load() {
136
+ try {
137
+ ctx.setState({ loading: true, error: null });
138
+ const types = (await ctx.client().getEventsManager().getTicketTypes(ctx.props.eventId, ctx.props.occurrenceId));
139
+ const quantities = {};
140
+ for (const type of types)
141
+ quantities[type.id] = 0;
142
+ ctx.setState({ ticketTypes: types, quantities });
143
+ }
144
+ catch (err) {
145
+ const message = err instanceof Error ? err.message : ctx.strings.loadFailed;
146
+ ctx.setState({ error: message });
147
+ ctx.emit('tlError', { message });
148
+ }
149
+ finally {
150
+ ctx.setState({ loading: false });
151
+ }
152
+ },
153
+ /** Nudge one row's quantity, clamped to 0 and the type's maxPerOrder. */
154
+ changeQuantity(ticketTypeId, delta) {
155
+ const ticketType = ctx.state.ticketTypes.find((t) => t.id === ticketTypeId);
156
+ if (!ticketType)
157
+ return;
158
+ const current = ctx.state.quantities[ticketTypeId] || 0;
159
+ const quantity = Math.max(0, Math.min(ticketType.maxPerOrder, current + delta));
160
+ ctx.setState((state) => ({ quantities: { ...state.quantities, [ticketTypeId]: quantity } }));
161
+ // After setState, so the total is the one the buyer can now see.
162
+ ctx.emit('tlQuantityChange', { ticketTypeId, quantity, total: totalsOf(ctx).total });
163
+ },
164
+ /**
165
+ * Add every selected line to the cart, then clear the selection.
166
+ *
167
+ * With no client, or a client too old to carry a cart manager, the
168
+ * selection is still cleared and `tlAddToCart` still fires: a host that
169
+ * listens for the event and does its own cart keeps working.
170
+ */
171
+ async addToCart() {
172
+ const items = Object.entries(ctx.state.quantities)
173
+ .filter(([, quantity]) => quantity > 0)
174
+ .map(([ticketTypeId, quantity]) => ({ ticketTypeId, quantity }));
175
+ if (items.length === 0)
176
+ return;
177
+ ctx.setState({ adding: true, addSuccess: false });
178
+ try {
179
+ const cart = ctx.optionalClient()?.getCartManager?.();
180
+ if (cart) {
181
+ for (const item of items) {
182
+ await cart.addItem(item.ticketTypeId, item.quantity, ctx.props.occurrenceId);
183
+ }
184
+ }
185
+ const quantities = {};
186
+ for (const type of ctx.state.ticketTypes)
187
+ quantities[type.id] = 0;
188
+ ctx.setState({ quantities, addSuccess: true });
189
+ ctx.after(SUCCESS_MS, () => ctx.setState({ addSuccess: false }));
190
+ ctx.emit('tlAddToCart', { items });
191
+ }
192
+ catch (err) {
193
+ const message = err instanceof Error ? err.message : ctx.strings.addFailed;
194
+ ctx.setState({ error: message });
195
+ ctx.emit('tlError', { message });
196
+ }
197
+ finally {
198
+ ctx.setState({ adding: false });
199
+ }
200
+ },
201
+ /** What the surface quoted for this selection, when it ever quotes one. */
202
+ setFeeQuote(quote) {
203
+ ctx.setState({ feeQuote: quote });
204
+ },
205
+ /**
206
+ * The host never produced a client. Shown, not emitted: the element has
207
+ * nothing to say to a page that has not started Live yet.
208
+ */
209
+ connectFailed(message) {
210
+ ctx.setState({ error: message || ctx.strings.noClient, loading: false });
211
+ },
212
+ }),
213
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `defineElement`: the shape every Ticketlayer element is built in.
3
+ *
4
+ * The platform plan's section 2.7 and AGENTS.md: behaviour lives in a headless
5
+ * core, views live in thin per-runtime harnesses. This module is the contract
6
+ * between the two. A core declares what it is (tag, category, runtimes, props,
7
+ * events, strings, tokens, the SDK methods it calls) and how it behaves (its
8
+ * state, the status it reports, the values it derives and the actions it
9
+ * offers). A harness creates an instance, renders `view`, and forwards the
10
+ * events. A harness never decides anything.
11
+ *
12
+ * Nothing here touches a DOM, a React tree or a network: no element, no node,
13
+ * no fetch, and no import of `@ticketlayer/live`. A core is handed a client, it
14
+ * never constructs one (TKT-11).
15
+ *
16
+ * The declarative half is also the manifest: `manifestOf` in `./manifest.js`
17
+ * projects it to the JSON the catalogue is emitted from, so the facts a
18
+ * consumer reads are the same object the code runs on rather than a second
19
+ * copy kept by hand.
20
+ */
21
+ import type { TokenName } from '@ticketlayer/theme';
22
+ import type { ElementClient } from './client.js';
23
+ /** Where a core's harnesses run. */
24
+ export type Runtime = 'web' | 'native';
25
+ /** How the catalogue groups an element. */
26
+ export type ElementCategory = 'discovery' | 'purchase' | 'cart' | 'checkout' | 'account' | 'tickets';
27
+ /** The prop types a harness can carry across every runtime. */
28
+ export type PropType = 'string' | 'number' | 'boolean' | 'object' | 'array';
29
+ /**
30
+ * One prop, as the core declares it. `attr` is the web attribute the Stencil
31
+ * harness exposes, or null for a prop a host can only set as a property; the
32
+ * catalogue emitter fails when a harness disagrees with what is declared here.
33
+ */
34
+ export interface PropSpec<V> {
35
+ type: PropType;
36
+ attr: string | null;
37
+ default?: V;
38
+ required?: boolean;
39
+ docs: string;
40
+ }
41
+ export type PropSpecs<P> = {
42
+ [K in keyof P]-?: PropSpec<P[K]>;
43
+ };
44
+ /**
45
+ * One `tl*` event. `detail` is the payload type written out, because the
46
+ * catalogue is JSON and a type is not: it must read exactly as the harness
47
+ * declares it, which the emitter checks against the Stencil build.
48
+ */
49
+ export interface EventSpec {
50
+ detail: string;
51
+ docs: string;
52
+ }
53
+ /** The copy a core renders, so a harness holds no strings of its own. */
54
+ export type StringMap = Record<string, string>;
55
+ /** What a core offers a harness to call. Actions may be async. */
56
+ export type ActionMap = Record<string, (...args: never[]) => unknown>;
57
+ /** What `status`, `derive` and a rendered view are computed from. */
58
+ export interface ViewContext<P extends object, S extends object, Str extends StringMap> {
59
+ props: Readonly<P>;
60
+ state: Readonly<S>;
61
+ strings: Readonly<Str>;
62
+ /** Minor units to the locale's currency string, for example 1333 to "13.33". */
63
+ formatMoney(amountInMinorUnits: number, currency?: string): string;
64
+ }
65
+ /** What an action is handed. */
66
+ export interface ActionContext<P extends object, S extends object, Str extends StringMap> extends ViewContext<P, S, Str> {
67
+ /** A patch merged into the state, which notifies the harness. */
68
+ setState(patch: Partial<S> | ((state: Readonly<S>) => Partial<S>)): void;
69
+ /** Emit a declared event. An undeclared name throws. */
70
+ emit(event: string, detail: unknown): void;
71
+ /** The client, or a throw when the host has not supplied one. */
72
+ client(): ElementClient;
73
+ /** The client, or null when the host has not supplied one. Never throws. */
74
+ optionalClient(): ElementClient | null;
75
+ /** A timer the instance clears on destroy. */
76
+ after(ms: number, run: () => void): void;
77
+ }
78
+ /** A core, as `defineElement` returns it. */
79
+ export interface ElementCore<P extends object, S extends object, D extends object, A extends ActionMap, Status extends string, Str extends StringMap> {
80
+ readonly tag: string;
81
+ readonly docs: string;
82
+ readonly category: ElementCategory;
83
+ readonly runtimes: readonly Runtime[];
84
+ /**
85
+ * The `@ticketlayer/theme` tokens the element's harnesses render with, by
86
+ * canonical name and nothing else. Not `--tl-primary`, not an alias, and
87
+ * never a value: a token's default is written once, in
88
+ * `packages/theme/tokens.json`. `TokenName` is generated from that file, so
89
+ * a token renamed there stops this line compiling, and
90
+ * `packages/elements/scripts/check-core-tokens.mjs` fails a web harness whose
91
+ * stylesheet reaches for a token the core did not declare.
92
+ */
93
+ readonly tokens: readonly TokenName[];
94
+ /** The client methods the core calls, as `manager.method`. */
95
+ readonly sdkMethods: readonly string[];
96
+ readonly props: PropSpecs<P>;
97
+ readonly events: Readonly<Record<string, EventSpec>>;
98
+ readonly strings: Readonly<Str>;
99
+ /** Every value `status` can return; a harness renders one branch per entry. */
100
+ readonly statuses: readonly Status[];
101
+ readonly state: (props: Readonly<P>) => S;
102
+ readonly status: (ctx: ViewContext<P, S, Str>) => Status;
103
+ readonly derive: (ctx: ViewContext<P, S, Str>) => D;
104
+ readonly actions: (ctx: ActionContext<P, S, Str>) => A;
105
+ }
106
+ export type ElementDefinition<P extends object, S extends object, D extends object, A extends ActionMap, Status extends string, Str extends StringMap> = ElementCore<P, S, D, A, Status, Str>;
107
+ /** Everything a harness renders from, recomputed on every change. */
108
+ export interface ElementView<P extends object, S extends object, D extends object, Status extends string, Str extends StringMap> {
109
+ status: Status;
110
+ props: Readonly<P>;
111
+ state: Readonly<S>;
112
+ strings: Readonly<Str>;
113
+ derived: D;
114
+ }
115
+ export interface CreateElementOptions<P extends object, Str extends StringMap> {
116
+ props?: Partial<P>;
117
+ /**
118
+ * The client, or a resolver for it. A resolver lets a harness hand over the
119
+ * one the host published without the core knowing how it was found; a
120
+ * resolver that throws is treated as no client by `optionalClient`.
121
+ */
122
+ client?: ElementClient | null | (() => ElementClient | null);
123
+ /** Where a declared event goes. A harness maps it onto its own emitter. */
124
+ emit?: (event: string, detail: unknown) => void;
125
+ /** Copy overrides, per instance. */
126
+ strings?: Partial<Str>;
127
+ /** The locale money is formatted in. */
128
+ locale?: string;
129
+ /** The currency used when a value carries none. */
130
+ currency?: string;
131
+ }
132
+ export interface ElementInstance<P extends object, S extends object, D extends object, A extends ActionMap, Status extends string, Str extends StringMap> {
133
+ readonly core: ElementCore<P, S, D, A, Status, Str>;
134
+ readonly props: Readonly<P>;
135
+ readonly state: Readonly<S>;
136
+ readonly view: ElementView<P, S, D, Status, Str>;
137
+ readonly actions: A;
138
+ setProps(next: Partial<P>): void;
139
+ setClient(client: ElementClient | null | (() => ElementClient | null)): void;
140
+ subscribe(listener: (view: ElementView<P, S, D, Status, Str>) => void): () => void;
141
+ destroy(): void;
142
+ }
143
+ /**
144
+ * Declare a core. The checks here are the ones a type cannot make: a tag that
145
+ * is not a `tl-` custom element name, a category or runtime outside the set the
146
+ * catalogue knows, a duplicated token, a status list that does not include what
147
+ * `status` can return. They run at import time, so a malformed core fails the
148
+ * build rather than a browser.
149
+ */
150
+ export declare function defineElement<P extends object, S extends object, D extends object, A extends ActionMap, Status extends string, Str extends StringMap>(core: ElementCore<P, S, D, A, Status, Str>): ElementCore<P, S, D, A, Status, Str>;
151
+ /**
152
+ * Instantiate a core. The instance owns the state and the timers; the harness
153
+ * owns the pixels. `subscribe` fires after every change with the whole view, so
154
+ * a harness only has to assign it to whatever makes its runtime re-render.
155
+ */
156
+ export declare function createElement<P extends object, S extends object, D extends object, A extends ActionMap, Status extends string, Str extends StringMap>(core: ElementCore<P, S, D, A, Status, Str>, options?: CreateElementOptions<P, Str>): ElementInstance<P, S, D, A, Status, Str>;