@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 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);
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.27",
2
+ "version": "0.2.28",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.27",
3
+ "version": "0.2.28",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {