@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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const CATEGORIES = [
|
|
2
|
+
'discovery',
|
|
3
|
+
'purchase',
|
|
4
|
+
'cart',
|
|
5
|
+
'checkout',
|
|
6
|
+
'account',
|
|
7
|
+
'tickets',
|
|
8
|
+
];
|
|
9
|
+
const RUNTIMES = ['web', 'native'];
|
|
10
|
+
/**
|
|
11
|
+
* Declare a core. The checks here are the ones a type cannot make: a tag that
|
|
12
|
+
* is not a `tl-` custom element name, a category or runtime outside the set the
|
|
13
|
+
* catalogue knows, a duplicated token, a status list that does not include what
|
|
14
|
+
* `status` can return. They run at import time, so a malformed core fails the
|
|
15
|
+
* build rather than a browser.
|
|
16
|
+
*/
|
|
17
|
+
export function defineElement(core) {
|
|
18
|
+
const fail = (message) => {
|
|
19
|
+
throw new Error(`[elements-core] ${core.tag || '<no tag>'}: ${message}`);
|
|
20
|
+
};
|
|
21
|
+
if (!/^tl-[a-z0-9]+(-[a-z0-9]+)*$/.test(core.tag))
|
|
22
|
+
fail('tag must look like tl-something');
|
|
23
|
+
if (!CATEGORIES.includes(core.category))
|
|
24
|
+
fail(`category must be one of ${CATEGORIES.join(', ')}`);
|
|
25
|
+
if (!core.runtimes.length || core.runtimes.some((r) => !RUNTIMES.includes(r))) {
|
|
26
|
+
fail(`runtimes must be a non-empty subset of ${RUNTIMES.join(', ')}`);
|
|
27
|
+
}
|
|
28
|
+
if (new Set(core.tokens).size !== core.tokens.length)
|
|
29
|
+
fail('tokens must not repeat');
|
|
30
|
+
if (!core.statuses.length)
|
|
31
|
+
fail('statuses must not be empty');
|
|
32
|
+
for (const [name, spec] of Object.entries(core.events)) {
|
|
33
|
+
if (!/^tl[A-Z]/.test(name))
|
|
34
|
+
fail(`event ${name} must be named tlSomething`);
|
|
35
|
+
if (!spec.detail)
|
|
36
|
+
fail(`event ${name} must state its detail type`);
|
|
37
|
+
}
|
|
38
|
+
for (const [name, spec] of Object.entries(core.props)) {
|
|
39
|
+
if (spec.required && spec.default !== undefined)
|
|
40
|
+
fail(`prop ${name} is required and has a default`);
|
|
41
|
+
}
|
|
42
|
+
return Object.freeze({ ...core });
|
|
43
|
+
}
|
|
44
|
+
const DEFAULT_LOCALE = 'en-GB';
|
|
45
|
+
const DEFAULT_CURRENCY = 'GBP';
|
|
46
|
+
/**
|
|
47
|
+
* Instantiate a core. The instance owns the state and the timers; the harness
|
|
48
|
+
* owns the pixels. `subscribe` fires after every change with the whole view, so
|
|
49
|
+
* a harness only has to assign it to whatever makes its runtime re-render.
|
|
50
|
+
*/
|
|
51
|
+
export function createElement(core, options = {}) {
|
|
52
|
+
const defaults = {};
|
|
53
|
+
for (const [name, spec] of Object.entries(core.props)) {
|
|
54
|
+
if (spec.default !== undefined)
|
|
55
|
+
defaults[name] = spec.default;
|
|
56
|
+
}
|
|
57
|
+
let props = { ...defaults, ...(options.props ?? {}) };
|
|
58
|
+
const strings = { ...core.strings, ...(options.strings ?? {}) };
|
|
59
|
+
const locale = options.locale ?? DEFAULT_LOCALE;
|
|
60
|
+
const fallbackCurrency = options.currency ?? DEFAULT_CURRENCY;
|
|
61
|
+
let resolveClient = toResolver(options.client);
|
|
62
|
+
let state = core.state(props);
|
|
63
|
+
let destroyed = false;
|
|
64
|
+
const timers = new Set();
|
|
65
|
+
const listeners = new Set();
|
|
66
|
+
const formatMoney = (amountInMinorUnits, currency) => new Intl.NumberFormat(locale, {
|
|
67
|
+
style: 'currency',
|
|
68
|
+
currency: currency || fallbackCurrency,
|
|
69
|
+
}).format(amountInMinorUnits / 100);
|
|
70
|
+
const viewContext = () => ({ props, state, strings, formatMoney });
|
|
71
|
+
const buildView = () => {
|
|
72
|
+
const ctx = viewContext();
|
|
73
|
+
return {
|
|
74
|
+
status: core.status(ctx),
|
|
75
|
+
props,
|
|
76
|
+
state,
|
|
77
|
+
strings,
|
|
78
|
+
derived: core.derive(ctx),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const notify = () => {
|
|
82
|
+
if (destroyed)
|
|
83
|
+
return;
|
|
84
|
+
const view = buildView();
|
|
85
|
+
for (const listener of [...listeners])
|
|
86
|
+
listener(view);
|
|
87
|
+
};
|
|
88
|
+
const actionContext = {
|
|
89
|
+
get props() {
|
|
90
|
+
return props;
|
|
91
|
+
},
|
|
92
|
+
get state() {
|
|
93
|
+
return state;
|
|
94
|
+
},
|
|
95
|
+
strings,
|
|
96
|
+
formatMoney,
|
|
97
|
+
setState(patch) {
|
|
98
|
+
if (destroyed)
|
|
99
|
+
return;
|
|
100
|
+
const next = typeof patch === 'function' ? patch(state) : patch;
|
|
101
|
+
state = { ...state, ...next };
|
|
102
|
+
notify();
|
|
103
|
+
},
|
|
104
|
+
emit(event, detail) {
|
|
105
|
+
if (!core.events[event]) {
|
|
106
|
+
throw new Error(`[elements-core] ${core.tag}: ${event} is not a declared event`);
|
|
107
|
+
}
|
|
108
|
+
options.emit?.(event, detail);
|
|
109
|
+
},
|
|
110
|
+
client() {
|
|
111
|
+
const client = resolveClient();
|
|
112
|
+
if (!client) {
|
|
113
|
+
throw new Error(`[elements-core] ${core.tag}: no client. A harness hands the core the client the host published.`);
|
|
114
|
+
}
|
|
115
|
+
return client;
|
|
116
|
+
},
|
|
117
|
+
optionalClient() {
|
|
118
|
+
try {
|
|
119
|
+
return resolveClient();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
after(ms, run) {
|
|
126
|
+
if (destroyed)
|
|
127
|
+
return;
|
|
128
|
+
const handle = setTimeout(() => {
|
|
129
|
+
timers.delete(handle);
|
|
130
|
+
if (!destroyed)
|
|
131
|
+
run();
|
|
132
|
+
}, ms);
|
|
133
|
+
timers.add(handle);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const actions = core.actions(actionContext);
|
|
137
|
+
return {
|
|
138
|
+
core,
|
|
139
|
+
get props() {
|
|
140
|
+
return props;
|
|
141
|
+
},
|
|
142
|
+
get state() {
|
|
143
|
+
return state;
|
|
144
|
+
},
|
|
145
|
+
get view() {
|
|
146
|
+
return buildView();
|
|
147
|
+
},
|
|
148
|
+
actions,
|
|
149
|
+
setProps(next) {
|
|
150
|
+
if (destroyed)
|
|
151
|
+
return;
|
|
152
|
+
props = { ...props, ...next };
|
|
153
|
+
notify();
|
|
154
|
+
},
|
|
155
|
+
setClient(client) {
|
|
156
|
+
resolveClient = toResolver(client);
|
|
157
|
+
},
|
|
158
|
+
subscribe(listener) {
|
|
159
|
+
listeners.add(listener);
|
|
160
|
+
return () => {
|
|
161
|
+
listeners.delete(listener);
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
destroy() {
|
|
165
|
+
destroyed = true;
|
|
166
|
+
for (const handle of timers)
|
|
167
|
+
clearTimeout(handle);
|
|
168
|
+
timers.clear();
|
|
169
|
+
listeners.clear();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function toResolver(client) {
|
|
174
|
+
if (typeof client === 'function')
|
|
175
|
+
return client;
|
|
176
|
+
return () => client ?? null;
|
|
177
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ticketlayer/elements-core`: the headless cores the Ticketlayer Elements
|
|
3
|
+
* render.
|
|
4
|
+
*
|
|
5
|
+
* One core per element, in `src/cores/`. A core is plain TypeScript: state,
|
|
6
|
+
* actions, derived values, a status, its copy, the theme tokens its harnesses
|
|
7
|
+
* use and the `tl*` events it emits, with no DOM, no React and no client of its
|
|
8
|
+
* own. The harnesses are thin: `packages/elements` renders a core with Stencil
|
|
9
|
+
* on the web, and a React Native harness will render the same core in an app
|
|
10
|
+
* (platform plan section 2.7, AGENTS.md, TKT-11).
|
|
11
|
+
*
|
|
12
|
+
* `elements.catalogue.json` is emitted from the manifests these cores carry, so
|
|
13
|
+
* what the docs site and a native host read is a projection of the object that
|
|
14
|
+
* runs, not a second copy kept by hand.
|
|
15
|
+
*/
|
|
16
|
+
export { createElement, defineElement } from './define.js';
|
|
17
|
+
export type { ActionContext, ActionMap, CreateElementOptions, ElementCategory, ElementCore, ElementDefinition, ElementInstance, ElementView, EventSpec, PropSpec, PropSpecs, PropType, Runtime, StringMap, ViewContext, } from './define.js';
|
|
18
|
+
export { manifestOf } from './manifest.js';
|
|
19
|
+
export type { AnyElementCore, ElementManifest, ManifestEvent, ManifestProp } from './manifest.js';
|
|
20
|
+
export type { ElementCartManager, ElementClient, ElementEventsManager } from './client.js';
|
|
21
|
+
export { cartTotals, expressesFee, minorUnits, selectionTotals, FEE_LABEL_FALLBACK, } from './money.js';
|
|
22
|
+
export type { TlCartLike, TlFeeQuote, TlSelectionLine, TlSelectionTotals, TlTotals, } from './money.js';
|
|
23
|
+
export * from './cores/index.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ticketlayer/elements-core`: the headless cores the Ticketlayer Elements
|
|
3
|
+
* render.
|
|
4
|
+
*
|
|
5
|
+
* One core per element, in `src/cores/`. A core is plain TypeScript: state,
|
|
6
|
+
* actions, derived values, a status, its copy, the theme tokens its harnesses
|
|
7
|
+
* use and the `tl*` events it emits, with no DOM, no React and no client of its
|
|
8
|
+
* own. The harnesses are thin: `packages/elements` renders a core with Stencil
|
|
9
|
+
* on the web, and a React Native harness will render the same core in an app
|
|
10
|
+
* (platform plan section 2.7, AGENTS.md, TKT-11).
|
|
11
|
+
*
|
|
12
|
+
* `elements.catalogue.json` is emitted from the manifests these cores carry, so
|
|
13
|
+
* what the docs site and a native host read is a projection of the object that
|
|
14
|
+
* runs, not a second copy kept by hand.
|
|
15
|
+
*/
|
|
16
|
+
export { createElement, defineElement } from './define.js';
|
|
17
|
+
export { manifestOf } from './manifest.js';
|
|
18
|
+
export { cartTotals, expressesFee, minorUnits, selectionTotals, FEE_LABEL_FALLBACK, } from './money.js';
|
|
19
|
+
export * from './cores/index.js';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The manifest: the declarative half of a core, as JSON.
|
|
3
|
+
*
|
|
4
|
+
* `elements.catalogue.json` is emitted from these (TKT-11), so the facts the
|
|
5
|
+
* docs site, the CLI and a native host read are a projection of the object the
|
|
6
|
+
* element actually runs on. There is no second copy to forget:
|
|
7
|
+
* `packages/elements/src/catalogue.manifest.json` is the hand-kept file this
|
|
8
|
+
* replaces, one element at a time, and the emitter refuses a tag that appears
|
|
9
|
+
* in both.
|
|
10
|
+
*
|
|
11
|
+
* Only what can be serialised is here. The behaviour (`state`, `status`,
|
|
12
|
+
* `derive`, `actions`) is code, and all the manifest can say about it is the
|
|
13
|
+
* set of statuses a harness must render and the names of the actions it can
|
|
14
|
+
* call.
|
|
15
|
+
*/
|
|
16
|
+
import type { ElementCategory, ElementCore, Runtime } from './define.js';
|
|
17
|
+
export interface ManifestProp {
|
|
18
|
+
name: string;
|
|
19
|
+
attr: string | null;
|
|
20
|
+
type: string;
|
|
21
|
+
default: unknown;
|
|
22
|
+
required: boolean;
|
|
23
|
+
docs: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ManifestEvent {
|
|
26
|
+
name: string;
|
|
27
|
+
detail: string;
|
|
28
|
+
docs: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ElementManifest {
|
|
31
|
+
tag: string;
|
|
32
|
+
docs: string;
|
|
33
|
+
category: ElementCategory;
|
|
34
|
+
runtimes: Runtime[];
|
|
35
|
+
/** Canonical `@ticketlayer/theme` token names, sorted. */
|
|
36
|
+
tokens: string[];
|
|
37
|
+
/** Client methods the core calls, as `manager.method`, sorted. */
|
|
38
|
+
sdkMethods: string[];
|
|
39
|
+
props: ManifestProp[];
|
|
40
|
+
events: ManifestEvent[];
|
|
41
|
+
strings: Record<string, string>;
|
|
42
|
+
statuses: string[];
|
|
43
|
+
actions: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A core with its type parameters forgotten, which is all a registry or the
|
|
47
|
+
* catalogue emitter needs. `any` and not `unknown`: `status`, `derive` and
|
|
48
|
+
* `actions` take their context as a parameter, so a narrower type here would
|
|
49
|
+
* make every real core fail to fit.
|
|
50
|
+
*/
|
|
51
|
+
export type AnyElementCore = ElementCore<any, any, any, any, string, any>;
|
|
52
|
+
/** Project a core onto its manifest. Sorted, so the catalogue diff is stable. */
|
|
53
|
+
export declare function manifestOf(core: AnyElementCore): ElementManifest;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Project a core onto its manifest. Sorted, so the catalogue diff is stable. */
|
|
2
|
+
export function manifestOf(core) {
|
|
3
|
+
const props = Object.entries(core.props)
|
|
4
|
+
.map(([name, spec]) => ({
|
|
5
|
+
name,
|
|
6
|
+
attr: spec.attr,
|
|
7
|
+
type: spec.type,
|
|
8
|
+
default: spec.default ?? null,
|
|
9
|
+
required: !!spec.required,
|
|
10
|
+
docs: spec.docs,
|
|
11
|
+
}))
|
|
12
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
13
|
+
const events = Object.entries(core.events)
|
|
14
|
+
.map(([name, spec]) => ({ name, detail: spec.detail, docs: spec.docs }))
|
|
15
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
16
|
+
return {
|
|
17
|
+
tag: core.tag,
|
|
18
|
+
docs: core.docs,
|
|
19
|
+
category: core.category,
|
|
20
|
+
runtimes: [...core.runtimes],
|
|
21
|
+
tokens: [...core.tokens].sort(),
|
|
22
|
+
sdkMethods: [...core.sdkMethods].sort(),
|
|
23
|
+
props,
|
|
24
|
+
events,
|
|
25
|
+
strings: { ...core.strings },
|
|
26
|
+
statuses: [...core.statuses],
|
|
27
|
+
actions: Object.keys(dryRunActions(core)).sort(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The action names, without running anything. `actions` is a factory, so the
|
|
32
|
+
* only way to learn the names is to build it once against a context whose
|
|
33
|
+
* every member throws: a core that called the client or set state while
|
|
34
|
+
* declaring its actions would fail here, loudly, which is the intent.
|
|
35
|
+
*/
|
|
36
|
+
function dryRunActions(core) {
|
|
37
|
+
const refuse = (what) => () => {
|
|
38
|
+
throw new Error(`[elements-core] ${core.tag}: ${what} cannot run while the actions are declared`);
|
|
39
|
+
};
|
|
40
|
+
return core.actions({
|
|
41
|
+
props: {},
|
|
42
|
+
state: {},
|
|
43
|
+
strings: core.strings,
|
|
44
|
+
formatMoney: refuse('formatMoney'),
|
|
45
|
+
setState: refuse('setState'),
|
|
46
|
+
emit: refuse('emit'),
|
|
47
|
+
client: refuse('client'),
|
|
48
|
+
optionalClient: refuse('optionalClient'),
|
|
49
|
+
after: refuse('after'),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The money rows a cart-shaped view renders: subtotal, the booking fee, a
|
|
3
|
+
* discount and the total.
|
|
4
|
+
*
|
|
5
|
+
* Pure: no Stencil, no DOM, no SDK. A core derives these figures and a harness
|
|
6
|
+
* renders what it returns, so the rule for what is shown can be unit tested on
|
|
7
|
+
* its own (the platform plan's headless-core split, AGENTS.md). It lived in
|
|
8
|
+
* `packages/elements/src/utils/cart-totals.ts` until the core split (TKT-68)
|
|
9
|
+
* and is re-exported from there, so every existing import still resolves.
|
|
10
|
+
*
|
|
11
|
+
* The client (`@ticketlayer/live`) already derives these figures from the
|
|
12
|
+
* sales-surface response and publishes `subtotal`, `fees`, `discount`,
|
|
13
|
+
* `total`, `hasFees` and `feeLabel` on the cart it hands us. Cores never
|
|
14
|
+
* import the client, so the same rule is stated here for the case where a
|
|
15
|
+
* host binds an older connector, or a bare sales cart: read what is there,
|
|
16
|
+
* and never invent money that is not.
|
|
17
|
+
*
|
|
18
|
+
* Money is integer minor units (2500 = 25.00) everywhere on this platform.
|
|
19
|
+
* Nothing here divides or multiplies money; the only division is the one
|
|
20
|
+
* `formatPrice` does to render it.
|
|
21
|
+
*/
|
|
22
|
+
/** Shown when the response names no fee of its own. */
|
|
23
|
+
export declare const FEE_LABEL_FALLBACK = "Booking fee";
|
|
24
|
+
export interface TlCartLike {
|
|
25
|
+
currency?: string | null;
|
|
26
|
+
items?: Array<{
|
|
27
|
+
type?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
quantity?: number;
|
|
30
|
+
subtotal?: number | string;
|
|
31
|
+
}>;
|
|
32
|
+
itemsSubtotal?: number | string;
|
|
33
|
+
subtotal?: number | string;
|
|
34
|
+
fees?: number | string;
|
|
35
|
+
totalFees?: number | string;
|
|
36
|
+
discount?: number | string;
|
|
37
|
+
totalDiscounts?: number | string;
|
|
38
|
+
totalTax?: number | string;
|
|
39
|
+
total?: number | string;
|
|
40
|
+
hasFees?: boolean;
|
|
41
|
+
feeLabel?: string | null;
|
|
42
|
+
}
|
|
43
|
+
export interface TlTotals {
|
|
44
|
+
currency: string;
|
|
45
|
+
/** Ticket lines, in minor units. */
|
|
46
|
+
subtotal: number;
|
|
47
|
+
/** The fee, in minor units. 0 when the response reports none. */
|
|
48
|
+
fees: number;
|
|
49
|
+
/** Discount as a positive magnitude, in minor units. */
|
|
50
|
+
discount: number;
|
|
51
|
+
/** What the buyer pays: the server's figure whenever the response has one. */
|
|
52
|
+
total: number;
|
|
53
|
+
/** Whether to render the fee row. See {@link expressesFee}. */
|
|
54
|
+
showFee: boolean;
|
|
55
|
+
/** The fee profile's label as the response gives it, or the fallback. */
|
|
56
|
+
feeLabel: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Minor units, or null when the value is not minor units. Integers and
|
|
60
|
+
* all-digit strings only: a decimal is not minor units, and treating it as
|
|
61
|
+
* money here would put floating point into a total.
|
|
62
|
+
*/
|
|
63
|
+
export declare function minorUnits(value: unknown): number | null;
|
|
64
|
+
/**
|
|
65
|
+
* THE RULE: a fee of zero is not the same as no fee.
|
|
66
|
+
*
|
|
67
|
+
* Render the fee row when the response EXPRESSES a fee - an amount above
|
|
68
|
+
* zero, or a fee the surface has NAMED (a `type: 'fee'` line or a fee label)
|
|
69
|
+
* even where it computes to zero. A response with no fee figure at all, or a
|
|
70
|
+
* bare zero nothing names, expresses no fee: a channel with no fee profile
|
|
71
|
+
* renders no row, and we never show a "Booking fee 0.00" the surface did not
|
|
72
|
+
* put there.
|
|
73
|
+
*/
|
|
74
|
+
export declare function expressesFee(fees: number | null, named: boolean): boolean;
|
|
75
|
+
export declare function cartTotals(cart: TlCartLike | null | undefined): TlTotals;
|
|
76
|
+
export interface TlSelectionLine {
|
|
77
|
+
price?: number;
|
|
78
|
+
quantity?: number;
|
|
79
|
+
}
|
|
80
|
+
/** A fee the surface has quoted for a selection, when it ever quotes one. */
|
|
81
|
+
export interface TlFeeQuote {
|
|
82
|
+
amount: number;
|
|
83
|
+
label?: string | null;
|
|
84
|
+
}
|
|
85
|
+
export interface TlSelectionTotals {
|
|
86
|
+
subtotal: number;
|
|
87
|
+
fees: number;
|
|
88
|
+
total: number;
|
|
89
|
+
showFee: boolean;
|
|
90
|
+
feeLabel: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The totals for a selection that is not yet a cart (tl-ticket-selector).
|
|
94
|
+
*
|
|
95
|
+
* The buyer-facing surface quotes no fee before the cart exists - there is no
|
|
96
|
+
* quote or preview operation, and the channel's fee profile is not exposed to
|
|
97
|
+
* a publishable key - so `quote` is null on every call today and no fee row is
|
|
98
|
+
* rendered. It is a parameter rather than an assumption so that the row lights
|
|
99
|
+
* up from data the moment the surface quotes one, and so the rule stays
|
|
100
|
+
* testable now.
|
|
101
|
+
*/
|
|
102
|
+
export declare function selectionTotals(lines: TlSelectionLine[], quote?: TlFeeQuote | null): TlSelectionTotals;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The money rows a cart-shaped view renders: subtotal, the booking fee, a
|
|
3
|
+
* discount and the total.
|
|
4
|
+
*
|
|
5
|
+
* Pure: no Stencil, no DOM, no SDK. A core derives these figures and a harness
|
|
6
|
+
* renders what it returns, so the rule for what is shown can be unit tested on
|
|
7
|
+
* its own (the platform plan's headless-core split, AGENTS.md). It lived in
|
|
8
|
+
* `packages/elements/src/utils/cart-totals.ts` until the core split (TKT-68)
|
|
9
|
+
* and is re-exported from there, so every existing import still resolves.
|
|
10
|
+
*
|
|
11
|
+
* The client (`@ticketlayer/live`) already derives these figures from the
|
|
12
|
+
* sales-surface response and publishes `subtotal`, `fees`, `discount`,
|
|
13
|
+
* `total`, `hasFees` and `feeLabel` on the cart it hands us. Cores never
|
|
14
|
+
* import the client, so the same rule is stated here for the case where a
|
|
15
|
+
* host binds an older connector, or a bare sales cart: read what is there,
|
|
16
|
+
* and never invent money that is not.
|
|
17
|
+
*
|
|
18
|
+
* Money is integer minor units (2500 = 25.00) everywhere on this platform.
|
|
19
|
+
* Nothing here divides or multiplies money; the only division is the one
|
|
20
|
+
* `formatPrice` does to render it.
|
|
21
|
+
*/
|
|
22
|
+
/** Shown when the response names no fee of its own. */
|
|
23
|
+
export const FEE_LABEL_FALLBACK = 'Booking fee';
|
|
24
|
+
/**
|
|
25
|
+
* Minor units, or null when the value is not minor units. Integers and
|
|
26
|
+
* all-digit strings only: a decimal is not minor units, and treating it as
|
|
27
|
+
* money here would put floating point into a total.
|
|
28
|
+
*/
|
|
29
|
+
export function minorUnits(value) {
|
|
30
|
+
if (typeof value === 'number')
|
|
31
|
+
return Number.isInteger(value) ? value : null;
|
|
32
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value))
|
|
33
|
+
return Number(value);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* THE RULE: a fee of zero is not the same as no fee.
|
|
38
|
+
*
|
|
39
|
+
* Render the fee row when the response EXPRESSES a fee - an amount above
|
|
40
|
+
* zero, or a fee the surface has NAMED (a `type: 'fee'` line or a fee label)
|
|
41
|
+
* even where it computes to zero. A response with no fee figure at all, or a
|
|
42
|
+
* bare zero nothing names, expresses no fee: a channel with no fee profile
|
|
43
|
+
* renders no row, and we never show a "Booking fee 0.00" the surface did not
|
|
44
|
+
* put there.
|
|
45
|
+
*/
|
|
46
|
+
export function expressesFee(fees, named) {
|
|
47
|
+
if (fees === null)
|
|
48
|
+
return named;
|
|
49
|
+
return fees > 0 || named;
|
|
50
|
+
}
|
|
51
|
+
const lineType = (line) => line.type || 'ticket';
|
|
52
|
+
export function cartTotals(cart) {
|
|
53
|
+
const c = cart || {};
|
|
54
|
+
const lines = Array.isArray(c.items) ? c.items : [];
|
|
55
|
+
const feeLines = lines.filter((l) => lineType(l) === 'fee');
|
|
56
|
+
const discountLines = lines.filter((l) => lineType(l) === 'discount');
|
|
57
|
+
const subtotal = minorUnits(c.itemsSubtotal) ??
|
|
58
|
+
minorUnits(c.subtotal) ??
|
|
59
|
+
lines
|
|
60
|
+
.filter((l) => !['fee', 'tax', 'discount'].includes(lineType(l)))
|
|
61
|
+
.reduce((sum, l) => sum + (minorUnits(l.subtotal) ?? 0), 0);
|
|
62
|
+
const feeFromLines = feeLines.length
|
|
63
|
+
? feeLines.reduce((sum, l) => sum + (minorUnits(l.subtotal) ?? 0), 0)
|
|
64
|
+
: null;
|
|
65
|
+
const fees = minorUnits(c.totalFees) ?? minorUnits(c.fees) ?? feeFromLines;
|
|
66
|
+
const discountFromLines = discountLines.length
|
|
67
|
+
? discountLines.reduce((sum, l) => sum + Math.abs(minorUnits(l.subtotal) ?? 0), 0)
|
|
68
|
+
: null;
|
|
69
|
+
const discount = minorUnits(c.totalDiscounts) ?? minorUnits(c.discount) ?? discountFromLines;
|
|
70
|
+
const tax = minorUnits(c.totalTax) ?? 0;
|
|
71
|
+
const named = (typeof c.feeLabel === 'string' && !!c.feeLabel) ||
|
|
72
|
+
feeLines.some((l) => typeof l.name === 'string' && !!l.name);
|
|
73
|
+
const label = (typeof c.feeLabel === 'string' && c.feeLabel) ||
|
|
74
|
+
feeLines.map((l) => l.name).find((n) => typeof n === 'string' && !!n) ||
|
|
75
|
+
FEE_LABEL_FALLBACK;
|
|
76
|
+
return {
|
|
77
|
+
currency: c.currency || 'GBP',
|
|
78
|
+
subtotal,
|
|
79
|
+
fees: fees ?? 0,
|
|
80
|
+
discount: discount ?? 0,
|
|
81
|
+
// The server's total is what the buyer is charged, so it wins over any sum
|
|
82
|
+
// we could do here. We only add up when the response carries no total.
|
|
83
|
+
total: minorUnits(c.total) ?? subtotal + (fees ?? 0) + tax - (discount ?? 0),
|
|
84
|
+
// The client already applied the rule; honour its answer when it sent one.
|
|
85
|
+
showFee: typeof c.hasFees === 'boolean' ? c.hasFees : expressesFee(fees, named),
|
|
86
|
+
feeLabel: label,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The totals for a selection that is not yet a cart (tl-ticket-selector).
|
|
91
|
+
*
|
|
92
|
+
* The buyer-facing surface quotes no fee before the cart exists - there is no
|
|
93
|
+
* quote or preview operation, and the channel's fee profile is not exposed to
|
|
94
|
+
* a publishable key - so `quote` is null on every call today and no fee row is
|
|
95
|
+
* rendered. It is a parameter rather than an assumption so that the row lights
|
|
96
|
+
* up from data the moment the surface quotes one, and so the rule stays
|
|
97
|
+
* testable now.
|
|
98
|
+
*/
|
|
99
|
+
export function selectionTotals(lines, quote = null) {
|
|
100
|
+
const subtotal = (lines || []).reduce((sum, l) => sum + (minorUnits(l.price) ?? 0) * (Math.max(0, Math.trunc(l.quantity || 0)) || 0), 0);
|
|
101
|
+
const fees = quote ? minorUnits(quote.amount) : null;
|
|
102
|
+
const named = !!quote?.label;
|
|
103
|
+
return {
|
|
104
|
+
subtotal,
|
|
105
|
+
fees: fees ?? 0,
|
|
106
|
+
total: subtotal + (fees ?? 0),
|
|
107
|
+
showFee: quote ? expressesFee(fees, named) : false,
|
|
108
|
+
feeLabel: quote?.label || FEE_LABEL_FALLBACK,
|
|
109
|
+
};
|
|
110
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ticketlayer/elements-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The headless cores the Ticketlayer Elements render: state, actions, derived values, status, strings, tokens and events, with no runtime in them",
|
|
5
|
+
"main": "./dist/cjs/index.js",
|
|
6
|
+
"module": "./dist/esm/index.js",
|
|
7
|
+
"types": "./dist/esm/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/esm/index.d.ts",
|
|
11
|
+
"import": "./dist/esm/index.js",
|
|
12
|
+
"require": "./dist/cjs/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/ticketlayer/live.git",
|
|
25
|
+
"directory": "packages/elements-core"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://docs.ticketlayer.com/elements",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/ticketlayer/live/issues"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"ticketlayer",
|
|
33
|
+
"elements",
|
|
34
|
+
"headless",
|
|
35
|
+
"ticketing"
|
|
36
|
+
],
|
|
37
|
+
"author": "Ticketlayer",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.0.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"registry": "https://registry.npmjs.org/"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@ticketlayer/theme": "^0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"typescript": "^5.3.3"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "node scripts/build.mjs",
|
|
54
|
+
"clean": "rm -rf dist",
|
|
55
|
+
"pretest": "pnpm run build",
|
|
56
|
+
"test": "node --test test/*.test.mjs",
|
|
57
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
58
|
+
}
|
|
59
|
+
}
|