domma-cms 0.84.1 → 0.86.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/sidebar-renderer.js +3 -3
- package/admin/js/lib/themes.js +1 -1
- package/config/menus/admin-sidebar.json +21 -8
- package/config/plugins.json +0 -18
- package/package.json +2 -2
- package/plugins/_lib/mail/diagnostics.js +3 -2
- package/plugins/blog/CLAUDE.md +33 -0
- package/plugins/blog/lib/access.js +64 -0
- package/plugins/blog/lib/notices.js +38 -0
- package/plugins/blog/plugin.js +84 -26
- package/plugins/blog/plugin.json +173 -20
- package/plugins/blog/roles/blog-author.json +15 -2
- package/plugins/blog/tests/access.test.js +32 -0
- package/plugins/blog/tests/comment-notices.test.js +140 -0
- package/plugins/blog/tests/notices.test.js +20 -0
- package/plugins/blog/tests/public.test.js +3 -1
- package/plugins/free-tier.lock.json +63 -10
- package/plugins/mail-reader/plugin.js +12 -1
- package/plugins/mail-reader/plugin.json +23 -4
- 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
- package/server/routes/api/notifications.js +4 -3
- package/server/services/notify.js +4 -2
- package/server/services/pluginScope.js +85 -0
- package/server/services/plugins.js +7 -2
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shop › Orders.
|
|
3
|
+
*
|
|
4
|
+
* Chips by status (with counts), search over number, name, email and items,
|
|
5
|
+
* rows with a right-click / ⋮ / Shift+F10 menu of the moves the order can make
|
|
6
|
+
* (the same NEXT table the server enforces). Click opens the order in a
|
|
7
|
+
* slideover: the items and totals, the shopper, delivery and tracking, a note
|
|
8
|
+
* for staff, its history, and the actions - fulfil, refund, cancel, a packing
|
|
9
|
+
* slip. A notification's link (#/plugins/shopping-cart/orders/<id>) opens one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {api, copy, entriesOf, esc, money, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
|
|
13
|
+
import {NEXT, STATUS_LABEL, STATUS_TONE, addressLines, orderLabel} from '/plugins/shopping-cart/public/lib/orders.js';
|
|
14
|
+
|
|
15
|
+
const FILTERS = [
|
|
16
|
+
{key: 'open', label: 'To do', match: (s) => s === 'paid' || s === 'awaiting'},
|
|
17
|
+
{key: 'paid', label: 'To fulfil', match: (s) => s === 'paid'},
|
|
18
|
+
{key: 'awaiting', label: 'Awaiting payment', match: (s) => s === 'awaiting'},
|
|
19
|
+
{key: 'fulfilled', label: 'Fulfilled', match: (s) => s === 'fulfilled'},
|
|
20
|
+
{key: 'pending', label: 'Started', match: (s) => s === 'pending'},
|
|
21
|
+
{key: 'closed', label: 'Cancelled & refunded', match: (s) => s === 'cancelled' || s === 'refunded'},
|
|
22
|
+
{key: 'all', label: 'All', match: (s) => s !== 'pending'}
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const ROW = `
|
|
26
|
+
<div class="shp-row" data-id="{{id}}" tabindex="0">
|
|
27
|
+
<span class="shp-num" data-bind-text="num"></span>
|
|
28
|
+
<span class="shp-main"><strong data-bind-text="who"></strong><small data-bind-text="meta"></small></span>
|
|
29
|
+
<span class="shp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
|
|
30
|
+
<span class="shp-amt" data-bind-text="amount"></span>
|
|
31
|
+
<button type="button" class="shp-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
32
|
+
</div>`;
|
|
33
|
+
|
|
34
|
+
const PROVIDER = {stripe: 'Stripe', paypal: 'PayPal', offline: 'Offline', free: 'No payment'};
|
|
35
|
+
|
|
36
|
+
export async function mount(root, ctx) {
|
|
37
|
+
const {kit, scope, here, settings} = ctx;
|
|
38
|
+
const orders = M.observable([]);
|
|
39
|
+
const loading = M.observable(true);
|
|
40
|
+
const error = M.observable('');
|
|
41
|
+
const filter = M.observable(store.get('shopping-cart.orders-filter') || 'open');
|
|
42
|
+
const query = M.observable('');
|
|
43
|
+
scope(M.effect(() => store.set('shopping-cart.orders-filter', filter.value)));
|
|
44
|
+
|
|
45
|
+
const counts = M.computed(() => {
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const f of FILTERS) out[f.key] = orders.value.filter(o => f.match(o.data?.status)).length;
|
|
48
|
+
return out;
|
|
49
|
+
});
|
|
50
|
+
const rows = M.observable([]);
|
|
51
|
+
scope(M.effect(() => {
|
|
52
|
+
const f = FILTERS.find(x => x.key === filter.value) || FILTERS[0];
|
|
53
|
+
const q = query.value.trim().toLowerCase();
|
|
54
|
+
rows.value = orders.value
|
|
55
|
+
.filter(o => f.match(o.data?.status))
|
|
56
|
+
.filter(o => !q || [o.data.number, o.data.customer?.name, o.data.email, ...(o.data.lines || []).map(l => l.name)]
|
|
57
|
+
.join(' ').toLowerCase().includes(q.replace(/^#/, '')))
|
|
58
|
+
.map(o => {
|
|
59
|
+
const items = (o.data.lines || []).reduce((s, l) => s + l.qty, 0);
|
|
60
|
+
return {
|
|
61
|
+
id: o.id,
|
|
62
|
+
num: orderLabel(o.data),
|
|
63
|
+
who: o.data.customer?.name || o.data.email || 'Shopper',
|
|
64
|
+
meta: `${when(o.meta?.createdAt)} · ${items} item${items === 1 ? '' : 's'} · ${PROVIDER[o.data.provider] || o.data.provider}${o.data.delivery === 'collect' ? ' · collecting' : ''}`,
|
|
65
|
+
statusLabel: STATUS_LABEL[o.data.status] || o.data.status,
|
|
66
|
+
tone: `is-${STATUS_TONE[o.data.status] || 'muted'}`,
|
|
67
|
+
amount: money(o.data.total)
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}));
|
|
71
|
+
const emptyText = M.computed(() => (orders.value.length
|
|
72
|
+
? (query.value ? 'No orders match.' : 'Nothing here.')
|
|
73
|
+
: 'No orders yet. They appear here the moment a shopper checks out.'));
|
|
74
|
+
|
|
75
|
+
root.innerHTML = '';
|
|
76
|
+
const view = document.createElement('div');
|
|
77
|
+
view.className = 'shp-card shp-orders';
|
|
78
|
+
view.innerHTML = `
|
|
79
|
+
<div class="shp-bar">
|
|
80
|
+
<div class="shp-seg" role="group" aria-label="Show">
|
|
81
|
+
${FILTERS.map(f => `<button type="button" data-filter="${f.key}" data-bind-class="filter.value === '${f.key}' && 'is-on'">${esc(f.label)} <b data-bind-text="counts.value.${f.key}"></b></button>`).join('')}
|
|
82
|
+
</div>
|
|
83
|
+
<input type="search" class="form-input shp-search" placeholder="Search # , name, email, item…" aria-label="Search orders" data-model="query.value">
|
|
84
|
+
<span data-help-title="Orders" data-help="Click an order to open it. Right-click (or ⋮, or Shift+F10) for its moves: mark it paid, fulfilled, refunded or cancelled. 'Started' are checkouts that never finished paying - they hold no stock and can be deleted."></span>
|
|
85
|
+
</div>
|
|
86
|
+
<p class="shp-note" data-bind-hidden="!loading.value">Loading…</p>
|
|
87
|
+
<p class="shp-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
88
|
+
<p class="shp-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
|
|
89
|
+
<div class="shp-list" data-each="rows.value key=id"></div>`;
|
|
90
|
+
root.appendChild(view);
|
|
91
|
+
const list = view.querySelector('.shp-list');
|
|
92
|
+
list.innerHTML = ROW;
|
|
93
|
+
scope(kit.helpPopovers(view, {position: 'bottom'}));
|
|
94
|
+
const bindings = M.applyBindings({orders, loading, error, filter, query, rows, counts, emptyText}, view);
|
|
95
|
+
scope(() => bindings.dispose());
|
|
96
|
+
view.querySelector('.shp-seg').addEventListener('click', (e) => {
|
|
97
|
+
const b = e.target.closest('[data-filter]');
|
|
98
|
+
if (b) filter.value = b.dataset.filter;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
async function load() {
|
|
102
|
+
try {
|
|
103
|
+
orders.value = entriesOf(await api.orders.list());
|
|
104
|
+
error.value = '';
|
|
105
|
+
} catch (err) {
|
|
106
|
+
error.value = `Could not load the orders: ${err.message || err}`;
|
|
107
|
+
} finally {
|
|
108
|
+
loading.value = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const byId = (id) => orders.peek().find(o => o.id === id);
|
|
112
|
+
const replace = (o) => { orders.value = orders.peek().map(x => (x.id === o.id ? o : x)); };
|
|
113
|
+
|
|
114
|
+
// ---- Moves ----------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
async function move(o, status, extra = {}) {
|
|
117
|
+
try {
|
|
118
|
+
const next = await api.orders.move(o.id, {status, ...extra});
|
|
119
|
+
replace(next);
|
|
120
|
+
refreshBadge();
|
|
121
|
+
E.toast(`${orderLabel(o.data)} is ${STATUS_LABEL[status].toLowerCase()}.`, {type: 'success'});
|
|
122
|
+
return next;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
E.toast(`Not changed: ${err.message || err}`, {type: 'error'});
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function markPaid(o) {
|
|
130
|
+
if (!await E.confirm(`Mark ${orderLabel(o.data)} as paid (${esc(money(o.data.total))})? Stock is taken now.`)) return null;
|
|
131
|
+
return move(o, 'paid', {note: 'Payment received'});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function cancel(o) {
|
|
135
|
+
if (!await E.confirm(`Cancel ${orderLabel(o.data)}? Nothing was paid, so nothing is refunded.`)) return null;
|
|
136
|
+
return move(o, 'cancelled');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Fulfil, with tracking if there is any - a small form of its own. */
|
|
140
|
+
function fulfil(o) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const t = o.data.tracking || {};
|
|
143
|
+
const f = {carrier: M.observable(t.carrier || ''), number: M.observable(t.number || ''), url: M.observable(t.url || '')};
|
|
144
|
+
let panel = null;
|
|
145
|
+
const collect = o.data.delivery === 'collect';
|
|
146
|
+
openPanel({
|
|
147
|
+
title: `Fulfil ${orderLabel(o.data)}`,
|
|
148
|
+
size: 'sm',
|
|
149
|
+
model: f,
|
|
150
|
+
html: `<div class="shp-form">
|
|
151
|
+
<p class="shp-note">${collect ? 'Mark it collected.' : o.data.requiresShipping ? 'Mark it sent. Add tracking if you have it - the shopper sees it on their receipt.' : 'Mark it delivered.'}</p>
|
|
152
|
+
${collect || !o.data.requiresShipping ? '' : `<label class="form-label">Carrier</label><input class="form-input" data-model="carrier.value" placeholder="Royal Mail, DPD…" list="shp-carriers">
|
|
153
|
+
<datalist id="shp-carriers"><option>Royal Mail</option><option>Parcelforce</option><option>DPD</option><option>DHL</option><option>Evri</option><option>UPS</option><option>FedEx</option><option>Yodel</option></datalist>
|
|
154
|
+
<label class="form-label">Tracking number</label><input class="form-input" data-model="number.value">
|
|
155
|
+
<label class="form-label">Tracking link <span data-help="An https:// address where the parcel can be followed."></span></label><input class="form-input" type="url" data-model="url.value" placeholder="https://…">`}
|
|
156
|
+
<div class="shp-actions"><span class="shp-spacer"></span>
|
|
157
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
158
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save"><span data-icon="check" data-icon-size="13"></span> ${collect ? 'Collected' : 'Fulfilled'}</button></div>
|
|
159
|
+
</div>`,
|
|
160
|
+
cleanup: () => resolve(null),
|
|
161
|
+
methods: {
|
|
162
|
+
cancel: () => panel?.close(true),
|
|
163
|
+
async save() {
|
|
164
|
+
const next = await move(o, 'fulfilled', {tracking: {carrier: f.carrier.peek(), number: f.number.peek(), url: f.url.peek()}});
|
|
165
|
+
if (next) { resolve(next); panel?.close(true); }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}).then(p => { panel = p; });
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function refund(o) {
|
|
173
|
+
const via = {stripe: 'through Stripe', paypal: 'through PayPal'}[o.data.provider];
|
|
174
|
+
const html = `<p>Refund ${esc(orderLabel(o.data))}, <strong>${esc(money(o.data.total))}</strong>, in full?</p>
|
|
175
|
+
<p>${via && o.data.paymentRef ? `The money goes back ${via} straight away.` : 'This only marks it refunded - give the money back the way it came.'}</p>
|
|
176
|
+
<label class="shp-check"><input type="checkbox" id="shp-restock" ${o.data.stockTaken ? 'checked' : 'disabled'}> Put the items back in stock</label>`;
|
|
177
|
+
let restock = Boolean(o.data.stockTaken);
|
|
178
|
+
const onChange = (e) => { if (e.target.id === 'shp-restock') restock = e.target.checked; };
|
|
179
|
+
document.addEventListener('change', onChange);
|
|
180
|
+
const ok = await E.confirm(html);
|
|
181
|
+
document.removeEventListener('change', onChange);
|
|
182
|
+
if (!ok) return null;
|
|
183
|
+
try {
|
|
184
|
+
const r = await api.orders.refund(o.id, {restock});
|
|
185
|
+
replace(r.order);
|
|
186
|
+
refreshBadge();
|
|
187
|
+
E.toast(`${orderLabel(o.data)} ${r.how}.`, {type: 'success'});
|
|
188
|
+
return r.order;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
E.toast(err.message || String(err), {type: 'error'});
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function remove(o) {
|
|
196
|
+
if (!await E.confirm(`Delete ${orderLabel(o.data)}? It never took money; it cannot be brought back.`)) return false;
|
|
197
|
+
try {
|
|
198
|
+
await api.orders.remove(o.id);
|
|
199
|
+
orders.value = orders.peek().filter(x => x.id !== o.id);
|
|
200
|
+
E.toast(`${orderLabel(o.data)} deleted.`, {type: 'success'});
|
|
201
|
+
return true;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
E.toast(err.message || String(err), {type: 'error'});
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const receiptUrl = (o) => `${location.origin}${settings.shopPath}/order/${o.id}?t=${o.data.token}`;
|
|
209
|
+
|
|
210
|
+
/** A packing slip, printed from a window of its own. Everything escaped. */
|
|
211
|
+
function packingSlip(o) {
|
|
212
|
+
const d = o.data;
|
|
213
|
+
const w = window.open('', '_blank', 'width=720,height=900');
|
|
214
|
+
if (!w) { E.toast('Allow pop-ups to print the slip.', {type: 'warning'}); return; }
|
|
215
|
+
const addr = addressLines(d.address).map(esc).join('<br>');
|
|
216
|
+
w.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>Packing slip ${esc(orderLabel(d))}</title>
|
|
217
|
+
<style>body{font:14px/1.5 system-ui,sans-serif;margin:2.5rem;color:#111}h1{font-size:1.5rem;margin:0}table{width:100%;border-collapse:collapse;margin:1.5rem 0}
|
|
218
|
+
th,td{text-align:left;padding:.5rem;border-bottom:1px solid #ddd}td.q{width:4rem;text-align:center;font-weight:700}.box{display:flex;justify-content:space-between;gap:2rem}
|
|
219
|
+
.muted{color:#666}.note{border:1px dashed #999;padding:.75rem;margin-top:1rem}</style></head><body>
|
|
220
|
+
<div class="box"><div><h1>${esc(settings.shopTitle || 'Shop')}</h1><p class="muted">Packing slip · ${esc(orderLabel(d))} · ${esc(new Date(o.meta?.createdAt).toLocaleDateString())}</p></div>
|
|
221
|
+
<div><strong>${esc(d.customer?.name || '')}</strong><br>${d.delivery === 'collect' ? 'Collecting in person' : addr}</div></div>
|
|
222
|
+
<table><thead><tr><th>Qty</th><th>Item</th><th>SKU</th></tr></thead><tbody>
|
|
223
|
+
${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l.variantName ? ` - ${esc(l.variantName)}` : ''}</td><td class="muted">${esc(l.sku || '')}</td></tr>`).join('')}
|
|
224
|
+
</tbody></table>${d.note ? `<div class="note"><strong>Note from the shopper:</strong><br>${esc(d.note)}</div>` : ''}
|
|
225
|
+
<p class="muted">Thank you for your order.</p><script>onload=()=>print()<\/script></body></html>`);
|
|
226
|
+
w.document.close();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- The menu (one definition: right-click, ⋮, Shift+F10) --------------------
|
|
230
|
+
|
|
231
|
+
function actions(o) {
|
|
232
|
+
const s = o.data.status;
|
|
233
|
+
const can = (to) => (NEXT[s] || []).includes(to);
|
|
234
|
+
return [
|
|
235
|
+
...(can('paid') && s !== 'fulfilled' ? [{label: 'Mark paid…', icon: 'check-circle', run: () => markPaid(o)}] : []),
|
|
236
|
+
...(can('fulfilled') ? [{label: o.data.delivery === 'collect' ? 'Mark collected…' : 'Mark fulfilled…', icon: 'truck', run: () => fulfil(o)}] : []),
|
|
237
|
+
...(s === 'fulfilled' ? [{label: 'Back to "to fulfil"', icon: 'rotate-ccw', run: () => move(o, 'paid', {note: 'Reopened'})}] : []),
|
|
238
|
+
...(can('refunded') ? [{label: 'Refund…', icon: 'credit-card', danger: true, run: () => refund(o)}] : []),
|
|
239
|
+
...(can('cancelled') ? [{label: 'Cancel order…', icon: 'x-circle', danger: true, run: () => cancel(o)}] : [])
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function items(id) {
|
|
244
|
+
const o = byId(id);
|
|
245
|
+
if (!o) return [];
|
|
246
|
+
const acts = actions(o);
|
|
247
|
+
return [
|
|
248
|
+
{header: `${orderLabel(o.data)} · ${o.data.customer?.name || o.data.email}`},
|
|
249
|
+
{label: 'Open', icon: 'eye', action: () => openOrder(id)},
|
|
250
|
+
...(acts.length ? [{type: 'divider'}] : []),
|
|
251
|
+
...acts.map(a => ({label: a.label, icon: a.icon, danger: a.danger, action: a.run})),
|
|
252
|
+
{type: 'divider'},
|
|
253
|
+
{label: 'Packing slip', icon: 'printer', action: () => packingSlip(o)},
|
|
254
|
+
{label: 'Copy receipt link', icon: 'link', action: () => copy(receiptUrl(o), 'Receipt link copied')},
|
|
255
|
+
{label: 'Email the shopper', icon: 'mail', action: () => { location.href = `mailto:${encodeURIComponent(o.data.email)}?subject=${encodeURIComponent(`Your order ${orderLabel(o.data)}`)}`; }},
|
|
256
|
+
...(['pending', 'cancelled', 'awaiting'].includes(o.data.status) ? [{type: 'divider'}, {label: 'Delete…', icon: 'trash', danger: true, action: () => remove(o)}] : [])
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- One order ----------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
const panels = new Set();
|
|
263
|
+
async function openOrder(id) {
|
|
264
|
+
let o = byId(id);
|
|
265
|
+
if (!o) {
|
|
266
|
+
try { o = await api.orders.get(id); } catch { E.toast('That order is gone.', {type: 'warning'}); return; }
|
|
267
|
+
}
|
|
268
|
+
const d = o.data;
|
|
269
|
+
const staffNote = M.observable(d.staffNote || '');
|
|
270
|
+
const status = M.observable(d.status);
|
|
271
|
+
let panel = null;
|
|
272
|
+
const ext = Object.entries(d.ext || {}).flatMap(([owner, facts]) => Object.entries(facts || {}).map(([k, v]) => ({owner, k, v})))
|
|
273
|
+
.filter(x => ['string', 'number', 'boolean'].includes(typeof x.v));
|
|
274
|
+
const addr = addressLines(d.address);
|
|
275
|
+
const html = `<div class="shp-order">
|
|
276
|
+
<div class="shp-order-head">
|
|
277
|
+
<span class="shp-chip is-${STATUS_TONE[d.status]}" data-part="status">${esc(STATUS_LABEL[d.status])}</span>
|
|
278
|
+
<span class="shp-muted">${esc(new Date(o.meta?.createdAt).toLocaleString())} · ${esc(PROVIDER[d.provider] || d.provider)}</span>
|
|
279
|
+
<span class="shp-spacer"></span>
|
|
280
|
+
<div class="shp-order-acts" data-part="acts"></div>
|
|
281
|
+
<button type="button" class="shp-kebab" data-part="more" aria-label="More">⋮</button>
|
|
282
|
+
</div>
|
|
283
|
+
<table class="shp-lines">
|
|
284
|
+
${(d.lines || []).map(l => `<tr><td class="shp-lines-img">${l.image ? `<img src="${esc(l.image)}" alt="">` : ''}</td>
|
|
285
|
+
<td><strong>${esc(l.name)}</strong>${l.variantName ? `<small>${esc(l.optionName || 'Option')}: ${esc(l.variantName)}</small>` : ''}${l.sku ? `<small>SKU ${esc(l.sku)}</small>` : ''}</td>
|
|
286
|
+
<td class="shp-num">${l.qty} × ${esc(money(l.unitPrice))}</td><td class="shp-amt">${esc(money(l.lineTotal))}</td></tr>`).join('')}
|
|
287
|
+
</table>
|
|
288
|
+
<dl class="shp-totals">
|
|
289
|
+
<div><dt>Subtotal</dt><dd>${esc(money(d.subtotal))}</dd></div>
|
|
290
|
+
${(d.adjustments || []).map(a => `<div class="${a.included ? 'is-included' : ''}"><dt>${esc(a.label)}${a.code ? ` <code>${esc(a.code)}</code>` : ''}</dt><dd>${esc(money(a.amount))}</dd></div>`).join('')}
|
|
291
|
+
<div class="is-total"><dt>Total</dt><dd>${esc(money(d.total))}</dd></div>
|
|
292
|
+
</dl>
|
|
293
|
+
<div class="shp-order-cols">
|
|
294
|
+
<section><h4>Shopper</h4><p><strong>${esc(d.customer?.name || '')}</strong><br><a href="mailto:${esc(d.email)}">${esc(d.email)}</a>${d.customer?.phone ? `<br><a href="tel:${esc(d.customer.phone)}">${esc(d.customer.phone)}</a>` : ''}</p></section>
|
|
295
|
+
<section><h4>${d.delivery === 'collect' ? 'Collecting' : d.requiresShipping ? 'Deliver to' : 'Delivery'}</h4>
|
|
296
|
+
<p>${d.delivery === 'collect' ? esc(settings.collectionLabel || 'In person') : addr.length ? addr.map(esc).join('<br>') : 'Nothing to ship.'}</p>
|
|
297
|
+
${d.tracking ? `<p class="shp-muted">${esc([d.tracking.carrier, d.tracking.number].filter(Boolean).join(' · '))}${d.tracking.url ? ` · <a href="${esc(d.tracking.url)}" target="_blank" rel="noopener">track</a>` : ''}</p>` : ''}</section>
|
|
298
|
+
</div>
|
|
299
|
+
${d.note ? `<div class="shp-callout"><strong>Note from the shopper</strong><p></p></div>` : ''}
|
|
300
|
+
<label class="form-label">Staff note <span data-help="Only you see this. Saved when you click away."></span></label>
|
|
301
|
+
<textarea class="form-input" rows="2" data-model="staffNote.value" data-part="staff" placeholder="Wrapped as a gift, sent second class…"></textarea>
|
|
302
|
+
${d.paymentRef || d.providerRef ? `<p class="shp-muted shp-ref">Payment reference: <code>${esc(d.paymentRef || d.providerRef)}</code></p>` : ''}
|
|
303
|
+
${ext.length ? `<div class="shp-ext">${ext.map(x => `<span class="shp-chip is-muted">${esc(x.k)}: ${esc(String(x.v))}</span>`).join('')}</div>` : ''}
|
|
304
|
+
<h4>History</h4>
|
|
305
|
+
<ol class="shp-history">${(d.history || []).slice().reverse().map(h => `<li><span class="shp-chip is-${STATUS_TONE[h.status] || 'muted'}">${esc(STATUS_LABEL[h.status] || h.status)}</span>
|
|
306
|
+
<span>${esc(new Date(h.at).toLocaleString())} · ${esc(h.by || '')}${h.note ? ` - ${esc(h.note)}` : ''}</span></li>`).join('')}</ol>
|
|
307
|
+
</div>`;
|
|
308
|
+
panel = await openPanel({
|
|
309
|
+
title: `Order ${orderLabel(d)}`,
|
|
310
|
+
size: 'lg',
|
|
311
|
+
model: {staffNote, status},
|
|
312
|
+
html,
|
|
313
|
+
before: (el) => { const n = el.querySelector('.shp-callout p'); if (n) n.textContent = d.note; },
|
|
314
|
+
cleanup: () => panels.delete(panel?.so)
|
|
315
|
+
});
|
|
316
|
+
panels.add(panel.so);
|
|
317
|
+
const el = panel.panel;
|
|
318
|
+
|
|
319
|
+
// Main actions as buttons, the rest in ⋮.
|
|
320
|
+
const renderActs = (cur) => {
|
|
321
|
+
const box = el.querySelector('[data-part="acts"]');
|
|
322
|
+
box.innerHTML = '';
|
|
323
|
+
for (const a of actions(cur).slice(0, 2)) {
|
|
324
|
+
const b = document.createElement('button');
|
|
325
|
+
b.type = 'button';
|
|
326
|
+
b.className = `btn btn-sm ${a.danger ? 'btn-outline' : 'btn-primary'}`;
|
|
327
|
+
b.innerHTML = `<span data-icon="${a.icon}" data-icon-size="13"></span> `;
|
|
328
|
+
b.appendChild(document.createTextNode(a.label.replace('…', '')));
|
|
329
|
+
b.addEventListener('click', async () => {
|
|
330
|
+
const next = await a.run();
|
|
331
|
+
if (next && typeof next === 'object') { panel.close(true); openOrder(next.id); }
|
|
332
|
+
});
|
|
333
|
+
box.appendChild(b);
|
|
334
|
+
}
|
|
335
|
+
I.scan(box);
|
|
336
|
+
};
|
|
337
|
+
renderActs(o);
|
|
338
|
+
const more = el.querySelector('[data-part="more"]');
|
|
339
|
+
const menu = E.contextMenu(more, {items: () => items(o.id).filter(x => x.label !== 'Open'), inherit: false, density: 'compact'});
|
|
340
|
+
more.addEventListener('click', () => kit.openMenuAt(more));
|
|
341
|
+
panel.so.element.addEventListener('remove', () => menu?.destroy?.());
|
|
342
|
+
|
|
343
|
+
const staff = el.querySelector('[data-part="staff"]');
|
|
344
|
+
staff.addEventListener('blur', async () => {
|
|
345
|
+
if (staffNote.peek() === (byId(o.id)?.data.staffNote || '')) return;
|
|
346
|
+
try {
|
|
347
|
+
replace(await api.orders.note(o.id, {staffNote: staffNote.peek()}));
|
|
348
|
+
E.toast('Note saved.', {type: 'success'});
|
|
349
|
+
} catch (err) {
|
|
350
|
+
E.toast(`Note not saved: ${err.message || err}`, {type: 'error'});
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
wireRows(list, {openMenuAt: kit.openMenuAt, scope, items, main: openOrder});
|
|
356
|
+
scope(() => [...panels].forEach(so => { try { so.close(true); } catch { /* gone */ } }));
|
|
357
|
+
|
|
358
|
+
await load();
|
|
359
|
+
if (!here()) return;
|
|
360
|
+
I.scan(view);
|
|
361
|
+
if (ctx.id) {
|
|
362
|
+
if (byId(ctx.id) && !FILTERS.find(f => f.key === filter.peek()).match(byId(ctx.id).data.status)) filter.value = 'all';
|
|
363
|
+
openOrder(ctx.id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -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
|
+
}
|