domma-cms 1.6.33 → 1.6.35
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/js/api.js +1 -1
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/scribe-model.js +1 -1
- package/admin/js/lib/shortcode-preview.js +1 -1
- package/admin/js/lib/shortcode-registry.js +3 -3
- package/admin/js/templates/docs/usage-site-settings.html +2 -1
- package/admin/js/templates/page-editor.html +2 -1
- package/admin/js/templates/pages.html +1 -1
- package/admin/js/templates/theme.html +1 -1
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/theme.js +5 -5
- package/package.json +1 -1
- package/plugins/_lib/outreach/audience.js +76 -0
- package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
- package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
- package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
- package/plugins/_lib/outreach/expiry.js +46 -0
- package/plugins/_lib/outreach/index.js +15 -0
- package/plugins/_lib/outreach/ledger.js +127 -0
- package/plugins/_lib/outreach/sending.js +121 -0
- package/plugins/_lib/outreach/suppression.js +62 -0
- package/plugins/_lib/outreach/tokens.js +35 -0
- package/plugins/_lib/outreach/unsubscribe.js +46 -0
- package/plugins/free-tier.lock.json +35 -24
- package/plugins/shopping-cart/CLAUDE.md +64 -0
- package/plugins/shopping-cart/admin/css/index.css +16 -0
- package/plugins/shopping-cart/admin/lib/chart.js +64 -0
- package/plugins/shopping-cart/admin/lib/kit.js +15 -0
- package/plugins/shopping-cart/admin/views/orders.js +3 -2
- package/plugins/shopping-cart/admin/views/overview.js +4 -53
- package/plugins/shopping-cart/admin/views/sales.js +133 -0
- package/plugins/shopping-cart/admin/views/settings.js +3 -1
- package/plugins/shopping-cart/admin/views/shop.js +2 -1
- package/plugins/shopping-cart/config.js +1 -0
- package/plugins/shopping-cart/docs/guide.md +25 -0
- package/plugins/shopping-cart/inject/body.html +2 -2
- package/plugins/shopping-cart/lib/accounts.js +180 -0
- package/plugins/shopping-cart/lib/commission.js +90 -0
- package/plugins/shopping-cart/lib/extensions.js +12 -4
- package/plugins/shopping-cart/lib/pricing.js +65 -13
- package/plugins/shopping-cart/lib/render.js +17 -4
- package/plugins/shopping-cart/lib/sales.js +193 -0
- package/plugins/shopping-cart/lib/shop.js +24 -3
- package/plugins/shopping-cart/lib/summary.js +4 -2
- package/plugins/shopping-cart/plugin.js +156 -22
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/plugin.public.js +17 -1
- package/plugins/shopping-cart/public/account.js +277 -0
- package/plugins/shopping-cart/public/cart.js +16 -0
- package/plugins/shopping-cart/public/lib/session.js +82 -0
- package/plugins/shopping-cart/public/shop.css +27 -0
- package/plugins/shopping-cart/roles/shop-customer.json +8 -0
- package/plugins/shopping-cart/tests/accounts.test.js +127 -0
- package/plugins/shopping-cart/tests/api.test.js +63 -0
- package/plugins/shopping-cart/tests/commission.test.js +90 -0
- package/plugins/shopping-cart/tests/pure.test.js +30 -1
- package/plugins/shopping-cart/tests/sales.test.js +73 -0
- package/public/css/site.css +1 -1
- package/public/js/effects.js +1 -1
- package/public/js/scribe-style.mjs +1 -1
- package/public/js/shortcode-expiry.mjs +1 -0
- package/public/js/site.js +1 -1
- package/public/js/text-style.mjs +1 -1
- package/server/routes/api/theme.js +12 -2
- package/server/routes/outreach-public.js +68 -0
- package/server/routes/public.js +24 -1
- package/server/server.js +5 -0
- package/server/services/content.js +7 -7
- package/server/services/markdown.js +15 -5
- package/server/services/outreachUnsubscribe.js +54 -0
- package/server/services/shortcodeExpiry.js +146 -0
- package/server/templates/page.html +3 -3
|
@@ -28,7 +28,11 @@ import {formatMoney} from './public/lib/money.js';
|
|
|
28
28
|
import {publicProduct, byShelf} from './public/lib/products.js';
|
|
29
29
|
import {cartRegistry} from './lib/extensions.js';
|
|
30
30
|
import {SAMPLES} from './lib/samples.js';
|
|
31
|
-
import
|
|
31
|
+
import * as accounts from './lib/accounts.js';
|
|
32
|
+
import {dateIn, rangeFor, salesCsv, salesReport} from './lib/sales.js';
|
|
33
|
+
import {TYPES as COMMISSION_TYPES, currentCommission, rateFor, rateText} from './lib/commission.js';
|
|
34
|
+
import {getUserById} from '../../server/services/users.js';
|
|
35
|
+
import {cleanCharges, cleanCustomLines, cleanLines, priceBasket} from './lib/pricing.js';
|
|
32
36
|
import {badge, summarise} from './lib/summary.js';
|
|
33
37
|
import * as pay from './lib/payments.js';
|
|
34
38
|
import {
|
|
@@ -58,7 +62,7 @@ const receiptPath = (s, order) => `${s.shopPath}/order/${order.id}?t=${order.dat
|
|
|
58
62
|
const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
|
|
59
63
|
|
|
60
64
|
/** The quote, from a request body, at today's prices. */
|
|
61
|
-
async function quoteFor(body, settings, {allowUnlisted = false} = {}) {
|
|
65
|
+
async function quoteFor(body, settings, {allowUnlisted = false, custom = [], charges = null} = {}) {
|
|
62
66
|
const lines = cleanLines(body?.lines);
|
|
63
67
|
const ids = new Set(lines.map(l => l.productId));
|
|
64
68
|
const products = new Map();
|
|
@@ -73,7 +77,8 @@ async function quoteFor(body, settings, {allowUnlisted = false} = {}) {
|
|
|
73
77
|
email: String(body?.customer?.email || '').slice(0, 200),
|
|
74
78
|
allowUnlisted
|
|
75
79
|
};
|
|
76
|
-
const quote = await priceBasket({lines, products, settings, input, handlers: cartRegistry().handlers()
|
|
80
|
+
const quote = await priceBasket({lines, products, settings, input, handlers: cartRegistry().handlers(),
|
|
81
|
+
custom: cleanCustomLines(custom), charges: cleanCharges(charges)});
|
|
77
82
|
return {quote, input};
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -200,6 +205,38 @@ export default async function shoppingCart(fastify, options) {
|
|
|
200
205
|
};
|
|
201
206
|
});
|
|
202
207
|
|
|
208
|
+
/**
|
|
209
|
+
* The Sales report: ?range=today|week|last7|month|lastmonth|last30|year, or ?from=&to= (dates), and ?by=day|week|month.
|
|
210
|
+
* Dates are the shop's (settings.timeZone, Europe/London unless set).
|
|
211
|
+
*/
|
|
212
|
+
const salesRange = (q, s) => {
|
|
213
|
+
const today = dateIn(Date.now(), s.timeZone || 'Europe/London');
|
|
214
|
+
const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(String(d || ''));
|
|
215
|
+
let {from, to} = isDate(q.from) && isDate(q.to) ? {from: q.from, to: q.to} : rangeFor(String(q.range || 'last30'), today);
|
|
216
|
+
if (from > to) [from, to] = [to, from];
|
|
217
|
+
return {from, to, today};
|
|
218
|
+
};
|
|
219
|
+
/** The platform's commission for this site, for people: each kind of sale and its rate. Read-only here. */
|
|
220
|
+
const commissionInfo = (s) => {
|
|
221
|
+
const {rates, updatedAt} = currentCommission();
|
|
222
|
+
const money = (n) => formatMoney(n, s.currency);
|
|
223
|
+
return {set: Object.keys(rates).length > 0, updatedAt,
|
|
224
|
+
rates: Object.entries(COMMISSION_TYPES).map(([k, label]) => ({key: k, label, text: rateText(rateFor(k, rates), money)}))};
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
fastify.get('/sales', can('read'), async (request) => {
|
|
228
|
+
const [orders, s] = await Promise.all([allOrders(), liveSettings()]);
|
|
229
|
+
const {from, to, today} = salesRange(request.query || {}, s);
|
|
230
|
+
const by = ['day', 'week', 'month'].includes(request.query?.by) ? request.query.by : 'day';
|
|
231
|
+
return {...salesReport(orders, {from, to, by, timeZone: s.timeZone || 'Europe/London'}), today, currency: s.currency, commission: commissionInfo(s)};
|
|
232
|
+
});
|
|
233
|
+
fastify.get('/sales.csv', can('read'), async (request, reply) => {
|
|
234
|
+
const [orders, s] = await Promise.all([allOrders(), liveSettings()]);
|
|
235
|
+
const {from, to} = salesRange(request.query || {}, s);
|
|
236
|
+
return reply.type('text/csv; charset=utf-8').header('Content-Disposition', `attachment; filename="sales-${from}-to-${to}.csv"`)
|
|
237
|
+
.header('Cache-Control', 'no-store').send(salesCsv(orders, {from, to, timeZone: s.timeZone || 'Europe/London'}));
|
|
238
|
+
});
|
|
239
|
+
|
|
203
240
|
fastify.get('/count', can('read'), async () => {
|
|
204
241
|
const [orders, settings, products] = await Promise.all([allOrders(), liveSettings(), allProducts()]);
|
|
205
242
|
return badge(orders, settings, Date.now(), products);
|
|
@@ -326,32 +363,37 @@ export default async function shoppingCart(fastify, options) {
|
|
|
326
363
|
* through them first; only when they say yes is the order marked refunded.
|
|
327
364
|
* An offline order is marked only - the money goes back the way it came.
|
|
328
365
|
*/
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
366
|
+
/** Refund an order in full (see above). Throws {status, message}. */
|
|
367
|
+
async function refundOrder(id, {restock = false, note = '', viaProvider = true, by = 'admin'} = {}) {
|
|
368
|
+
const bad = (status, message) => Object.assign(new Error(message), {status});
|
|
369
|
+
const o = await getOrder(id);
|
|
370
|
+
if (!o) throw bad(404, 'No such order.');
|
|
332
371
|
const d = o.data;
|
|
333
|
-
if (!['paid', 'fulfilled'].includes(d.status))
|
|
372
|
+
if (!['paid', 'fulfilled'].includes(d.status)) throw bad(409, 'Only a paid order can be refunded.');
|
|
334
373
|
const s = await liveSettings();
|
|
335
|
-
const {restock = false, note = '', viaProvider = true} = request.body || {};
|
|
336
374
|
let how = 'marked refunded';
|
|
337
375
|
try {
|
|
338
376
|
if (viaProvider && d.provider === 'stripe' && d.paymentRef) {
|
|
339
|
-
if (!s.stripeSecretKey)
|
|
377
|
+
if (!s.stripeSecretKey) throw bad(409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
|
|
340
378
|
await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef});
|
|
341
379
|
how = 'refunded through Stripe';
|
|
342
380
|
} else if (viaProvider && d.provider === 'paypal' && d.paymentRef) {
|
|
343
|
-
if (!s.paypalClientId || !s.paypalSecret)
|
|
381
|
+
if (!s.paypalClientId || !s.paypalSecret) throw bad(409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
|
|
344
382
|
await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef});
|
|
345
383
|
how = 'refunded through PayPal';
|
|
346
384
|
}
|
|
347
385
|
} catch (err) {
|
|
348
|
-
|
|
386
|
+
throw bad(err.status || 502, err.status === 409 ? err.message : `Not refunded: ${err.message}`);
|
|
349
387
|
}
|
|
388
|
+
const {order, from} = await moveOrder(o.id, 'refunded', {by, note: [how, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
|
|
389
|
+
announceMove(order, from, 'refunded');
|
|
390
|
+
return {order, how};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
fastify.post('/orders/:id/refund', can('manage'), async (request, reply) => {
|
|
394
|
+
const {restock = false, note = '', viaProvider = true} = request.body || {};
|
|
350
395
|
try {
|
|
351
|
-
|
|
352
|
-
note: [how, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
|
|
353
|
-
announceMove(order, from, 'refunded');
|
|
354
|
-
return {order, how};
|
|
396
|
+
return await refundOrder(request.params.id, {restock, note, viaProvider, by: request.user?.name || request.user?.email || 'admin'});
|
|
355
397
|
} catch (err) {
|
|
356
398
|
return fail(reply, err.status || 500, err.message);
|
|
357
399
|
}
|
|
@@ -373,7 +415,8 @@ export default async function shoppingCart(fastify, options) {
|
|
|
373
415
|
return {
|
|
374
416
|
settings: maskSettings(s),
|
|
375
417
|
webhookUrl: `${siteBase(request)}/api/plugins/shopping-cart/stripe/webhook`,
|
|
376
|
-
stripeMode: pay.stripeMode(s.stripeSecretKey)
|
|
418
|
+
stripeMode: pay.stripeMode(s.stripeSecretKey),
|
|
419
|
+
commission: commissionInfo(s)
|
|
377
420
|
};
|
|
378
421
|
});
|
|
379
422
|
|
|
@@ -407,6 +450,78 @@ export default async function shoppingCart(fastify, options) {
|
|
|
407
450
|
}
|
|
408
451
|
});
|
|
409
452
|
|
|
453
|
+
// ---- Customer accounts (lib/accounts.js) --------------------------------------------
|
|
454
|
+
//
|
|
455
|
+
// Optional (settings.accounts). Made, confirmed and described here; signed in, refreshed and given a new
|
|
456
|
+
// password by core's own /api/auth routes. A signed-in customer sends core's Bearer token.
|
|
457
|
+
|
|
458
|
+
// A boolean, the reply sent on the side: awaiting a Fastify reply resolves (empty) once it is sent, so
|
|
459
|
+
// `if (await fail(...))` would carry on and run the route anyway.
|
|
460
|
+
const accountsOff = async (reply) => {
|
|
461
|
+
if ((await liveSettings()).accounts) return false;
|
|
462
|
+
fail(reply, 404, 'Customer accounts are not switched on.');
|
|
463
|
+
return true;
|
|
464
|
+
};
|
|
465
|
+
/** `rest` -> an absolute address under the shop, for emails. */
|
|
466
|
+
const shopLink = async (request) => {
|
|
467
|
+
const s = await liveSettings();
|
|
468
|
+
const base = siteBase(request);
|
|
469
|
+
return {s, link: (rest) => `${base}${s.shopPath}/${rest}`};
|
|
470
|
+
};
|
|
471
|
+
const accountFail = (reply, err) => fail(reply, err.status || 500, err.message, err.errors ? {errors: err.errors} : {});
|
|
472
|
+
|
|
473
|
+
fastify.post('/account/register', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
474
|
+
if (await accountsOff(reply)) return undefined;
|
|
475
|
+
const {s, link} = await shopLink(request);
|
|
476
|
+
try {
|
|
477
|
+
return await accounts.register(request.body || {}, {link, shopTitle: s.shopTitle || 'the shop', log: request.log});
|
|
478
|
+
} catch (err) { return accountFail(reply, err); }
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
fastify.post('/account/confirm', {config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
482
|
+
try {
|
|
483
|
+
const user = await accounts.confirm(request.body?.u, request.body?.t);
|
|
484
|
+
return {confirmed: true, email: user.email};
|
|
485
|
+
} catch (err) { return accountFail(reply, err); }
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
fastify.post('/account/forgot', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
489
|
+
if (await accountsOff(reply)) return undefined;
|
|
490
|
+
const {s, link} = await shopLink(request);
|
|
491
|
+
await accounts.forgot(request.body?.email, {link, shopTitle: s.shopTitle || 'the shop', log: request.log}).catch(() => null);
|
|
492
|
+
// The same answer whoever asked: the form must not tell anyone who has an account.
|
|
493
|
+
return {ok: true};
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
/** The signed-in customer: details, saved addresses and the orders placed with their email. */
|
|
497
|
+
fastify.get('/account', {preHandler: [authenticate]}, async (request, reply) => {
|
|
498
|
+
const user = await getUserById(request.user.id);
|
|
499
|
+
if (!user || !user.isActive) return fail(reply, 401, 'Please sign in again.');
|
|
500
|
+
const s = await liveSettings();
|
|
501
|
+
const mine = accounts.ordersFor(await allOrders(), user.email).slice(0, 50);
|
|
502
|
+
const orders = [];
|
|
503
|
+
for (const o of mine) {
|
|
504
|
+
orders.push({id: o.id, number: o.data.number, status: o.data.status, total: o.data.total, currency: o.data.currency,
|
|
505
|
+
placedAt: o.meta?.createdAt, receipt: receiptPath(s, o), lines: (o.data.lines || []).map(l => `${l.qty} × ${l.name}${l.variantName ? ` (${l.variantName})` : ''}`),
|
|
506
|
+
parts: await registry.accountParts(o, {settings: s})});
|
|
507
|
+
}
|
|
508
|
+
return {...accounts.describe(user), orders, accounts: Boolean(s.accounts)};
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
fastify.put('/account', {preHandler: [authenticate]}, async (request, reply) => {
|
|
512
|
+
try {
|
|
513
|
+
const b = request.body || {};
|
|
514
|
+
return await accounts.updateDetails(request.user.id, {name: b.name, phone: b.phone, addresses: b.addresses});
|
|
515
|
+
} catch (err) { return accountFail(reply, err); }
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
fastify.delete('/account', {preHandler: [authenticate], config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
519
|
+
try {
|
|
520
|
+
await accounts.remove(request.user.id, request.body?.password);
|
|
521
|
+
return {deleted: true};
|
|
522
|
+
} catch (err) { return accountFail(reply, err); }
|
|
523
|
+
});
|
|
524
|
+
|
|
410
525
|
// ---- Shoppers --------------------------------------------------------------------
|
|
411
526
|
|
|
412
527
|
fastify.get('/catalogue', async (request) => {
|
|
@@ -546,28 +661,39 @@ export default async function shoppingCart(fastify, options) {
|
|
|
546
661
|
* (it comes back in on.paid) and its own page to come back to. Throws {status, message}.
|
|
547
662
|
*
|
|
548
663
|
* @param {object} p
|
|
549
|
-
* @param {{productId: string, qty: number}[]} p.lines
|
|
550
|
-
* @param {
|
|
664
|
+
* @param {{productId: string, qty: number}[]} [p.lines]
|
|
665
|
+
* @param {object[]} [p.custom] - lines the extension priced (pricing.js cleanCustomLines): Takeaway's dishes
|
|
666
|
+
* @param {object[]} [p.charges] - [{label, amount}] standing in for delivery (Takeaway's delivery fee)
|
|
667
|
+
* @param {'none'|'collect'|'deliver'} [p.delivery] - 'deliver' needs `address`; neither is ever posted
|
|
668
|
+
* @param {object} [p.address]
|
|
669
|
+
* @param {string} [p.note]
|
|
670
|
+
* @param {string} [p.code] - a discount code (Shopping Cart Pro)
|
|
671
|
+
* @param {boolean} [p.ownEmails] - the extension writes to the shopper itself: other extensions' shopper emails stay quiet
|
|
672
|
+
* @param {string} [p.payNote] - offline only: what the receipt says about paying, instead of the shop's offline instructions
|
|
673
|
+
* @param {{name: string, email: string, phone?: string}} p.customer
|
|
551
674
|
* @param {string} [p.provider] - one of the Shop's methods; the first when left out
|
|
552
675
|
* @param {string} [p.ref] - the extension's reference, 64 characters at most
|
|
553
676
|
* @param {string} [p.returnTo] - a path on this site ('/admin/#/...'), where the shopper comes back to
|
|
554
677
|
* @param {string} [p.base] - this site's origin, from the extension's request
|
|
555
678
|
*/
|
|
556
|
-
async function startCheckout({lines,
|
|
679
|
+
async function startCheckout({lines = [], custom = [], charges = null, delivery = 'none', address = null, note = '', code = '',
|
|
680
|
+
customer, provider = '', ref = '', returnTo = '', base = '', ownEmails = false, payNote = ''} = {}) {
|
|
557
681
|
const bad = (status, message) => Object.assign(new Error(message), {status});
|
|
558
682
|
const s = await liveSettings();
|
|
559
|
-
const {quote} = await quoteFor({lines, customer}, s, {allowUnlisted: true});
|
|
683
|
+
const {quote} = await quoteFor({lines, customer, code, address}, s, {allowUnlisted: true, custom, charges});
|
|
560
684
|
if (!quote.lines.length) throw bad(400, 'Nothing to buy.');
|
|
561
685
|
if (quote.problems.length) throw bad(409, quote.problems.map(p => p.message).join(' '));
|
|
562
686
|
if (quote.requiresShipping) throw bad(400, 'This checkout is only for things that are not posted.');
|
|
563
|
-
const
|
|
687
|
+
const deliver = delivery === 'deliver';
|
|
688
|
+
const checkout = cleanCheckout({customer, address, note}, {needsAddress: deliver, countries: s.countries});
|
|
564
689
|
if (Object.keys(checkout.errors).length) throw bad(400, Object.values(checkout.errors)[0]);
|
|
565
690
|
const methods = methodsOf(s).map(m => m.id);
|
|
566
691
|
const via = quote.total === 0 ? 'free' : (provider || methods[0] || '');
|
|
567
692
|
if (via !== 'free' && !methods.includes(via)) throw bad(409, 'The Shop has no way to take payment set up yet.');
|
|
568
693
|
const origin = /^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') : siteBase(null);
|
|
569
694
|
const back = /^\/(?!\/)/.test(String(returnTo)) ? `${origin}${returnTo}` : '';
|
|
570
|
-
const order = await createOrder({quote, checkout, provider: via, delivery: '
|
|
695
|
+
const order = await createOrder({quote, checkout, provider: via, delivery: deliver ? 'ship' : delivery === 'collect' ? 'collect' : 'none',
|
|
696
|
+
code: String(code || '').slice(0, 60), ref, returnTo: back, ownEmails: ownEmails === true, payNote});
|
|
571
697
|
registry.emit('checkout', order);
|
|
572
698
|
const opened = await openPayment(order, s, via, origin);
|
|
573
699
|
if (!opened) throw bad(400, 'Choose how to pay.');
|
|
@@ -581,6 +707,14 @@ export default async function shoppingCart(fastify, options) {
|
|
|
581
707
|
}
|
|
582
708
|
|
|
583
709
|
registry.services.startCheckout = startCheckout;
|
|
710
|
+
/** What startCheckout would charge, without ordering: a basket page's totals. Same arguments. */
|
|
711
|
+
registry.services.quote = async ({lines = [], custom = [], charges = null, code = '', customer = {}, address = null} = {}) => {
|
|
712
|
+
const s = await liveSettings();
|
|
713
|
+
const {quote} = await quoteFor({lines, customer, code, address}, s, {allowUnlisted: true, custom, charges});
|
|
714
|
+
return shopperQuote(quote, s);
|
|
715
|
+
};
|
|
716
|
+
/** Refund an order in full, through its provider first (Takeaway's Reject). Throws {status, message}. */
|
|
717
|
+
registry.services.refund = (id, opts = {}) => refundOrder(id, {by: 'shop', ...opts});
|
|
584
718
|
registry.services.checkOrder = checkOrder;
|
|
585
719
|
registry.services.paymentMethods = async () => methodsOf(await liveSettings()).map(m => ({id: m.id, label: m.label}));
|
|
586
720
|
// An extension looks after its own (unlisted) products: made, changed and removed through these.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopping-cart",
|
|
3
3
|
"displayName": "Shopping Cart",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
|
|
7
7
|
"author": "Domma CMS",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
],
|
|
46
46
|
"views": {
|
|
47
47
|
"plugin-shopping-cart": {
|
|
48
|
-
"entry": "shopping-cart/admin/views/shop.js?v=1.
|
|
48
|
+
"entry": "shopping-cart/admin/views/shop.js?v=1.6.0",
|
|
49
49
|
"exportName": "shopView"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* /shop/<slug> one product
|
|
7
7
|
* /shop/cart the basket and checkout, one page
|
|
8
8
|
* /shop/order/<id>?t= a shopper's receipt
|
|
9
|
+
* /shop/account a customer's account: sign in, create one, details, addresses, orders (settings.accounts)
|
|
10
|
+
* /shop/account/confirm?u=&t= /shop/account/reset?t= the links emailed to them
|
|
9
11
|
*
|
|
10
12
|
* Like the Blog, the pages are answered from ONE onRequest hook matching the
|
|
11
13
|
* address as it is saved NOW, so moving the shop needs no restart. Every page
|
|
@@ -16,7 +18,7 @@ import {fileURLToPath} from 'url';
|
|
|
16
18
|
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
17
19
|
import {parseMarkdown} from '../../server/services/markdown.js';
|
|
18
20
|
import {byShelf, safeImage} from './public/lib/products.js';
|
|
19
|
-
import {cartHtml, esc, productHtml, receiptHtml, scriptJson, storefrontHtml} from './lib/render.js';
|
|
21
|
+
import {accountHtml, cartHtml, esc, productHtml, receiptHtml, scriptJson, storefrontHtml} from './lib/render.js';
|
|
20
22
|
import {allProducts, getOrder, liveSettings, publicOrder, tokenOk} from './lib/shop.js';
|
|
21
23
|
import {cartRegistry} from './lib/extensions.js';
|
|
22
24
|
|
|
@@ -31,6 +33,8 @@ export function matchShopPath(pathname, base) {
|
|
|
31
33
|
try { parts = pathname.slice(base.length + 1).split('/').map(decodeURIComponent); } catch { return null; }
|
|
32
34
|
if (parts.length === 1 && parts[0] === '') return {name: 'slash', params: {}};
|
|
33
35
|
if (parts.length === 1 && (parts[0] === 'cart' || parts[0] === 'checkout' || parts[0] === 'basket')) return {name: 'cart', params: {}};
|
|
36
|
+
// Before products: a product called "account" would otherwise take the page.
|
|
37
|
+
if (parts[0] === 'account' && parts.length <= 2 && ['', 'confirm', 'reset'].includes(parts[1] || '')) return {name: 'account', params: {view: parts[1] || 'account'}};
|
|
34
38
|
if (parts.length === 2 && parts[0] === 'order' && /^[\w-]{8,64}$/.test(parts[1])) return {name: 'order', params: {id: parts[1]}};
|
|
35
39
|
if (parts.length === 1 && /^[a-z0-9-]{1,80}$/.test(parts[0])) return {name: 'product', params: {slug: parts[0]}};
|
|
36
40
|
return null;
|
|
@@ -112,6 +116,18 @@ export default async function shopPublic(fastify) {
|
|
|
112
116
|
});
|
|
113
117
|
},
|
|
114
118
|
|
|
119
|
+
async account(request, reply, s, {view}) {
|
|
120
|
+
if (!s.accounts) return null;
|
|
121
|
+
return send(reply, request, s, {
|
|
122
|
+
page: 'account',
|
|
123
|
+
body: accountHtml({settings: s, view}),
|
|
124
|
+
title: view === 'reset' ? 'Set a new password' : view === 'confirm' ? 'Confirm your account' : `Your account - ${s.shopTitle || 'Shop'}`,
|
|
125
|
+
urlPath: `${s.shopPath}/account`,
|
|
126
|
+
noindex: true,
|
|
127
|
+
extra: {account: {view}}
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
115
131
|
async order(request, reply, s, {id}) {
|
|
116
132
|
let entry = await getOrder(id);
|
|
117
133
|
if (!entry || !tokenOk(entry, request.query?.t)) return null;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer accounts in the browser (loaded by cart.js on the account pages, and on a receipt that offers to make
|
|
3
|
+
* one): sign in, create an account, forgotten password; signed in - details, saved addresses, orders (with what
|
|
4
|
+
* extensions add beside each: Takeaway's Reorder), sign out, delete my account. The confirm and reset links
|
|
5
|
+
* emailed to customers land on /account/confirm and /account/reset.
|
|
6
|
+
*
|
|
7
|
+
* @module shopping-cart/public/account
|
|
8
|
+
*/
|
|
9
|
+
import {formatMoney} from './lib/money.js';
|
|
10
|
+
import {COUNTRIES, STATUS_PUBLIC} from './lib/orders.js';
|
|
11
|
+
import {account, me, signIn, signOut, signedIn} from './lib/session.js';
|
|
12
|
+
|
|
13
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
14
|
+
const el = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
|
|
15
|
+
const params = new URLSearchParams(location.search);
|
|
16
|
+
const config = (() => { try { return JSON.parse(document.getElementById('dm-shop-config')?.textContent || '{}'); } catch { return {}; } })();
|
|
17
|
+
const shopPath = config.shopPath || '/shop';
|
|
18
|
+
const dmy = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; };
|
|
19
|
+
|
|
20
|
+
/** A form's error line. */
|
|
21
|
+
const say = (box, msg, good = false) => { box.textContent = msg; box.hidden = !msg; box.classList.toggle('is-good', good); };
|
|
22
|
+
async function busy(btn, fn) {
|
|
23
|
+
const was = btn.textContent;
|
|
24
|
+
btn.disabled = true;
|
|
25
|
+
btn.textContent = 'One moment…';
|
|
26
|
+
try { return await fn(); } finally { btn.disabled = false; btn.textContent = was; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---- Signed out: sign in, create, forgotten -----------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function signedOutView(body, {email = '', notice = ''} = {}) {
|
|
32
|
+
body.innerHTML = '';
|
|
33
|
+
const view = el(`<div class="shop-acc">
|
|
34
|
+
${notice ? `<p class="shop-acc-note is-good">${esc(notice)}</p>` : ''}
|
|
35
|
+
<div class="shop-seg" role="tablist"><button type="button" data-tab="in" class="is-on">Sign in</button><button type="button" data-tab="new">Create an account</button></div>
|
|
36
|
+
<form class="shop-acc-form" data-form="in" novalidate>
|
|
37
|
+
<label class="shop-field"><span>Email</span><input name="email" type="email" autocomplete="email" required></label>
|
|
38
|
+
<label class="shop-field"><span>Password</span><input name="password" type="password" autocomplete="current-password" required></label>
|
|
39
|
+
<p class="shop-co-error" data-err hidden></p>
|
|
40
|
+
<button type="submit" class="shop-drawer-go">Sign in</button>
|
|
41
|
+
<button type="button" class="shop-linkbtn" data-forgot>Forgotten your password?</button>
|
|
42
|
+
</form>
|
|
43
|
+
<form class="shop-acc-form" data-form="new" novalidate hidden>
|
|
44
|
+
<p class="shop-muted">Your details are filled in when you check out, and your orders are listed here. You can still check out as a guest.</p>
|
|
45
|
+
<label class="shop-field"><span>Name</span><input name="name" autocomplete="name" required maxlength="120"></label>
|
|
46
|
+
<label class="shop-field"><span>Email</span><input name="email" type="email" autocomplete="email" required></label>
|
|
47
|
+
<label class="shop-field"><span>Password <em>8 characters or more</em></span><input name="password" type="password" autocomplete="new-password" required minlength="8"></label>
|
|
48
|
+
<p class="shop-co-error" data-err hidden></p>
|
|
49
|
+
<button type="submit" class="shop-drawer-go">Create my account</button>
|
|
50
|
+
</form>
|
|
51
|
+
<form class="shop-acc-form" data-form="forgot" novalidate hidden>
|
|
52
|
+
<p class="shop-muted">We will email you a link to set a new password.</p>
|
|
53
|
+
<label class="shop-field"><span>Email</span><input name="email" type="email" autocomplete="email" required></label>
|
|
54
|
+
<p class="shop-co-error" data-err hidden></p>
|
|
55
|
+
<button type="submit" class="shop-drawer-go">Send the link</button>
|
|
56
|
+
<button type="button" class="shop-linkbtn" data-back>Back to sign in</button>
|
|
57
|
+
</form>
|
|
58
|
+
</div>`);
|
|
59
|
+
body.appendChild(view);
|
|
60
|
+
const show = (key) => {
|
|
61
|
+
for (const f of view.querySelectorAll('[data-form]')) f.hidden = f.dataset.form !== key;
|
|
62
|
+
for (const b of view.querySelectorAll('[data-tab]')) b.classList.toggle('is-on', b.dataset.tab === key);
|
|
63
|
+
view.querySelector(`[data-form="${key}"] input`)?.focus();
|
|
64
|
+
};
|
|
65
|
+
for (const f of view.querySelectorAll('input[name="email"]')) f.value = email;
|
|
66
|
+
view.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => show(b.dataset.tab)));
|
|
67
|
+
view.querySelector('[data-forgot]').addEventListener('click', () => { view.querySelector('[data-form="forgot"] [name="email"]').value = view.querySelector('[data-form="in"] [name="email"]').value; show('forgot'); });
|
|
68
|
+
view.querySelector('[data-back]').addEventListener('click', () => show('in'));
|
|
69
|
+
|
|
70
|
+
view.querySelector('[data-form="in"]').addEventListener('submit', async (e) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
const f = e.currentTarget;
|
|
73
|
+
const err = f.querySelector('[data-err]');
|
|
74
|
+
say(err, '');
|
|
75
|
+
try {
|
|
76
|
+
await busy(f.querySelector('button[type="submit"]'), () => signIn(f.email.value.trim(), f.password.value));
|
|
77
|
+
await render();
|
|
78
|
+
} catch (ex) {
|
|
79
|
+
say(err, ex.status === 401 || ex.status === 400 ? 'That email and password do not match an account - or the account is not confirmed yet.' : ex.message);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
view.querySelector('[data-form="new"]').addEventListener('submit', async (e) => {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
const f = e.currentTarget;
|
|
85
|
+
const err = f.querySelector('[data-err]');
|
|
86
|
+
say(err, '');
|
|
87
|
+
try {
|
|
88
|
+
await busy(f.querySelector('button[type="submit"]'), () => account.register({name: f.name.value, email: f.email.value.trim(), password: f.password.value}));
|
|
89
|
+
f.innerHTML = `<p class="shop-acc-note is-good">Nearly there - we have emailed <strong>${esc(f.email.value.trim())}</strong> a link to confirm your account. It works for 48 hours.</p>`;
|
|
90
|
+
} catch (ex) { say(err, ex.message); }
|
|
91
|
+
});
|
|
92
|
+
view.querySelector('[data-form="forgot"]').addEventListener('submit', async (e) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
const f = e.currentTarget;
|
|
95
|
+
try {
|
|
96
|
+
await busy(f.querySelector('button[type="submit"]'), () => account.forgot(f.email.value.trim()));
|
|
97
|
+
f.innerHTML = '<p class="shop-acc-note is-good">If there is an account with that email, a link to set a new password is on its way.</p>';
|
|
98
|
+
} catch (ex) { say(f.querySelector('[data-err]'), ex.message); }
|
|
99
|
+
});
|
|
100
|
+
if (params.get('join') === '1') show('new');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- Signed in: details, addresses, orders ------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
const addressText = (a) => [a.line1, a.line2, a.city, a.postcode].filter(Boolean).join(', ');
|
|
106
|
+
|
|
107
|
+
function signedInView(body, me) {
|
|
108
|
+
let addresses = me.addresses.map(a => ({...a}));
|
|
109
|
+
body.innerHTML = '';
|
|
110
|
+
const countries = Object.entries(COUNTRIES).map(([c, n]) => `<option value="${c}">${esc(n)}</option>`).join('');
|
|
111
|
+
const view = el(`<div class="shop-acc is-in">
|
|
112
|
+
<p class="shop-muted">Signed in as <strong>${esc(me.email)}</strong> · <button type="button" class="shop-linkbtn" data-out>Sign out</button></p>
|
|
113
|
+
<section class="shop-acc-card"><h2>Your orders</h2><div data-orders></div></section>
|
|
114
|
+
<section class="shop-acc-card"><h2>Your details</h2>
|
|
115
|
+
<form data-details novalidate>
|
|
116
|
+
<label class="shop-field"><span>Name</span><input name="name" autocomplete="name" maxlength="120"></label>
|
|
117
|
+
<label class="shop-field"><span>Phone</span><input name="phone" type="tel" autocomplete="tel" maxlength="40"></label>
|
|
118
|
+
<p class="shop-co-error" data-err hidden></p>
|
|
119
|
+
<button type="submit" class="shop-pay-btn">Save</button>
|
|
120
|
+
</form>
|
|
121
|
+
</section>
|
|
122
|
+
<section class="shop-acc-card"><h2>Saved addresses</h2><ul class="shop-acc-addrs" data-addrs></ul>
|
|
123
|
+
<button type="button" class="shop-linkbtn" data-add>Add an address</button>
|
|
124
|
+
<form class="shop-acc-addr" data-addr-form hidden novalidate>
|
|
125
|
+
<input type="hidden" name="id">
|
|
126
|
+
<label class="shop-field"><span>Label <em>optional</em></span><input name="label" maxlength="40" placeholder="Home, Work…"></label>
|
|
127
|
+
<label class="shop-field"><span>Address</span><input name="line1" autocomplete="address-line1" maxlength="160"></label>
|
|
128
|
+
<label class="shop-field"><span class="sr-only">Address line 2</span><input name="line2" autocomplete="address-line2" maxlength="160" placeholder="Flat, building (optional)"></label>
|
|
129
|
+
<label class="shop-field"><span>Town or city</span><input name="city" autocomplete="address-level2" maxlength="100"></label>
|
|
130
|
+
<label class="shop-field"><span>Postcode</span><input name="postcode" autocomplete="postal-code" maxlength="20"></label>
|
|
131
|
+
<label class="shop-field"><span>Country</span><select name="country">${countries}</select></label>
|
|
132
|
+
<p class="shop-co-error" data-err hidden></p>
|
|
133
|
+
<button type="submit" class="shop-pay-btn">Save address</button> <button type="button" class="shop-linkbtn" data-cancel>Cancel</button>
|
|
134
|
+
</form>
|
|
135
|
+
</section>
|
|
136
|
+
<details class="shop-acc-card shop-acc-danger"><summary>Delete my account</summary>
|
|
137
|
+
<form data-delete novalidate><p class="shop-muted">Your sign-in and saved details go. Orders you have placed stay with the shop as a record of the sale.</p>
|
|
138
|
+
<label class="shop-field"><span>Your password, to confirm</span><input name="password" type="password" autocomplete="current-password"></label>
|
|
139
|
+
<p class="shop-co-error" data-err hidden></p>
|
|
140
|
+
<button type="submit" class="shop-linkbtn is-danger">Delete my account</button></form>
|
|
141
|
+
</details>
|
|
142
|
+
</div>`);
|
|
143
|
+
body.appendChild(view);
|
|
144
|
+
const details = view.querySelector('[data-details]');
|
|
145
|
+
details.name.value = me.name;
|
|
146
|
+
details.phone.value = me.phone;
|
|
147
|
+
|
|
148
|
+
// Orders, newest first, with what extensions add (Takeaway: Reorder).
|
|
149
|
+
view.querySelector('[data-orders]').innerHTML = me.orders.length ? `<ul class="shop-acc-orders">${me.orders.map(o => `<li>
|
|
150
|
+
<a href="${esc(o.receipt)}" class="shop-acc-order"><strong>#${esc(o.number)}</strong><span>${esc(dmy(o.placedAt))}</span><span>${esc(STATUS_PUBLIC[o.status] || o.status)}</span><span>${esc(formatMoney(o.total, o.currency))}</span></a>
|
|
151
|
+
<small>${esc(o.lines.slice(0, 4).join(', '))}${o.lines.length > 4 ? '…' : ''}</small>
|
|
152
|
+
${o.parts.map(p => `<div class="shop-acc-ext" data-ext="${esc(p.owner)}">${p.html}</div>`).join('')}</li>`).join('')}</ul>`
|
|
153
|
+
: '<p class="shop-muted">No orders yet - everything you order with this email shows here.</p>';
|
|
154
|
+
|
|
155
|
+
const addrList = view.querySelector('[data-addrs]');
|
|
156
|
+
const addrForm = view.querySelector('[data-addr-form]');
|
|
157
|
+
const drawAddresses = () => {
|
|
158
|
+
addrList.innerHTML = addresses.length ? addresses.map(a => `<li data-id="${esc(a.id)}"><span>${a.label ? `<strong>${esc(a.label)}</strong> ` : ''}${esc(addressText(a))}</span>
|
|
159
|
+
<span><button type="button" class="shop-linkbtn" data-edit>Edit</button> · <button type="button" class="shop-linkbtn" data-remove>Remove</button></span></li>`).join('') : '<li class="shop-muted">None yet.</li>';
|
|
160
|
+
};
|
|
161
|
+
drawAddresses();
|
|
162
|
+
const saveAll = async (patch) => {
|
|
163
|
+
const r = await account.save(patch);
|
|
164
|
+
addresses = r.addresses.map(a => ({...a}));
|
|
165
|
+
drawAddresses();
|
|
166
|
+
return r;
|
|
167
|
+
};
|
|
168
|
+
const openAddr = (a = {}) => {
|
|
169
|
+
addrForm.hidden = false;
|
|
170
|
+
for (const k of ['id', 'label', 'line1', 'line2', 'city', 'postcode']) addrForm.elements[k].value = a[k] || '';
|
|
171
|
+
addrForm.country.value = a.country || 'GB';
|
|
172
|
+
addrForm.line1.focus();
|
|
173
|
+
};
|
|
174
|
+
view.querySelector('[data-add]').addEventListener('click', () => openAddr());
|
|
175
|
+
view.querySelector('[data-cancel]').addEventListener('click', () => { addrForm.hidden = true; });
|
|
176
|
+
addrList.addEventListener('click', async (e) => {
|
|
177
|
+
const id = e.target.closest('[data-id]')?.dataset.id;
|
|
178
|
+
if (!id) return;
|
|
179
|
+
if (e.target.closest('[data-edit]')) openAddr(addresses.find(a => a.id === id));
|
|
180
|
+
if (e.target.closest('[data-remove]')) await saveAll({addresses: addresses.filter(a => a.id !== id)}).catch(err => alert(err.message));
|
|
181
|
+
});
|
|
182
|
+
addrForm.addEventListener('submit', async (e) => {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
const a = Object.fromEntries(['id', 'label', 'line1', 'line2', 'city', 'postcode', 'country'].map(k => [k, addrForm.elements[k].value.trim()]));
|
|
185
|
+
const err = addrForm.querySelector('[data-err]');
|
|
186
|
+
if (!a.line1 || !a.city) return say(err, 'The first line of the address and the town.');
|
|
187
|
+
const next = a.id ? addresses.map(x => (x.id === a.id ? {...x, ...a} : x)) : [...addresses, a];
|
|
188
|
+
try { await saveAll({addresses: next}); addrForm.hidden = true; say(err, ''); } catch (ex) { say(err, ex.message); }
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
details.addEventListener('submit', async (e) => {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
const err = details.querySelector('[data-err]');
|
|
194
|
+
try { await busy(details.querySelector('button'), () => account.save({name: details.name.value, phone: details.phone.value})); say(err, 'Saved.', true); }
|
|
195
|
+
catch (ex) { say(err, ex.message); }
|
|
196
|
+
});
|
|
197
|
+
view.querySelector('[data-out]').addEventListener('click', async () => { await signOut(); render({notice: 'You are signed out.'}); });
|
|
198
|
+
view.querySelector('[data-delete]').addEventListener('submit', async (e) => {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
const f = e.currentTarget;
|
|
201
|
+
if (!confirm('Delete your account? This cannot be undone.')) return;
|
|
202
|
+
try { await account.remove(f.password.value); await signOut(); render({notice: 'Your account has been deleted.'}); }
|
|
203
|
+
catch (ex) { say(f.querySelector('[data-err]'), ex.message); }
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- The emailed links --------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async function confirmView(body) {
|
|
210
|
+
try {
|
|
211
|
+
const r = await account.confirm(params.get('u'), params.get('t'));
|
|
212
|
+
document.querySelector('.shop-account h1').textContent = 'Your account';
|
|
213
|
+
signedOutView(body, {email: r.email, notice: 'Thank you - your account is confirmed. Sign in to carry on.'});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
document.querySelector('.shop-account h1').textContent = 'That link did not work';
|
|
216
|
+
body.innerHTML = `<p class="shop-co-error">${esc(err.message)}</p><p><a class="shop-linkbtn" href="${esc(shopPath)}/account">Sign in or create an account</a></p>`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resetView(body) {
|
|
221
|
+
body.innerHTML = '';
|
|
222
|
+
const f = el(`<form class="shop-acc-form" novalidate>
|
|
223
|
+
<label class="shop-field"><span>New password <em>8 characters or more</em></span><input name="password" type="password" autocomplete="new-password" minlength="8"></label>
|
|
224
|
+
<label class="shop-field"><span>Again</span><input name="again" type="password" autocomplete="new-password"></label>
|
|
225
|
+
<p class="shop-co-error" data-err hidden></p>
|
|
226
|
+
<button type="submit" class="shop-drawer-go">Set my password</button>
|
|
227
|
+
</form>`);
|
|
228
|
+
body.appendChild(f);
|
|
229
|
+
f.addEventListener('submit', async (e) => {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
const err = f.querySelector('[data-err]');
|
|
232
|
+
if (f.password.value.length < 8) return say(err, '8 characters or more, please.');
|
|
233
|
+
if (f.password.value !== f.again.value) return say(err, 'The two passwords are not the same.');
|
|
234
|
+
try {
|
|
235
|
+
await busy(f.querySelector('button'), () => account.reset(params.get('t') || '', f.password.value));
|
|
236
|
+
document.querySelector('.shop-account h1').textContent = 'Your account';
|
|
237
|
+
signedOutView(body, {notice: 'Your password is set - sign in with it now.'});
|
|
238
|
+
} catch (ex) { say(err, ex.status === 400 && /token/i.test(ex.message) ? 'That link has run out or was used already - ask for a new one.' : ex.message); }
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- The receipt: "save your details" -------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function joinBox(box) {
|
|
245
|
+
if (signedIn()) return;
|
|
246
|
+
box.hidden = false;
|
|
247
|
+
box.innerHTML = `<h2>Save your details for next time</h2>
|
|
248
|
+
<p class="shop-muted">Create an account with <strong>${esc(box.dataset.email)}</strong> - your details are filled in next time, and this order is listed in it.</p>
|
|
249
|
+
<form novalidate><label class="shop-field"><span>Choose a password <em>8 characters or more</em></span><input name="password" type="password" autocomplete="new-password" minlength="8"></label>
|
|
250
|
+
<p class="shop-co-error" data-err hidden></p><button type="submit" class="shop-pay-btn">Create my account</button></form>`;
|
|
251
|
+
const f = box.querySelector('form');
|
|
252
|
+
f.addEventListener('submit', async (e) => {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
try {
|
|
255
|
+
await busy(f.querySelector('button'), () => account.register({name: box.dataset.name || box.dataset.email.split('@')[0], email: box.dataset.email, password: f.password.value}));
|
|
256
|
+
f.innerHTML = `<p class="shop-acc-note is-good">We have emailed you a link to confirm your account.</p>`;
|
|
257
|
+
} catch (ex) { say(f.querySelector('[data-err]'), ex.message); }
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- Start ---------------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
async function render({notice = ''} = {}) {
|
|
264
|
+
const body = document.querySelector('[data-acc-body]');
|
|
265
|
+
if (!body) return;
|
|
266
|
+
const view = document.querySelector('[data-shop-account]').dataset.shopAccount;
|
|
267
|
+
if (view === 'confirm') return confirmView(body);
|
|
268
|
+
if (view === 'reset') return resetView(body);
|
|
269
|
+
let mine = null;
|
|
270
|
+
try { mine = await me(); } catch { /* signed out below */ }
|
|
271
|
+
if (mine) signedInView(body, mine);
|
|
272
|
+
else signedOutView(body, {notice});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const join = document.querySelector('[data-shop-join]');
|
|
276
|
+
if (join) joinBox(join);
|
|
277
|
+
render();
|