kempo-commerce 0.1.0 → 0.1.2
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/admin/components/Settings.js +6 -1
- package/package.json +4 -4
- package/public/api/my/orders/[id]/GET.js +16 -16
- package/public/api/my/orders/[id]/cancel/POST.js +22 -22
- package/public/api/orders/[id]/guest/GET.js +13 -13
- package/public/api/orders/[id]/guest/cancel/POST.js +16 -16
- package/public/components/Checkout.js +669 -669
- package/public/components/checkoutSections.js +76 -76
- package/public/sdk.js +130 -130
- package/sdk.js +66 -66
- package/server/utils/checkout/tax.js +115 -115
|
@@ -1,669 +1,669 @@
|
|
|
1
|
-
import LightComponent from '/kempo-ui/components/LightComponent.js';
|
|
2
|
-
import { html, staticHtml, unsafeStatic, keyed, nothing, live } from '/kempo-ui/lit-all.min.js';
|
|
3
|
-
import '/kempo-ui/components/Icon.js';
|
|
4
|
-
import '/kempo-ui/components/Spinner.js';
|
|
5
|
-
import '/kempo-ui/components/Accordion.js';
|
|
6
|
-
import Toast from '/kempo-ui/components/Toast.js';
|
|
7
|
-
import '/payments/components/PaymentForm.js';
|
|
8
|
-
import { startCheckout, validateCheckout, placeOrder, confirmPayment, getRegions, onCartChanged, newKey } from '/commerce/sdk.js';
|
|
9
|
-
import { loadFormat, optionsText, itemNames } from '/commerce/utils/format.js';
|
|
10
|
-
import { toMinor, fromMinor, toCount, clamp } from '/commerce/utils/amount.js';
|
|
11
|
-
import { countryOptions, subdivisionOptions } from '/commerce/utils/regions.js';
|
|
12
|
-
import { checkoutSections } from '/commerce/components/checkoutSections.js';
|
|
13
|
-
|
|
14
|
-
/* A table with no header row has no top border or rounded top corners of its own, so its first row draws them. */
|
|
15
|
-
const TOP_LEFT = 'border-top: 1px solid var(--c_border); border-top-left-radius: var(--radius);';
|
|
16
|
-
const TOP_RIGHT = 'border-top: 1px solid var(--c_border); border-top-right-radius: var(--radius);';
|
|
17
|
-
|
|
18
|
-
/*
|
|
19
|
-
The checkout page's body, served at /commerce/components/Checkout.js.
|
|
20
|
-
|
|
21
|
-
```html
|
|
22
|
-
<script type="module" src="/commerce/components/Checkout.js"></script>
|
|
23
|
-
<k-commerce-checkout></k-commerce-checkout>
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
One page, one button. Down the left, an accordion of panels, one open at a time: **Contact** (who you are,
|
|
27
|
-
an account if one is wanted, and the fields extensions asked for), **Billing address** (where, which sets
|
|
28
|
-
the tax), **Store credit and credits** (when the customer has either), and **Payment** (the card for
|
|
29
|
-
whatever is left). Extensions add panels of their own, a shipping address for one, by priority. On the
|
|
30
|
-
right: the order, the refund policy, anything an extension puts under the summary, and the button.
|
|
31
|
-
|
|
32
|
-
Placing the order makes it, makes the card payment, and charges the card in the same press. A card
|
|
33
|
-
that is declined leaves the order saved with the form still in front of you, to try again.
|
|
34
|
-
|
|
35
|
-
**Other extensions** add to the page with `registerCheckoutSection` (checkoutSections.js). See that file.
|
|
36
|
-
|
|
37
|
-
**Light DOM, on purpose.** Stripe's card fields do not mount inside a shadow root (they reserve the
|
|
38
|
-
space and draw nothing), so the payment form, and everything around it, is light DOM.
|
|
39
|
-
|
|
40
|
-
The totals are the server's. Changing the address (tax) or the credit used asks the server again,
|
|
41
|
-
and placing the order sends the total that was shown, so a total that moved is refused and shown
|
|
42
|
-
instead of being charged. The checkout key is kept for as long as the cart's contents are the same,
|
|
43
|
-
so a reload, a double click or a second tab returns the order that was already made and never a
|
|
44
|
-
second one. The card is only ever believed through the server asking the processor.
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
const SIGNATURE = cart => cart.lines.map(line => `${line.productId}:${line.quantity}:${JSON.stringify(line.selections)}`).join('|');
|
|
48
|
-
|
|
49
|
-
const keyFor = cart => {
|
|
50
|
-
try {
|
|
51
|
-
const saved = JSON.parse(sessionStorage.getItem('kempo-commerce-checkout') || 'null');
|
|
52
|
-
if(saved?.signature === SIGNATURE(cart) && saved.cartId === cart.id) return saved.key;
|
|
53
|
-
const created = { signature: SIGNATURE(cart), cartId: cart.id, key: newKey() };
|
|
54
|
-
sessionStorage.setItem('kempo-commerce-checkout', JSON.stringify(created));
|
|
55
|
-
return created.key;
|
|
56
|
-
} catch {
|
|
57
|
-
return newKey();
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export default class Checkout extends LightComponent {
|
|
62
|
-
static properties = {
|
|
63
|
-
quote: { state: true },
|
|
64
|
-
form: { state: true },
|
|
65
|
-
storeCredit: { state: true },
|
|
66
|
-
creditsUsed: { state: true },
|
|
67
|
-
createAccount: { state: true },
|
|
68
|
-
extra: { state: true },
|
|
69
|
-
errors: { state: true },
|
|
70
|
-
error: { state: true },
|
|
71
|
-
busy: { state: true },
|
|
72
|
-
finished: { state: true },
|
|
73
|
-
pricing: { state: true },
|
|
74
|
-
placed: { state: true },
|
|
75
|
-
regions: { state: true },
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
constructor(){
|
|
79
|
-
super();
|
|
80
|
-
this.quote = null;
|
|
81
|
-
this.form = { name: '', email: '', phone: '', line1: '', line2: '', city: '', state: '', postalCode: '', country: '' };
|
|
82
|
-
this.storeCredit = 0;
|
|
83
|
-
this.creditsUsed = 0;
|
|
84
|
-
this.createAccount = false;
|
|
85
|
-
this.extra = {};
|
|
86
|
-
this.errors = {};
|
|
87
|
-
this.error = '';
|
|
88
|
-
this.busy = false;
|
|
89
|
-
this.finished = false;
|
|
90
|
-
this.pricing = false;
|
|
91
|
-
this.placed = null;
|
|
92
|
-
this.regions = null;
|
|
93
|
-
this.decimals = 2;
|
|
94
|
-
this.money = minor => String(minor);
|
|
95
|
-
this.priceRequest = 0;
|
|
96
|
-
this.timer = null;
|
|
97
|
-
this.completed = new Set();
|
|
98
|
-
this.visited = new Set();
|
|
99
|
-
this.userOpened = new Set();
|
|
100
|
-
this.automating = false;
|
|
101
|
-
this.initialized = false;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/*
|
|
105
|
-
Lifecycle
|
|
106
|
-
*/
|
|
107
|
-
|
|
108
|
-
connectedCallback(){
|
|
109
|
-
super.connectedCallback();
|
|
110
|
-
window.addEventListener('checkout-sections-changed', this.refreshSections);
|
|
111
|
-
this.stopListening = onCartChanged(() => this.refresh());
|
|
112
|
-
this.start();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
disconnectedCallback(){
|
|
116
|
-
super.disconnectedCallback();
|
|
117
|
-
window.removeEventListener('checkout-sections-changed', this.refreshSections);
|
|
118
|
-
this.stopListening?.();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
updated(){
|
|
122
|
-
super.updated();
|
|
123
|
-
this.settle();
|
|
124
|
-
/* The sections read `checkout.quote` (the shipping choices), so they draw again whenever the page does. */
|
|
125
|
-
for(const { tag } of [...checkoutSections('panel'), ...checkoutSections('summary')]) this.querySelectorAll(tag).forEach($section => $section.requestUpdate?.());
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
refreshSections = () => this.requestUpdate();
|
|
129
|
-
|
|
130
|
-
start = async () => {
|
|
131
|
-
const format = await loadFormat();
|
|
132
|
-
this.money = format.money;
|
|
133
|
-
this.decimals = format.config.decimals ?? 2;
|
|
134
|
-
/* The pickers fill in when the lists arrive; until then, or if they never do, the fields are plain text. */
|
|
135
|
-
getRegions().then(([, regions]) => { this.regions = regions; });
|
|
136
|
-
const [error, data] = await startCheckout();
|
|
137
|
-
if(error){
|
|
138
|
-
this.error = error.msg;
|
|
139
|
-
this.quote = { empty: true, notices: error.notices ?? [] };
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
this.quote = data.quote;
|
|
143
|
-
/* The form is still empty here, so what the shop already knows only fills in blanks. */
|
|
144
|
-
this.form = { ...this.form, ...Object.fromEntries(Object.entries(data.prefill ?? {}).filter(([name]) => !this.form[name])) };
|
|
145
|
-
this.storeCredit = this.offer('store-credit')?.max ?? 0;
|
|
146
|
-
if(data.quote.account.required) this.createAccount = true;
|
|
147
|
-
this.reprice();
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
/*
|
|
151
|
-
The cart changed somewhere else on the page (the side cart): take its lines again and price them, keeping
|
|
152
|
-
what has been typed here. A cart that is now empty shows the empty page; one that was empty starts over.
|
|
153
|
-
*/
|
|
154
|
-
refresh = async () => {
|
|
155
|
-
if(!this.quote || this.placed) return;
|
|
156
|
-
if(this.quote.empty) return this.start();
|
|
157
|
-
const [error, data] = await startCheckout();
|
|
158
|
-
if(error){
|
|
159
|
-
this.error = error.msg;
|
|
160
|
-
this.quote = { empty: true, notices: error.notices ?? [] };
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
this.quote = data.quote;
|
|
164
|
-
this.creditsUsed = clamp(this.creditsUsed, this.creditsCap);
|
|
165
|
-
this.storeCredit = clamp(this.storeCredit, this.storeCap);
|
|
166
|
-
this.reprice();
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
/*
|
|
170
|
-
Public methods, for the sections other extensions add
|
|
171
|
-
*/
|
|
172
|
-
|
|
173
|
-
/* Asks the server for the totals with this address and these tenders. */
|
|
174
|
-
reprice = async () => {
|
|
175
|
-
if(!this.quote || this.quote.empty) return;
|
|
176
|
-
const request = ++this.priceRequest;
|
|
177
|
-
this.pricing = true;
|
|
178
|
-
const [error, data] = await validateCheckout({ billing: this.form, data: this.extra, tenders: this.tenders });
|
|
179
|
-
if(request !== this.priceRequest) return;
|
|
180
|
-
this.pricing = false;
|
|
181
|
-
if(error){
|
|
182
|
-
this.error = error.msg;
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
this.error = '';
|
|
186
|
-
this.quote = data.quote;
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
/* A value sent with the order as `data`, for the `checkout:validate` guard on the server. */
|
|
190
|
-
setData = (key, value) => {
|
|
191
|
-
this.extra = { ...this.extra, [key]: value };
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
/*
|
|
195
|
-
What can be used
|
|
196
|
-
*/
|
|
197
|
-
|
|
198
|
-
offer(kind){
|
|
199
|
-
return this.quote?.tenders?.find(offer => offer.kind === kind) ?? null;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/* The money credits cover, as the server works it out. */
|
|
203
|
-
creditsMinor(count){
|
|
204
|
-
const offer = this.offer('credits');
|
|
205
|
-
return offer ? Math.min(Math.floor(count * 10 ** this.decimals / offer.detail.perUnit), offer.max) : 0;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/* The most credits that can be asked for: what is held, what the cart lets them pay, what is due. */
|
|
209
|
-
get creditsCap(){
|
|
210
|
-
const offer = this.offer('credits');
|
|
211
|
-
return offer ? Math.min(offer.detail.maxCredits, Math.floor(offer.max * offer.detail.perUnit / 10 ** this.decimals)) : 0;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/* The most store credit that can be used: what is held and what is still due after credits. */
|
|
215
|
-
get storeCap(){
|
|
216
|
-
const offer = this.offer('store-credit');
|
|
217
|
-
if(!offer) return 0;
|
|
218
|
-
return Math.max(0, Math.min(offer.max, this.quote.totals.total - this.creditsMinor(Math.min(this.creditsUsed, this.creditsCap))));
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
get tenders(){
|
|
222
|
-
const tenders = {};
|
|
223
|
-
const credits = Math.min(this.creditsUsed, this.creditsCap);
|
|
224
|
-
if(credits > 0) tenders.credits = credits;
|
|
225
|
-
const store = Math.min(this.storeCredit, this.storeCap);
|
|
226
|
-
if(store > 0) tenders['store-credit'] = store;
|
|
227
|
-
return tenders;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
get language(){
|
|
231
|
-
return navigator.language || 'en';
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
get stateOptions(){
|
|
235
|
-
return subdivisionOptions(this.regions, this.form.country, this.language);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/*
|
|
239
|
-
What the customer still has to give before the order can be placed, as words for the hint under the
|
|
240
|
-
button. The server decides what tax needs (`quote.required`); the rest is who they are, what extensions
|
|
241
|
-
asked for, and a card when one is needed.
|
|
242
|
-
*/
|
|
243
|
-
get missing(){
|
|
244
|
-
const { quote, form } = this;
|
|
245
|
-
if(!quote || quote.empty) return [];
|
|
246
|
-
const need = quote.required ?? {};
|
|
247
|
-
const missing = [];
|
|
248
|
-
if(!form.name?.trim()) missing.push('your name');
|
|
249
|
-
if(!quote.account.signedIn && !form.email?.trim()) missing.push('your email');
|
|
250
|
-
if(need.country && !form.country) missing.push('your country');
|
|
251
|
-
if(need.state && this.stateOptions.length && !form.state) missing.push('your state or region');
|
|
252
|
-
if(need.postalCode && !form.postalCode?.trim()) missing.push('your postal code');
|
|
253
|
-
for(const field of quote.fields) if(field.required && !this.extra[field.key]) missing.push(field.label.toLowerCase());
|
|
254
|
-
if(this.needsCard && !this.payForm?.complete) missing.push('your card details');
|
|
255
|
-
return missing;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/* What a section says keeps the order from being placed at all (shipping that cannot be priced), or '' when nothing does. */
|
|
259
|
-
get blocker(){
|
|
260
|
-
for(const { tag } of [...checkoutSections('panel'), ...checkoutSections('summary')]){
|
|
261
|
-
const reason = this.querySelector(tag)?.blocker;
|
|
262
|
-
if(reason) return reason;
|
|
263
|
-
}
|
|
264
|
-
return '';
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
get needsCard(){
|
|
268
|
-
return Boolean(this.quote?.plan?.some(entry => entry.kind === 'card'));
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
get payForm(){
|
|
272
|
-
return this.querySelector('k-pay-form');
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/*
|
|
276
|
-
Event handlers
|
|
277
|
-
*/
|
|
278
|
-
|
|
279
|
-
type = (name, event) => {
|
|
280
|
-
this.form = { ...this.form, [name]: event.target.value };
|
|
281
|
-
if(['country', 'state', 'postalCode', 'city'].includes(name)) this.schedulePricing();
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
/* A picker: choosing a country clears a state that is not one of its states, and asks for the tax again. */
|
|
285
|
-
pick = (name, event) => {
|
|
286
|
-
const value = event.target.value;
|
|
287
|
-
const next = { ...this.form, [name]: value };
|
|
288
|
-
if(name === 'country' && !subdivisionOptions(this.regions, value).some(state => state.code === next.state)) next.state = '';
|
|
289
|
-
this.form = next;
|
|
290
|
-
this.reprice();
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
typeExtra = (name, event) => {
|
|
294
|
-
this.setData(name, event.target.type === 'checkbox' ? event.target.checked : event.target.value);
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
setStoreCredit = event => {
|
|
298
|
-
const entered = toMinor(event.target.value, this.decimals);
|
|
299
|
-
this.storeCredit = clamp(entered ?? 0, this.storeCap);
|
|
300
|
-
event.target.value = fromMinor(this.storeCredit, this.decimals);
|
|
301
|
-
this.reprice();
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
setCredits = event => {
|
|
305
|
-
const entered = toCount(event.target.value);
|
|
306
|
-
this.creditsUsed = clamp(entered ?? 0, this.creditsCap);
|
|
307
|
-
event.target.value = String(this.creditsUsed);
|
|
308
|
-
this.storeCredit = clamp(this.storeCredit, this.storeCap);
|
|
309
|
-
this.reprice();
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
schedulePricing = () => {
|
|
313
|
-
clearTimeout(this.timer);
|
|
314
|
-
this.timer = setTimeout(this.reprice, 400);
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
place = async event => {
|
|
318
|
-
event.preventDefault();
|
|
319
|
-
const { quote } = this;
|
|
320
|
-
if(this.blocker){
|
|
321
|
-
this.error = this.blocker;
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if(this.needsCard && !this.payForm?.complete){
|
|
325
|
-
this.error = 'Enter your card details to place the order.';
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
this.busy = true;
|
|
330
|
-
this.error = '';
|
|
331
|
-
this.errors = {};
|
|
332
|
-
const [error, data] = await placeOrder({
|
|
333
|
-
checkoutKey: keyFor(quote.cart),
|
|
334
|
-
billing: this.form,
|
|
335
|
-
data: this.extra,
|
|
336
|
-
tenders: this.tenders,
|
|
337
|
-
account: this.createAccount && !quote.account.signedIn ? { create: true } : null,
|
|
338
|
-
expectedTotal: quote.totals.total,
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
if(error){
|
|
342
|
-
this.busy = false;
|
|
343
|
-
if(error.totals) this.quote = { ...quote, totals: error.totals };
|
|
344
|
-
this.error = error.code === 429 && error.retryAfter ? `${error.msg} (try again in ${Math.ceil(error.retryAfter / 60)} minutes)` : error.msg;
|
|
345
|
-
if(error.errors) this.errors = Object.fromEntries(error.errors.map(entry => [entry.field, entry.msg]));
|
|
346
|
-
if(error.existing) this.error = `${error.msg} `;
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
this.placed = data;
|
|
351
|
-
if(data.payment) await this.pay(data);
|
|
352
|
-
else this.finish(data.receivedUrl, false);
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
/*
|
|
356
|
-
The order and its payment exist; charge the card on this page. A decline leaves the order saved and
|
|
357
|
-
the form here, so the next press is another try at the same order. The browser saying it worked is
|
|
358
|
-
never believed on its own: the server asks the processor.
|
|
359
|
-
*/
|
|
360
|
-
pay = async data => {
|
|
361
|
-
const { error, paymentIntent } = await this.payForm.confirm({ clientSecret: data.payment.clientSecret });
|
|
362
|
-
if(error){
|
|
363
|
-
this.busy = false;
|
|
364
|
-
this.error = `${error.message || 'The payment could not be completed.'} Your order is saved. Check your card details and try again.`;
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
if(paymentIntent?.status !== 'processing'){
|
|
368
|
-
const key = new URL(data.receivedUrl, location.origin).searchParams.get('key');
|
|
369
|
-
await confirmPayment(data.order.id, key);
|
|
370
|
-
}
|
|
371
|
-
this.finish(data.receivedUrl, true);
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
/*
|
|
375
|
-
The order is placed (and paid, when a card was needed). The form is locked so nothing can be changed or sent twice,
|
|
376
|
-
and a toast says so with a way to the order. Closing the toast for any reason, its button or its ten seconds running
|
|
377
|
-
out, goes to the order, once.
|
|
378
|
-
*/
|
|
379
|
-
finish = (url, paid) => {
|
|
380
|
-
try {
|
|
381
|
-
sessionStorage.removeItem('kempo-commerce-checkout');
|
|
382
|
-
} catch { /* storage may be blocked; the next checkout simply makes a new key */ }
|
|
383
|
-
this.busy = true;
|
|
384
|
-
this.finished = true;
|
|
385
|
-
let left = false;
|
|
386
|
-
const go = () => {
|
|
387
|
-
if(left) return;
|
|
388
|
-
left = true;
|
|
389
|
-
location.href = url;
|
|
390
|
-
};
|
|
391
|
-
Toast.success(paid ? 'Payment complete. Your order is placed.' : 'Your order is placed.', { timeout: 10000, action: 'View order', actionCallback: go, closeCallback: go });
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
/*
|
|
395
|
-
Rendering
|
|
396
|
-
*/
|
|
397
|
-
|
|
398
|
-
field(name, label, { type = 'text', required = false, hint = '', autocomplete = '' } = {}){
|
|
399
|
-
return html`
|
|
400
|
-
<label class="mb">
|
|
401
|
-
${label}${required ? html` <span class="tc-danger" aria-hidden="true">*</span>` : nothing}
|
|
402
|
-
${hint ? html`<small class="tc-muted d-b">${hint}</small>` : nothing}
|
|
403
|
-
<input type=${type} .value=${this.form[name] ?? ''} ?required=${required} autocomplete=${autocomplete || 'off'} @input=${event => this.type(name, event)}>
|
|
404
|
-
${this.errors[name] ? html`<small class="tc-danger d-b">${this.errors[name]}</small>` : nothing}
|
|
405
|
-
</label>
|
|
406
|
-
`;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
select(name, label, options, { required = false, hint = '', autocomplete = '', placeholder = '' } = {}){
|
|
410
|
-
return html`
|
|
411
|
-
<label class="mb">
|
|
412
|
-
${label}${required ? html` <span class="tc-danger" aria-hidden="true">*</span>` : nothing}
|
|
413
|
-
${hint ? html`<small class="tc-muted d-b">${hint}</small>` : nothing}
|
|
414
|
-
<select ?required=${required} autocomplete=${autocomplete || 'off'} @change=${event => this.pick(name, event)}>
|
|
415
|
-
<option value="" ?selected=${!this.form[name]}>${placeholder}</option>
|
|
416
|
-
${options.map(option => html`<option value=${option.code} ?selected=${option.code === this.form[name]}>${option.name}</option>`)}
|
|
417
|
-
</select>
|
|
418
|
-
${this.errors[name] ? html`<small class="tc-danger d-b">${this.errors[name]}</small>` : nothing}
|
|
419
|
-
</label>
|
|
420
|
-
`;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
renderExtraField(field){
|
|
424
|
-
if(field.type === 'checkbox') return html`<label class="checkbox mb"><input type="checkbox" .checked=${Boolean(this.extra[field.key])} @change=${event => this.typeExtra(field.key, event)}> ${field.label}</label>`;
|
|
425
|
-
if(field.type === 'select') return html`<label class="mb">${field.label}<select @change=${event => this.typeExtra(field.key, event)}>${(field.options ?? []).map(option => html`<option value=${option.value ?? option}>${option.label ?? option}</option>`)}</select></label>`;
|
|
426
|
-
return html`<label class="mb">${field.label}${field.hint ? html`<small class="tc-muted d-b">${field.hint}</small>` : nothing}<input type=${field.type || 'text'} ?required=${field.required} @input=${event => this.typeExtra(field.key, event)}></label>`;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
renderSummary(){
|
|
430
|
-
const { quote } = this;
|
|
431
|
-
const { totals } = quote;
|
|
432
|
-
return html`
|
|
433
|
-
<h2 class="h4">Cart totals</h2>
|
|
434
|
-
<table class="mb">
|
|
435
|
-
<tbody>
|
|
436
|
-
${quote.cart.lines.map((line, index) => html`<tr><td style=${index === 0 ? TOP_LEFT : nothing}>${line.name} <span class="tc-muted">× ${line.quantity}</span>${optionsText(line) ? html`<div class="tc-muted small">${optionsText(line)}</div>` : nothing}</td><td class="ta-right" style=${index === 0 ? TOP_RIGHT : nothing}>${this.money(line.lineTotal)}</td></tr>`)}
|
|
437
|
-
<tr><td style="background-color: var(--c_bg__alt);"><strong>Subtotal</strong></td><td class="ta-right">${this.money(totals.subtotal)}</td></tr>
|
|
438
|
-
${quote.cart.adjustments.map(adjustment => html`<tr><td style="background-color: var(--c_bg__alt);"><strong>${adjustment.label}</strong>${itemNames(adjustment.lineId, quote.cart.lines) ? html`<small class="d-b tc-muted">${itemNames(adjustment.lineId, quote.cart.lines)}</small>` : nothing}</td><td class="ta-right">${this.money(adjustment.amount)}</td></tr>`)}
|
|
439
|
-
${totals.taxComputed ? html`<tr><td style="background-color: var(--c_bg__alt);"><strong>Tax${totals.pricesIncludeTax ? ' (included)' : ''}</strong></td><td class="ta-right">${this.money(totals.taxTotal)}</td></tr>`
|
|
440
|
-
: quote.taxEnabled ? html`<tr><td style="background-color: var(--c_bg__alt);"><strong>Tax</strong></td><td class="ta-right tc-muted">Enter your address to calculate</td></tr>` : nothing}
|
|
441
|
-
${quote.plan.filter(entry => entry.kind !== 'card').map(entry => html`<tr><td style="background-color: var(--c_bg__alt);"><strong>${quote.tenders.find(offer => offer.kind === entry.kind)?.label ?? entry.kind}</strong></td><td class="ta-right">−${this.money(entry.amount)}</td></tr>`)}
|
|
442
|
-
<tr><td style="background-color: var(--c_bg__alt);"><strong>Total${quote.plan.some(entry => entry.kind !== 'card') ? ' to pay' : ''}</strong></td><td class="ta-right"><strong aria-live="polite">${this.pricing ? html`<k-spinner size="xs"></k-spinner>` : this.money(quote.plan.find(entry => entry.kind === 'card')?.amount ?? 0)}</strong></td></tr>
|
|
443
|
-
</tbody>
|
|
444
|
-
</table>
|
|
445
|
-
${quote.refundPolicy ? html`<details class="mb"><summary>Refund policy</summary><p class="small" style="white-space: pre-line;">${quote.refundPolicy}</p></details>` : nothing}
|
|
446
|
-
`;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/*
|
|
450
|
-
The accordion. Each panel is `{ id, title, priority, body }` for the checkout's own, or `{ id, title, priority, tag }`
|
|
451
|
-
for one an extension registered, which is created here and handed the page.
|
|
452
|
-
*/
|
|
453
|
-
|
|
454
|
-
get panels(){
|
|
455
|
-
const { quote } = this;
|
|
456
|
-
const panels = [
|
|
457
|
-
{ id: 'contact', title: 'Contact', priority: 500, body: this.renderContact() },
|
|
458
|
-
{ id: 'billing', title: 'Billing address', priority: 400, body: this.renderBilling() },
|
|
459
|
-
...(this.offer('store-credit') || this.offer('credits') ? [{ id: 'credits', title: 'Store credit and credits', priority: 200, body: this.renderCredits() }] : []),
|
|
460
|
-
...(this.needsCard || quote.plan.some(entry => entry.kind !== 'card') ? [{ id: 'payment', title: 'Payment', priority: 100, body: this.renderPayment() }] : []),
|
|
461
|
-
...checkoutSections('panel').map(({ id, title, priority, tag }) => ({ id, title, priority, tag })),
|
|
462
|
-
];
|
|
463
|
-
return panels.sort((a, b) => b.priority - a.priority);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/*
|
|
467
|
-
Several panels can be open. settle() opens and closes them by what is filled in; a panel the customer
|
|
468
|
-
opened themselves stays open until they close it. Keyed, so a panel that comes and goes keeps the rest's state.
|
|
469
|
-
*/
|
|
470
|
-
renderPanels(){
|
|
471
|
-
return html`
|
|
472
|
-
<k-accordion multiple @openpanel=${event => this.panelToggled(event, true)} @closepanel=${event => this.panelToggled(event, false)} @focusout=${this.panelBlurred}>
|
|
473
|
-
${this.panels.map(panel => keyed(panel.id, html`
|
|
474
|
-
<k-accordion-header for-panel=${panel.id}>${panel.title}</k-accordion-header>
|
|
475
|
-
<k-accordion-panel name=${panel.id}>
|
|
476
|
-
<div class="p">
|
|
477
|
-
${panel.tag ? staticHtml`<${unsafeStatic(panel.tag)} .checkout=${this}></${unsafeStatic(panel.tag)}>` : panel.body}
|
|
478
|
-
</div>
|
|
479
|
-
</k-accordion-panel>
|
|
480
|
-
`))}
|
|
481
|
-
</k-accordion>
|
|
482
|
-
`;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
get accordion(){
|
|
486
|
-
return this.querySelector('k-accordion');
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/*
|
|
490
|
-
Whether a panel's fields are all filled in. The checkout's own: contact is a name, an email (unless signed in)
|
|
491
|
-
and any extra field an extension requires; billing is the country, address, city and postal code, and the
|
|
492
|
-
state when there is a list of them (the apartment line is optional). Credits are optional, so they are done
|
|
493
|
-
once the customer has been in them and left. Payment is done when no card is needed or the card is complete.
|
|
494
|
-
An extension's panel is done when its element says so with `complete = true`.
|
|
495
|
-
*/
|
|
496
|
-
isComplete({ id, tag }){
|
|
497
|
-
const { form, quote, extra } = this;
|
|
498
|
-
if(tag) return this.querySelector(tag)?.complete === true;
|
|
499
|
-
if(id === 'contact') return Boolean(form.name?.trim()) && (quote.account.signedIn || Boolean(form.email?.trim())) && quote.fields.every(field => !field.required || Boolean(extra[field.key]));
|
|
500
|
-
if(id === 'billing') return Boolean(form.country && form.line1?.trim() && form.city?.trim() && form.postalCode?.trim()) && (this.stateOptions.length === 0 || Boolean(form.state));
|
|
501
|
-
if(id === 'credits') return this.visited.has('credits');
|
|
502
|
-
return !this.needsCard || Boolean(this.payForm?.complete);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/*
|
|
506
|
-
Opens and closes panels to match what is filled in. When a panel has just become done, the next one that is
|
|
507
|
-
not done opens. A done panel closes once focus is out of it, unless the customer opened it themselves.
|
|
508
|
-
*/
|
|
509
|
-
settle = () => {
|
|
510
|
-
const { accordion } = this;
|
|
511
|
-
if(!accordion || !this.quote || this.quote.empty) return;
|
|
512
|
-
const panels = this.panels;
|
|
513
|
-
const focused = document.activeElement?.closest?.('k-accordion-panel')?.getAttribute('name');
|
|
514
|
-
const done = new Set(panels.filter(panel => this.isComplete(panel)).map(panel => panel.id));
|
|
515
|
-
const isOpen = id => accordion.getPanel(id)?.active === true;
|
|
516
|
-
const open = id => {
|
|
517
|
-
if(!isOpen(id)) accordion.openPanel(id);
|
|
518
|
-
};
|
|
519
|
-
this.automating = true;
|
|
520
|
-
try {
|
|
521
|
-
if(!this.initialized){
|
|
522
|
-
const first = panels.find(panel => !done.has(panel.id));
|
|
523
|
-
if(first) open(first.id);
|
|
524
|
-
}
|
|
525
|
-
panels.forEach((panel, index) => {
|
|
526
|
-
if(!done.has(panel.id) || this.completed.has(panel.id)) return;
|
|
527
|
-
const next = panels.slice(index + 1).find(later => !done.has(later.id));
|
|
528
|
-
if(next) open(next.id);
|
|
529
|
-
});
|
|
530
|
-
for(const panel of panels){
|
|
531
|
-
if(done.has(panel.id) && panel.id !== focused && !this.userOpened.has(panel.id) && isOpen(panel.id)) accordion.closePanel(panel.id);
|
|
532
|
-
}
|
|
533
|
-
} finally {
|
|
534
|
-
this.automating = false;
|
|
535
|
-
}
|
|
536
|
-
this.completed = done;
|
|
537
|
-
this.initialized = true;
|
|
538
|
-
};
|
|
539
|
-
|
|
540
|
-
/* The customer opened or closed a panel. Closing one counts as having been in it. */
|
|
541
|
-
panelToggled = (event, opened) => {
|
|
542
|
-
if(this.automating) return;
|
|
543
|
-
const name = event.detail.panelName;
|
|
544
|
-
if(opened) this.userOpened.add(name);
|
|
545
|
-
else {
|
|
546
|
-
this.userOpened.delete(name);
|
|
547
|
-
this.visited.add(name);
|
|
548
|
-
}
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
/* Focus left a field: note the panel was used, then settle once focus has actually moved. */
|
|
552
|
-
panelBlurred = event => {
|
|
553
|
-
const name = event.target.closest?.('k-accordion-panel')?.getAttribute('name');
|
|
554
|
-
if(name) this.visited.add(name);
|
|
555
|
-
setTimeout(this.settle);
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
renderContact(){
|
|
559
|
-
const { quote } = this;
|
|
560
|
-
return html`
|
|
561
|
-
${this.field('name', 'Full name', { required: true, autocomplete: 'name' })}
|
|
562
|
-
${quote.account.signedIn ? nothing : this.field('email', 'Email', { type: 'email', required: true, hint: 'Your receipt and the link to your order are sent here', autocomplete: 'email' })}
|
|
563
|
-
|
|
564
|
-
${quote.account.signedIn ? nothing : html`
|
|
565
|
-
<h3 class="h5">Account</h3>
|
|
566
|
-
${quote.account.required
|
|
567
|
-
? html`<p>${quote.account.reason === 'credits' ? 'Credits are added to an account, so one is needed to buy them.' : 'This store needs you to have an account to check out.'} We will make one with the email above and send you a link to set your password. <a href=${`/login?redirect=${encodeURIComponent('/checkout/')}`}>Already have one? Sign in</a>.</p>`
|
|
568
|
-
: quote.account.canCreate ? html`<label class="checkbox mb"><input type="checkbox" .checked=${this.createAccount} @change=${event => { this.createAccount = event.target.checked; }}> Create an account so I can follow my order and check out faster next time</label>` : nothing}
|
|
569
|
-
`}
|
|
570
|
-
|
|
571
|
-
${quote.fields.length ? html`<h3 class="h5">More details</h3>${quote.fields.map(field => this.renderExtraField(field))}` : nothing}
|
|
572
|
-
`;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
renderBilling(){
|
|
576
|
-
const { quote } = this;
|
|
577
|
-
return html`
|
|
578
|
-
${this.regions
|
|
579
|
-
? this.select('country', 'Country', countryOptions(this.regions, this.language), { required: quote.required?.country, hint: quote.required?.country ? 'Tax depends on it' : '', autocomplete: 'country', placeholder: 'Select a country' })
|
|
580
|
-
: this.field('country', 'Country', { required: quote.required?.country, hint: 'A two-letter code, such as US or GB', autocomplete: 'country' })}
|
|
581
|
-
${this.field('line1', 'Address', { required: true, autocomplete: 'address-line1' })}
|
|
582
|
-
${this.field('line2', 'Apartment, suite, etc.', { autocomplete: 'address-line2' })}
|
|
583
|
-
${this.field('city', 'City', { autocomplete: 'address-level2' })}
|
|
584
|
-
${this.stateOptions.length
|
|
585
|
-
? this.select('state', 'State or region', this.stateOptions, { required: quote.required?.state, hint: quote.required?.state ? 'Tax depends on it' : '', autocomplete: 'address-level1', placeholder: 'Select a state or region' })
|
|
586
|
-
: this.field('state', 'State or region', { autocomplete: 'address-level1' })}
|
|
587
|
-
${this.field('postalCode', 'Postal code', { required: quote.required?.postalCode, hint: quote.required?.postalCode ? 'Tax depends on it' : '', autocomplete: 'postal-code' })}
|
|
588
|
-
`;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
renderCredits(){
|
|
592
|
-
const store = this.offer('store-credit');
|
|
593
|
-
const credits = this.offer('credits');
|
|
594
|
-
const step = (1 / 10 ** this.decimals).toFixed(this.decimals);
|
|
595
|
-
return html`
|
|
596
|
-
${store ? html`
|
|
597
|
-
<label class="mb">
|
|
598
|
-
Store credit to use
|
|
599
|
-
<small class="tc-muted d-b">You have ${this.money(store.detail.balance)}. Up to ${this.money(this.storeCap)} can pay for this order.</small>
|
|
600
|
-
<input type="number" min="0" max=${fromMinor(this.storeCap, this.decimals)} step=${step} .value=${live(fromMinor(this.storeCredit, this.decimals))} @change=${this.setStoreCredit} style="max-width: 12rem;">
|
|
601
|
-
</label>
|
|
602
|
-
` : nothing}
|
|
603
|
-
|
|
604
|
-
${credits ? html`
|
|
605
|
-
<label class="mb">
|
|
606
|
-
Credits to use
|
|
607
|
-
<small class="tc-muted d-b">You have ${credits.detail.balance} credits. Up to ${this.creditsCap}, worth ${this.money(this.creditsMinor(this.creditsCap))}, can pay for this order.</small>
|
|
608
|
-
<input type="number" min="0" max=${this.creditsCap} step="1" .value=${live(String(this.creditsUsed))} @change=${this.setCredits} style="max-width: 12rem;">
|
|
609
|
-
</label>
|
|
610
|
-
` : nothing}
|
|
611
|
-
`;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
renderPayment(){
|
|
615
|
-
const { quote } = this;
|
|
616
|
-
const store = this.offer('store-credit');
|
|
617
|
-
const credits = this.offer('credits');
|
|
618
|
-
const covered = quote.totals.total > 0 && !this.needsCard;
|
|
619
|
-
return html`
|
|
620
|
-
${covered ? html`<p class="tc-muted">Your ${store && credits ? 'store credit and credits cover' : store ? 'store credit covers' : 'credits cover'} this order, so there is nothing to pay by card.</p>` : nothing}
|
|
621
|
-
|
|
622
|
-
${quote.totals.total > 0 ? html`
|
|
623
|
-
<div ?hidden=${!this.needsCard}>
|
|
624
|
-
<p class="small tc-muted">${store || credits ? 'Pays what is left after the above. ' : ''}Your card details go straight to our payment processor. They never touch this site.</p>
|
|
625
|
-
<k-pay-form deferred .postalCode=${this.form.postalCode} @card-change=${this.refreshSections}></k-pay-form>
|
|
626
|
-
</div>
|
|
627
|
-
` : nothing}
|
|
628
|
-
`;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
renderForm(){
|
|
632
|
-
const { quote } = this;
|
|
633
|
-
return html`
|
|
634
|
-
<form class="row" @submit=${this.place} ?inert=${this.finished}>
|
|
635
|
-
<div class="span-12 d-span-7" style="padding-right: var(--spacer);">
|
|
636
|
-
${this.error ? html`<p class="tc-danger" role="alert">${this.error}</p>` : nothing}
|
|
637
|
-
${quote.cart.notices.map(notice => html`<p class=${notice.blocking ? 'tc-danger' : 'tc-warning'}>${notice.message}</p>`)}
|
|
638
|
-
|
|
639
|
-
${this.renderPanels()}
|
|
640
|
-
</div>
|
|
641
|
-
|
|
642
|
-
<div class="span-12 d-span-5">
|
|
643
|
-
${this.renderSummary()}
|
|
644
|
-
${checkoutSections('summary').map(({ tag }) => staticHtml`<${unsafeStatic(tag)} .checkout=${this}></${unsafeStatic(tag)}>`)}
|
|
645
|
-
<button class="primary full" ?disabled=${this.busy || this.pricing || this.missing.length > 0 || Boolean(this.blocker)}>
|
|
646
|
-
${this.finished ? 'Order placed' : this.busy ? (this.placed ? 'Paying…' : 'Placing your order…') : (this.placed ? 'Try payment again' : 'Place order')}
|
|
647
|
-
</button>
|
|
648
|
-
${this.blocker && !this.busy ? html`<p class="small tc-danger mt" role="alert">${this.blocker}</p>` : nothing}
|
|
649
|
-
${this.missing.length && !this.busy ? html`<p class="small tc-muted mt">To place your order, add ${this.missing.join(', ').replace(/, ([^,]*)$/, ' and $1')}.</p>` : nothing}
|
|
650
|
-
</div>
|
|
651
|
-
</form>
|
|
652
|
-
`;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
renderLightDom(){
|
|
656
|
-
const { quote } = this;
|
|
657
|
-
if(!quote) return html`<k-spinner></k-spinner>`;
|
|
658
|
-
if(quote.empty){
|
|
659
|
-
return html`<div class="ta-center py">
|
|
660
|
-
<p class="large">${this.error || 'Your cart is empty.'}</p>
|
|
661
|
-
${(quote.notices ?? []).map(notice => html`<p class="tc-danger">${notice.message}</p>`)}
|
|
662
|
-
<a class="btn primary" href="/cart/">Back to your cart</a>
|
|
663
|
-
</div>`;
|
|
664
|
-
}
|
|
665
|
-
return this.renderForm();
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
customElements.define('k-commerce-checkout', Checkout);
|
|
1
|
+
import LightComponent from '/kempo-ui/components/LightComponent.js';
|
|
2
|
+
import { html, staticHtml, unsafeStatic, keyed, nothing, live } from '/kempo-ui/lit-all.min.js';
|
|
3
|
+
import '/kempo-ui/components/Icon.js';
|
|
4
|
+
import '/kempo-ui/components/Spinner.js';
|
|
5
|
+
import '/kempo-ui/components/Accordion.js';
|
|
6
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
7
|
+
import '/payments/components/PaymentForm.js';
|
|
8
|
+
import { startCheckout, validateCheckout, placeOrder, confirmPayment, getRegions, onCartChanged, newKey } from '/commerce/sdk.js';
|
|
9
|
+
import { loadFormat, optionsText, itemNames } from '/commerce/utils/format.js';
|
|
10
|
+
import { toMinor, fromMinor, toCount, clamp } from '/commerce/utils/amount.js';
|
|
11
|
+
import { countryOptions, subdivisionOptions } from '/commerce/utils/regions.js';
|
|
12
|
+
import { checkoutSections } from '/commerce/components/checkoutSections.js';
|
|
13
|
+
|
|
14
|
+
/* A table with no header row has no top border or rounded top corners of its own, so its first row draws them. */
|
|
15
|
+
const TOP_LEFT = 'border-top: 1px solid var(--c_border); border-top-left-radius: var(--radius);';
|
|
16
|
+
const TOP_RIGHT = 'border-top: 1px solid var(--c_border); border-top-right-radius: var(--radius);';
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
The checkout page's body, served at /commerce/components/Checkout.js.
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<script type="module" src="/commerce/components/Checkout.js"></script>
|
|
23
|
+
<k-commerce-checkout></k-commerce-checkout>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
One page, one button. Down the left, an accordion of panels, one open at a time: **Contact** (who you are,
|
|
27
|
+
an account if one is wanted, and the fields extensions asked for), **Billing address** (where, which sets
|
|
28
|
+
the tax), **Store credit and credits** (when the customer has either), and **Payment** (the card for
|
|
29
|
+
whatever is left). Extensions add panels of their own, a shipping address for one, by priority. On the
|
|
30
|
+
right: the order, the refund policy, anything an extension puts under the summary, and the button.
|
|
31
|
+
|
|
32
|
+
Placing the order makes it, makes the card payment, and charges the card in the same press. A card
|
|
33
|
+
that is declined leaves the order saved with the form still in front of you, to try again.
|
|
34
|
+
|
|
35
|
+
**Other extensions** add to the page with `registerCheckoutSection` (checkoutSections.js). See that file.
|
|
36
|
+
|
|
37
|
+
**Light DOM, on purpose.** Stripe's card fields do not mount inside a shadow root (they reserve the
|
|
38
|
+
space and draw nothing), so the payment form, and everything around it, is light DOM.
|
|
39
|
+
|
|
40
|
+
The totals are the server's. Changing the address (tax) or the credit used asks the server again,
|
|
41
|
+
and placing the order sends the total that was shown, so a total that moved is refused and shown
|
|
42
|
+
instead of being charged. The checkout key is kept for as long as the cart's contents are the same,
|
|
43
|
+
so a reload, a double click or a second tab returns the order that was already made and never a
|
|
44
|
+
second one. The card is only ever believed through the server asking the processor.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const SIGNATURE = cart => cart.lines.map(line => `${line.productId}:${line.quantity}:${JSON.stringify(line.selections)}`).join('|');
|
|
48
|
+
|
|
49
|
+
const keyFor = cart => {
|
|
50
|
+
try {
|
|
51
|
+
const saved = JSON.parse(sessionStorage.getItem('kempo-commerce-checkout') || 'null');
|
|
52
|
+
if(saved?.signature === SIGNATURE(cart) && saved.cartId === cart.id) return saved.key;
|
|
53
|
+
const created = { signature: SIGNATURE(cart), cartId: cart.id, key: newKey() };
|
|
54
|
+
sessionStorage.setItem('kempo-commerce-checkout', JSON.stringify(created));
|
|
55
|
+
return created.key;
|
|
56
|
+
} catch {
|
|
57
|
+
return newKey();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default class Checkout extends LightComponent {
|
|
62
|
+
static properties = {
|
|
63
|
+
quote: { state: true },
|
|
64
|
+
form: { state: true },
|
|
65
|
+
storeCredit: { state: true },
|
|
66
|
+
creditsUsed: { state: true },
|
|
67
|
+
createAccount: { state: true },
|
|
68
|
+
extra: { state: true },
|
|
69
|
+
errors: { state: true },
|
|
70
|
+
error: { state: true },
|
|
71
|
+
busy: { state: true },
|
|
72
|
+
finished: { state: true },
|
|
73
|
+
pricing: { state: true },
|
|
74
|
+
placed: { state: true },
|
|
75
|
+
regions: { state: true },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
constructor(){
|
|
79
|
+
super();
|
|
80
|
+
this.quote = null;
|
|
81
|
+
this.form = { name: '', email: '', phone: '', line1: '', line2: '', city: '', state: '', postalCode: '', country: '' };
|
|
82
|
+
this.storeCredit = 0;
|
|
83
|
+
this.creditsUsed = 0;
|
|
84
|
+
this.createAccount = false;
|
|
85
|
+
this.extra = {};
|
|
86
|
+
this.errors = {};
|
|
87
|
+
this.error = '';
|
|
88
|
+
this.busy = false;
|
|
89
|
+
this.finished = false;
|
|
90
|
+
this.pricing = false;
|
|
91
|
+
this.placed = null;
|
|
92
|
+
this.regions = null;
|
|
93
|
+
this.decimals = 2;
|
|
94
|
+
this.money = minor => String(minor);
|
|
95
|
+
this.priceRequest = 0;
|
|
96
|
+
this.timer = null;
|
|
97
|
+
this.completed = new Set();
|
|
98
|
+
this.visited = new Set();
|
|
99
|
+
this.userOpened = new Set();
|
|
100
|
+
this.automating = false;
|
|
101
|
+
this.initialized = false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/*
|
|
105
|
+
Lifecycle
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
connectedCallback(){
|
|
109
|
+
super.connectedCallback();
|
|
110
|
+
window.addEventListener('checkout-sections-changed', this.refreshSections);
|
|
111
|
+
this.stopListening = onCartChanged(() => this.refresh());
|
|
112
|
+
this.start();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
disconnectedCallback(){
|
|
116
|
+
super.disconnectedCallback();
|
|
117
|
+
window.removeEventListener('checkout-sections-changed', this.refreshSections);
|
|
118
|
+
this.stopListening?.();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
updated(){
|
|
122
|
+
super.updated();
|
|
123
|
+
this.settle();
|
|
124
|
+
/* The sections read `checkout.quote` (the shipping choices), so they draw again whenever the page does. */
|
|
125
|
+
for(const { tag } of [...checkoutSections('panel'), ...checkoutSections('summary')]) this.querySelectorAll(tag).forEach($section => $section.requestUpdate?.());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
refreshSections = () => this.requestUpdate();
|
|
129
|
+
|
|
130
|
+
start = async () => {
|
|
131
|
+
const format = await loadFormat();
|
|
132
|
+
this.money = format.money;
|
|
133
|
+
this.decimals = format.config.decimals ?? 2;
|
|
134
|
+
/* The pickers fill in when the lists arrive; until then, or if they never do, the fields are plain text. */
|
|
135
|
+
getRegions().then(([, regions]) => { this.regions = regions; });
|
|
136
|
+
const [error, data] = await startCheckout();
|
|
137
|
+
if(error){
|
|
138
|
+
this.error = error.msg;
|
|
139
|
+
this.quote = { empty: true, notices: error.notices ?? [] };
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.quote = data.quote;
|
|
143
|
+
/* The form is still empty here, so what the shop already knows only fills in blanks. */
|
|
144
|
+
this.form = { ...this.form, ...Object.fromEntries(Object.entries(data.prefill ?? {}).filter(([name]) => !this.form[name])) };
|
|
145
|
+
this.storeCredit = this.offer('store-credit')?.max ?? 0;
|
|
146
|
+
if(data.quote.account.required) this.createAccount = true;
|
|
147
|
+
this.reprice();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
The cart changed somewhere else on the page (the side cart): take its lines again and price them, keeping
|
|
152
|
+
what has been typed here. A cart that is now empty shows the empty page; one that was empty starts over.
|
|
153
|
+
*/
|
|
154
|
+
refresh = async () => {
|
|
155
|
+
if(!this.quote || this.placed) return;
|
|
156
|
+
if(this.quote.empty) return this.start();
|
|
157
|
+
const [error, data] = await startCheckout();
|
|
158
|
+
if(error){
|
|
159
|
+
this.error = error.msg;
|
|
160
|
+
this.quote = { empty: true, notices: error.notices ?? [] };
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.quote = data.quote;
|
|
164
|
+
this.creditsUsed = clamp(this.creditsUsed, this.creditsCap);
|
|
165
|
+
this.storeCredit = clamp(this.storeCredit, this.storeCap);
|
|
166
|
+
this.reprice();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
Public methods, for the sections other extensions add
|
|
171
|
+
*/
|
|
172
|
+
|
|
173
|
+
/* Asks the server for the totals with this address and these tenders. */
|
|
174
|
+
reprice = async () => {
|
|
175
|
+
if(!this.quote || this.quote.empty) return;
|
|
176
|
+
const request = ++this.priceRequest;
|
|
177
|
+
this.pricing = true;
|
|
178
|
+
const [error, data] = await validateCheckout({ billing: this.form, data: this.extra, tenders: this.tenders });
|
|
179
|
+
if(request !== this.priceRequest) return;
|
|
180
|
+
this.pricing = false;
|
|
181
|
+
if(error){
|
|
182
|
+
this.error = error.msg;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.error = '';
|
|
186
|
+
this.quote = data.quote;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/* A value sent with the order as `data`, for the `checkout:validate` guard on the server. */
|
|
190
|
+
setData = (key, value) => {
|
|
191
|
+
this.extra = { ...this.extra, [key]: value };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/*
|
|
195
|
+
What can be used
|
|
196
|
+
*/
|
|
197
|
+
|
|
198
|
+
offer(kind){
|
|
199
|
+
return this.quote?.tenders?.find(offer => offer.kind === kind) ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* The money credits cover, as the server works it out. */
|
|
203
|
+
creditsMinor(count){
|
|
204
|
+
const offer = this.offer('credits');
|
|
205
|
+
return offer ? Math.min(Math.floor(count * 10 ** this.decimals / offer.detail.perUnit), offer.max) : 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* The most credits that can be asked for: what is held, what the cart lets them pay, what is due. */
|
|
209
|
+
get creditsCap(){
|
|
210
|
+
const offer = this.offer('credits');
|
|
211
|
+
return offer ? Math.min(offer.detail.maxCredits, Math.floor(offer.max * offer.detail.perUnit / 10 ** this.decimals)) : 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* The most store credit that can be used: what is held and what is still due after credits. */
|
|
215
|
+
get storeCap(){
|
|
216
|
+
const offer = this.offer('store-credit');
|
|
217
|
+
if(!offer) return 0;
|
|
218
|
+
return Math.max(0, Math.min(offer.max, this.quote.totals.total - this.creditsMinor(Math.min(this.creditsUsed, this.creditsCap))));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get tenders(){
|
|
222
|
+
const tenders = {};
|
|
223
|
+
const credits = Math.min(this.creditsUsed, this.creditsCap);
|
|
224
|
+
if(credits > 0) tenders.credits = credits;
|
|
225
|
+
const store = Math.min(this.storeCredit, this.storeCap);
|
|
226
|
+
if(store > 0) tenders['store-credit'] = store;
|
|
227
|
+
return tenders;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
get language(){
|
|
231
|
+
return navigator.language || 'en';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
get stateOptions(){
|
|
235
|
+
return subdivisionOptions(this.regions, this.form.country, this.language);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
What the customer still has to give before the order can be placed, as words for the hint under the
|
|
240
|
+
button. The server decides what tax needs (`quote.required`); the rest is who they are, what extensions
|
|
241
|
+
asked for, and a card when one is needed.
|
|
242
|
+
*/
|
|
243
|
+
get missing(){
|
|
244
|
+
const { quote, form } = this;
|
|
245
|
+
if(!quote || quote.empty) return [];
|
|
246
|
+
const need = quote.required ?? {};
|
|
247
|
+
const missing = [];
|
|
248
|
+
if(!form.name?.trim()) missing.push('your name');
|
|
249
|
+
if(!quote.account.signedIn && !form.email?.trim()) missing.push('your email');
|
|
250
|
+
if(need.country && !form.country) missing.push('your country');
|
|
251
|
+
if(need.state && this.stateOptions.length && !form.state) missing.push('your state or region');
|
|
252
|
+
if(need.postalCode && !form.postalCode?.trim()) missing.push('your postal code');
|
|
253
|
+
for(const field of quote.fields) if(field.required && !this.extra[field.key]) missing.push(field.label.toLowerCase());
|
|
254
|
+
if(this.needsCard && !this.payForm?.complete) missing.push('your card details');
|
|
255
|
+
return missing;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* What a section says keeps the order from being placed at all (shipping that cannot be priced), or '' when nothing does. */
|
|
259
|
+
get blocker(){
|
|
260
|
+
for(const { tag } of [...checkoutSections('panel'), ...checkoutSections('summary')]){
|
|
261
|
+
const reason = this.querySelector(tag)?.blocker;
|
|
262
|
+
if(reason) return reason;
|
|
263
|
+
}
|
|
264
|
+
return '';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get needsCard(){
|
|
268
|
+
return Boolean(this.quote?.plan?.some(entry => entry.kind === 'card'));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get payForm(){
|
|
272
|
+
return this.querySelector('k-pay-form');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/*
|
|
276
|
+
Event handlers
|
|
277
|
+
*/
|
|
278
|
+
|
|
279
|
+
type = (name, event) => {
|
|
280
|
+
this.form = { ...this.form, [name]: event.target.value };
|
|
281
|
+
if(['country', 'state', 'postalCode', 'city'].includes(name)) this.schedulePricing();
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/* A picker: choosing a country clears a state that is not one of its states, and asks for the tax again. */
|
|
285
|
+
pick = (name, event) => {
|
|
286
|
+
const value = event.target.value;
|
|
287
|
+
const next = { ...this.form, [name]: value };
|
|
288
|
+
if(name === 'country' && !subdivisionOptions(this.regions, value).some(state => state.code === next.state)) next.state = '';
|
|
289
|
+
this.form = next;
|
|
290
|
+
this.reprice();
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
typeExtra = (name, event) => {
|
|
294
|
+
this.setData(name, event.target.type === 'checkbox' ? event.target.checked : event.target.value);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
setStoreCredit = event => {
|
|
298
|
+
const entered = toMinor(event.target.value, this.decimals);
|
|
299
|
+
this.storeCredit = clamp(entered ?? 0, this.storeCap);
|
|
300
|
+
event.target.value = fromMinor(this.storeCredit, this.decimals);
|
|
301
|
+
this.reprice();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
setCredits = event => {
|
|
305
|
+
const entered = toCount(event.target.value);
|
|
306
|
+
this.creditsUsed = clamp(entered ?? 0, this.creditsCap);
|
|
307
|
+
event.target.value = String(this.creditsUsed);
|
|
308
|
+
this.storeCredit = clamp(this.storeCredit, this.storeCap);
|
|
309
|
+
this.reprice();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
schedulePricing = () => {
|
|
313
|
+
clearTimeout(this.timer);
|
|
314
|
+
this.timer = setTimeout(this.reprice, 400);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
place = async event => {
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
const { quote } = this;
|
|
320
|
+
if(this.blocker){
|
|
321
|
+
this.error = this.blocker;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if(this.needsCard && !this.payForm?.complete){
|
|
325
|
+
this.error = 'Enter your card details to place the order.';
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this.busy = true;
|
|
330
|
+
this.error = '';
|
|
331
|
+
this.errors = {};
|
|
332
|
+
const [error, data] = await placeOrder({
|
|
333
|
+
checkoutKey: keyFor(quote.cart),
|
|
334
|
+
billing: this.form,
|
|
335
|
+
data: this.extra,
|
|
336
|
+
tenders: this.tenders,
|
|
337
|
+
account: this.createAccount && !quote.account.signedIn ? { create: true } : null,
|
|
338
|
+
expectedTotal: quote.totals.total,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if(error){
|
|
342
|
+
this.busy = false;
|
|
343
|
+
if(error.totals) this.quote = { ...quote, totals: error.totals };
|
|
344
|
+
this.error = error.code === 429 && error.retryAfter ? `${error.msg} (try again in ${Math.ceil(error.retryAfter / 60)} minutes)` : error.msg;
|
|
345
|
+
if(error.errors) this.errors = Object.fromEntries(error.errors.map(entry => [entry.field, entry.msg]));
|
|
346
|
+
if(error.existing) this.error = `${error.msg} `;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
this.placed = data;
|
|
351
|
+
if(data.payment) await this.pay(data);
|
|
352
|
+
else this.finish(data.receivedUrl, false);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/*
|
|
356
|
+
The order and its payment exist; charge the card on this page. A decline leaves the order saved and
|
|
357
|
+
the form here, so the next press is another try at the same order. The browser saying it worked is
|
|
358
|
+
never believed on its own: the server asks the processor.
|
|
359
|
+
*/
|
|
360
|
+
pay = async data => {
|
|
361
|
+
const { error, paymentIntent } = await this.payForm.confirm({ clientSecret: data.payment.clientSecret });
|
|
362
|
+
if(error){
|
|
363
|
+
this.busy = false;
|
|
364
|
+
this.error = `${error.message || 'The payment could not be completed.'} Your order is saved. Check your card details and try again.`;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if(paymentIntent?.status !== 'processing'){
|
|
368
|
+
const key = new URL(data.receivedUrl, location.origin).searchParams.get('key');
|
|
369
|
+
await confirmPayment(data.order.id, key);
|
|
370
|
+
}
|
|
371
|
+
this.finish(data.receivedUrl, true);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/*
|
|
375
|
+
The order is placed (and paid, when a card was needed). The form is locked so nothing can be changed or sent twice,
|
|
376
|
+
and a toast says so with a way to the order. Closing the toast for any reason, its button or its ten seconds running
|
|
377
|
+
out, goes to the order, once.
|
|
378
|
+
*/
|
|
379
|
+
finish = (url, paid) => {
|
|
380
|
+
try {
|
|
381
|
+
sessionStorage.removeItem('kempo-commerce-checkout');
|
|
382
|
+
} catch { /* storage may be blocked; the next checkout simply makes a new key */ }
|
|
383
|
+
this.busy = true;
|
|
384
|
+
this.finished = true;
|
|
385
|
+
let left = false;
|
|
386
|
+
const go = () => {
|
|
387
|
+
if(left) return;
|
|
388
|
+
left = true;
|
|
389
|
+
location.href = url;
|
|
390
|
+
};
|
|
391
|
+
Toast.success(paid ? 'Payment complete. Your order is placed.' : 'Your order is placed.', { timeout: 10000, action: 'View order', actionCallback: go, closeCallback: go });
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/*
|
|
395
|
+
Rendering
|
|
396
|
+
*/
|
|
397
|
+
|
|
398
|
+
field(name, label, { type = 'text', required = false, hint = '', autocomplete = '' } = {}){
|
|
399
|
+
return html`
|
|
400
|
+
<label class="mb">
|
|
401
|
+
${label}${required ? html` <span class="tc-danger" aria-hidden="true">*</span>` : nothing}
|
|
402
|
+
${hint ? html`<small class="tc-muted d-b">${hint}</small>` : nothing}
|
|
403
|
+
<input type=${type} .value=${this.form[name] ?? ''} ?required=${required} autocomplete=${autocomplete || 'off'} @input=${event => this.type(name, event)}>
|
|
404
|
+
${this.errors[name] ? html`<small class="tc-danger d-b">${this.errors[name]}</small>` : nothing}
|
|
405
|
+
</label>
|
|
406
|
+
`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
select(name, label, options, { required = false, hint = '', autocomplete = '', placeholder = '' } = {}){
|
|
410
|
+
return html`
|
|
411
|
+
<label class="mb">
|
|
412
|
+
${label}${required ? html` <span class="tc-danger" aria-hidden="true">*</span>` : nothing}
|
|
413
|
+
${hint ? html`<small class="tc-muted d-b">${hint}</small>` : nothing}
|
|
414
|
+
<select ?required=${required} autocomplete=${autocomplete || 'off'} @change=${event => this.pick(name, event)}>
|
|
415
|
+
<option value="" ?selected=${!this.form[name]}>${placeholder}</option>
|
|
416
|
+
${options.map(option => html`<option value=${option.code} ?selected=${option.code === this.form[name]}>${option.name}</option>`)}
|
|
417
|
+
</select>
|
|
418
|
+
${this.errors[name] ? html`<small class="tc-danger d-b">${this.errors[name]}</small>` : nothing}
|
|
419
|
+
</label>
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
renderExtraField(field){
|
|
424
|
+
if(field.type === 'checkbox') return html`<label class="checkbox mb"><input type="checkbox" .checked=${Boolean(this.extra[field.key])} @change=${event => this.typeExtra(field.key, event)}> ${field.label}</label>`;
|
|
425
|
+
if(field.type === 'select') return html`<label class="mb">${field.label}<select @change=${event => this.typeExtra(field.key, event)}>${(field.options ?? []).map(option => html`<option value=${option.value ?? option}>${option.label ?? option}</option>`)}</select></label>`;
|
|
426
|
+
return html`<label class="mb">${field.label}${field.hint ? html`<small class="tc-muted d-b">${field.hint}</small>` : nothing}<input type=${field.type || 'text'} ?required=${field.required} @input=${event => this.typeExtra(field.key, event)}></label>`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
renderSummary(){
|
|
430
|
+
const { quote } = this;
|
|
431
|
+
const { totals } = quote;
|
|
432
|
+
return html`
|
|
433
|
+
<h2 class="h4">Cart totals</h2>
|
|
434
|
+
<table class="mb">
|
|
435
|
+
<tbody>
|
|
436
|
+
${quote.cart.lines.map((line, index) => html`<tr><td style=${index === 0 ? TOP_LEFT : nothing}>${line.name} <span class="tc-muted">× ${line.quantity}</span>${optionsText(line) ? html`<div class="tc-muted small">${optionsText(line)}</div>` : nothing}</td><td class="ta-right" style=${index === 0 ? TOP_RIGHT : nothing}>${this.money(line.lineTotal)}</td></tr>`)}
|
|
437
|
+
<tr><td style="background-color: var(--c_bg__alt);"><strong>Subtotal</strong></td><td class="ta-right">${this.money(totals.subtotal)}</td></tr>
|
|
438
|
+
${quote.cart.adjustments.map(adjustment => html`<tr><td style="background-color: var(--c_bg__alt);"><strong>${adjustment.label}</strong>${itemNames(adjustment.lineId, quote.cart.lines) ? html`<small class="d-b tc-muted">${itemNames(adjustment.lineId, quote.cart.lines)}</small>` : nothing}</td><td class="ta-right">${this.money(adjustment.amount)}</td></tr>`)}
|
|
439
|
+
${totals.taxComputed ? html`<tr><td style="background-color: var(--c_bg__alt);"><strong>Tax${totals.pricesIncludeTax ? ' (included)' : ''}</strong></td><td class="ta-right">${this.money(totals.taxTotal)}</td></tr>`
|
|
440
|
+
: quote.taxEnabled ? html`<tr><td style="background-color: var(--c_bg__alt);"><strong>Tax</strong></td><td class="ta-right tc-muted">Enter your address to calculate</td></tr>` : nothing}
|
|
441
|
+
${quote.plan.filter(entry => entry.kind !== 'card').map(entry => html`<tr><td style="background-color: var(--c_bg__alt);"><strong>${quote.tenders.find(offer => offer.kind === entry.kind)?.label ?? entry.kind}</strong></td><td class="ta-right">−${this.money(entry.amount)}</td></tr>`)}
|
|
442
|
+
<tr><td style="background-color: var(--c_bg__alt);"><strong>Total${quote.plan.some(entry => entry.kind !== 'card') ? ' to pay' : ''}</strong></td><td class="ta-right"><strong aria-live="polite">${this.pricing ? html`<k-spinner size="xs"></k-spinner>` : this.money(quote.plan.find(entry => entry.kind === 'card')?.amount ?? 0)}</strong></td></tr>
|
|
443
|
+
</tbody>
|
|
444
|
+
</table>
|
|
445
|
+
${quote.refundPolicy ? html`<details class="mb"><summary>Refund policy</summary><p class="small" style="white-space: pre-line;">${quote.refundPolicy}</p></details>` : nothing}
|
|
446
|
+
`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/*
|
|
450
|
+
The accordion. Each panel is `{ id, title, priority, body }` for the checkout's own, or `{ id, title, priority, tag }`
|
|
451
|
+
for one an extension registered, which is created here and handed the page.
|
|
452
|
+
*/
|
|
453
|
+
|
|
454
|
+
get panels(){
|
|
455
|
+
const { quote } = this;
|
|
456
|
+
const panels = [
|
|
457
|
+
{ id: 'contact', title: 'Contact', priority: 500, body: this.renderContact() },
|
|
458
|
+
{ id: 'billing', title: 'Billing address', priority: 400, body: this.renderBilling() },
|
|
459
|
+
...(this.offer('store-credit') || this.offer('credits') ? [{ id: 'credits', title: 'Store credit and credits', priority: 200, body: this.renderCredits() }] : []),
|
|
460
|
+
...(this.needsCard || quote.plan.some(entry => entry.kind !== 'card') ? [{ id: 'payment', title: 'Payment', priority: 100, body: this.renderPayment() }] : []),
|
|
461
|
+
...checkoutSections('panel').map(({ id, title, priority, tag }) => ({ id, title, priority, tag })),
|
|
462
|
+
];
|
|
463
|
+
return panels.sort((a, b) => b.priority - a.priority);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/*
|
|
467
|
+
Several panels can be open. settle() opens and closes them by what is filled in; a panel the customer
|
|
468
|
+
opened themselves stays open until they close it. Keyed, so a panel that comes and goes keeps the rest's state.
|
|
469
|
+
*/
|
|
470
|
+
renderPanels(){
|
|
471
|
+
return html`
|
|
472
|
+
<k-accordion multiple @openpanel=${event => this.panelToggled(event, true)} @closepanel=${event => this.panelToggled(event, false)} @focusout=${this.panelBlurred}>
|
|
473
|
+
${this.panels.map(panel => keyed(panel.id, html`
|
|
474
|
+
<k-accordion-header for-panel=${panel.id}>${panel.title}</k-accordion-header>
|
|
475
|
+
<k-accordion-panel name=${panel.id}>
|
|
476
|
+
<div class="p">
|
|
477
|
+
${panel.tag ? staticHtml`<${unsafeStatic(panel.tag)} .checkout=${this}></${unsafeStatic(panel.tag)}>` : panel.body}
|
|
478
|
+
</div>
|
|
479
|
+
</k-accordion-panel>
|
|
480
|
+
`))}
|
|
481
|
+
</k-accordion>
|
|
482
|
+
`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
get accordion(){
|
|
486
|
+
return this.querySelector('k-accordion');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/*
|
|
490
|
+
Whether a panel's fields are all filled in. The checkout's own: contact is a name, an email (unless signed in)
|
|
491
|
+
and any extra field an extension requires; billing is the country, address, city and postal code, and the
|
|
492
|
+
state when there is a list of them (the apartment line is optional). Credits are optional, so they are done
|
|
493
|
+
once the customer has been in them and left. Payment is done when no card is needed or the card is complete.
|
|
494
|
+
An extension's panel is done when its element says so with `complete = true`.
|
|
495
|
+
*/
|
|
496
|
+
isComplete({ id, tag }){
|
|
497
|
+
const { form, quote, extra } = this;
|
|
498
|
+
if(tag) return this.querySelector(tag)?.complete === true;
|
|
499
|
+
if(id === 'contact') return Boolean(form.name?.trim()) && (quote.account.signedIn || Boolean(form.email?.trim())) && quote.fields.every(field => !field.required || Boolean(extra[field.key]));
|
|
500
|
+
if(id === 'billing') return Boolean(form.country && form.line1?.trim() && form.city?.trim() && form.postalCode?.trim()) && (this.stateOptions.length === 0 || Boolean(form.state));
|
|
501
|
+
if(id === 'credits') return this.visited.has('credits');
|
|
502
|
+
return !this.needsCard || Boolean(this.payForm?.complete);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/*
|
|
506
|
+
Opens and closes panels to match what is filled in. When a panel has just become done, the next one that is
|
|
507
|
+
not done opens. A done panel closes once focus is out of it, unless the customer opened it themselves.
|
|
508
|
+
*/
|
|
509
|
+
settle = () => {
|
|
510
|
+
const { accordion } = this;
|
|
511
|
+
if(!accordion || !this.quote || this.quote.empty) return;
|
|
512
|
+
const panels = this.panels;
|
|
513
|
+
const focused = document.activeElement?.closest?.('k-accordion-panel')?.getAttribute('name');
|
|
514
|
+
const done = new Set(panels.filter(panel => this.isComplete(panel)).map(panel => panel.id));
|
|
515
|
+
const isOpen = id => accordion.getPanel(id)?.active === true;
|
|
516
|
+
const open = id => {
|
|
517
|
+
if(!isOpen(id)) accordion.openPanel(id);
|
|
518
|
+
};
|
|
519
|
+
this.automating = true;
|
|
520
|
+
try {
|
|
521
|
+
if(!this.initialized){
|
|
522
|
+
const first = panels.find(panel => !done.has(panel.id));
|
|
523
|
+
if(first) open(first.id);
|
|
524
|
+
}
|
|
525
|
+
panels.forEach((panel, index) => {
|
|
526
|
+
if(!done.has(panel.id) || this.completed.has(panel.id)) return;
|
|
527
|
+
const next = panels.slice(index + 1).find(later => !done.has(later.id));
|
|
528
|
+
if(next) open(next.id);
|
|
529
|
+
});
|
|
530
|
+
for(const panel of panels){
|
|
531
|
+
if(done.has(panel.id) && panel.id !== focused && !this.userOpened.has(panel.id) && isOpen(panel.id)) accordion.closePanel(panel.id);
|
|
532
|
+
}
|
|
533
|
+
} finally {
|
|
534
|
+
this.automating = false;
|
|
535
|
+
}
|
|
536
|
+
this.completed = done;
|
|
537
|
+
this.initialized = true;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
/* The customer opened or closed a panel. Closing one counts as having been in it. */
|
|
541
|
+
panelToggled = (event, opened) => {
|
|
542
|
+
if(this.automating) return;
|
|
543
|
+
const name = event.detail.panelName;
|
|
544
|
+
if(opened) this.userOpened.add(name);
|
|
545
|
+
else {
|
|
546
|
+
this.userOpened.delete(name);
|
|
547
|
+
this.visited.add(name);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/* Focus left a field: note the panel was used, then settle once focus has actually moved. */
|
|
552
|
+
panelBlurred = event => {
|
|
553
|
+
const name = event.target.closest?.('k-accordion-panel')?.getAttribute('name');
|
|
554
|
+
if(name) this.visited.add(name);
|
|
555
|
+
setTimeout(this.settle);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
renderContact(){
|
|
559
|
+
const { quote } = this;
|
|
560
|
+
return html`
|
|
561
|
+
${this.field('name', 'Full name', { required: true, autocomplete: 'name' })}
|
|
562
|
+
${quote.account.signedIn ? nothing : this.field('email', 'Email', { type: 'email', required: true, hint: 'Your receipt and the link to your order are sent here', autocomplete: 'email' })}
|
|
563
|
+
|
|
564
|
+
${quote.account.signedIn ? nothing : html`
|
|
565
|
+
<h3 class="h5">Account</h3>
|
|
566
|
+
${quote.account.required
|
|
567
|
+
? html`<p>${quote.account.reason === 'credits' ? 'Credits are added to an account, so one is needed to buy them.' : 'This store needs you to have an account to check out.'} We will make one with the email above and send you a link to set your password. <a href=${`/login?redirect=${encodeURIComponent('/checkout/')}`}>Already have one? Sign in</a>.</p>`
|
|
568
|
+
: quote.account.canCreate ? html`<label class="checkbox mb"><input type="checkbox" .checked=${this.createAccount} @change=${event => { this.createAccount = event.target.checked; }}> Create an account so I can follow my order and check out faster next time</label>` : nothing}
|
|
569
|
+
`}
|
|
570
|
+
|
|
571
|
+
${quote.fields.length ? html`<h3 class="h5">More details</h3>${quote.fields.map(field => this.renderExtraField(field))}` : nothing}
|
|
572
|
+
`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
renderBilling(){
|
|
576
|
+
const { quote } = this;
|
|
577
|
+
return html`
|
|
578
|
+
${this.regions
|
|
579
|
+
? this.select('country', 'Country', countryOptions(this.regions, this.language), { required: quote.required?.country, hint: quote.required?.country ? 'Tax depends on it' : '', autocomplete: 'country', placeholder: 'Select a country' })
|
|
580
|
+
: this.field('country', 'Country', { required: quote.required?.country, hint: 'A two-letter code, such as US or GB', autocomplete: 'country' })}
|
|
581
|
+
${this.field('line1', 'Address', { required: true, autocomplete: 'address-line1' })}
|
|
582
|
+
${this.field('line2', 'Apartment, suite, etc.', { autocomplete: 'address-line2' })}
|
|
583
|
+
${this.field('city', 'City', { autocomplete: 'address-level2' })}
|
|
584
|
+
${this.stateOptions.length
|
|
585
|
+
? this.select('state', 'State or region', this.stateOptions, { required: quote.required?.state, hint: quote.required?.state ? 'Tax depends on it' : '', autocomplete: 'address-level1', placeholder: 'Select a state or region' })
|
|
586
|
+
: this.field('state', 'State or region', { autocomplete: 'address-level1' })}
|
|
587
|
+
${this.field('postalCode', 'Postal code', { required: quote.required?.postalCode, hint: quote.required?.postalCode ? 'Tax depends on it' : '', autocomplete: 'postal-code' })}
|
|
588
|
+
`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
renderCredits(){
|
|
592
|
+
const store = this.offer('store-credit');
|
|
593
|
+
const credits = this.offer('credits');
|
|
594
|
+
const step = (1 / 10 ** this.decimals).toFixed(this.decimals);
|
|
595
|
+
return html`
|
|
596
|
+
${store ? html`
|
|
597
|
+
<label class="mb">
|
|
598
|
+
Store credit to use
|
|
599
|
+
<small class="tc-muted d-b">You have ${this.money(store.detail.balance)}. Up to ${this.money(this.storeCap)} can pay for this order.</small>
|
|
600
|
+
<input type="number" min="0" max=${fromMinor(this.storeCap, this.decimals)} step=${step} .value=${live(fromMinor(this.storeCredit, this.decimals))} @change=${this.setStoreCredit} style="max-width: 12rem;">
|
|
601
|
+
</label>
|
|
602
|
+
` : nothing}
|
|
603
|
+
|
|
604
|
+
${credits ? html`
|
|
605
|
+
<label class="mb">
|
|
606
|
+
Credits to use
|
|
607
|
+
<small class="tc-muted d-b">You have ${credits.detail.balance} credits. Up to ${this.creditsCap}, worth ${this.money(this.creditsMinor(this.creditsCap))}, can pay for this order.</small>
|
|
608
|
+
<input type="number" min="0" max=${this.creditsCap} step="1" .value=${live(String(this.creditsUsed))} @change=${this.setCredits} style="max-width: 12rem;">
|
|
609
|
+
</label>
|
|
610
|
+
` : nothing}
|
|
611
|
+
`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
renderPayment(){
|
|
615
|
+
const { quote } = this;
|
|
616
|
+
const store = this.offer('store-credit');
|
|
617
|
+
const credits = this.offer('credits');
|
|
618
|
+
const covered = quote.totals.total > 0 && !this.needsCard;
|
|
619
|
+
return html`
|
|
620
|
+
${covered ? html`<p class="tc-muted">Your ${store && credits ? 'store credit and credits cover' : store ? 'store credit covers' : 'credits cover'} this order, so there is nothing to pay by card.</p>` : nothing}
|
|
621
|
+
|
|
622
|
+
${quote.totals.total > 0 ? html`
|
|
623
|
+
<div ?hidden=${!this.needsCard}>
|
|
624
|
+
<p class="small tc-muted">${store || credits ? 'Pays what is left after the above. ' : ''}Your card details go straight to our payment processor. They never touch this site.</p>
|
|
625
|
+
<k-pay-form deferred .postalCode=${this.form.postalCode} @card-change=${this.refreshSections}></k-pay-form>
|
|
626
|
+
</div>
|
|
627
|
+
` : nothing}
|
|
628
|
+
`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
renderForm(){
|
|
632
|
+
const { quote } = this;
|
|
633
|
+
return html`
|
|
634
|
+
<form class="row" @submit=${this.place} ?inert=${this.finished}>
|
|
635
|
+
<div class="span-12 d-span-7" style="padding-right: var(--spacer);">
|
|
636
|
+
${this.error ? html`<p class="tc-danger" role="alert">${this.error}</p>` : nothing}
|
|
637
|
+
${quote.cart.notices.map(notice => html`<p class=${notice.blocking ? 'tc-danger' : 'tc-warning'}>${notice.message}</p>`)}
|
|
638
|
+
|
|
639
|
+
${this.renderPanels()}
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
<div class="span-12 d-span-5">
|
|
643
|
+
${this.renderSummary()}
|
|
644
|
+
${checkoutSections('summary').map(({ tag }) => staticHtml`<${unsafeStatic(tag)} .checkout=${this}></${unsafeStatic(tag)}>`)}
|
|
645
|
+
<button class="primary full" ?disabled=${this.busy || this.pricing || this.missing.length > 0 || Boolean(this.blocker)}>
|
|
646
|
+
${this.finished ? 'Order placed' : this.busy ? (this.placed ? 'Paying…' : 'Placing your order…') : (this.placed ? 'Try payment again' : 'Place order')}
|
|
647
|
+
</button>
|
|
648
|
+
${this.blocker && !this.busy ? html`<p class="small tc-danger mt" role="alert">${this.blocker}</p>` : nothing}
|
|
649
|
+
${this.missing.length && !this.busy ? html`<p class="small tc-muted mt">To place your order, add ${this.missing.join(', ').replace(/, ([^,]*)$/, ' and $1')}.</p>` : nothing}
|
|
650
|
+
</div>
|
|
651
|
+
</form>
|
|
652
|
+
`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
renderLightDom(){
|
|
656
|
+
const { quote } = this;
|
|
657
|
+
if(!quote) return html`<k-spinner></k-spinner>`;
|
|
658
|
+
if(quote.empty){
|
|
659
|
+
return html`<div class="ta-center py">
|
|
660
|
+
<p class="large">${this.error || 'Your cart is empty.'}</p>
|
|
661
|
+
${(quote.notices ?? []).map(notice => html`<p class="tc-danger">${notice.message}</p>`)}
|
|
662
|
+
<a class="btn primary" href="/cart/">Back to your cart</a>
|
|
663
|
+
</div>`;
|
|
664
|
+
}
|
|
665
|
+
return this.renderForm();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
customElements.define('k-commerce-checkout', Checkout);
|