domma-cms 0.84.0 → 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.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/js/lib/themes.js +1 -1
  5. package/package.json +2 -2
  6. package/plugins/free-tier.lock.json +53 -5
  7. package/plugins/security/plugin.js +10 -0
  8. package/plugins/security/plugin.json +1 -1
  9. package/plugins/security/server/facts.js +3 -1
  10. package/plugins/shopping-cart/CLAUDE.md +93 -0
  11. package/plugins/shopping-cart/admin/css/index.css +224 -0
  12. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  13. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  14. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  15. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  16. package/plugins/shopping-cart/admin/views/products.js +547 -0
  17. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  18. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  19. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  20. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  21. package/plugins/shopping-cart/config.js +52 -0
  22. package/plugins/shopping-cart/inject/body.html +2 -0
  23. package/plugins/shopping-cart/lib/extensions.js +96 -0
  24. package/plugins/shopping-cart/lib/payments.js +281 -0
  25. package/plugins/shopping-cart/lib/pricing.js +232 -0
  26. package/plugins/shopping-cart/lib/render.js +282 -0
  27. package/plugins/shopping-cart/lib/samples.js +34 -0
  28. package/plugins/shopping-cart/lib/shop.js +436 -0
  29. package/plugins/shopping-cart/lib/summary.js +127 -0
  30. package/plugins/shopping-cart/plugin.js +559 -0
  31. package/plugins/shopping-cart/plugin.json +52 -0
  32. package/plugins/shopping-cart/plugin.public.js +144 -0
  33. package/plugins/shopping-cart/public/cart.js +715 -0
  34. package/plugins/shopping-cart/public/lib/money.js +105 -0
  35. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  36. package/plugins/shopping-cart/public/lib/products.js +235 -0
  37. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  38. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  39. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  40. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  41. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  42. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  43. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  44. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  45. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  46. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  47. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  48. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  49. package/plugins/shopping-cart/public/shop.css +415 -0
  50. package/plugins/shopping-cart/templates/shop.html +4 -0
  51. package/plugins/shopping-cart/tests/api.test.js +144 -0
  52. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  53. package/scripts/setup.js +1 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Shop › Overview - how the shop is doing, and what needs you.
