@blamejs/blamejs-shop 0.3.39 → 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 +2 -0
- package/lib/admin.js +331 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +230 -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.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
|
+
|
|
11
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.
|
|
12
14
|
|
|
13
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.
|
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
|
}
|
package/lib/asset-manifest.json
CHANGED
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
|
-
"<
|
|
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)) : "—") + "</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 ? " · " + 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