@ticketlayer/elements-core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -4
- package/dist/cjs/cores/buy-tickets-button.js +213 -0
- package/dist/cjs/cores/buy-tickets-wrapper.js +106 -0
- package/dist/cjs/cores/cart-badge.js +150 -0
- package/dist/cjs/cores/cart-drawer.js +122 -0
- package/dist/cjs/cores/cart.js +247 -0
- package/dist/cjs/cores/checkout-wrapper.js +75 -0
- package/dist/cjs/cores/event-card.js +209 -0
- package/dist/cjs/cores/event-list.js +109 -0
- package/dist/cjs/cores/index.js +64 -1
- package/dist/cjs/cores/login.js +309 -0
- package/dist/cjs/cores/my-orders.js +221 -0
- package/dist/cjs/cores/occurrence-selector.js +528 -0
- package/dist/cjs/cores/order-confirmation.js +301 -0
- package/dist/cjs/cores/order-tickets.js +172 -0
- package/dist/cjs/cores/promo-code.js +136 -0
- package/dist/cjs/cores/ticket-selector.js +30 -0
- package/dist/cjs/define.js +76 -0
- package/dist/cjs/index.js +17 -1
- package/dist/cjs/manifest.js +5 -0
- package/dist/cjs/orders.js +169 -0
- package/dist/esm/client.d.ts +106 -0
- package/dist/esm/cores/buy-tickets-button.d.ts +55 -0
- package/dist/esm/cores/buy-tickets-button.js +210 -0
- package/dist/esm/cores/buy-tickets-wrapper.d.ts +27 -0
- package/dist/esm/cores/buy-tickets-wrapper.js +103 -0
- package/dist/esm/cores/cart-badge.d.ts +48 -0
- package/dist/esm/cores/cart-badge.js +147 -0
- package/dist/esm/cores/cart-drawer.d.ts +44 -0
- package/dist/esm/cores/cart-drawer.js +119 -0
- package/dist/esm/cores/cart.d.ts +105 -0
- package/dist/esm/cores/cart.js +244 -0
- package/dist/esm/cores/checkout-wrapper.d.ts +22 -0
- package/dist/esm/cores/checkout-wrapper.js +72 -0
- package/dist/esm/cores/event-card.d.ts +80 -0
- package/dist/esm/cores/event-card.js +205 -0
- package/dist/esm/cores/event-list.d.ts +35 -0
- package/dist/esm/cores/event-list.js +106 -0
- package/dist/esm/cores/index.d.ts +28 -0
- package/dist/esm/cores/index.js +42 -0
- package/dist/esm/cores/login.d.ts +125 -0
- package/dist/esm/cores/login.js +306 -0
- package/dist/esm/cores/my-orders.d.ts +63 -0
- package/dist/esm/cores/my-orders.js +218 -0
- package/dist/esm/cores/occurrence-selector.d.ts +170 -0
- package/dist/esm/cores/occurrence-selector.js +525 -0
- package/dist/esm/cores/order-confirmation.d.ts +99 -0
- package/dist/esm/cores/order-confirmation.js +298 -0
- package/dist/esm/cores/order-tickets.d.ts +46 -0
- package/dist/esm/cores/order-tickets.js +169 -0
- package/dist/esm/cores/promo-code.d.ts +56 -0
- package/dist/esm/cores/promo-code.js +132 -0
- package/dist/esm/cores/ticket-selector.js +30 -0
- package/dist/esm/define.d.ts +146 -0
- package/dist/esm/define.js +75 -0
- package/dist/esm/index.d.ts +5 -3
- package/dist/esm/index.js +2 -1
- package/dist/esm/manifest.d.ts +12 -0
- package/dist/esm/manifest.js +5 -0
- package/dist/esm/orders.d.ts +110 -0
- package/dist/esm/orders.js +154 -0
- package/package.json +2 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tl-buy-tickets-button`: the pre-styled button that opens the purchase
|
|
3
|
+
* modal for one event.
|
|
4
|
+
*
|
|
5
|
+
* The sibling of `tl-buy-tickets-wrapper`. The wrapper turns whatever the host
|
|
6
|
+
* already has into the thing that opens the modal; this one IS the thing, so
|
|
7
|
+
* on top of the wrapper's single action it carries the four sale states a
|
|
8
|
+
* seller puts a button in, the label each one defaults to, and the price the
|
|
9
|
+
* button may show beside it.
|
|
10
|
+
*
|
|
11
|
+
* Until now it could not be split for a different reason from the wrapper's.
|
|
12
|
+
* Its four states painted themselves from literal hex in the component, and a
|
|
13
|
+
* core may hold no value at all, so there was nothing to lift. TKT-203 moved
|
|
14
|
+
* them onto token families - `available` to primary, `soldOut` to error,
|
|
15
|
+
* `comingSoon` to warning, `waitlist` to info, each deriving its hover with
|
|
16
|
+
* `color-mix` - and that is what unblocked this.
|
|
17
|
+
*
|
|
18
|
+
* What a core still cannot say, and this one does not try to: WHICH family a
|
|
19
|
+
* state paints with. `tokens` is a flat list and there is no shape for mapping
|
|
20
|
+
* one onto a status. So this core does what `tl-event-card`'s status badge
|
|
21
|
+
* does - it derives the CLASS NAME the state is rendered under
|
|
22
|
+
* (`derived.stateClass`) and leaves the stylesheet to say which family that
|
|
23
|
+
* class reaches for. The core names the state; the harness names the paint.
|
|
24
|
+
*
|
|
25
|
+
* Behaviour is what the Stencil component did, to the letter:
|
|
26
|
+
*
|
|
27
|
+
* - `disabled`, `soldOut` and `comingSoon` are not pressable, and a press on
|
|
28
|
+
* one of them does nothing whatever: no event, no modal.
|
|
29
|
+
* - a press announces `tlClick` FIRST and opens the modal after, so a host
|
|
30
|
+
* hears the intent even on a page that never started Live.
|
|
31
|
+
* - with no client, or a connector too old to have the modal verbs, the press
|
|
32
|
+
* still announces `tlClick` and then quietly opens nothing. Unlike the
|
|
33
|
+
* wrapper this element has no error event to say so with, and inventing one
|
|
34
|
+
* would be a new published surface rather than a split.
|
|
35
|
+
* - the label is the `label` prop when the host set one, else the state's own.
|
|
36
|
+
* - the price is formatted `en-GB`, in the button's currency, with the pence
|
|
37
|
+
* shown only when there are any.
|
|
38
|
+
*
|
|
39
|
+
* What did not come across is the `console.error` the component wrote when the
|
|
40
|
+
* modal threw, for the reason `tl-cart` and `tl-promo-code` dropped theirs: a
|
|
41
|
+
* library should not write to its host's console.
|
|
42
|
+
*
|
|
43
|
+
* The other thing a core refuses to know is which key press counts as
|
|
44
|
+
* activation. `activate()` is the whole input surface; the harness decides what
|
|
45
|
+
* reaches it - a click and Enter or Space on the web, a press on a phone.
|
|
46
|
+
*/
|
|
47
|
+
import { defineElement } from '../define.js';
|
|
48
|
+
const STRINGS = {
|
|
49
|
+
available: 'Buy Tickets',
|
|
50
|
+
soldOut: 'Sold Out',
|
|
51
|
+
waitlist: 'Join Waitlist',
|
|
52
|
+
comingSoon: 'On Sale Soon',
|
|
53
|
+
};
|
|
54
|
+
/** Sale state -> the class it renders under. */
|
|
55
|
+
const STATE_CLASSES = {
|
|
56
|
+
available: 'state-available',
|
|
57
|
+
soldOut: 'state-sold-out',
|
|
58
|
+
waitlist: 'state-waitlist',
|
|
59
|
+
comingSoon: 'state-coming-soon',
|
|
60
|
+
};
|
|
61
|
+
/** The states a press does nothing in, whatever `disabled` says. */
|
|
62
|
+
const NOT_PRESSABLE = ['soldOut', 'comingSoon'];
|
|
63
|
+
const pressable = (props) => !props.disabled && !NOT_PRESSABLE.includes(props.state);
|
|
64
|
+
/**
|
|
65
|
+
* As the component formatted it: `en-GB`, minor units, and the pence only when
|
|
66
|
+
* there are any. The locale is written down here rather than taken from
|
|
67
|
+
* `ctx.formatMoney` because that is what the element did before the split and
|
|
68
|
+
* this is a split, not a change (see the note on the card core's date).
|
|
69
|
+
*/
|
|
70
|
+
function formatPrice(priceInMinorUnits, currency) {
|
|
71
|
+
const amount = priceInMinorUnits / 100;
|
|
72
|
+
const hasDecimals = amount % 1 !== 0;
|
|
73
|
+
return new Intl.NumberFormat('en-GB', {
|
|
74
|
+
style: 'currency',
|
|
75
|
+
currency,
|
|
76
|
+
minimumFractionDigits: hasDecimals ? 2 : 0,
|
|
77
|
+
maximumFractionDigits: hasDecimals ? 2 : 0,
|
|
78
|
+
}).format(amount);
|
|
79
|
+
}
|
|
80
|
+
export const buyTicketsButtonCore = defineElement({
|
|
81
|
+
tag: 'tl-buy-tickets-button',
|
|
82
|
+
docs: 'A buy-tickets button in one of four sale states, which opens the purchase modal.',
|
|
83
|
+
category: 'purchase',
|
|
84
|
+
runtimes: ['web'],
|
|
85
|
+
tokens: [
|
|
86
|
+
'error',
|
|
87
|
+
'error-foreground',
|
|
88
|
+
'font-family',
|
|
89
|
+
'info',
|
|
90
|
+
'info-foreground',
|
|
91
|
+
'primary',
|
|
92
|
+
'primary-foreground',
|
|
93
|
+
'radius-lg',
|
|
94
|
+
'radius-md',
|
|
95
|
+
'radius-sm',
|
|
96
|
+
'warning',
|
|
97
|
+
'warning-foreground',
|
|
98
|
+
],
|
|
99
|
+
sdkMethods: ['event.openModal'],
|
|
100
|
+
props: {
|
|
101
|
+
eventId: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
attr: 'event-id',
|
|
104
|
+
required: true,
|
|
105
|
+
docs: 'Event ID to purchase tickets for',
|
|
106
|
+
},
|
|
107
|
+
occurrenceId: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
tsType: 'string | undefined',
|
|
110
|
+
attr: 'occurrence-id',
|
|
111
|
+
docs: 'Optional pre-selected occurrence ID',
|
|
112
|
+
},
|
|
113
|
+
state: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
tsType: '"available" | "comingSoon" | "soldOut" | "waitlist"',
|
|
116
|
+
attr: 'state',
|
|
117
|
+
default: 'available',
|
|
118
|
+
docs: 'Button state - determines colour, label, and icon',
|
|
119
|
+
},
|
|
120
|
+
label: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
tsType: 'string | undefined',
|
|
123
|
+
attr: 'label',
|
|
124
|
+
docs: 'Button label override (default depends on state)',
|
|
125
|
+
},
|
|
126
|
+
variant: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
tsType: '"outline" | "primary" | "secondary"',
|
|
129
|
+
attr: 'variant',
|
|
130
|
+
default: 'primary',
|
|
131
|
+
docs: 'Button variant',
|
|
132
|
+
},
|
|
133
|
+
size: {
|
|
134
|
+
type: 'string',
|
|
135
|
+
tsType: '"lg" | "md" | "sm"',
|
|
136
|
+
attr: 'size',
|
|
137
|
+
default: 'md',
|
|
138
|
+
docs: 'Button size',
|
|
139
|
+
},
|
|
140
|
+
fullWidth: {
|
|
141
|
+
type: 'boolean',
|
|
142
|
+
attr: 'full-width',
|
|
143
|
+
default: false,
|
|
144
|
+
docs: 'Full width button',
|
|
145
|
+
},
|
|
146
|
+
price: {
|
|
147
|
+
type: 'number',
|
|
148
|
+
tsType: 'number | undefined',
|
|
149
|
+
attr: 'price',
|
|
150
|
+
docs: 'Price to display (in minor units e.g. pence)',
|
|
151
|
+
},
|
|
152
|
+
currency: {
|
|
153
|
+
type: 'string',
|
|
154
|
+
attr: 'currency',
|
|
155
|
+
default: 'GBP',
|
|
156
|
+
docs: 'Currency code for price formatting',
|
|
157
|
+
},
|
|
158
|
+
pricePrefix: {
|
|
159
|
+
type: 'string',
|
|
160
|
+
attr: 'price-prefix',
|
|
161
|
+
default: 'From',
|
|
162
|
+
docs: 'Show price prefix text',
|
|
163
|
+
},
|
|
164
|
+
disabled: {
|
|
165
|
+
type: 'boolean',
|
|
166
|
+
attr: 'disabled',
|
|
167
|
+
default: false,
|
|
168
|
+
docs: 'Disabled state',
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
events: {
|
|
172
|
+
tlClick: {
|
|
173
|
+
detail: '{ eventId: string; occurrenceId?: string | undefined; }',
|
|
174
|
+
docs: 'Emitted when button is clicked (before modal opens)',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
strings: STRINGS,
|
|
178
|
+
statuses: ['ready', 'disabled'],
|
|
179
|
+
state: () => ({}),
|
|
180
|
+
status: ({ props }) => (pressable(props) ? 'ready' : 'disabled'),
|
|
181
|
+
derive: ({ props, strings }) => ({
|
|
182
|
+
interactive: pressable(props),
|
|
183
|
+
buttonLabel: props.label || strings[props.state],
|
|
184
|
+
stateClass: STATE_CLASSES[props.state],
|
|
185
|
+
priceLabel: props.price === undefined ? null : formatPrice(props.price, props.currency),
|
|
186
|
+
}),
|
|
187
|
+
actions: (ctx) => ({
|
|
188
|
+
/** A click, a tap, Enter or Space: whatever the harness calls a press. */
|
|
189
|
+
activate() {
|
|
190
|
+
if (!pressable(ctx.props))
|
|
191
|
+
return;
|
|
192
|
+
// Announced first, and whatever happens next: a host that tracks buying
|
|
193
|
+
// intent hears it even on a page that never started Live.
|
|
194
|
+
ctx.emit('tlClick', { eventId: ctx.props.eventId, occurrenceId: ctx.props.occurrenceId });
|
|
195
|
+
const client = ctx.optionalClient();
|
|
196
|
+
// Tolerated rather than assumed, like every other accessor a core reads:
|
|
197
|
+
// the same core runs against whatever connector version the host has.
|
|
198
|
+
if (!client || !client.event)
|
|
199
|
+
return;
|
|
200
|
+
try {
|
|
201
|
+
client.event(ctx.props.eventId).openModal({ occurrenceId: ctx.props.occurrenceId });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// The component swallowed this too (it logged and carried on). This
|
|
205
|
+
// element has no error event to announce it with, and giving it one
|
|
206
|
+
// would be a new published surface rather than a split.
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface BuyTicketsWrapperProps {
|
|
2
|
+
eventId: string;
|
|
3
|
+
occurrenceId: string | undefined;
|
|
4
|
+
disabled: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Nothing. The modal belongs to the client and the content belongs to the
|
|
8
|
+
* host, so there is nothing for this element to remember between clicks.
|
|
9
|
+
*/
|
|
10
|
+
export type BuyTicketsWrapperState = Record<string, never>;
|
|
11
|
+
export interface BuyTicketsWrapperDerived {
|
|
12
|
+
/** Whether a press should do anything. The harness draws the rest from it. */
|
|
13
|
+
interactive: boolean;
|
|
14
|
+
}
|
|
15
|
+
export type BuyTicketsWrapperStatus = 'ready' | 'disabled';
|
|
16
|
+
declare const STRINGS: {
|
|
17
|
+
noClient: string;
|
|
18
|
+
notAvailable: string;
|
|
19
|
+
};
|
|
20
|
+
export type BuyTicketsWrapperStrings = typeof STRINGS;
|
|
21
|
+
export declare const buyTicketsWrapperCore: import("../define.js").ElementCore<BuyTicketsWrapperProps, BuyTicketsWrapperState, BuyTicketsWrapperDerived, {
|
|
22
|
+
activate(): void;
|
|
23
|
+
}, BuyTicketsWrapperStatus, {
|
|
24
|
+
noClient: string;
|
|
25
|
+
notAvailable: string;
|
|
26
|
+
}>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tl-buy-tickets-wrapper`: whatever the page already has, turned into the
|
|
3
|
+
* thing that opens the purchase modal.
|
|
4
|
+
*
|
|
5
|
+
* The element renders nothing of its own - the host's own button, link or card
|
|
6
|
+
* is slotted into it - so this is the smallest core in the package: no state,
|
|
7
|
+
* one action, and three events that exist only because the modal reports back
|
|
8
|
+
* to whoever opened it.
|
|
9
|
+
*
|
|
10
|
+
* Until now it could not be split at all. Its events were `tl-complete`,
|
|
11
|
+
* `tl-error` and `tl-close`, and `defineElement` requires `tlSomething`; the
|
|
12
|
+
* names were renamed to `tlComplete`, `tlError` and `tlClose` (TKT-203), which
|
|
13
|
+
* is what made this possible. The rename is a breaking change to a published
|
|
14
|
+
* element and is deliberate.
|
|
15
|
+
*
|
|
16
|
+
* Behaviour is what the Stencil component did, to the letter:
|
|
17
|
+
*
|
|
18
|
+
* - a disabled wrapper does nothing at all, and announces nothing.
|
|
19
|
+
* - with no client on the page the click announces `tlError` rather than
|
|
20
|
+
* throwing, because the host asked for a modal and deserves to hear that it
|
|
21
|
+
* did not open.
|
|
22
|
+
* - the modal is the client's, not the element's. This core asks for it and
|
|
23
|
+
* then only relays what comes back.
|
|
24
|
+
*
|
|
25
|
+
* What did not come across is the `console.error` tracing, for the reason
|
|
26
|
+
* `tl-cart` and `tl-promo-code` dropped theirs: a library should not write to
|
|
27
|
+
* its host's console when it has an event for saying the same thing.
|
|
28
|
+
*
|
|
29
|
+
* What a core still cannot say, and this one does not try to: which key press
|
|
30
|
+
* counts as activation. `activate()` is the whole input surface, and each
|
|
31
|
+
* harness decides what reaches it - a click and Enter or Space on the web, a
|
|
32
|
+
* press on a phone.
|
|
33
|
+
*/
|
|
34
|
+
import { defineElement } from '../define.js';
|
|
35
|
+
const STRINGS = {
|
|
36
|
+
noClient: 'SDK not initialized',
|
|
37
|
+
notAvailable: 'Tickets are not available',
|
|
38
|
+
};
|
|
39
|
+
export const buyTicketsWrapperCore = defineElement({
|
|
40
|
+
tag: 'tl-buy-tickets-wrapper',
|
|
41
|
+
docs: 'Wrap any content and open the purchase modal when it is activated.',
|
|
42
|
+
category: 'purchase',
|
|
43
|
+
runtimes: ['web'],
|
|
44
|
+
tokens: ['radius-sm', 'ring'],
|
|
45
|
+
sdkMethods: ['event.openModal'],
|
|
46
|
+
props: {
|
|
47
|
+
eventId: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
attr: 'event-id',
|
|
50
|
+
required: true,
|
|
51
|
+
docs: 'The event ID to purchase tickets for',
|
|
52
|
+
},
|
|
53
|
+
occurrenceId: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
tsType: 'string | undefined',
|
|
56
|
+
attr: 'occurrence-id',
|
|
57
|
+
docs: 'Optional specific occurrence ID (skips occurrence selector)',
|
|
58
|
+
},
|
|
59
|
+
disabled: {
|
|
60
|
+
type: 'boolean',
|
|
61
|
+
attr: 'disabled',
|
|
62
|
+
default: false,
|
|
63
|
+
docs: 'Whether the wrapper is disabled',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
events: {
|
|
67
|
+
// `OrderResult` because that is the name the web harness writes, and the
|
|
68
|
+
// catalogue compares the two: it is the connector's name for the shape
|
|
69
|
+
// `ElementOrderResult` describes here.
|
|
70
|
+
tlComplete: { detail: 'OrderResult', docs: 'Emitted when purchase completes successfully' },
|
|
71
|
+
tlError: { detail: '{ message: string; }', docs: 'Emitted when an error occurs' },
|
|
72
|
+
tlClose: { detail: 'void', docs: 'Emitted when the modal closes' },
|
|
73
|
+
},
|
|
74
|
+
strings: STRINGS,
|
|
75
|
+
statuses: ['ready', 'disabled'],
|
|
76
|
+
state: () => ({}),
|
|
77
|
+
status: ({ props }) => (props.disabled ? 'disabled' : 'ready'),
|
|
78
|
+
derive: ({ props }) => ({ interactive: !props.disabled }),
|
|
79
|
+
actions: (ctx) => ({
|
|
80
|
+
/** A click, a tap, Enter or Space: whatever the harness calls a press. */
|
|
81
|
+
activate() {
|
|
82
|
+
if (ctx.props.disabled)
|
|
83
|
+
return;
|
|
84
|
+
const client = ctx.optionalClient();
|
|
85
|
+
if (!client) {
|
|
86
|
+
ctx.emit('tlError', { message: ctx.strings.noClient });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Tolerated rather than assumed, like every other accessor a core reads:
|
|
90
|
+
// the same core runs against whatever connector version the host has.
|
|
91
|
+
if (!client.event) {
|
|
92
|
+
ctx.emit('tlError', { message: ctx.strings.notAvailable });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
client.event(ctx.props.eventId).openModal({
|
|
96
|
+
occurrenceId: ctx.props.occurrenceId,
|
|
97
|
+
onComplete: (order) => ctx.emit('tlComplete', order),
|
|
98
|
+
onError: (err) => ctx.emit('tlError', { message: err.message }),
|
|
99
|
+
onClose: () => ctx.emit('tlClose', undefined),
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Where the last known count is kept. The key the component used. */
|
|
2
|
+
export declare const CART_COUNT_KEY = "tl_cart_count";
|
|
3
|
+
export interface CartBadgeProps {
|
|
4
|
+
showEmpty: boolean;
|
|
5
|
+
icon?: string;
|
|
6
|
+
size: 'sm' | 'md' | 'lg';
|
|
7
|
+
badgeOnly: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface CartBadgeState {
|
|
10
|
+
itemCount: number;
|
|
11
|
+
}
|
|
12
|
+
export interface CartBadgeDerived {
|
|
13
|
+
/** The count as it is shown: anything over ninety-nine is "99+". */
|
|
14
|
+
displayCount: string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the count bubble is rendered.
|
|
17
|
+
*
|
|
18
|
+
* `showEmpty` has no effect on it, and deliberately still has none: the
|
|
19
|
+
* component computed `itemCount > 0 || showEmpty` and then rendered the
|
|
20
|
+
* bubble only when `showBadge && itemCount > 0`, so the prop has never done
|
|
21
|
+
* anything. Preserved rather than quietly fixed, because this split is
|
|
22
|
+
* behaviour-identical; making the prop work is a change a host can see.
|
|
23
|
+
*/
|
|
24
|
+
showBadge: boolean;
|
|
25
|
+
/** Badge-only mode with nothing in the cart renders nothing whatsoever. */
|
|
26
|
+
renderNothing: boolean;
|
|
27
|
+
ariaLabel: string;
|
|
28
|
+
sizeClass: string;
|
|
29
|
+
}
|
|
30
|
+
export type CartBadgeStatus = 'ready';
|
|
31
|
+
declare const STRINGS: {
|
|
32
|
+
/** Read as "Cart with 2 items". The count is appended. */
|
|
33
|
+
ariaPrefix: string;
|
|
34
|
+
ariaSuffix: string;
|
|
35
|
+
overflow: string;
|
|
36
|
+
};
|
|
37
|
+
export type CartBadgeStrings = typeof STRINGS;
|
|
38
|
+
export declare const cartBadgeCore: import("../define.js").ElementCore<CartBadgeProps, CartBadgeState, CartBadgeDerived, {
|
|
39
|
+
countChanged(count: number): void;
|
|
40
|
+
hydrate(): void;
|
|
41
|
+
click(): void;
|
|
42
|
+
}, "ready", {
|
|
43
|
+
/** Read as "Cart with 2 items". The count is appended. */
|
|
44
|
+
ariaPrefix: string;
|
|
45
|
+
ariaSuffix: string;
|
|
46
|
+
overflow: string;
|
|
47
|
+
}>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tl-cart-badge`: the cart icon with the item count on it.
|
|
3
|
+
*
|
|
4
|
+
* Small, and the second element that lives on a subscription: it binds
|
|
5
|
+
* `cart:updated` inside its own `mount` and hands the unsubscribe to
|
|
6
|
+
* `ctx.onTeardown` (TKT-197), so a badge that is moved in the DOM, or rendered
|
|
7
|
+
* twice by a host that mounts and unmounts it, never ends up counting twice.
|
|
8
|
+
*
|
|
9
|
+
* The one thing it remembers is the last count, so the badge does not flash
|
|
10
|
+
* empty on the next page before the cart has been read. That is behaviour, so
|
|
11
|
+
* it is here; where the count is written down is per runtime, so that is
|
|
12
|
+
* `ctx.storage`, which the harness supplies.
|
|
13
|
+
*/
|
|
14
|
+
import { defineElement } from '../define.js';
|
|
15
|
+
/** Where the last known count is kept. The key the component used. */
|
|
16
|
+
export const CART_COUNT_KEY = 'tl_cart_count';
|
|
17
|
+
const STRINGS = {
|
|
18
|
+
/** Read as "Cart with 2 items". The count is appended. */
|
|
19
|
+
ariaPrefix: 'Cart with ',
|
|
20
|
+
ariaSuffix: ' items',
|
|
21
|
+
overflow: '99+',
|
|
22
|
+
};
|
|
23
|
+
/** Above this the bubble reads "99+" rather than a number nothing can fit. */
|
|
24
|
+
const OVERFLOW_ABOVE = 99;
|
|
25
|
+
const countOf = (cart) => {
|
|
26
|
+
const c = cart;
|
|
27
|
+
return typeof c?.itemCount === 'number' ? c.itemCount : 0;
|
|
28
|
+
};
|
|
29
|
+
export const cartBadgeCore = defineElement({
|
|
30
|
+
tag: 'tl-cart-badge',
|
|
31
|
+
docs: 'A cart icon carrying the number of items in the cart, kept current from the cart bus.',
|
|
32
|
+
category: 'cart',
|
|
33
|
+
runtimes: ['web'],
|
|
34
|
+
tokens: [
|
|
35
|
+
'font-family',
|
|
36
|
+
'foreground',
|
|
37
|
+
'muted',
|
|
38
|
+
'primary',
|
|
39
|
+
'primary-foreground',
|
|
40
|
+
'radius-md',
|
|
41
|
+
'spacing-1',
|
|
42
|
+
'spacing-2',
|
|
43
|
+
'spacing-3',
|
|
44
|
+
],
|
|
45
|
+
sdkMethods: ['cart.get', 'cart.on'],
|
|
46
|
+
props: {
|
|
47
|
+
showEmpty: {
|
|
48
|
+
type: 'boolean',
|
|
49
|
+
attr: 'show-empty',
|
|
50
|
+
default: false,
|
|
51
|
+
docs: 'Show the badge even when count is 0',
|
|
52
|
+
},
|
|
53
|
+
icon: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
tsType: 'string | undefined',
|
|
56
|
+
attr: 'icon',
|
|
57
|
+
docs: 'Custom icon (emoji or text). If not provided, uses cart SVG',
|
|
58
|
+
},
|
|
59
|
+
size: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
tsType: '"lg" | "md" | "sm"',
|
|
62
|
+
attr: 'size',
|
|
63
|
+
default: 'md',
|
|
64
|
+
docs: 'Size variant',
|
|
65
|
+
},
|
|
66
|
+
badgeOnly: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
attr: 'badge-only',
|
|
69
|
+
default: false,
|
|
70
|
+
docs: 'Badge only mode - only shows the count bubble, no icon or button',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
events: {
|
|
74
|
+
tlClick: { detail: '{ itemCount: number; }', docs: 'Emitted when the badge is clicked' },
|
|
75
|
+
},
|
|
76
|
+
strings: STRINGS,
|
|
77
|
+
statuses: ['ready'],
|
|
78
|
+
state: () => ({ itemCount: 0 }),
|
|
79
|
+
/**
|
|
80
|
+
* On screen: show the remembered count straight away, then wait for the
|
|
81
|
+
* client, subscribe, and read the cart.
|
|
82
|
+
*
|
|
83
|
+
* Hydrating first is what keeps the badge from flashing empty; the component
|
|
84
|
+
* did it in `componentWillLoad` and this runs earlier still, in the harness's
|
|
85
|
+
* `connectedCallback`. A host with no store simply starts at zero.
|
|
86
|
+
*/
|
|
87
|
+
mount: (ctx) => {
|
|
88
|
+
ctx.actions.hydrate();
|
|
89
|
+
void (async () => {
|
|
90
|
+
let client;
|
|
91
|
+
try {
|
|
92
|
+
client = await ctx.whenClient();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// A page that never started Live keeps the remembered count and says
|
|
96
|
+
// nothing, which is what the component did.
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!ctx.active())
|
|
100
|
+
return;
|
|
101
|
+
const manager = client.getCartManager?.();
|
|
102
|
+
if (!manager)
|
|
103
|
+
return;
|
|
104
|
+
const off = manager.on?.('cart:updated', (cart) => {
|
|
105
|
+
ctx.actions.countChanged(countOf(cart));
|
|
106
|
+
});
|
|
107
|
+
if (typeof off === 'function')
|
|
108
|
+
ctx.onTeardown(off);
|
|
109
|
+
try {
|
|
110
|
+
const read = manager.get?.bind(manager);
|
|
111
|
+
if (!read)
|
|
112
|
+
return;
|
|
113
|
+
const cart = await read();
|
|
114
|
+
if (!ctx.active())
|
|
115
|
+
return;
|
|
116
|
+
ctx.actions.countChanged(countOf(cart));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// The cart may not exist yet. Keep the remembered value.
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
},
|
|
123
|
+
status: () => 'ready',
|
|
124
|
+
derive: ({ state, props, strings }) => ({
|
|
125
|
+
displayCount: state.itemCount > OVERFLOW_ABOVE ? strings.overflow : state.itemCount.toString(),
|
|
126
|
+
showBadge: state.itemCount > 0,
|
|
127
|
+
renderNothing: props.badgeOnly && state.itemCount === 0,
|
|
128
|
+
ariaLabel: `${strings.ariaPrefix}${state.itemCount}${strings.ariaSuffix}`,
|
|
129
|
+
sizeClass: `size-${props.size}`,
|
|
130
|
+
}),
|
|
131
|
+
actions: (ctx) => ({
|
|
132
|
+
/** A new count from the cart: show it, and remember it for next time. */
|
|
133
|
+
countChanged(count) {
|
|
134
|
+
ctx.setState({ itemCount: count });
|
|
135
|
+
ctx.storage.set(CART_COUNT_KEY, String(count));
|
|
136
|
+
},
|
|
137
|
+
/** Start from whatever the last visit left behind, or zero. */
|
|
138
|
+
hydrate() {
|
|
139
|
+
const stored = ctx.storage.get(CART_COUNT_KEY);
|
|
140
|
+
ctx.setState({ itemCount: (stored ? parseInt(stored, 10) : 0) || 0 });
|
|
141
|
+
},
|
|
142
|
+
/** The buyer asked for the cart. What that opens is the host's business. */
|
|
143
|
+
click() {
|
|
144
|
+
ctx.emit('tlClick', { itemCount: ctx.state.itemCount });
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** What opens the drawer, and what closes it. Relayed on the client's bus. */
|
|
2
|
+
export declare const OPEN_INTENT = "ui:cart-requested";
|
|
3
|
+
export declare const CLOSE_INTENT = "ui:modal-close-requested";
|
|
4
|
+
export interface CartDrawerProps {
|
|
5
|
+
side: 'right' | 'left';
|
|
6
|
+
heading: string;
|
|
7
|
+
open: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface CartDrawerState {
|
|
10
|
+
visible: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface CartDrawerDerived {
|
|
13
|
+
visible: boolean;
|
|
14
|
+
/** The modifier the panel carries for the edge it slides from. */
|
|
15
|
+
sideClass: string;
|
|
16
|
+
/** `aria-hidden`, as the attribute's string. */
|
|
17
|
+
ariaHidden: 'true' | 'false';
|
|
18
|
+
}
|
|
19
|
+
export type CartDrawerStatus = 'open' | 'closed';
|
|
20
|
+
declare const STRINGS: {
|
|
21
|
+
close: string;
|
|
22
|
+
privacy: string;
|
|
23
|
+
privacyHref: string;
|
|
24
|
+
reportAbuse: string;
|
|
25
|
+
reportAbuseHref: string;
|
|
26
|
+
poweredBy: string;
|
|
27
|
+
brand: string;
|
|
28
|
+
brandHref: string;
|
|
29
|
+
};
|
|
30
|
+
export type CartDrawerStrings = typeof STRINGS;
|
|
31
|
+
export declare const cartDrawerCore: import("../define.js").ElementCore<CartDrawerProps, CartDrawerState, CartDrawerDerived, {
|
|
32
|
+
setOpen(open: boolean): void;
|
|
33
|
+
openChanged(open: boolean): void;
|
|
34
|
+
}, CartDrawerStatus, {
|
|
35
|
+
close: string;
|
|
36
|
+
privacy: string;
|
|
37
|
+
privacyHref: string;
|
|
38
|
+
reportAbuse: string;
|
|
39
|
+
reportAbuseHref: string;
|
|
40
|
+
poweredBy: string;
|
|
41
|
+
brand: string;
|
|
42
|
+
brandHref: string;
|
|
43
|
+
}>;
|
|
44
|
+
export {};
|