domma-cms 0.84.1 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/js/lib/themes.js +1 -1
- package/package.json +2 -2
- package/plugins/free-tier.lock.json +49 -1
- package/plugins/shopping-cart/CLAUDE.md +93 -0
- package/plugins/shopping-cart/admin/css/index.css +224 -0
- package/plugins/shopping-cart/admin/lib/kit.js +190 -0
- package/plugins/shopping-cart/admin/templates/shop.html +1 -0
- package/plugins/shopping-cart/admin/views/orders.js +365 -0
- package/plugins/shopping-cart/admin/views/overview.js +214 -0
- package/plugins/shopping-cart/admin/views/products.js +547 -0
- package/plugins/shopping-cart/admin/views/settings.js +247 -0
- package/plugins/shopping-cart/admin/views/shop.js +130 -0
- package/plugins/shopping-cart/collections/shop-orders.json +20 -0
- package/plugins/shopping-cart/collections/shop-products.json +24 -0
- package/plugins/shopping-cart/config.js +52 -0
- package/plugins/shopping-cart/inject/body.html +2 -0
- package/plugins/shopping-cart/lib/extensions.js +96 -0
- package/plugins/shopping-cart/lib/payments.js +281 -0
- package/plugins/shopping-cart/lib/pricing.js +232 -0
- package/plugins/shopping-cart/lib/render.js +282 -0
- package/plugins/shopping-cart/lib/samples.js +34 -0
- package/plugins/shopping-cart/lib/shop.js +436 -0
- package/plugins/shopping-cart/lib/summary.js +127 -0
- package/plugins/shopping-cart/plugin.js +559 -0
- package/plugins/shopping-cart/plugin.json +52 -0
- package/plugins/shopping-cart/plugin.public.js +144 -0
- package/plugins/shopping-cart/public/cart.js +715 -0
- package/plugins/shopping-cart/public/lib/money.js +105 -0
- package/plugins/shopping-cart/public/lib/orders.js +122 -0
- package/plugins/shopping-cart/public/lib/products.js +235 -0
- package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
- package/plugins/shopping-cart/public/samples/candle.svg +16 -0
- package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
- package/plugins/shopping-cart/public/samples/mug.svg +14 -0
- package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
- package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
- package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
- package/plugins/shopping-cart/public/samples/print.svg +14 -0
- package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
- package/plugins/shopping-cart/public/samples/tee.svg +12 -0
- package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
- package/plugins/shopping-cart/public/samples/tote.svg +13 -0
- package/plugins/shopping-cart/public/shop.css +415 -0
- package/plugins/shopping-cart/templates/shop.html +4 -0
- package/plugins/shopping-cart/tests/api.test.js +144 -0
- package/plugins/shopping-cart/tests/pure.test.js +210 -0
- package/scripts/setup.js +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopping Cart - the storefront's pages, at the shop's address (Settings >
|
|
3
|
+
* Storefront, "/shop" by default):
|
|
4
|
+
*
|
|
5
|
+
* /shop every product on sale (?category=, ?q=)
|
|
6
|
+
* /shop/<slug> one product
|
|
7
|
+
* /shop/cart the basket and checkout, one page
|
|
8
|
+
* /shop/order/<id>?t= a shopper's receipt
|
|
9
|
+
*
|
|
10
|
+
* Like the Blog, the pages are answered from ONE onRequest hook matching the
|
|
11
|
+
* address as it is saved NOW, so moving the shop needs no restart. Every page
|
|
12
|
+
* is complete HTML without script; public/cart.js makes it live.
|
|
13
|
+
*/
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import {fileURLToPath} from 'url';
|
|
16
|
+
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
17
|
+
import {parseMarkdown} from '../../server/services/markdown.js';
|
|
18
|
+
import {byShelf, safeImage} from './public/lib/products.js';
|
|
19
|
+
import {cartHtml, esc, productHtml, receiptHtml, scriptJson, storefrontHtml} from './lib/render.js';
|
|
20
|
+
import {allProducts, getOrder, liveSettings, publicOrder, tokenOk} from './lib/shop.js';
|
|
21
|
+
import {cartRegistry} from './lib/extensions.js';
|
|
22
|
+
|
|
23
|
+
const TEMPLATE = path.join(fileURLToPath(new URL('.', import.meta.url)), 'templates', 'shop.html');
|
|
24
|
+
export const ASSET_V = '1.0.0';
|
|
25
|
+
|
|
26
|
+
/** Which page a path is, under the shop's base. */
|
|
27
|
+
export function matchShopPath(pathname, base) {
|
|
28
|
+
if (pathname === base) return {name: 'index', params: {}};
|
|
29
|
+
if (!pathname.startsWith(`${base}/`)) return null;
|
|
30
|
+
let parts;
|
|
31
|
+
try { parts = pathname.slice(base.length + 1).split('/').map(decodeURIComponent); } catch { return null; }
|
|
32
|
+
if (parts.length === 1 && parts[0] === '') return {name: 'slash', params: {}};
|
|
33
|
+
if (parts.length === 1 && (parts[0] === 'cart' || parts[0] === 'checkout' || parts[0] === 'basket')) return {name: 'cart', params: {}};
|
|
34
|
+
if (parts.length === 2 && parts[0] === 'order' && /^[\w-]{8,64}$/.test(parts[1])) return {name: 'order', params: {id: parts[1]}};
|
|
35
|
+
if (parts.length === 1 && /^[a-z0-9-]{1,80}$/.test(parts[0])) return {name: 'product', params: {slug: parts[0]}};
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the browser script needs, carried in the page. */
|
|
40
|
+
function pageConfig(s, extra = {}) {
|
|
41
|
+
return {
|
|
42
|
+
shopPath: s.shopPath, currency: s.currency, floatingCart: Boolean(s.floatingCart), cartPosition: s.cartPosition,
|
|
43
|
+
freeShippingOver: Number(s.freeShippingOver) || 0, title: s.shopTitle, ...extra
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default async function shopPublic(fastify) {
|
|
48
|
+
async function send(reply, request, s, {body, title, description = '', image = '', urlPath, noindex = false, page, extra = {}}) {
|
|
49
|
+
const html = await renderBlogPage(TEMPLATE, {
|
|
50
|
+
body,
|
|
51
|
+
page,
|
|
52
|
+
assetV: ASSET_V,
|
|
53
|
+
config: scriptJson(pageConfig(s, extra))
|
|
54
|
+
}, {
|
|
55
|
+
title, description, ogImage: image, urlPath, noindex,
|
|
56
|
+
baseUrl: `${request.protocol}://${request.host || request.headers.host || request.hostname}`
|
|
57
|
+
});
|
|
58
|
+
return reply.type('text/html').header('Cache-Control', 'no-store').send(html);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pages = {
|
|
62
|
+
async index(request, reply, s) {
|
|
63
|
+
const entries = (await allProducts()).filter(e => e.data?.status === 'active').sort(byShelf);
|
|
64
|
+
const category = String(request.query?.category || '').slice(0, 60);
|
|
65
|
+
const query = String(request.query?.q || '').slice(0, 80);
|
|
66
|
+
return send(reply, request, s, {
|
|
67
|
+
page: 'index',
|
|
68
|
+
body: storefrontHtml({entries, settings: s, base: s.shopPath, category, query}),
|
|
69
|
+
title: s.shopTitle || 'Shop',
|
|
70
|
+
description: s.shopIntro || '',
|
|
71
|
+
image: safeImage(entries[0]?.data?.images?.[0]),
|
|
72
|
+
urlPath: s.shopPath,
|
|
73
|
+
extra: {category, query}
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async slash(request, reply, s) {
|
|
78
|
+
return reply.redirect(s.shopPath, 301);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async product(request, reply, s, {slug}) {
|
|
82
|
+
const all = (await allProducts()).filter(e => e.data?.status === 'active');
|
|
83
|
+
const entry = all.find(e => e.data.slug === slug);
|
|
84
|
+
if (!entry) return null; // fall through to the CMS's own 404 (or a page at that address)
|
|
85
|
+
let descriptionHtml = '';
|
|
86
|
+
if (entry.data.description) {
|
|
87
|
+
try { descriptionHtml = (await parseMarkdown(entry.data.description, {urlPath: `${s.shopPath}/${slug}`})).html; }
|
|
88
|
+
catch { descriptionHtml = `<p>${esc(entry.data.description)}</p>`; }
|
|
89
|
+
}
|
|
90
|
+
const related = all.filter(e => e.id !== entry.id)
|
|
91
|
+
.sort((a, b) => (Number(b.data.category === entry.data.category) - Number(a.data.category === entry.data.category)) || byShelf(a, b))
|
|
92
|
+
.slice(0, 4);
|
|
93
|
+
return send(reply, request, s, {
|
|
94
|
+
page: 'product',
|
|
95
|
+
body: productHtml({entry, settings: s, base: s.shopPath, descriptionHtml, related}),
|
|
96
|
+
title: entry.data.name,
|
|
97
|
+
description: entry.data.summary || '',
|
|
98
|
+
image: safeImage(entry.data.images?.[0]),
|
|
99
|
+
urlPath: `${s.shopPath}/${slug}`,
|
|
100
|
+
extra: {product: {id: entry.id, variants: (entry.data.variants || []).length}}
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async cart(request, reply, s) {
|
|
105
|
+
return send(reply, request, s, {
|
|
106
|
+
page: 'cart',
|
|
107
|
+
body: cartHtml({settings: s, base: s.shopPath}),
|
|
108
|
+
title: `Basket - ${s.shopTitle || 'Shop'}`,
|
|
109
|
+
urlPath: `${s.shopPath}/cart`,
|
|
110
|
+
noindex: true,
|
|
111
|
+
extra: {features: cartRegistry().checkoutFeatures(), cancelled: String(request.query?.cancelled || '').slice(0, 64)}
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async order(request, reply, s, {id}) {
|
|
116
|
+
let entry = await getOrder(id);
|
|
117
|
+
if (!entry || !tokenOk(entry, request.query?.t)) return null;
|
|
118
|
+
const confirm = cartRegistry().services?.confirmStripe;
|
|
119
|
+
if (confirm) entry = await confirm(entry, request.query?.session_id, request.log);
|
|
120
|
+
const order = publicOrder(entry);
|
|
121
|
+
return send(reply, request, s, {
|
|
122
|
+
page: 'order',
|
|
123
|
+
body: receiptHtml({order, settings: s, base: s.shopPath, problem: String(request.query?.problem || '')}),
|
|
124
|
+
title: `Order #${order.number}`,
|
|
125
|
+
urlPath: `${s.shopPath}/order/${id}`,
|
|
126
|
+
noindex: true,
|
|
127
|
+
extra: {order: {id: order.id, status: order.status, token: String(request.query?.t || '')}}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
133
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
|
134
|
+
const s = await liveSettings();
|
|
135
|
+
const hit = matchShopPath(request.url.split('?')[0], s.shopPath);
|
|
136
|
+
if (!hit) return;
|
|
137
|
+
const sent = await pages[hit.name](request, reply, s, hit.params);
|
|
138
|
+
if (sent === null) return; // not ours after all
|
|
139
|
+
return sent;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The onRequest hook must see every request, not only routes registered here (it registers none).
|
|
144
|
+
shopPublic[Symbol.for('skip-override')] = true;
|