@blamejs/blamejs-shop 0.3.38 → 0.3.40

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.40 (2026-05-31) — **Return shipping labels — operators issue one, customers download and track it.** After a return was approved there was no way to give the customer a shipping label or for them to follow the parcel back. Operators can now issue a return label on an approved return from the admin console — entering the carrier, tracking number, and the label link generated in their carrier account — and post tracking updates as the parcel moves. The customer sees the label on their return: they can open or print it, see the carrier and tracking number, and follow the scan history, with the return marked received once it arrives back. A customer can only see and download the label for their own return. **Added:** *Return shipping labels and tracking* — Opening a return in the account area now shows its detail, and once a label has been issued the customer can open or print the return label, see the carrier and tracking number, and follow the parcel's scan history — all scoped so a customer only ever sees their own return. On the operator side, an approved return gains an Issue return label form (carrier, service level, weight, the carrier label link, tracking number, and cost) and controls to mark the parcel shipped, in transit, delivered, or held with an exception; marking it delivered records the return as received. The label link is required to be a secure URL, and the customer download is served without exposing that link in the page.
12
+
13
+ - v0.3.39 (2026-05-31) — **Paginated lists no longer show an empty extra page at exact page boundaries.** Several paginated lists offered a next page even when the current page was the last one, if the list's length happened to be an exact multiple of the page size — following that next link landed on an empty page. This affected a customer's order history, loyalty history, and store-credit history, and the admin customer list. Each now checks whether a further item actually exists before offering a next page, so the next link appears only when there is genuinely more to show. **Fixed:** *No phantom next page on exactly-full lists* — A customer's order history, loyalty history, and store-credit history, and the admin customer list, offered a next page whenever the current page was full — so a list whose total was an exact multiple of the page size showed a next link that led to an empty page. These lists now look one item past the page before offering a next link, so it appears only when a further item exists. This matches the fix already in place for collection pages.
14
+
11
15
  - v0.3.38 (2026-05-31) — **Pre-order campaigns — reserve a product before it is released.** Products that are not yet released could not be sold or reserved. Operators can now run a pre-order campaign for a product from the admin console — setting its release date, price, and an optional reservation cap. While the campaign is open, the product page shows a Pre-order control with the release date and how many reservations remain, and a signed-in customer can reserve their quantity without paying upfront. Customers see and can cancel their reservations from their account, and when the operator launches the campaign on its release date, each reservation is turned into a pending order the customer pays for through normal checkout. **Added:** *Pre-order campaigns* — A Pre-orders screen in the admin console runs a campaign for a product's SKU — release date, price, an optional deposit, and a reservation cap — with launch and close controls (launch is available once the release date arrives). While a campaign is open, the product page shows a Pre-order control with the release date and remaining availability instead of the usual add-to-cart, and a signed-in customer can reserve their quantity (no charge at reservation time; reserving past the cap or on a closed campaign is refused). Customers manage their reservations under Pre-orders in their account and can cancel one, which frees that capacity. Launching the campaign converts each open reservation into a pending order the customer completes through normal checkout. A customer can only see and cancel their own reservations.
12
16
 
13
17
  - v0.3.37 (2026-05-31) — **Gift registries — create one, share it, and let people mark gifts as bought.** A customer can now create a gift registry for an occasion — a wedding, baby, or birthday — add the products they want with the quantity desired, and share a public link. Anyone with the link can open the registry without an account, see what is still needed, add an item to their own cart to buy it through normal checkout, or mark an item as already purchased so it stops showing as needed. Registries can be public, unlisted, or private, and a private registry is not viewable from its link. The shared page never reveals who owns the registry or who bought what, and is not indexed by search engines. **Added:** *Gift registries* — A new Registry section in the account area lets a customer create a registry (title, occasion, event date, and a public/unlisted/private visibility), add and remove the items they want with a desired quantity, edit its details, and close it. Each registry has a shareable public page at its own link where anyone — no account needed — can see the items and how many of each are still needed, add an item to their cart to buy it through the normal checkout, or mark an item as already purchased so the registry's progress updates. A private registry is not viewable from its link, the public page shows only the items (never the owner's identity, address, or who purchased what), and registry pages are excluded from search indexing.
package/lib/admin.js CHANGED
@@ -496,6 +496,7 @@ function mount(router, deps) {
496
496
  var reviews = deps.reviews || null; // moderation endpoints disabled when absent
497
497
  var productQa = deps.productQa || null; // Q&A moderation endpoints disabled when absent
498
498
  var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
499
+ var returnLabels = deps.returnLabels || null; // return-label issuance + tracking-update endpoints disabled when absent
499
500
  var customers = deps.customers || null; // read-only customers console disabled when absent
500
501
  var storeCredit = deps.storeCredit || null; // per-customer store-credit panel + grant/deduct disabled when absent
501
502
  var customerNotes = deps.customerNotes || null; // per-customer CRM notes panel disabled when absent
@@ -3447,6 +3448,7 @@ function mount(router, deps) {
3447
3448
  }));
3448
3449
  var url = req.url ? new URL(req.url, "http://localhost") : null;
3449
3450
  var rmaCtx = await _rmaProviderContext(rma);