3
+ *
4
+ * Stat tiles (today, 30 days against the 30 before, average order, to fulfil),
5
+ * thirty days of takings as bars with a tooltip each, the best sellers, stock
6
+ * running low, and the latest orders. A shop with nothing yet gets the three
7
+ * steps to its first sale instead.
8
+ */
9
+
10
+ import {api, esc, money, when} from '../lib/kit.js';
11
+ import {STATUS_LABEL, STATUS_TONE} from '/plugins/shopping-cart/public/lib/orders.js';
12
+
13
+ export async function mount(root, ctx) {
14
+ const {kit, scope, here, go} = ctx;
15
+ const s = M.observable(null);
16
+ const error = M.observable('');
17
+
18
+ root.innerHTML = '';
19
+ const view = document.createElement('div');
20
+ view.className = 'shp-overview';
21
+ view.innerHTML = `
22
+ <p class="shp-error" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
23
+ <div class="shp-loading" data-bind-hidden="s.value || error.value">Loading…</div>
24
+ <div data-part="start" hidden></div>
25
+ <div data-part="main" hidden>
26
+ <div class="shp-tiles" data-part="tiles"></div>
27
+ <div class="shp-grid2">
28
+ <section class="shp-card shp-chart-card">
29
+ <header><h3>Takings, last 30 days <span data-help="Orders paid or fulfilled, by the day they were paid. Refunded and cancelled orders are left out."></span></h3>
30
+ <span class="shp-muted" data-part="chart-total"></span></header>
31
+ <div class="shp-chart" data-part="chart"></div>
32
+ </section>
33
+ <section class="shp-card">
34
+ <header><h3>Needs you</h3></header>
35
+ <ul class="shp-todo" data-part="todo"></ul>
36
+ </section>
37
+ </div>
38
+ <div class="shp-grid2">
39
+ <section class="shp-card">
40
+ <header><h3>Latest orders</h3><a href="#/plugins/shopping-cart/orders" class="shp-more">All orders</a></header>
41
+ <div class="shp-mini-list" data-part="recent"></div>
42
+ </section>
43
+ <section class="shp-card">
44
+ <header><h3>Best sellers <span data-help="By takings over the last 30 days."></span></h3><a href="#/plugins/shopping-cart/products" class="shp-more">Products</a></header>
45
+ <div class="shp-mini-list" data-part="top"></div>
46
+ </section>
47
+ </div>
48
+ </div>`;
49
+ root.appendChild(view);
50
+ const part = (k) => view.querySelector(`[data-part="${k}"]`);
51
+ scope(kit.helpPopovers(view));
52
+ const bindings = M.applyBindings({s, error}, view);
53
+ scope(() => bindings.dispose());
54
+
55
+ try {
56
+ s.value = await api.summary();
57
+ } catch (err) {
58
+ error.value = `Could not load the figures: ${err.message || err}`;
59
+ return;
60
+ }
61
+ if (!here()) return;
62
+ const d = s.peek();
63
+
64
+ // Nothing sold and nothing to sell: show the way in.
65
+ if (!d.products.total && !d.allTime.orders) {
66
+ const start = part('start');
67
+ start.hidden = false;
68
+ const pay = d.methods.filter(m => m !== 'offline').length;
69
+ start.innerHTML = `<div class="shp-welcome">
70
+ <span class="shp-welcome-icon" data-icon="shopping-bag" data-icon-size="30"></span>
71
+ <h2>Open your shop</h2>
72
+ <p>Three steps to your first sale. Your shop lives at <a href="${esc(d.shopPath)}" target="_blank" rel="noopener">${esc(d.shopPath)}</a>.</p>
73
+ <ol class="shp-steps">
74
+ <li class="${d.products.total ? 'is-done' : ''}"><strong>Add a product</strong><span>Name, price, a picture or two.</span><span class="shp-step-btns"><button type="button" class="btn btn-sm btn-primary" data-go="product">Add a product</button><button type="button" class="btn btn-sm btn-ghost" data-go="samples" title="Six products with pictures, options and stock - edit or delete them later">or try samples</button></span></li>
75
+ <li class="${pay ? 'is-done' : ''}"><strong>Choose how you get paid</strong><span>Stripe for cards, Apple Pay and Google Pay; PayPal; or payment offline.</span><button type="button" class="btn btn-sm btn-outline" data-go="pay">Payment settings</button></li>
76
+ <li><strong>Put it on a page</strong><span>Link to ${esc(d.shopPath)} from your menu, or drop <code>[shop-products /]</code> into any page.</span><button type="button" class="btn btn-sm btn-outline" data-go="visit">See the shop</button></li>
77
+ </ol>
78
+ </div>`;
79
+ start.addEventListener('click', (e) => {
80
+ const b = e.target.closest('[data-go]');
81
+ if (!b) return;
82
+ if (b.dataset.go === 'product') go('products', 'new');
83
+ if (b.dataset.go === 'pay') ctx.openSettings('payment');
84
+ if (b.dataset.go === 'visit') window.open(d.shopPath, '_blank', 'noopener');
85
+ if (b.dataset.go === 'samples') {
86
+ b.disabled = true;
87
+ api.products.samples().then(() => { E.toast('Six sample products are on sale - try the shop.', {type: 'success'}); go('products'); },
88
+ (err) => { b.disabled = false; E.toast(err.message || String(err), {type: 'error'}); });
89
+ }
90
+ });
91
+ I.scan(start);
92
+ return;
93
+ }
94
+
95
+ part('main').hidden = false;
96
+
97
+ // ---- Tiles ----
98
+ const change = d.days30.change;
99
+ const tiles = [
100
+ {label: 'Today', value: money(d.today.revenue), sub: `${d.today.orders} order${d.today.orders === 1 ? '' : 's'}`, icon: 'sun'},
101
+ {label: 'Last 30 days', value: money(d.days30.revenue),
102
+ sub: change === null ? `${d.days30.orders} orders` : `${change >= 0 ? '▲' : '▼'} ${Math.abs(change)}% on the 30 before`,
103
+ tone: change === null ? '' : change >= 0 ? 'up' : 'down', icon: 'trending-up'},
104
+ {label: 'Average order', value: money(d.days30.average), sub: 'last 30 days', icon: 'receipt'},
105
+ {label: 'To fulfil', value: String(d.toFulfil), sub: d.awaiting ? `${d.awaiting} awaiting payment` : 'paid, not yet sent',
106
+ icon: 'truck', link: 'orders', tone: d.toFulfil ? 'attention' : ''}
107
+ ];
108
+ part('tiles').innerHTML = tiles.map(t => `
109
+ <${t.link ? 'a href="#/plugins/shopping-cart/orders"' : 'div'} class="shp-tile${t.tone ? ` is-${t.tone}` : ''}">
110
+ <span class="shp-tile-icon" data-icon="${t.icon}" data-icon-size="18"></span>
111
+ <span class="shp-tile-label">${esc(t.label)}</span>
112
+ <strong class="shp-tile-value">${esc(t.value)}</strong>
113
+ <span class="shp-tile-sub">${esc(t.sub)}</span>
114
+ </${t.link ? 'a' : 'div'}>`).join('');
115
+
116
+ // ---- Chart: one series, bars, a tooltip on each ----
117
+ drawChart(part('chart'), d.series, scope);
118
+ part('chart-total').textContent = `${money(d.days30.revenue)} · ${d.days30.orders} orders`;
119
+
120
+ // ---- Needs you ----
121
+ const todo = [];
122
+ if (d.toFulfil) todo.push({icon: 'truck', text: `${d.toFulfil} paid order${d.toFulfil === 1 ? '' : 's'} to send`, go: 'orders'});
123
+ if (d.awaiting) todo.push({icon: 'clock', text: `${d.awaiting} awaiting payment`, go: 'orders'});
124
+ for (const p of d.lowStock.slice(0, 5)) {
125
+ todo.push({icon: 'alert-triangle', text: p.stock === 0 ? `${p.name} is sold out` : p.variants.length ? `${p.name} - ${p.variants.join(', ')}` : `${p.name}: ${p.stock} left`, go: 'products', id: p.id, tone: p.stock === 0 ? 'danger' : 'warning'});
126
+ }
127
+ if (d.abandoned) todo.push({icon: 'inbox', text: `${d.abandoned} checkout${d.abandoned === 1 ? '' : 's'} started this week and not paid`, go: 'orders',
128
+ help: 'Shoppers who went to pay and did not finish. Shopping Cart Pro can email them a reminder.'});
129
+ if (!d.methods.some(m => m !== 'offline')) todo.push({icon: 'credit-card', text: 'Take card payments - set up Stripe or PayPal', settings: 'payment'});
130
+ part('todo').innerHTML = todo.length
131
+ ? todo.map((t, i) => `<li><button type="button" class="shp-todo-item${t.tone ? ` is-${t.tone}` : ''}" data-i="${i}"><span data-icon="${t.icon}" data-icon-size="15"></span><span>${esc(t.text)}</span><span class="shp-todo-go" data-icon="chevron-right" data-icon-size="14"></span></button></li>`).join('')
132
+ : '<li class="shp-all-clear"><span data-icon="check-circle" data-icon-size="18"></span> All clear - nothing waiting.</li>';
133
+ part('todo').addEventListener('click', (e) => {
134
+ const b = e.target.closest('[data-i]');
135
+ if (!b) return;
136
+ const t = todo[Number(b.dataset.i)];
137
+ if (t.settings) ctx.openSettings(t.settings);
138
+ else go(t.go, t.id || '');
139
+ });
140
+
141
+ // ---- Latest orders, best sellers ----
142
+ part('recent').innerHTML = d.recent.length ? d.recent.map(o => `
143
+ <a class="shp-mini" href="#/plugins/shopping-cart/orders/${esc(o.id)}">
144
+ <span class="shp-mini-main"><span><strong>#${esc(o.number)}</strong> ${esc(o.name || '')}</span><small>${esc(when(o.at))} · ${o.items} item${o.items === 1 ? '' : 's'}</small></span>
145
+ <span class="shp-chip is-${STATUS_TONE[o.status]}">${esc(STATUS_LABEL[o.status])}</span>
146
+ <span class="shp-mini-amt">${esc(money(o.total))}</span>
147
+ </a>`).join('') : '<p class="shp-muted">No orders yet.</p>';
148
+
149
+ const maxRev = Math.max(1, ...d.topProducts.map(p => p.revenue));
150
+ part('top').innerHTML = d.topProducts.length ? d.topProducts.map(p => `
151
+ <div class="shp-top">
152
+ <span class="shp-top-name">${esc(p.name)}<small>${p.qty} sold</small></span>
153
+ <span class="shp-top-bar"><span style="width:${Math.round(p.revenue / maxRev * 100)}%"></span></span>
154
+ <span class="shp-mini-amt">${esc(money(p.revenue))}</span>
155
+ </div>`).join('') : '<p class="shp-muted">Nothing sold in the last 30 days.</p>';
156
+
157
+ I.scan(view);
158
+ }
159
+
160
+ /** Thirty bars, 2px apart, rounded tops on the baseline; hover (or focus) says the day and the takings. */
161
+ function drawChart(el, series, scope) {
162
+ const W = Math.max(360, Math.round(el.clientWidth) || 600), H = 170, padL = 44, padB = 22, padT = 8;
163
+ const max = Math.max(...series.map(d => d.revenue));
164
+ const top = niceMax(max);
165
+ const n = series.length;
166
+ const slot = (W - padL) / n;
167
+ const bw = Math.max(2, slot - 2);
168
+ const y = (v) => padT + (H - padT - padB) * (1 - v / top);
169
+ const ticks = [0, top / 2, top];
170
+ const day = (k) => new Date(`${k}T12:00:00`).toLocaleDateString(undefined, {day: 'numeric', month: 'short'});
171
+ const bars = series.map((d, i) => {
172
+ const x = padL + i * slot + 1;
173
+ const h = Math.max(0, H - padB - y(d.revenue));
174
+ const r = Math.min(4, bw / 2, h);
175
+ const path = h > 0
176
+ ? `M${x},${H - padB} v${-(h - r)} q0,${-r} ${r},${-r} h${bw - 2 * r} q${r},0 ${r},${r} v${h - r} z`
177
+ : '';
178
+ return `<g class="shp-bar" data-i="${i}" tabindex="0" aria-label="${esc(day(d.day))}: ${esc(money(d.revenue))}, ${d.orders} orders">
179
+ <rect x="${padL + i * slot}" y="${padT}" width="${slot}" height="${H - padT - padB}" fill="transparent"/>
180
+ ${path ? `<path d="${path}"/>` : `<rect class="shp-bar-zero" x="${x}" y="${H - padB - 1}" width="${bw}" height="1"/>`}
181
+ </g>`;
182
+ }).join('');
183
+ const labels = [0, Math.floor(n / 2), n - 1].map(i => `<text x="${padL + i * slot + slot / 2}" y="${H - 6}" text-anchor="middle">${esc(day(series[i].day))}</text>`).join('');
184
+ el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Takings for each of the last 30 days">
185
+ ${ticks.map(t => `<line class="shp-gridline" x1="${padL}" x2="${W}" y1="${y(t)}" y2="${y(t)}"/><text class="shp-axis" x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${esc(money(t, {compact: true}))}</text>`).join('')}
186
+ ${bars}
187
+ <g class="shp-axis">${labels}</g>
188
+ </svg><div class="shp-tip" hidden></div>`;
189
+ const tip = el.querySelector('.shp-tip');
190
+ const show = (g) => {
191
+ const d = series[Number(g.dataset.i)];
192
+ tip.innerHTML = `<strong>${esc(money(d.revenue))}</strong><span>${esc(day(d.day))} · ${d.orders} order${d.orders === 1 ? '' : 's'}</span>`;
193
+ tip.hidden = false;
194
+ const r = g.getBoundingClientRect(), box = el.getBoundingClientRect();
195
+ tip.style.left = `${Math.min(box.width - 120, Math.max(0, r.left - box.left + r.width / 2 - 60))}px`;
196
+ el.querySelectorAll('.shp-bar.is-on').forEach(b => b.classList.remove('is-on'));
197
+ g.classList.add('is-on');
198
+ };
199
+ const hide = () => { tip.hidden = true; el.querySelectorAll('.shp-bar.is-on').forEach(b => b.classList.remove('is-on')); };
200
+ const over = (e) => { const g = e.target.closest('.shp-bar'); if (g) show(g); };
201
+ el.addEventListener('mouseover', over);
202
+ el.addEventListener('focusin', over);
203
+ el.addEventListener('mouseleave', hide);
204
+ el.addEventListener('focusout', hide);
205
+ scope(() => { el.removeEventListener('mouseover', over); el.removeEventListener('focusin', over); });
206
+ }
207
+
208
+ /** A round top for the axis: 1, 2 or 5 × a power of ten, in minor units. */
209
+ function niceMax(v) {
210
+ if (v <= 0) return 1000;
211
+ const p = 10 ** Math.floor(Math.log10(v));
212
+ for (const m of [1, 2, 5, 10]) if (m * p >= v) return m * p;
213
+ return 10 * p;
214
+ }