@blamejs/blamejs-shop 0.2.27 → 0.2.28
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/CHANGELOG.md +2 -0
- package/lib/admin.js +427 -0
- package/lib/asset-manifest.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.28 (2026-05-29) — **Sales reports and printable receipts in the admin console.** The admin console gains a Reports screen and printable order documents. Reports aggregates a sales and revenue summary over a date range you choose — order count, gross and net revenue, refunds, average order value, an order-status funnel, a by-day revenue table, and top products — with a CSV export of the daily series. Every order detail page now offers a printable receipt and a printable packing slip (print-optimized, carrying your shop name and contact), so you can pull a revenue report or print an order without querying the database directly. **Added:** *Reports screen* — `/admin/reports` aggregates a sales/revenue summary over a selectable date range: order count, gross and net revenue, refunds and refund rate, average order value, an order-status funnel, a by-day revenue table, and top products. The daily series exports to CSV. The screen reads only — it never writes. · *Printable receipts and packing slips* — `GET /admin/orders/:id/receipt` and `GET /admin/orders/:id/packing-slip` render self-contained, print-optimized order documents (lines, totals, ship-to, tracking barcode) with your shop name and contact in the masthead. Both are linked from a Documents panel on the admin order detail screen.
|
|
12
|
+
|
|
11
13
|
- v0.2.27 (2026-05-29) — **Upload product images directly from the admin console.** The admin product media manager can now take an image file uploaded straight from your device, in addition to attaching one by URL. On the product detail screen, pick a PNG, JPEG, WebP, GIF, AVIF, or SVG and it streams to object storage and attaches to the product (or a specific variant) in one step. Every upload is validated by both its declared content type and its actual magic bytes — a mismatched or disguised file is refused — and capped at 10 MiB. The upload surface is only present when object storage is configured, so a store without it sees no change. **Added:** *Direct image-file upload in the admin product media manager* — A file picker on the admin product detail screen accepts a local image (`multipart/form-data`) alongside the existing attach-by-URL field. The file is content-type- and magic-byte-validated (png/jpeg/webp/gif/avif/svg), size-capped at 10 MiB, streamed to the object-storage bridge, and attached to the product or variant. JSON API: `POST /admin/products/:id/media/upload-file` and `POST /admin/media/upload-file`. The routes mount only when the object-storage bridge is configured.
|
|
12
14
|
|
|
13
15
|
- v0.2.26 (2026-05-29) — **Update the vendored blamejs runtime to v0.13.44.** The bundled blamejs runtime moves from v0.13.0 to v0.13.44, carrying a run of upstream security and reliability fixes through the crypto, storage, and middleware layers the shop is built on, with no API or configuration changes. Highlights: DNSSEC validation now bounds the work spent on colliding keys and signatures (KeyTrap / NSEC3 resource-exhaustion defense); a sealed database column that fails to unseal now returns null instead of risking forged or cross-row ciphertext; the encrypted database gains a temporary-storage free-space guard and a shutdown watchdog that preserves the final flush on exit; S/MIME chain verification binds the leaf certificate to the verifying signer key; archive extraction refuses Windows reserved and alternate-data-stream path names; and the replay-nonce store is memory-capped and fails closed under flood. **Changed:** *Database reliability under low temporary storage* — The encrypted database now refuses growth-writes with a clear error when its temporary storage drops below a safety headroom (rather than risking corruption), and a shutdown watchdog preserves the final database flush if a shutdown phase hangs. Both harden the container restart path. **Security:** *Bundled blamejs runtime updated to v0.13.44* — Pulls in the upstream security fixes accumulated since v0.13.0: DNSSEC KeyTrap / NSEC3 work caps, sealed-column null-on-unseal-failure (no forged or cross-row ciphertext), S/MIME leaf-to-signer binding, archive-extraction rejection of Windows reserved / alternate-data-stream names, static-file sibling-prefix path containment, and a memory-capped, fail-closed replay-nonce store. No API or configuration changes are required.
|
package/lib/admin.js
CHANGED
|
@@ -371,10 +371,16 @@ function mount(router, deps) {
|
|
|
371
371
|
var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
|
|
372
372
|
var customers = deps.customers || null; // read-only customers console disabled when absent
|
|
373
373
|
var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
|
|
374
|
+
var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
|
|
375
|
+
var printReceipts = deps.printReceipts || null; // order receipt document disabled when absent
|
|
376
|
+
var packingSlips = deps.packingSlips || null; // order packing-slip document disabled when absent
|
|
374
377
|
|
|
375
378
|
// Which optional console sections are wired — gates their nav links so a
|
|
376
379
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
377
380
|
// into every authed render call as `nav_available`.
|
|
381
|
+
// `reports` is always present in the nav (read-only sales summary needs no
|
|
382
|
+
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
383
|
+
// notice when the salesReports primitive isn't wired.
|
|
378
384
|
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount };
|
|
379
385
|
|
|
380
386
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
@@ -1379,6 +1385,10 @@ function mount(router, deps) {
|
|
|
1379
1385
|
// Shipment/tracking panel only renders when the tracking primitive
|
|
1380
1386
|
// is wired; the carrier + status enums drive its form selects.
|
|
1381
1387
|
can_track: !!orderTracking,
|
|
1388
|
+
// Printable-document links — shown only when the render primitive is
|
|
1389
|
+
// wired (its route is mounted only then).
|
|
1390
|
+
can_receipt: !!printReceipts,
|
|
1391
|
+
can_packing_slip: !!packingSlips,
|
|
1382
1392
|
shipments: shipments,
|
|
1383
1393
|
carriers: orderTracking ? orderTracking.CARRIERS : null,
|
|
1384
1394
|
statuses: orderTracking ? orderTracking.STATUSES : null,
|
|
@@ -3160,6 +3170,288 @@ function mount(router, deps) {
|
|
|
3160
3170
|
));
|
|
3161
3171
|
}
|
|
3162
3172
|
|
|
3173
|
+
// ---- reporting ------------------------------------------------------
|
|
3174
|
+
//
|
|
3175
|
+
// A sales/revenue report over a selectable date range: order count,
|
|
3176
|
+
// gross/net revenue, refunds, AOV, top products, broken down by day and
|
|
3177
|
+
// by status. Aggregated from the salesReports primitive (pure read-only
|
|
3178
|
+
// SQL over orders/order_lines). The route mounts unconditionally so the
|
|
3179
|
+
// always-present "Reports" nav link never points at a missing route; when
|
|
3180
|
+
// the salesReports primitive isn't wired it renders an unconfigured
|
|
3181
|
+
// notice. CSV export of the by-day series is exposed via `?format=csv`.
|
|
3182
|
+
//
|
|
3183
|
+
// Date range: `from`/`to` are epoch-ms query params (defaults to the last
|
|
3184
|
+
// 30 days). A malformed range re-renders the page with a 400 + the
|
|
3185
|
+
// validator's message rather than crashing — the report is config/entry
|
|
3186
|
+
// tier, so a bad operator-typed range surfaces as a correction, not a 500.
|
|
3187
|
+
|
|
3188
|
+
// Parse a "YYYY-MM-DD" calendar-date param to the epoch-ms at UTC
|
|
3189
|
+
// midnight. Returns null when the param is absent; throws TypeError on a
|
|
3190
|
+
// malformed value so the config/entry-tier 400 path catches it. The
|
|
3191
|
+
// browser date <input> submits this shape; the JSON API can use either
|
|
3192
|
+
// this or the raw epoch-ms `from`/`to`.
|
|
3193
|
+
function _parseDateParam(str, label) {
|
|
3194
|
+
if (str == null || str === "") return null;
|
|
3195
|
+
if (typeof str !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(str)) {
|
|
3196
|
+
throw new TypeError("admin: " + label + " must be a YYYY-MM-DD date");
|
|
3197
|
+
}
|
|
3198
|
+
var ms = Date.parse(str + "T00:00:00Z");
|
|
3199
|
+
if (!isFinite(ms)) throw new TypeError("admin: " + label + " is not a valid date");
|
|
3200
|
+
return ms;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// Resolve the report window from the query string. Returns
|
|
3204
|
+
// `{ from, to }` (epoch-ms) or throws TypeError on a malformed value.
|
|
3205
|
+
// Accepts either the raw epoch-ms `from`/`to` pair (machine clients) or
|
|
3206
|
+
// the calendar-date `from-date`/`to-date` pair (the browser date inputs);
|
|
3207
|
+
// the epoch-ms form wins when both are present. Defaults: to = now,
|
|
3208
|
+
// from = now - 30d. The salesReports primitive re-validates (from < to,
|
|
3209
|
+
// span ≤ 1y), so this only coerces the shape.
|
|
3210
|
+
function _reportWindow(url) {
|
|
3211
|
+
var from = _parseEpochMs(url && url.searchParams.get("from"), "from");
|
|
3212
|
+
var to = _parseEpochMs(url && url.searchParams.get("to"), "to");
|
|
3213
|
+
if (from == null) from = _parseDateParam(url && url.searchParams.get("from-date"), "from");
|
|
3214
|
+
// `to-date` is the inclusive end day — advance to the next UTC midnight
|
|
3215
|
+
// so the window covers the whole selected day (salesReports treats `to`
|
|
3216
|
+
// as exclusive: `updated_at < to`).
|
|
3217
|
+
if (to == null) {
|
|
3218
|
+
var toDate = _parseDateParam(url && url.searchParams.get("to-date"), "to");
|
|
3219
|
+
if (toDate != null) to = toDate + b.constants.TIME.days(1);
|
|
3220
|
+
}
|
|
3221
|
+
var now = Date.now();
|
|
3222
|
+
return {
|
|
3223
|
+
to: to == null ? now : to,
|
|
3224
|
+
from: from == null ? (now - b.constants.TIME.days(30)) : from,
|
|
3225
|
+
};
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
// Aggregate the report payload from the salesReports primitive. Composes
|
|
3229
|
+
// the by-day revenue series, AOV, refund rate, the status funnel, and the
|
|
3230
|
+
// top products into one object the HTML + CSV + JSON surfaces all read.
|
|
3231
|
+
async function _buildReport(win) {
|
|
3232
|
+
var byDay = await salesReports.revenueByDay({ from: win.from, to: win.to });
|
|
3233
|
+
var aov = await salesReports.aov({ from: win.from, to: win.to });
|
|
3234
|
+
var refund = await salesReports.refundRate({ from: win.from, to: win.to });
|
|
3235
|
+
var funnel = await salesReports.funnel({ from: win.from, to: win.to });
|
|
3236
|
+
var top = await salesReports.topProducts({ from: win.from, to: win.to, limit: 10 });
|
|
3237
|
+
|
|
3238
|
+
// Headline totals from the by-day series — gross/net/refunds summed
|
|
3239
|
+
// across every currency bucket in the window. The per-currency split
|
|
3240
|
+
// stays on the by-day rows for the operator who needs it; the headline
|
|
3241
|
+
// is the single-number summary a dashboard leads with.
|
|
3242
|
+
var orderCount = 0, gross = 0, net = 0, refunds = 0;
|
|
3243
|
+
var currency = (aov && aov.currency) || "USD";
|
|
3244
|
+
for (var i = 0; i < byDay.length; i += 1) {
|
|
3245
|
+
var row = byDay[i];
|
|
3246
|
+
orderCount += row.order_count;
|
|
3247
|
+
gross += row.gross_revenue_minor;
|
|
3248
|
+
net += row.net_revenue_minor;
|
|
3249
|
+
refunds += row.refund_total_minor;
|
|
3250
|
+
}
|
|
3251
|
+
return {
|
|
3252
|
+
from: win.from,
|
|
3253
|
+
to: win.to,
|
|
3254
|
+
currency: currency,
|
|
3255
|
+
order_count: orderCount,
|
|
3256
|
+
gross_revenue_minor: gross,
|
|
3257
|
+
net_revenue_minor: net,
|
|
3258
|
+
refund_total_minor: refunds,
|
|
3259
|
+
aov_minor: (aov && aov.aov_minor) || 0,
|
|
3260
|
+
refund_rate_bps: (refund && refund.refund_rate_bps) || 0,
|
|
3261
|
+
by_day: byDay,
|
|
3262
|
+
by_status: funnel,
|
|
3263
|
+
top_products: (top && top.rows) || [],
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
// CSV body for the by-day revenue series. Composes `b.csv.stringify`
|
|
3268
|
+
// (RFC 4180) — the cell values are integer aggregates + ISO date buckets
|
|
3269
|
+
// + 3-letter currency codes, all framework-internal (never raw
|
|
3270
|
+
// operator/customer prose), so the RFC-4180 quoting is sufficient here;
|
|
3271
|
+
// there is no untrusted free-text column that would require b.guardCsv's
|
|
3272
|
+
// formula-injection defenses.
|
|
3273
|
+
function _reportCsv(report) {
|
|
3274
|
+
var rows = report.by_day.map(function (r) {
|
|
3275
|
+
return {
|
|
3276
|
+
date: r.bucket_start,
|
|
3277
|
+
currency: r.currency,
|
|
3278
|
+
order_count: r.order_count,
|
|
3279
|
+
gross_revenue_minor: r.gross_revenue_minor,
|
|
3280
|
+
net_revenue_minor: r.net_revenue_minor,
|
|
3281
|
+
refund_total_minor: r.refund_total_minor,
|
|
3282
|
+
};
|
|
3283
|
+
});
|
|
3284
|
+
return b.csv.stringify(rows, {
|
|
3285
|
+
columns: ["date", "currency", "order_count", "gross_revenue_minor", "net_revenue_minor", "refund_total_minor"],
|
|
3286
|
+
header: true,
|
|
3287
|
+
eol: "\n",
|
|
3288
|
+
});
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
router.get("/admin/reports", _pageOrApi(true,
|
|
3292
|
+
R(async function (req, res) {
|
|
3293
|
+
if (!salesReports) return _problem(res, 503, "reporting-unconfigured", "the salesReports primitive is not wired");
|
|
3294
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3295
|
+
// A malformed from/to surfaces as a 400 problem (config/entry tier).
|
|
3296
|
+
var win;
|
|
3297
|
+
try { win = _reportWindow(url); }
|
|
3298
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3299
|
+
var report;
|
|
3300
|
+
try { report = await _buildReport(win); }
|
|
3301
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3302
|
+
if (url && url.searchParams.get("format") === "csv") {
|
|
3303
|
+
res.status(200);
|
|
3304
|
+
if (res.setHeader) {
|
|
3305
|
+
res.setHeader("content-type", "text/csv; charset=utf-8");
|
|
3306
|
+
res.setHeader("content-disposition", "attachment; filename=\"sales-report.csv\"");
|
|
3307
|
+
}
|
|
3308
|
+
var csv = _reportCsv(report);
|
|
3309
|
+
if (res.end) res.end(csv); else res.send(csv);
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
_json(res, 200, report);
|
|
3313
|
+
}),
|
|
3314
|
+
async function (req, res) {
|
|
3315
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3316
|
+
if (!salesReports) {
|
|
3317
|
+
return _sendHtml(res, 200, renderAdminReports({
|
|
3318
|
+
shop_name: deps.shop_name, nav_available: navAvailable, report: null,
|
|
3319
|
+
notice: "Reporting isn't configured on this deployment.",
|
|
3320
|
+
}));
|
|
3321
|
+
}
|
|
3322
|
+
var win, notice = null;
|
|
3323
|
+
try { win = _reportWindow(url); }
|
|
3324
|
+
catch (e) {
|
|
3325
|
+
if (!(e instanceof TypeError)) throw e;
|
|
3326
|
+
// Bad range → re-render with the default window + a correction
|
|
3327
|
+
// notice (config/entry tier: the operator fixes the typo).
|
|
3328
|
+
win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
|
|
3329
|
+
notice = e.message.replace(/^admin:\s*/, "");
|
|
3330
|
+
}
|
|
3331
|
+
// CSV download from the browser surface too (a link, not a fetch).
|
|
3332
|
+
if (url && url.searchParams.get("format") === "csv") {
|
|
3333
|
+
var report0;
|
|
3334
|
+
try { report0 = await _buildReport(win); }
|
|
3335
|
+
catch (e2) { if (!(e2 instanceof TypeError)) throw e2; report0 = null; }
|
|
3336
|
+
if (report0) {
|
|
3337
|
+
res.status(200);
|
|
3338
|
+
if (res.setHeader) {
|
|
3339
|
+
res.setHeader("content-type", "text/csv; charset=utf-8");
|
|
3340
|
+
res.setHeader("content-disposition", "attachment; filename=\"sales-report.csv\"");
|
|
3341
|
+
}
|
|
3342
|
+
var csv0 = _reportCsv(report0);
|
|
3343
|
+
if (res.end) res.end(csv0); else res.send(csv0);
|
|
3344
|
+
return;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
var report;
|
|
3348
|
+
try { report = await _buildReport(win); }
|
|
3349
|
+
catch (e3) {
|
|
3350
|
+
if (!(e3 instanceof TypeError)) throw e3;
|
|
3351
|
+
win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
|
|
3352
|
+
notice = e3.message.replace(/^salesReports:\s*/, "");
|
|
3353
|
+
report = await _buildReport(win);
|
|
3354
|
+
}
|
|
3355
|
+
_sendHtml(res, 200, renderAdminReports({
|
|
3356
|
+
shop_name: deps.shop_name, nav_available: navAvailable, report: report, notice: notice,
|
|
3357
|
+
}));
|
|
3358
|
+
},
|
|
3359
|
+
));
|
|
3360
|
+
|
|
3361
|
+
// ---- printable order documents --------------------------------------
|
|
3362
|
+
//
|
|
3363
|
+
// Operator-facing receipt + packing slip for an order. These are the
|
|
3364
|
+
// warehouse/fulfilment paper trail (the customer's own order page is a
|
|
3365
|
+
// separate storefront surface — untouched here). Each renders clean,
|
|
3366
|
+
// self-contained, print-optimized HTML the operator prints or pipes to a
|
|
3367
|
+
// PDF. Mounted only when the corresponding render primitive is wired.
|
|
3368
|
+
//
|
|
3369
|
+
// Failure modes: a malformed order id (TypeError from the primitive's id
|
|
3370
|
+
// reader) → 404; a missing order → 404. Only TypeError is swallowed to a
|
|
3371
|
+
// 404 — any other error propagates to the 500 path.
|
|
3372
|
+
|
|
3373
|
+
// The receipt + packing-slip render primitives emit a self-contained
|
|
3374
|
+
// document driven purely by the order; the shop's own name + contact live
|
|
3375
|
+
// in shop_config, so we read them here and inject a small masthead at the
|
|
3376
|
+
// top of the document `<body>`. Both renderers emit a literal "<body>\n"
|
|
3377
|
+
// marker; the header slots in right after it. When config is unwired or
|
|
3378
|
+
// the keys are unset, the document renders without a masthead (the order
|
|
3379
|
+
// data alone is still a complete, valid document).
|
|
3380
|
+
async function _shopMastheadHtml() {
|
|
3381
|
+
if (!deps.config) return "";
|
|
3382
|
+
var name = null, contact = null;
|
|
3383
|
+
try {
|
|
3384
|
+
name = await deps.config.get("shop.name", deps.shop_name || null);
|
|
3385
|
+
// The setup wizard persists the operator's contact under
|
|
3386
|
+
// "shop.contact_email"; fall back to the conventional
|
|
3387
|
+
// "shop.support_email" key the config module documents.
|
|
3388
|
+
contact = await deps.config.get("shop.contact_email", null);
|
|
3389
|
+
if (contact == null) contact = await deps.config.get("shop.support_email", null);
|
|
3390
|
+
} catch (_e) {
|
|
3391
|
+
// Config read failure must not break the print document — degrade to
|
|
3392
|
+
// no masthead rather than 500-ing the operator's print job.
|
|
3393
|
+
return "";
|
|
3394
|
+
}
|
|
3395
|
+
if (!name && !contact) return "";
|
|
3396
|
+
var parts = "";
|
|
3397
|
+
if (name) parts += "<strong>" + _htmlEscape(String(name)) + "</strong>";
|
|
3398
|
+
if (contact) parts += (name ? "<br>" : "") + _htmlEscape(String(contact));
|
|
3399
|
+
// Inline style is acceptable here: this document is served standalone
|
|
3400
|
+
// (not under the admin console's strict style-src CSP) and is built for
|
|
3401
|
+
// printing, where an external stylesheet round-trip is undesirable.
|
|
3402
|
+
return "<div style=\"margin-bottom:8mm;font-size:11pt;\">" + parts + "</div>\n";
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
function _injectMasthead(html, masthead) {
|
|
3406
|
+
if (!masthead) return html;
|
|
3407
|
+
var marker = "<body>\n";
|
|
3408
|
+
var at = html.indexOf(marker);
|
|
3409
|
+
if (at === -1) return html; // renderer changed shape — emit the doc unaltered
|
|
3410
|
+
return html.slice(0, at + marker.length) + masthead + html.slice(at + marker.length);
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
if (printReceipts) {
|
|
3414
|
+
router.get("/admin/orders/:id/receipt", async function (req, res) {
|
|
3415
|
+
if (!_htmlAuthed(req, expectedToken)) {
|
|
3416
|
+
if (req.method === "GET" || !req.method) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
|
|
3417
|
+
return _problem(res, 401, "unauthorized");
|
|
3418
|
+
}
|
|
3419
|
+
var html;
|
|
3420
|
+
try {
|
|
3421
|
+
html = await printReceipts.htmlPdf({ order_id: req.params.id });
|
|
3422
|
+
} catch (e) {
|
|
3423
|
+
if (e instanceof TypeError) {
|
|
3424
|
+
return _sendHtml(res, 404, renderAdminOrders({
|
|
3425
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
3426
|
+
}));
|
|
3427
|
+
}
|
|
3428
|
+
return _problem(res, 500, "internal-error", (e && e.message) || String(e));
|
|
3429
|
+
}
|
|
3430
|
+
_sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
|
|
3431
|
+
});
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
if (packingSlips) {
|
|
3435
|
+
router.get("/admin/orders/:id/packing-slip", async function (req, res) {
|
|
3436
|
+
if (!_htmlAuthed(req, expectedToken)) {
|
|
3437
|
+
if (req.method === "GET" || !req.method) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
|
|
3438
|
+
return _problem(res, 401, "unauthorized");
|
|
3439
|
+
}
|
|
3440
|
+
var html;
|
|
3441
|
+
try {
|
|
3442
|
+
html = await packingSlips.renderHtml({ order_id: req.params.id });
|
|
3443
|
+
} catch (e) {
|
|
3444
|
+
if (e instanceof TypeError) {
|
|
3445
|
+
return _sendHtml(res, 404, renderAdminOrders({
|
|
3446
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
3447
|
+
}));
|
|
3448
|
+
}
|
|
3449
|
+
return _problem(res, 500, "internal-error", (e && e.message) || String(e));
|
|
3450
|
+
}
|
|
3451
|
+
_sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
|
|
3452
|
+
});
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3163
3455
|
// ---- analytics ------------------------------------------------------
|
|
3164
3456
|
|
|
3165
3457
|
var analytics = deps.analytics || null;
|
|
@@ -4505,6 +4797,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
4505
4797
|
{ key: "products", href: "/admin/products", label: "Products" },
|
|
4506
4798
|
{ key: "inventory", href: "/admin/inventory", label: "Inventory" },
|
|
4507
4799
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
4800
|
+
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
4508
4801
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
4509
4802
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
4510
4803
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
@@ -4793,6 +5086,123 @@ function renderAdminOrders(opts) {
|
|
|
4793
5086
|
return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
|
|
4794
5087
|
}
|
|
4795
5088
|
|
|
5089
|
+
// epoch-ms → "YYYY-MM-DD" for a date <input>'s value. Mirrors `_fmtDate`'s
|
|
5090
|
+
// guard so a bad value never throws inside the template.
|
|
5091
|
+
function _dateInputValue(v) {
|
|
5092
|
+
var n = typeof v === "number" ? v : Date.parse(v);
|
|
5093
|
+
if (!isFinite(n)) return "";
|
|
5094
|
+
return new Date(n).toISOString().slice(0, 10);
|
|
5095
|
+
}
|
|
5096
|
+
|
|
5097
|
+
// Sales/revenue report screen. Renders the headline totals (orders,
|
|
5098
|
+
// gross/net revenue, refunds, AOV, refund rate), the order-status funnel,
|
|
5099
|
+
// a by-day revenue table, and the top products — all for the selected
|
|
5100
|
+
// date range. A date-range form lets the operator retune the window; a CSV
|
|
5101
|
+
// link exports the by-day series. `opts.report` is null when reporting
|
|
5102
|
+
// isn't configured (renders the notice only).
|
|
5103
|
+
function renderAdminReports(opts) {
|
|
5104
|
+
opts = opts || {};
|
|
5105
|
+
var report = opts.report;
|
|
5106
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
5107
|
+
|
|
5108
|
+
if (!report) {
|
|
5109
|
+
var emptyBody = "<section><h2>Reports</h2>" + notice +
|
|
5110
|
+
"<p class=\"empty\">No sales report available.</p></section>";
|
|
5111
|
+
return _renderAdminShell(opts.shop_name, "Reports", emptyBody, "reports", opts.nav_available);
|
|
5112
|
+
}
|
|
5113
|
+
|
|
5114
|
+
var fromVal = _dateInputValue(report.from);
|
|
5115
|
+
// The window's `to` is exclusive (next UTC midnight after the operator's
|
|
5116
|
+
// chosen end day); render the inclusive end day back into the date input
|
|
5117
|
+
// by stepping one day back.
|
|
5118
|
+
var toVal = _dateInputValue(report.to - b.constants.TIME.days(1));
|
|
5119
|
+
|
|
5120
|
+
// Date-range form (GET so the window lives in the URL — bookmarkable +
|
|
5121
|
+
// shareable). The browser submits calendar-date params; the CSV link
|
|
5122
|
+
// carries the same window as raw epoch-ms.
|
|
5123
|
+
var csvHref = "/admin/reports?format=csv" +
|
|
5124
|
+
"&from=" + encodeURIComponent(String(report.from)) +
|
|
5125
|
+
"&to=" + encodeURIComponent(String(report.to));
|
|
5126
|
+
var rangeForm =
|
|
5127
|
+
"<form method=\"get\" action=\"/admin/reports\" class=\"order-filters\">" +
|
|
5128
|
+
"<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" value=\"" + _htmlEscape(fromVal) + "\"></label>" +
|
|
5129
|
+
"<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" value=\"" + _htmlEscape(toVal) + "\"></label>" +
|
|
5130
|
+
"<button class=\"btn\" type=\"submit\">Apply</button>" +
|
|
5131
|
+
"<a class=\"btn btn--ghost\" href=\"" + _htmlEscape(csvHref) + "\">Export CSV</a>" +
|
|
5132
|
+
"</form>";
|
|
5133
|
+
|
|
5134
|
+
// Headline stat cards. Money formats through the same pricing helper the
|
|
5135
|
+
// dashboard + order pages use, in the report's headline currency.
|
|
5136
|
+
var cur = report.currency;
|
|
5137
|
+
var statsBlock =
|
|
5138
|
+
"<section><h2>Summary</h2><div class=\"stat-grid\">" +
|
|
5139
|
+
_statCard("Orders", String(report.order_count)) +
|
|
5140
|
+
_statCard("Gross revenue", pricing.format(report.gross_revenue_minor, cur)) +
|
|
5141
|
+
_statCard("Net revenue", pricing.format(report.net_revenue_minor, cur)) +
|
|
5142
|
+
_statCard("Refunds", pricing.format(report.refund_total_minor, cur)) +
|
|
5143
|
+
_statCard("Avg order value", pricing.format(report.aov_minor, cur)) +
|
|
5144
|
+
_statCard("Refund rate", (report.refund_rate_bps / 100).toFixed(2) + "%") +
|
|
5145
|
+
"</div></section>";
|
|
5146
|
+
|
|
5147
|
+
// Order-status funnel — each milestone counts orders that reached at
|
|
5148
|
+
// least that stage within the window.
|
|
5149
|
+
var f = report.by_status || {};
|
|
5150
|
+
var funnelRows = [
|
|
5151
|
+
["Checkout started", f.checkout_started],
|
|
5152
|
+
["Payment intent created", f.payment_intent_created],
|
|
5153
|
+
["Paid", f.paid],
|
|
5154
|
+
["Fulfilled", f.fulfilled],
|
|
5155
|
+
["Refunded", f.refunded],
|
|
5156
|
+
].map(function (pair) {
|
|
5157
|
+
return "<tr><td>" + _htmlEscape(pair[0]) + "</td><td class=\"num\">" + _htmlEscape(String(pair[1] == null ? 0 : pair[1])) + "</td></tr>";
|
|
5158
|
+
}).join("");
|
|
5159
|
+
var funnelBlock =
|
|
5160
|
+
"<section><h2>Order status</h2><div class=\"panel\">" +
|
|
5161
|
+
"<table><thead><tr><th scope=\"col\">Stage</th><th scope=\"col\" class=\"num\">Orders</th></tr></thead><tbody>" + funnelRows + "</tbody></table>" +
|
|
5162
|
+
"</div></section>";
|
|
5163
|
+
|
|
5164
|
+
// Top products by gross revenue across the window.
|
|
5165
|
+
var top = report.top_products || [];
|
|
5166
|
+
var topRows = top.length
|
|
5167
|
+
? top.map(function (r) {
|
|
5168
|
+
return "<tr><td>" + _htmlEscape(r.sku) + "</td>" +
|
|
5169
|
+
"<td class=\"num\">" + _htmlEscape(String(r.units_sold)) + "</td>" +
|
|
5170
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.gross_revenue_minor, r.currency)) + "</td></tr>";
|
|
5171
|
+
}).join("")
|
|
5172
|
+
: "<tr><td colspan=\"3\" class=\"empty\">No sales in this window.</td></tr>";
|
|
5173
|
+
var topBlock =
|
|
5174
|
+
"<section><h2>Top products</h2><div class=\"panel\">" +
|
|
5175
|
+
"<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Units</th><th scope=\"col\" class=\"num\">Revenue</th></tr></thead><tbody>" + topRows + "</tbody></table>" +
|
|
5176
|
+
"</div></section>";
|
|
5177
|
+
|
|
5178
|
+
// By-day revenue series — one row per (day, currency) bucket.
|
|
5179
|
+
var byDay = report.by_day || [];
|
|
5180
|
+
var dayRows = byDay.length
|
|
5181
|
+
? byDay.map(function (r) {
|
|
5182
|
+
return "<tr>" +
|
|
5183
|
+
"<td>" + _htmlEscape(r.bucket_start) + "</td>" +
|
|
5184
|
+
"<td>" + _htmlEscape(r.currency) + "</td>" +
|
|
5185
|
+
"<td class=\"num\">" + _htmlEscape(String(r.order_count)) + "</td>" +
|
|
5186
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.gross_revenue_minor, r.currency)) + "</td>" +
|
|
5187
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.net_revenue_minor, r.currency)) + "</td>" +
|
|
5188
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.refund_total_minor, r.currency)) + "</td>" +
|
|
5189
|
+
"</tr>";
|
|
5190
|
+
}).join("")
|
|
5191
|
+
: "<tr><td colspan=\"6\" class=\"empty\">No sales in this window.</td></tr>";
|
|
5192
|
+
var dayBlock =
|
|
5193
|
+
"<section><h2>By day</h2><div class=\"panel\">" +
|
|
5194
|
+
"<table><thead><tr><th scope=\"col\">Date</th><th scope=\"col\">Currency</th><th scope=\"col\" class=\"num\">Orders</th><th scope=\"col\" class=\"num\">Gross</th><th scope=\"col\" class=\"num\">Net</th><th scope=\"col\" class=\"num\">Refunds</th></tr></thead><tbody>" + dayRows + "</tbody></table>" +
|
|
5195
|
+
"</div></section>";
|
|
5196
|
+
|
|
5197
|
+
var body =
|
|
5198
|
+
"<section><h2>Reports</h2>" + notice +
|
|
5199
|
+
"<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(report.from)) + " → " + _htmlEscape(_fmtDate(report.to)) + "</p>" +
|
|
5200
|
+
rangeForm +
|
|
5201
|
+
"</section>" +
|
|
5202
|
+
statsBlock + funnelBlock + topBlock + dayBlock;
|
|
5203
|
+
return _renderAdminShell(opts.shop_name, "Reports", body, "reports", opts.nav_available);
|
|
5204
|
+
}
|
|
5205
|
+
|
|
4796
5206
|
function renderAdminOrder(opts) {
|
|
4797
5207
|
opts = opts || {};
|
|
4798
5208
|
var o = opts.order;
|
|
@@ -4854,6 +5264,22 @@ function renderAdminOrder(opts) {
|
|
|
4854
5264
|
}).filter(Boolean).join(" ");
|
|
4855
5265
|
var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
|
|
4856
5266
|
|
|
5267
|
+
// Printable operator documents — a receipt + a packing slip, each opening
|
|
5268
|
+
// the print-optimized HTML in a new tab. Rendered only when the matching
|
|
5269
|
+
// render primitive is wired (the routes mount only then).
|
|
5270
|
+
var docLinks = [];
|
|
5271
|
+
if (opts.can_receipt) {
|
|
5272
|
+
docLinks.push("<a class=\"btn btn--ghost\" href=\"/admin/orders/" + _htmlEscape(o.id) + "/receipt\" target=\"_blank\" rel=\"noopener\">Print receipt</a>");
|
|
5273
|
+
}
|
|
5274
|
+
if (opts.can_packing_slip) {
|
|
5275
|
+
docLinks.push("<a class=\"btn btn--ghost\" href=\"/admin/orders/" + _htmlEscape(o.id) + "/packing-slip\" target=\"_blank\" rel=\"noopener\">Print packing slip</a>");
|
|
5276
|
+
}
|
|
5277
|
+
var documentsPanel = docLinks.length
|
|
5278
|
+
? "<div class=\"panel mt\"><h3 class=\"subhead\">Documents</h3>" +
|
|
5279
|
+
"<div class=\"order-actions\">" + docLinks.join(" ") + "</div>" +
|
|
5280
|
+
"</div>"
|
|
5281
|
+
: "";
|
|
5282
|
+
|
|
4857
5283
|
// Shipment + tracking panel. Renders only when the tracking primitive is
|
|
4858
5284
|
// wired (`can_track`). For each existing shipment: the carrier, status,
|
|
4859
5285
|
// tracking number (linked to the carrier's public URL when known), and
|
|
@@ -4938,6 +5364,7 @@ function renderAdminOrder(opts) {
|
|
|
4938
5364
|
"<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
|
|
4939
5365
|
"<div class=\"order-actions\">" + actions + "</div>" +
|
|
4940
5366
|
"</div>" +
|
|
5367
|
+
documentsPanel +
|
|
4941
5368
|
trackingPanel +
|
|
4942
5369
|
"</section>";
|
|
4943
5370
|
return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
|
package/lib/asset-manifest.json
CHANGED
package/package.json
CHANGED