3451
+ var labelCtx = await _returnLabelContext(rma);
3450
3452
  _sendHtml(res, 200, renderAdminReturn({
3451
3453
  shop_name: deps.shop_name,
3452
3454
  nav_available: navAvailable,
@@ -3457,6 +3459,15 @@ function mount(router, deps) {
3457
3459
  // provider via a confirm interstitial. Absent either, it stays
3458
3460
  // record-only with a pointer to the order page for the money side.
3459
3461
  can_provider_refund: rmaCtx.canProviderRefund,
3462
+ // The return-shipping label surface: the issue-label form mounts on
3463
+ // an approved return with no label; once a label is issued, the
3464
+ // label + its carrier-scan timeline + the legal tracking-update
3465
+ // actions take its place. Absent the returnLabels dep this whole
3466
+ // panel is suppressed.
3467
+ labels_available: !!returnLabels,
3468
+ label: labelCtx.label,
3469
+ label_events: labelCtx.events,
3470
+ label_currency: rmaCtx.order ? rmaCtx.order.currency : (rma.refund_currency || "USD"),
3460
3471
  moved: url && url.searchParams.get("moved"),
3461
3472
  notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this return." : null,
3462
3473
  }));
@@ -3477,6 +3488,190 @@ function mount(router, deps) {
3477
3488
  return ctx;
3478
3489
  }
3479
3490
 
3491
+ // Resolve the return's current (most-recently-issued) shipping label plus
3492
+ // its ordered carrier-scan timeline for the detail screen. Best-effort —
3493
+ // the labels primitive may be unwired, the tables may not be migrated, or
3494
+ // a legacy return id may not parse; any of those degrades to "no label"
3495
+ // rather than throwing, so the detail never 500s on the label read.
3496
+ async function _returnLabelContext(rma) {
3497
+ var ctx = { label: null, events: [] };
3498
+ if (!returnLabels || !rma || !rma.id) return ctx;
3499
+ try { ctx.label = await returnLabels.labelForReturn(rma.id); }
3500
+ catch (e) { if (!(e instanceof TypeError)) throw e; ctx.label = null; }
3501
+ if (ctx.label) {
3502
+ try { ctx.events = await returnLabels.eventsForLabel(ctx.label.id); }
3503
+ catch (e) { if (!(e instanceof TypeError)) throw e; ctx.events = []; }
3504
+ }
3505
+ return ctx;
3506
+ }
3507
+
3508
+ // Single classifier for a thrown return-label error → an operator-safe
3509
+ // status, shared by the bearer-JSON path and the cookie/HTML form path.
3510
+ // This MUST run BEFORE a generic `instanceof TypeError → 400` branch: the
3511
+ // labels primitive raises its not-found misses as *coded TypeErrors*
3512
+ // (issueLabel's RETURN_NOT_FOUND, _currentStatus's RETURN_LABEL_NOT_FOUND),
3513
+ // so checking `instanceof TypeError` first would map a genuine 404 to a
3514
+ // 400. Order is: coded-error map (by `.code`) first, then the generic
3515
+ // bad-shape TypeError fall-through the caller applies.
3516
+ // - a missing return / a missing label → 404 (the id named nothing)
3517
+ // - an issue refused (RMA not approved) → 409 (state conflict)
3518
+ // - a transition refused (illegal FSM hop) → 409 (state conflict)
3519
+ // - anything else → null (caller's default map)
3520
+ function _returnLabelsClientError(e) {
3521
+ if (!e) return null;
3522
+ if (e.code === "RETURN_NOT_FOUND" || e.code === "RETURN_LABEL_NOT_FOUND") {
3523
+ return { status: 404, slug: "return-label-not-found" };
3524
+ }
3525
+ if (e.code === "RETURN_LABEL_ISSUE_REFUSED" || e.code === "RETURN_LABEL_TRANSITION_REFUSED") {
3526
+ return { status: 409, slug: "return-label-action-refused" };
3527
+ }
3528
+ return null;
3529
+ }
3530
+
3531
+ // Coerce a browser form's string fields to the numeric shapes
3532
+ // returnLabels.issueLabel demands. ONLY a clean unsigned-integer string
3533
+ // becomes a number; anything else (e.g. "340g", "", "12.5") passes through
3534
+ // unchanged so the primitive's _positiveInt / _nonNegInt rejects it as a
3535
+ // clean 400, never silently truncated by parseInt.
3536
+ function _intField(raw) {
3537
+ return (typeof raw === "string" && /^\d+$/.test(raw.trim())) ? Number(raw.trim()) : raw;
3538
+ }
3539
+
3540
+ // The return-label issuance + tracking-update routes mount only when the
3541
+ // returnLabels primitive is wired — without it the customer download
3542
+ // surface has nothing to show, and these operator routes have no backing
3543
+ // store.
3544
+ if (returnLabels) {
3545
+
3546
+ // POST /admin/returns/:id/label — issue an operator-funded return label.
3547
+ // The route forwards the form/JSON straight to returnLabels.issueLabel,
3548
+ // which owns ALL validation: the carrier / service_level / tracking
3549
+ // bounds, the weight + cost integer shapes, the currency ISO check, the
3550
+ // HTTPS-only label_url (b.safeUrl), and the refusal unless the RMA is
3551
+ // `approved`. The route never hand-rolls an INSERT — composing the
3552
+ // primitive keeps the label_url SSRF/scheme gate and the approved-only
3553
+ // rule in one place. Coded errors map via _returnLabelsClientError BEFORE
3554
+ // the generic TypeError→400, so a missing return 404s (not 400).
3555
+ router.post("/admin/returns/:id/label", _pageOrApi(false,
3556
+ W("return.label.issue", async function (req, res) {
3557
+ var body = req.body || {};
3558
+ var label;
3559
+ try {
3560
+ label = await returnLabels.issueLabel({
3561
+ return_id: req.params.id,
3562
+ carrier: body.carrier,
3563
+ service_level: body.service_level,
3564
+ weight_grams: _intField(body.weight_grams),
3565
+ label_url: body.label_url,
3566
+ tracking_number: body.tracking_number,
3567
+ cost_minor: _intField(body.cost_minor),
3568
+ currency: body.currency,
3569
+ });
3570
+ } catch (e) {
3571
+ var ce = _returnLabelsClientError(e);
3572
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
3573
+ throw e; // bad-shape TypeError → _wrap maps to a clean 400
3574
+ }
3575
+ _json(res, 200, label);
3576
+ return label;
3577
+ }),
3578
+ async function (req, res) {
3579
+ var id = req.params.id;
3580
+ var enc = encodeURIComponent(id);
3581
+ var body = req.body || {};
3582
+ try {
3583
+ await returnLabels.issueLabel({
3584
+ return_id: id,
3585
+ carrier: body.carrier,
3586
+ service_level: body.service_level,
3587
+ weight_grams: _intField(body.weight_grams),
3588
+ label_url: body.label_url,
3589
+ tracking_number: body.tracking_number,
3590
+ cost_minor: _intField(body.cost_minor),
3591
+ currency: body.currency,
3592
+ });
3593
+ } catch (e) {
3594
+ // A coded miss/refusal OR a bad-shape TypeError both become a notice
3595
+ // on the detail (PRG), never a 500; anything else propagates.
3596
+ if (_returnLabelsClientError(e) || e instanceof TypeError) {
3597
+ return _redirect(res, "/admin/returns/" + enc + "?err=1");
3598
+ }
3599
+ throw e;
3600
+ }
3601
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".return.label.issue", outcome: "success", metadata: { id: id } });
3602
+ _redirect(res, "/admin/returns/" + enc + "?moved=1");
3603
+ },
3604
+ ));
3605
+
3606
+ // The four tracking-update actions on a return that HAS a label. Each
3607
+ // appends one carrier-scan event via the matching mark-* primitive (which
3608
+ // owns the FSM-legality check + the event write); mark-exception carries a
3609
+ // short operator-supplied detail. The :id here is the RETURN id — the
3610
+ // route resolves its current label first (labelForReturn), so a return
3611
+ // with no label 404s before any mark-* call. `field` (when set) names the
3612
+ // body key forwarded to the primitive.
3613
+ function _returnLabelMark(auditEvent, markFn, field) {
3614
+ return _pageOrApi(false,
3615
+ W("return.label." + auditEvent, async function (req, res) {
3616
+ var body = req.body || {};
3617
+ var label;
3618
+ try {
3619
+ var rma = await returns.get(req.params.id);
3620
+ if (!rma) return _problem(res, 404, "return-label-not-found");
3621
+ label = await returnLabels.labelForReturn(rma.id);
3622
+ if (!label) return _problem(res, 404, "return-label-not-found");
3623
+ var updated = await markFn(label.id, body);
3624
+ _json(res, 200, updated);
3625
+ return updated;
3626
+ } catch (e) {
3627
+ var ce = _returnLabelsClientError(e);
3628
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
3629
+ throw e;
3630
+ }
3631
+ }),
3632
+ async function (req, res) {
3633
+ var id = req.params.id;
3634
+ var enc = encodeURIComponent(id);
3635
+ try {
3636
+ var rma = await returns.get(id);
3637
+ if (!rma) return _redirect(res, "/admin/returns/" + enc + "?err=1");
3638
+ var label = await returnLabels.labelForReturn(rma.id);
3639
+ if (!label) return _redirect(res, "/admin/returns/" + enc + "?err=1");
3640
+ await markFn(label.id, req.body || {});
3641
+ } catch (e) {
3642
+ if (_returnLabelsClientError(e) || e instanceof TypeError) {
3643
+ return _redirect(res, "/admin/returns/" + enc + "?err=1");
3644
+ }
3645
+ throw e;
3646
+ }
3647
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".return.label." + auditEvent, outcome: "success", metadata: { id: id } });
3648
+ _redirect(res, "/admin/returns/" + enc + "?moved=1");
3649
+ },
3650
+ );
3651
+ }
3652
+
3653
+ router.post("/admin/returns/:id/label/shipped",
3654
+ _returnLabelMark("shipped", function (labelId) {
3655
+ return returnLabels.markShipped({ label_id: labelId });
3656
+ }));
3657
+ router.post("/admin/returns/:id/label/in-transit",
3658
+ _returnLabelMark("in_transit", function (labelId, body) {
3659
+ // location is optional — a bare scan with no place is legal.
3660
+ return returnLabels.markInTransit({ label_id: labelId, location: body.location || undefined });
3661
+ }));
3662
+ router.post("/admin/returns/:id/label/delivered",
3663
+ _returnLabelMark("delivered", function (labelId) {
3664
+ return returnLabels.markDelivered({ label_id: labelId });
3665
+ }));
3666
+ router.post("/admin/returns/:id/label/exception",
3667
+ _returnLabelMark("exception", function (labelId, body) {
3668
+ // exception detail is REQUIRED by the primitive — a blank one throws a
3669
+ // TypeError the wrapper maps to a clean 400.
3670
+ return returnLabels.markException({ label_id: labelId, exception: body.exception });
3671
+ }));
3672
+
3673
+ } // end if (returnLabels)
3674
+
3480
3675
  // The browser side of an RMA action: run `opFn(id, body)`, then PRG
3481
3676
  // back to the detail. A bad id / shape (TypeError) or an FSM refusal /
3482
3677
  // not-found (mapped by _returnsClientError) becomes a notice on the
@@ -10458,6 +10653,141 @@ function _returnPillClass(status) {
10458
10653
  return "pending";
10459
10654
  }
10460
10655
 
10656
+ // Operator-facing phrasing for a return_labels FSM state + a label-event
10657
+ // row's event_type. The customer storefront has its own shopper-facing
10658
+ // phrasings; the console states it plainly for the operator.
10659
+ var RETURN_LABEL_STATUS_TEXT = {
10660
+ issued: "Issued",
10661
+ shipped: "Shipped",
10662
+ in_transit: "In transit",
10663
+ delivered: "Delivered",
10664
+ exception: "Exception",
10665
+ };
10666
+ var RETURN_LABEL_EVENT_TEXT = {
10667
+ issued: "Label issued",
10668
+ shipped: "Marked shipped",
10669
+ in_transit: "In transit",
10670
+ delivered: "Delivered to merchant",
10671
+ exception: "Exception",
10672
+ };
10673
+ function _returnLabelPillClass(status) {
10674
+ if (status === "delivered") return "delivered";
10675
+ if (status === "exception") return "cancelled";
10676
+ if (status === "shipped" || status === "in_transit") return "shipped";
10677
+ return "pending"; // issued
10678
+ }
10679
+ // The legal next tracking actions for a label in `status`, mirroring the
10680
+ // return-labels FSM transition graph. Each entry is the route suffix + the
10681
+ // button copy. Terminal states (delivered / exception) yield none.
10682
+ var RETURN_LABEL_NEXT_ACTIONS = {
10683
+ issued: [{ suffix: "shipped", label: "Mark shipped" }, { suffix: "exception", label: "Flag exception" }],
10684
+ shipped: [{ suffix: "in-transit", label: "Add transit scan" }, { suffix: "delivered", label: "Mark delivered" }, { suffix: "exception", label: "Flag exception" }],
10685
+ in_transit: [{ suffix: "in-transit", label: "Add transit scan" }, { suffix: "delivered", label: "Mark delivered" }, { suffix: "exception", label: "Flag exception" }],
10686
+ delivered: [],
10687
+ exception: [],
10688
+ };
10689
+
10690
+ // The return-shipping-label panel on the return detail. Three states:
10691
+ // - labels primitive unwired → suppressed entirely (empty string).
10692
+ // - no label yet, return is `approved` → the issue-label form (the only
10693
+ // status issueLabel accepts; on any other status we explain why).
10694
+ // - a label exists → the label summary + carrier-scan timeline + the legal
10695
+ // tracking-update actions.
10696
+ // Every form renders through _renderAdminShell, which injects the _csrf
10697
+ // field into each POST form, so no per-form token handling is needed here.
10698
+ function _returnLabelPanel(opts, r) {
10699
+ if (!opts.labels_available) return "";
10700
+ var label = opts.label || null;
10701
+ var events = opts.label_events || [];
10702
+
10703
+ if (!label) {
10704
+ // No label issued yet. issueLabel accepts ONLY an `approved` return, so
10705
+ // the form shows only there; on any other status we say why instead of
10706
+ // offering a form that would 4xx.
10707
+ if (r.status !== "approved") {
10708
+ return "<div class=\"panel mt\"><h3 class=\"subhead\">Return shipping label</h3>" +
10709
+ "<p class=\"meta\">A prepaid return label can be issued once this return is approved.</p></div>";
10710
+ }
10711
+ var ccy = opts.label_currency || r.refund_currency || "USD";
10712
+ var form =
10713
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/label\" class=\"return-action\">" +
10714
+ "<h4>Issue return label</h4>" +
10715
+ "<p class=\"meta\">Generate the label in your carrier account, then record it here. The shopper downloads it from their return status page.</p>" +
10716
+ _setupField("Carrier", "carrier", "", "text", "e.g. USPS, UPS, FedEx.", " maxlength=\"64\" required") +
10717
+ _setupField("Service level", "service_level", "", "text", "e.g. Ground Advantage.", " maxlength=\"128\" required") +
10718
+ _setupField("Weight (grams)", "weight_grams", "", "number", "Parcel weight in grams.", " min=\"1\" required") +
10719
+ _setupField("Label URL", "label_url", "", "url", "The https:// link to the prepaid label PDF in your carrier account.", " maxlength=\"2048\" required") +
10720
+ _setupField("Tracking number", "tracking_number", "", "text", "The carrier tracking number.", " maxlength=\"128\" required") +
10721
+ _setupField("Cost (minor units)", "cost_minor", "", "number", "e.g. 795 for $7.95 of postage.", " min=\"0\" required") +
10722
+ _setupField("Currency", "currency", ccy, "text", "3-letter ISO 4217.", " maxlength=\"3\" class=\"input-code\" required") +
10723
+ "<button class=\"btn\" type=\"submit\">Issue return label</button>" +
10724
+ "</form>";
10725
+ return "<div class=\"panel mt\"><h3 class=\"subhead\">Return shipping label</h3>" + form + "</div>";
10726
+ }
10727
+
10728
+ // A label exists — summary + timeline + the legal next actions.
10729
+ var statusText = RETURN_LABEL_STATUS_TEXT[label.status] || label.status;
10730
+ var costShown = (label.cost_minor != null)
10731
+ ? pricing.format(label.cost_minor, label.currency || "USD")
10732
+ : null;
10733
+ var summary =
10734
+ "<p><span class=\"status-pill " + _returnLabelPillClass(label.status) + "\">" + _htmlEscape(statusText) + "</span></p>" +
10735
+ "<p><span class=\"meta\">Carrier</span><br>" + _htmlEscape(label.carrier || "—") +
10736
+ (label.service_level ? " · " + _htmlEscape(label.service_level) : "") + "</p>" +
10737
+ "<p><span class=\"meta\">Tracking</span><br>" + _htmlEscape(label.tracking_number || "—") + "</p>" +
10738
+ (costShown ? "<p><span class=\"meta\">Postage</span><br>" + _htmlEscape(costShown) + "</p>" : "") +
10739
+ // The label_url is an https link the operator pasted — link it so they can
10740
+ // re-open the label. rel=noopener noreferrer + a literal-text check keeps
10741
+ // it inert beyond a navigation.
10742
+ (label.label_url
10743
+ ? "<p><a class=\"btn btn--ghost\" href=\"" + _htmlEscape(label.label_url) + "\" target=\"_blank\" rel=\"noopener noreferrer\">Open label</a></p>"
10744
+ : "");
10745
+
10746
+ var timelineRows = events.map(function (ev) {
10747
+ var evText = RETURN_LABEL_EVENT_TEXT[ev.event_type] || ev.event_type;
10748
+ var detail = "";
10749
+ try {
10750
+ var d = ev.detail_json ? JSON.parse(ev.detail_json) : {};
10751
+ if (d && d.location) detail = String(d.location);
10752
+ else if (d && d.exception) detail = String(d.exception);
10753
+ } catch (_e) { detail = ""; }
10754
+ return "<li>" + _htmlEscape(String(evText)) +
10755
+ (detail ? " — " + _htmlEscape(detail) : "") +
10756
+ " <span class=\"meta\">" + _htmlEscape(_fmtDate(ev.occurred_at)) + "</span></li>";
10757
+ }).join("");
10758
+ var timeline = timelineRows
10759
+ ? "<h4>Tracking timeline</h4><ul class=\"timeline\">" + timelineRows + "</ul>"
10760
+ : "";
10761
+
10762
+ var nextActions = (RETURN_LABEL_NEXT_ACTIONS[label.status] || []).map(function (a) {
10763
+ if (a.suffix === "exception") {
10764
+ // The exception action takes a short required detail.
10765
+ return "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/label/exception\" class=\"return-action\">" +
10766
+ "<h4>" + _htmlEscape(a.label) + "</h4>" +
10767
+ _setupField("What happened", "exception", "", "text", "Lost / damaged / refused — shown on the timeline.", " maxlength=\"256\" required") +
10768
+ "<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(a.label) + "</button>" +
10769
+ "</form>";
10770
+ }
10771
+ if (a.suffix === "in-transit") {
10772
+ return "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/label/in-transit\" class=\"return-action\">" +
10773
+ "<h4>" + _htmlEscape(a.label) + "</h4>" +
10774
+ _setupField("Location (optional)", "location", "", "text", "e.g. LAX hub.", " maxlength=\"256\"") +
10775
+ "<button class=\"btn\" type=\"submit\">" + _htmlEscape(a.label) + "</button>" +
10776
+ "</form>";
10777
+ }
10778
+ return "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/label/" + _htmlEscape(a.suffix) + "\" class=\"return-action\">" +
10779
+ "<h4>" + _htmlEscape(a.label) + "</h4>" +
10780
+ "<button class=\"btn\" type=\"submit\">" + _htmlEscape(a.label) + "</button>" +
10781
+ "</form>";
10782
+ }).join("");
10783
+ var actionsBlock = nextActions
10784
+ ? "<div class=\"return-actions\">" + nextActions + "</div>"
10785
+ : "<p class=\"meta\">This label is in a final state — no further tracking updates.</p>";
10786
+
10787
+ return "<div class=\"panel mt\"><h3 class=\"subhead\">Return shipping label</h3>" +
10788
+ summary + timeline + actionsBlock + "</div>";
10789
+ }
10790
+
10461
10791
  function renderAdminReturns(opts) {
10462
10792
  opts = opts || {};
10463
10793
  var rmas = opts.returns || [];
@@ -10595,6 +10925,7 @@ function renderAdminReturn(opts) {
10595
10925
  "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
10596
10926
  actions +
10597
10927
  "</div>" +
10928
+ _returnLabelPanel(opts, r) +
10598
10929
  "</section>";
10599
10930
  return _renderAdminShell(opts.shop_name, "Return " + (r.rma_code || r.id.slice(0, 8)), body, "returns", opts.nav_available);
10600
10931
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.38",
2
+ "version": "0.3.40",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/customers.js CHANGED
@@ -255,20 +255,27 @@ function create(opts) {
255
255
  }
256
256
  }
257
257
  var sql, params;
258
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
259
+ // when a customer past this page actually exists. Keying the cursor
260
+ // off a full page alone (rows.length === limit) advertises a
261
+ // phantom next page when the roster size is an exact multiple of
262
+ // the limit — the console's "Next page" link then lands empty.
258
263
  if (cursorVals) {
259
264
  sql = "SELECT id, email_hash, display_name, created_at, updated_at FROM customers " +
260
265
  "WHERE (created_at < ?1 OR (created_at = ?1 AND id < ?2)) " +
261
266
  "ORDER BY created_at DESC, id DESC LIMIT ?3";
262
- params = [cursorVals[0], cursorVals[1], limit];
267
+ params = [cursorVals[0], cursorVals[1], limit + 1];
263
268
  } else {
264
269
  sql = "SELECT id, email_hash, display_name, created_at, updated_at FROM customers " +
265
270
  "ORDER BY created_at DESC, id DESC LIMIT ?1";
266
- params = [limit];
271
+ params = [limit + 1];
267
272
  }
268
- var rows = (await query(sql, params)).rows;
273
+ var fetched = (await query(sql, params)).rows;
274
+ var hasMore = fetched.length > limit;
275
+ var rows = hasMore ? fetched.slice(0, limit) : fetched;
269
276
  var last = rows[rows.length - 1];
270
277
  var next = null;
271
- if (last && rows.length === limit) {
278
+ if (last && hasMore) {
272
279
  next = b.pagination.encodeCursor({
273
280
  orderKey: CUSTOMERS_ORDER_KEY,
274
281
  vals: [last.created_at, last.id],
package/lib/loyalty.js CHANGED
@@ -450,11 +450,17 @@ function create(opts) {
450
450
  sql += " AND occurred_at < ?2";
451
451
  params.push(cursor);
452
452
  }
453
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
454
+ // when an older transaction actually exists. Keying the cursor off
455
+ // a full page alone (rows.length === limit) advertises a phantom
456
+ // next page when the history length is an exact multiple of the
457
+ // limit — the "Older activity" link then lands on an empty page.
453
458
  sql += " ORDER BY occurred_at DESC LIMIT ?" + (params.length + 1);
454
- params.push(limit);
459
+ params.push(limit + 1);
455
460
  var r = await query(sql, params);
456
- var rows = r.rows;
457
- var nextCursor = rows.length === limit ? rows[rows.length - 1].occurred_at : null;
461
+ var hasMore = r.rows.length > limit;
462
+ var rows = hasMore ? r.rows.slice(0, limit) : r.rows;
463
+ var nextCursor = hasMore ? rows[rows.length - 1].occurred_at : null;
458
464
  return { rows: rows, next_cursor: nextCursor };
459
465
  },
460
466
 
package/lib/order.js CHANGED
@@ -412,19 +412,28 @@ function create(opts) {
412
412
  }
413
413
  }
414
414
  var sql, params;
415
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
416
+ // when an order past this page actually exists. Keying the cursor
417
+ // off a full page alone (rows.length === limit) advertises a
418
+ // phantom next page when the customer's order count is an exact
419
+ // multiple of the limit — the "Load more" link then lands on an
420
+ // empty page.
415
421
  if (cursorVals) {
416
422
  sql = "SELECT * FROM orders WHERE customer_id = ?1 AND " +
417
423
  "(updated_at < ?2 OR (updated_at = ?2 AND id < ?3)) " +
418
424
  "ORDER BY updated_at DESC, id DESC LIMIT ?4";
419
- params = [customerId, cursorVals[0], cursorVals[1], limit];
425
+ params = [customerId, cursorVals[0], cursorVals[1], limit + 1];
420
426
  } else {
421
427
  sql = "SELECT * FROM orders WHERE customer_id = ?1 " +
422
428
  "ORDER BY updated_at DESC, id DESC LIMIT ?2";
423
- params = [customerId, limit];
429
+ params = [customerId, limit + 1];
424
430
  }
425
- var rows = (await query(sql, params)).rows;
431
+ var fetched = (await query(sql, params)).rows;
432
+ var hasMore = fetched.length > limit;
433
+ var rows = hasMore ? fetched.slice(0, limit) : fetched;
426
434
  // Hydrate ship_to_json + lines on each row so the renderer
427
- // doesn't need a separate trip per order.
435
+ // doesn't need a separate trip per order. The peeked row is sliced
436
+ // off first so it never costs a hydration trip.
428
437
  for (var i = 0; i < rows.length; i += 1) {
429
438
  rows[i].ship_to = JSON.parse(rows[i].ship_to_json);
430
439
  rows[i].lines = (await query(
@@ -434,7 +443,7 @@ function create(opts) {
434
443
  }
435
444
  var last = rows[rows.length - 1];
436
445
  var next = null;
437
- if (last && rows.length === limit) {
446
+ if (last && hasMore) {
438
447
  next = b.pagination.encodeCursor({
439
448
  orderKey: ORDER_ORDER_KEY,
440
449
  vals: [last.updated_at, last.id],
@@ -331,11 +331,17 @@ function create(opts) {
331
331
  sql += " AND occurred_at < ?2";
332
332
  params.push(cursor);
333
333
  }
334
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
335
+ // when an older ledger entry actually exists. Keying the cursor off
336
+ // a full page alone (rows.length === limit) advertises a phantom
337
+ // next page when the ledger length is an exact multiple of the
338
+ // limit — the "Older activity" link then lands on an empty page.
334
339
  sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + (params.length + 1);
335
- params.push(limit);
340
+ params.push(limit + 1);
336
341
  var r = await query(sql, params);
337
- var rows = r.rows;
338
- var nextCursor = rows.length === limit ? rows[rows.length - 1].occurred_at : null;
342
+ var hasMore = r.rows.length > limit;
343
+ var rows = hasMore ? r.rows.slice(0, limit) : r.rows;
344
+ var nextCursor = hasMore ? rows[rows.length - 1].occurred_at : null;
339
345
  return { rows: rows, next_cursor: nextCursor };
340
346
  },
341
347
 
package/lib/storefront.js CHANGED
@@ -3916,7 +3916,7 @@ function renderReturns(opts) {
3916
3916
  rowsHtml +=
3917
3917
  "<li class=\"return-card\">" +
3918
3918
  "<div class=\"return-card__head\">" +
3919
- "<code class=\"return-card__rma\">" + esc(r.rma_code) + "</code>" +
3919
+ "<a class=\"return-card__rma\" href=\"/account/returns/" + esc(String(r.id)) + "\"><code>" + esc(r.rma_code) + "</code></a>" +
3920
3920
  _returnStatusBadge(r.status) +
3921
3921
  "</div>" +
3922
3922
  "<p class=\"return-card__meta\">" + esc(String(r.reason || "")) +
@@ -3962,6 +3962,150 @@ function renderReturns(opts) {
3962
3962
  });
3963
3963
  }
3964
3964
 
3965
+ // Customer-facing phrasing for a return label's carrier-scan status. The
3966
+ // return-labels module's FSM enum (issued / shipped / in_transit /
3967
+ // delivered / exception) is operator/carrier vocabulary; these read for
3968
+ // the shopper following their parcel back to the merchant.
3969
+ var RETURN_LABEL_STATUS_LABELS = {
3970
+ issued: "Label ready",
3971
+ shipped: "Dropped off",
3972
+ in_transit: "On its way back",
3973
+ delivered: "Delivered to us",
3974
+ exception: "Delivery issue",
3975
+ };
3976
+
3977
+ function _returnLabelStatusBadge(status) {
3978
+ var esc = b.template.escapeHtml;
3979
+ var label = RETURN_LABEL_STATUS_LABELS[status] || status;
3980
+ return "<span class=\"return-status return-status--" + esc(String(status)) + "\">" +
3981
+ esc(String(label)) + "</span>";
3982
+ }
3983
+
3984
+ // Human label for a return_label_events row. The event_type mirrors the
3985
+ // label FSM transitions (issued / shipped / in_transit / delivered /
3986
+ // exception); these are the shopper-facing phrasings on the timeline.
3987
+ var RETURN_LABEL_EVENT_LABELS = {
3988
+ issued: "Label issued",
3989
+ shipped: "Parcel dropped off",
3990
+ in_transit: "In transit",
3991
+ delivered: "Delivered to the merchant",
3992
+ exception: "Delivery exception",
3993
+ };
3994
+
3995
+ // One return's status detail, including its return-shipping label (if one
3996
+ // has been issued) and the carrier-scan timeline. Ownership-scoped: the
3997
+ // route only renders this for a return whose customer_id matches the
3998
+ // session customer, so the label + tracking reads are already gated.
3999
+ //
4000
+ // `opts.rma` is the return_authorizations row. `opts.label` is the most
4001
+ // recent return_labels row (or null — a return with no label yet shows a
4002
+ // neutral "no label" state, never an error). `opts.events` is the label's
4003
+ // ordered return_label_events timeline (empty when there's no label).
4004
+ // Reuses the account/returns layout classes — no new CSS.
4005
+ function renderReturnDetail(opts) {
4006
+ var esc = b.template.escapeHtml;
4007
+ var rma = opts.rma;
4008
+ var label = opts.label || null;
4009
+ var events = opts.events || [];
4010
+ var date = rma.created_at ? new Date(Number(rma.created_at)).toISOString().slice(0, 10) : "";
4011
+
4012
+ function _row(labelText, value) {
4013
+ return "<p class=\"return-card__meta\"><span class=\"form-field__label\">" + esc(labelText) + "</span> " +
4014
+ (value ? esc(String(value)) : "&mdash;") + "</p>";
4015
+ }
4016
+
4017
+ var summaryRows = "";
4018
+ summaryRows += _row("Reason", rma.reason);
4019
+ if (date) summaryRows += _row("Requested", date);
4020
+ if (rma.status === "rejected" && rma.rejected_reason) {
4021
+ summaryRows += _row("Why it was declined", rma.rejected_reason);
4022
+ }
4023
+ if (Number(rma.refund_amount_minor) > 0) {
4024
+ summaryRows += _row("Refund", pricing.format(Number(rma.refund_amount_minor), rma.refund_currency || "USD"));
4025
+ }
4026
+
4027
+ // The return-label panel. A return with no issued label shows a neutral
4028
+ // note (the label arrives once the operator approves + funds the return)
4029
+ // — never an error. A return WITH a label surfaces the carrier + tracking
4030
+ // number, a download/print affordance, the live status, and the timeline.
4031
+ var labelPanel;
4032
+ if (!label) {
4033
+ labelPanel =
4034
+ "<div class=\"return-card return-label-panel\">" +
4035
+ "<h2 class=\"return-card__subhead\">Return shipping label</h2>" +
4036
+ "<p class=\"return-card__meta return-label-panel__none\">No label yet. We'll add a prepaid return label here once your return is approved.</p>" +
4037
+ "</div>";
4038
+ } else {
4039
+ var carrierLine = label.carrier
4040
+ ? (esc(String(label.carrier)) + (label.service_level ? " &middot; " + esc(String(label.service_level)) : ""))
4041
+ : "";
4042
+ // The download is its own ownership-scoped route (GET
4043
+ // /account/returns/:id/label), which loads the return, re-checks
4044
+ // ownership, then redirects to the carrier label asset — the label_url
4045
+ // is never emitted in the page by id, so the affordance is a link to
4046
+ // the scoped route, not the raw carrier URL.
4047
+ var downloadHref = "/account/returns/" + esc(String(rma.id)) + "/label";
4048
+
4049
+ var timelineRows = "";
4050
+ for (var i = 0; i < events.length; i += 1) {
4051
+ var ev = events[i];
4052
+ var evLabel = RETURN_LABEL_EVENT_LABELS[ev.event_type] || ev.event_type;
4053
+ var when = ev.occurred_at ? new Date(Number(ev.occurred_at)).toISOString().slice(0, 16).replace("T", " ") : "";
4054
+ var detail = "";
4055
+ try {
4056
+ var d = ev.detail_json ? JSON.parse(ev.detail_json) : {};
4057
+ if (d && d.location) detail = String(d.location);
4058
+ else if (d && d.exception) detail = String(d.exception);
4059
+ } catch (_e) { detail = ""; }
4060
+ timelineRows +=
4061
+ "<li class=\"return-timeline__event\">" +
4062
+ "<span class=\"return-timeline__label\">" + esc(String(evLabel)) + "</span>" +
4063
+ (detail ? " <span class=\"return-timeline__detail\">" + esc(detail) + "</span>" : "") +
4064
+ (when ? " <time class=\"return-timeline__when\" datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") +
4065
+ "</li>";
4066
+ }
4067
+ var timeline = timelineRows
4068
+ ? "<ol class=\"return-timeline\">" + timelineRows + "</ol>"
4069
+ : "";
4070
+
4071
+ labelPanel =
4072
+ "<div class=\"return-card return-label-panel\">" +
4073
+ "<div class=\"return-card__head\">" +
4074
+ "<h2 class=\"return-card__subhead\">Return shipping label</h2>" +
4075
+ _returnLabelStatusBadge(label.status) +
4076
+ "</div>" +
4077
+ (carrierLine ? "<p class=\"return-card__meta\"><span class=\"form-field__label\">Carrier</span> " + carrierLine + "</p>" : "") +
4078
+ _row("Tracking number", label.tracking_number) +
4079
+ "<p class=\"return-label-panel__actions\">" +
4080
+ "<a class=\"btn-primary\" href=\"" + downloadHref + "\" rel=\"nofollow\">Download return label</a>" +
4081
+ "</p>" +
4082
+ (timeline ? "<h3 class=\"return-card__subhead\">Tracking</h3>" + timeline : "") +
4083
+ "</div>";
4084
+ }
4085
+
4086
+ var body =
4087
+ "<section class=\"account-returns\">" +
4088
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
4089
+ "<li><a href=\"/account\">Account</a></li>" +
4090
+ "<li><a href=\"/account/returns\">Returns</a></li>" +
4091
+ "<li aria-current=\"page\">" + esc(String(rma.rma_code || "")) + "</li>" +
4092
+ "</ol></nav>" +
4093
+ "<div class=\"return-card__head\">" +
4094
+ "<h1 class=\"account-returns__title\">Return <code>" + esc(String(rma.rma_code || "")) + "</code></h1>" +
4095
+ _returnStatusBadge(rma.status) +
4096
+ "</div>" +
4097
+ "<div class=\"return-card\">" + summaryRows + "</div>" +
4098
+ labelPanel +
4099
+ "</section>";
4100
+ return _wrap({
4101
+ title: "Return " + String(rma.rma_code || ""),
4102
+ shop_name: opts.shop_name || "blamejs.shop",
4103
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
4104
+ theme_css: opts.theme_css,
4105
+ body: body,
4106
+ });
4107
+ }
4108
+
3965
4109
  // Customer-facing reasons for an exchange. The values mirror the
3966
4110
  // order-exchanges module's REASONS allow-list; the backend validates the
3967
4111
  // submitted value against its own list, so a forged value is refused
@@ -13227,6 +13371,39 @@ function mount(router, deps) {
13227
13371
  return order;
13228
13372
  }
13229
13373
 
13374
+ // Load the return named in :id and confirm it belongs to the
13375
+ // signed-in customer. The return_authorizations row carries
13376
+ // `customer_id` directly, so ownership is a single comparison — no
13377
+ // transitive lookup. A malformed id (guardUuid TypeError), an unknown
13378
+ // return, or someone else's return ALL return 404 — never a 500,
13379
+ // never a cross-customer reveal. This is the gate the return detail
13380
+ // view + the label-download route funnel through, so a return label /
13381
+ // its tracking is never reachable by a return/label id alone.
13382
+ async function _ownedReturn(req, res, auth) {
13383
+ var rma;
13384
+ try { rma = await deps.returns.get(req.params && req.params.id); }
13385
+ catch (e) {
13386
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
13387
+ throw e;
13388
+ }
13389
+ if (!rma || rma.customer_id !== auth.customer_id) {
13390
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13391
+ return null;
13392
+ }
13393
+ return rma;
13394
+ }
13395
+
13396
+ // The most-recent issued label for an owned return (or null). Returns
13397
+ // null when the label primitive isn't wired or no label has been
13398
+ // issued — the detail view renders the neutral "no label yet" state.
13399
+ // Best-effort on a read failure (tables not migrated) so the return
13400
+ // detail never 500s on a missing label table.
13401
+ async function _labelForReturn(returnId) {
13402
+ if (!deps.returnLabels) return null;
13403
+ try { return await deps.returnLabels.labelForReturn(returnId); }
13404
+ catch (e) { if (e instanceof TypeError) return null; throw e; }
13405
+ }
13406
+
13230
13407
  router.get("/account/returns", async function (req, res) {
13231
13408
  var auth = _returnsAuth(req, res); if (!auth) return;
13232
13409
  var page = await deps.returns.listForCustomer(auth.customer_id, { limit: 50 });
@@ -13240,6 +13417,58 @@ function mount(router, deps) {
13240
13417
  }));
13241
13418
  });
13242
13419
 
13420
+ // One return's status detail, including its return-shipping label and
13421
+ // carrier-scan timeline once a label has been issued. Ownership-scoped
13422
+ // through _ownedReturn (foreign / unknown / malformed id → 404). A
13423
+ // return with no issued label renders a neutral "no label yet" state.
13424
+ router.get("/account/returns/:id", async function (req, res) {
13425
+ var auth = _returnsAuth(req, res); if (!auth) return;
13426
+ var rma = await _ownedReturn(req, res, auth); if (!rma) return;
13427
+ var label = await _labelForReturn(rma.id);
13428
+ var events = [];
13429
+ if (label && deps.returnLabels) {
13430
+ try { events = await deps.returnLabels.eventsForLabel(label.id); }
13431
+ catch (e) { if (!(e instanceof TypeError)) throw e; events = []; }
13432
+ }
13433
+ var cartCount = await _cartCountForReq(req);
13434
+ _send(res, 200, renderReturnDetail({
13435
+ rma: rma,
13436
+ label: label,
13437
+ events: events,
13438
+ shop_name: shopName,
13439
+ cart_count: cartCount,
13440
+ }));
13441
+ });
13442
+
13443
+ // Download / print the return label. Ownership-scoped: load + verify
13444
+ // the return belongs to the session customer (via _ownedReturn — a
13445
+ // foreign / unknown / malformed return id all 404 identically) BEFORE
13446
+ // resolving its label, then redirect to the carrier label asset. The
13447
+ // label_url is resolved server-side through the owning return, never
13448
+ // exposed by a bare label id, and a return with no issued label 404s
13449
+ // (there is nothing to download yet). Mounts only when the label
13450
+ // primitive is wired.
13451
+ if (deps.returnLabels) {
13452
+ router.get("/account/returns/:id/label", async function (req, res) {
13453
+ var auth = _returnsAuth(req, res); if (!auth) return;
13454
+ var rma = await _ownedReturn(req, res, auth); if (!rma) return;
13455
+ var label = await _labelForReturn(rma.id);
13456
+ if (!label || !label.label_url) {
13457
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13458
+ return;
13459
+ }
13460
+ // The carrier label asset is an already-validated HTTPS URL
13461
+ // (return-labels runs label_url through b.safeUrl on issue), so a
13462
+ // redirect can't smuggle a javascript:/credentialed target. The
13463
+ // shopper reaches the printable label through the scoped route,
13464
+ // never the raw URL by id.
13465
+ res.status(302);
13466
+ res.setHeader && res.setHeader("location", label.label_url);
13467
+ res.setHeader && res.setHeader("cache-control", "private, no-store");
13468
+ return res.end ? res.end() : res.send("");
13469
+ });
13470
+ }
13471
+
13243
13472
  router.get("/account/orders/:order_id/return", async function (req, res) {
13244
13473
  var auth = _returnsAuth(req, res); if (!auth) return;
13245
13474
  var order = await _ownedOrder(req, res, auth); if (!order) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.38",
3
+ "version": "0.3.40",
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": {