@blamejs/blamejs-shop 0.2.13 → 0.2.15
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 +4 -0
- package/README.md +1 -1
- package/lib/admin.js +1586 -22
- package/lib/asset-manifest.json +3 -3
- package/lib/catalog.js +19 -0
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +16 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/lib/agent-idempotency.js +50 -10
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +58 -5
- package/lib/vendor/blamejs/lib/agent-saga.js +8 -4
- package/lib/vendor/blamejs/lib/agent-tenant.js +56 -4
- package/lib/vendor/blamejs/lib/agent-trace.js +4 -3
- package/lib/vendor/blamejs/lib/ai-adverse-decision.js +1 -1
- package/lib/vendor/blamejs/lib/audit-daily-review.js +3 -3
- package/lib/vendor/blamejs/lib/audit.js +11 -8
- package/lib/vendor/blamejs/lib/calendar.js +14 -10
- package/lib/vendor/blamejs/lib/circuit-breaker.js +6 -4
- package/lib/vendor/blamejs/lib/compliance-ai-act-transparency.js +2 -2
- package/lib/vendor/blamejs/lib/compliance.js +7 -9
- package/lib/vendor/blamejs/lib/cookies.js +2 -2
- package/lib/vendor/blamejs/lib/crypto-field.js +8 -3
- package/lib/vendor/blamejs/lib/data-act.js +4 -3
- package/lib/vendor/blamejs/lib/file-upload.js +18 -1
- package/lib/vendor/blamejs/lib/mail-bimi.js +3 -2
- package/lib/vendor/blamejs/lib/mail-crypto-pgp.js +7 -11
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +6 -5
- package/lib/vendor/blamejs/lib/mail-crypto.js +5 -5
- package/lib/vendor/blamejs/lib/mail-store.js +3 -2
- package/lib/vendor/blamejs/lib/mcp.js +2 -3
- package/lib/vendor/blamejs/lib/middleware/age-gate.js +10 -6
- package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +3 -3
- package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/csp-nonce.js +2 -2
- package/lib/vendor/blamejs/lib/middleware/flag-context.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/require-auth.js +2 -1
- package/lib/vendor/blamejs/lib/queue-redis.js +9 -0
- package/lib/vendor/blamejs/lib/queue.js +17 -1
- package/lib/vendor/blamejs/lib/retry.js +26 -0
- package/lib/vendor/blamejs/lib/router.js +4 -1
- package/lib/vendor/blamejs/lib/safe-decompress.js +3 -2
- package/lib/vendor/blamejs/lib/safe-icap.js +3 -2
- package/lib/vendor/blamejs/lib/safe-mime.js +1 -1
- package/lib/vendor/blamejs/lib/safe-smtp.js +1 -1
- package/lib/vendor/blamejs/lib/sec-cyber.js +1 -1
- package/lib/vendor/blamejs/lib/storage.js +20 -16
- package/lib/vendor/blamejs/lib/vault/index.js +1 -0
- package/lib/vendor/blamejs/lib/vault-aad.js +2 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.25.json +39 -0
- package/lib/vendor/blamejs/release-notes/v0.13.26.json +31 -0
- package/lib/vendor/blamejs/release-notes/v0.13.27.json +34 -0
- package/lib/vendor/blamejs/release-notes/v0.13.28.json +30 -0
- package/lib/vendor/blamejs/release-notes/v0.13.29.json +30 -0
- package/lib/vendor/blamejs/release-notes/v0.13.30.json +30 -0
- package/lib/vendor/blamejs/release-notes/v0.13.31.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.32.json +34 -0
- package/lib/vendor/blamejs/test/20-db.js +30 -0
- package/lib/vendor/blamejs/test/integration/queue-redis.test.js +14 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-daily-review.test.js +9 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/retry.test.js +25 -0
- package/package.json +1 -1
package/lib/admin.js
CHANGED
|
@@ -138,6 +138,25 @@ function _strictMinorInt(value, prefix, label) {
|
|
|
138
138
|
return n;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// Parse a "k=v, k=v" form string into a variant options attribute map.
|
|
142
|
+
// An empty / whitespace string yields `{}` (no options). A pair missing
|
|
143
|
+
// its `=` is skipped rather than throwing — the catalog primitive does
|
|
144
|
+
// the authoritative validation on the resulting object. Keys + values
|
|
145
|
+
// are trimmed; a duplicate key keeps the last value.
|
|
146
|
+
function _parseOptionsString(s) {
|
|
147
|
+
if (s == null) return {};
|
|
148
|
+
if (typeof s !== "string") return {};
|
|
149
|
+
var out = {};
|
|
150
|
+
s.split(",").forEach(function (pair) {
|
|
151
|
+
var eq = pair.indexOf("=");
|
|
152
|
+
if (eq === -1) return;
|
|
153
|
+
var k = pair.slice(0, eq).trim();
|
|
154
|
+
var v = pair.slice(eq + 1).trim();
|
|
155
|
+
if (k) out[k] = v;
|
|
156
|
+
});
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
// ---- bearer auth --------------------------------------------------------
|
|
142
161
|
|
|
143
162
|
function _readBearer(req) {
|
|
@@ -300,7 +319,7 @@ function mount(router, deps) {
|
|
|
300
319
|
// Which optional console sections are wired — gates their nav links so a
|
|
301
320
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
302
321
|
// into every authed render call as `nav_available`.
|
|
303
|
-
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 };
|
|
322
|
+
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 };
|
|
304
323
|
|
|
305
324
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
306
325
|
|
|
@@ -401,11 +420,77 @@ function mount(router, deps) {
|
|
|
401
420
|
},
|
|
402
421
|
));
|
|
403
422
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
423
|
+
// Default currency for the price-set form — read from shop config when
|
|
424
|
+
// wired, falling back to USD. A config read failure (unconfigured store)
|
|
425
|
+
// is non-fatal: the form just defaults to USD.
|
|
426
|
+
async function _defaultCurrency() {
|
|
427
|
+
if (!deps.config) return "USD";
|
|
428
|
+
try {
|
|
429
|
+
var c = await deps.config.get("shop.currency", "USD");
|
|
430
|
+
return (typeof c === "string" && /^[A-Z]{3}$/.test(c)) ? c : "USD";
|
|
431
|
+
} catch (_e) { return "USD"; }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Hydrate the full detail model: the product, its variants, each
|
|
435
|
+
// variant's price-per-currency (current + history), and the product's
|
|
436
|
+
// media. Throws TypeError on a malformed id (defensive id reader) — the
|
|
437
|
+
// caller maps that to a 404, never a 500.
|
|
438
|
+
async function _productDetailModel(id) {
|
|
439
|
+
var p = await catalog.products.get(id);
|
|
440
|
+
if (!p) return null;
|
|
441
|
+
var variants = await catalog.variants.listForProduct(id);
|
|
442
|
+
var pricesByVariant = {};
|
|
443
|
+
for (var i = 0; i < variants.length; i += 1) {
|
|
444
|
+
var v = variants[i];
|
|
445
|
+
var currencies = await catalog.prices.currencies(v.id);
|
|
446
|
+
var perCurrency = [];
|
|
447
|
+
for (var j = 0; j < currencies.length; j += 1) {
|
|
448
|
+
var cur = currencies[j];
|
|
449
|
+
perCurrency.push({
|
|
450
|
+
currency: cur,
|
|
451
|
+
current: await catalog.prices.current(v.id, cur),
|
|
452
|
+
history: await catalog.prices.history(v.id, cur),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
pricesByVariant[v.id] = { currencies: perCurrency };
|
|
456
|
+
}
|
|
457
|
+
var media = await catalog.media.listForProduct(id);
|
|
458
|
+
return { product: p, variants: variants, prices_by_variant: pricesByVariant, media: media };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Detail content-negotiates: bearer → JSON (product + variants + prices
|
|
462
|
+
// + media); browser → the full management screen. A bad / unknown id is
|
|
463
|
+
// a 404 page, never a 500.
|
|
464
|
+
router.get("/admin/products/:id", _pageOrApi(true,
|
|
465
|
+
R(async function (req, res) {
|
|
466
|
+
var model;
|
|
467
|
+
try { model = await _productDetailModel(req.params.id); }
|
|
468
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 404, "product-not-found", e.message); throw e; }
|
|
469
|
+
if (!model) return _problem(res, 404, "product-not-found");
|
|
470
|
+
_json(res, 200, model);
|
|
471
|
+
}),
|
|
472
|
+
async function (req, res) {
|
|
473
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
474
|
+
var model;
|
|
475
|
+
try { model = await _productDetailModel(req.params.id); }
|
|
476
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; model = null; }
|
|
477
|
+
if (!model) {
|
|
478
|
+
var page = await catalog.products.list({ limit: 100 });
|
|
479
|
+
return _sendHtml(res, 404, renderAdminProducts({
|
|
480
|
+
shop_name: deps.shop_name, nav_available: navAvailable, products: page.rows || [], notice: "Product not found.",
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
_sendHtml(res, 200, renderAdminProduct({
|
|
484
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
485
|
+
product: model.product, variants: model.variants,
|
|
486
|
+
prices_by_variant: model.prices_by_variant, media: model.media,
|
|
487
|
+
asset_prefix: assetPrefix, upload_available: !!r2,
|
|
488
|
+
default_currency: await _defaultCurrency(),
|
|
489
|
+
saved: url && url.searchParams.get("saved"),
|
|
490
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
491
|
+
}));
|
|
492
|
+
},
|
|
493
|
+
));
|
|
409
494
|
|
|
410
495
|
router.patch("/admin/products/:id", W("product.update", async function (req, res) {
|
|
411
496
|
var p = await catalog.products.update(req.params.id, req.body || {});
|
|
@@ -414,6 +499,40 @@ function mount(router, deps) {
|
|
|
414
499
|
return p;
|
|
415
500
|
}));
|
|
416
501
|
|
|
502
|
+
// Browser POST alias for the product fields edit (HTML forms can't
|
|
503
|
+
// PATCH). Bearer clients keep using PATCH /admin/products/:id. Bad input
|
|
504
|
+
// re-renders the detail with a notice (?err=1), never a 500.
|
|
505
|
+
router.post("/admin/products/:id/edit", _pageOrApi(false,
|
|
506
|
+
W("product.update", async function (req, res) {
|
|
507
|
+
var p;
|
|
508
|
+
try { p = await catalog.products.update(req.params.id, req.body || {}); }
|
|
509
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
510
|
+
if (!p) return _problem(res, 404, "product-not-found");
|
|
511
|
+
_json(res, 200, p);
|
|
512
|
+
return p;
|
|
513
|
+
}),
|
|
514
|
+
async function (req, res) {
|
|
515
|
+
var id = req.params.id;
|
|
516
|
+
var enc = encodeURIComponent(id);
|
|
517
|
+
var body = req.body || {};
|
|
518
|
+
var patch = {};
|
|
519
|
+
if (typeof body.slug === "string") patch.slug = body.slug.trim();
|
|
520
|
+
if (typeof body.title === "string") patch.title = body.title;
|
|
521
|
+
if (typeof body.description === "string") patch.description = body.description;
|
|
522
|
+
if (typeof body.status === "string" && body.status) patch.status = body.status;
|
|
523
|
+
try {
|
|
524
|
+
if (Object.keys(patch).length === 0) return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
525
|
+
var p = await catalog.products.update(id, patch);
|
|
526
|
+
if (!p) return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
527
|
+
} catch (e) {
|
|
528
|
+
if (!(e instanceof TypeError)) throw e;
|
|
529
|
+
return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
530
|
+
}
|
|
531
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".product.update", outcome: "success", metadata: { id: id } });
|
|
532
|
+
_redirect(res, "/admin/products/" + enc + "?saved=1");
|
|
533
|
+
},
|
|
534
|
+
));
|
|
535
|
+
|
|
417
536
|
function _productStateAction(verb, op, audit) {
|
|
418
537
|
return _pageOrApi(false,
|
|
419
538
|
W(audit, async function (req, res) {
|
|
@@ -443,6 +562,37 @@ function mount(router, deps) {
|
|
|
443
562
|
return v;
|
|
444
563
|
}));
|
|
445
564
|
|
|
565
|
+
// Browser POST alias for variant create from the product detail screen.
|
|
566
|
+
// The options string ("k=v, k=v") and the weight / requires_shipping
|
|
567
|
+
// selects come in as form strings — coerce them to the shapes the
|
|
568
|
+
// primitive expects before handing off. Bad input → ?err=1 notice.
|
|
569
|
+
router.post("/admin/products/:id/variants/create", _pageOrApi(false,
|
|
570
|
+
W("variant.create", async function (req, res) {
|
|
571
|
+
var v = await catalog.variants.create(req.params.id, req.body || {});
|
|
572
|
+
_json(res, 201, v);
|
|
573
|
+
return v;
|
|
574
|
+
}),
|
|
575
|
+
async function (req, res) {
|
|
576
|
+
var id = req.params.id;
|
|
577
|
+
var enc = encodeURIComponent(id);
|
|
578
|
+
var body = req.body || {};
|
|
579
|
+
try {
|
|
580
|
+
await catalog.variants.create(id, {
|
|
581
|
+
sku: typeof body.sku === "string" ? body.sku.trim() : body.sku,
|
|
582
|
+
title: typeof body.title === "string" ? body.title : undefined,
|
|
583
|
+
options: _parseOptionsString(body.options),
|
|
584
|
+
weight_grams: body.weight_grams === undefined || body.weight_grams === "" ? undefined : _strictMinorInt(body.weight_grams, "admin.variant", "weight_grams"),
|
|
585
|
+
requires_shipping: body.requires_shipping === undefined ? undefined : (String(body.requires_shipping) === "1"),
|
|
586
|
+
});
|
|
587
|
+
} catch (e) {
|
|
588
|
+
if (!(e instanceof TypeError)) throw e;
|
|
589
|
+
return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
590
|
+
}
|
|
591
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".variant.create", outcome: "success", metadata: { id: id } });
|
|
592
|
+
_redirect(res, "/admin/products/" + enc + "?saved=1");
|
|
593
|
+
},
|
|
594
|
+
));
|
|
595
|
+
|
|
446
596
|
router.patch("/admin/variants/:id", W("variant.update", async function (req, res) {
|
|
447
597
|
var v = await catalog.variants.update(req.params.id, req.body || {});
|
|
448
598
|
if (!v) return _problem(res, 404, "variant-not-found");
|
|
@@ -450,6 +600,51 @@ function mount(router, deps) {
|
|
|
450
600
|
return v;
|
|
451
601
|
}));
|
|
452
602
|
|
|
603
|
+
// Resolve the owning product id for a variant so a variant-scoped
|
|
604
|
+
// browser route can redirect back to the product detail page. Returns
|
|
605
|
+
// null for a missing / malformed id.
|
|
606
|
+
async function _variantProductId(variantId) {
|
|
607
|
+
var v = null;
|
|
608
|
+
try { v = await catalog.variants.get(variantId); }
|
|
609
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
610
|
+
return v ? v.product_id : null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Browser POST alias for variant edit (HTML forms can't PATCH).
|
|
614
|
+
router.post("/admin/variants/:id/edit", _pageOrApi(false,
|
|
615
|
+
W("variant.update", async function (req, res) {
|
|
616
|
+
var v;
|
|
617
|
+
try { v = await catalog.variants.update(req.params.id, req.body || {}); }
|
|
618
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
619
|
+
if (!v) return _problem(res, 404, "variant-not-found");
|
|
620
|
+
_json(res, 200, v);
|
|
621
|
+
return v;
|
|
622
|
+
}),
|
|
623
|
+
async function (req, res) {
|
|
624
|
+
var vid = req.params.id;
|
|
625
|
+
var body = req.body || {};
|
|
626
|
+
var productId = await _variantProductId(vid);
|
|
627
|
+
var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
|
|
628
|
+
if (!productId) return _redirect(res, back + "?err=1");
|
|
629
|
+
try {
|
|
630
|
+
var patch = {};
|
|
631
|
+
if (typeof body.sku === "string") patch.sku = body.sku.trim();
|
|
632
|
+
if (typeof body.title === "string") patch.title = body.title;
|
|
633
|
+
if (typeof body.options === "string") patch.options = _parseOptionsString(body.options);
|
|
634
|
+
if (body.weight_grams !== undefined && body.weight_grams !== "") patch.weight_grams = _strictMinorInt(body.weight_grams, "admin.variant", "weight_grams");
|
|
635
|
+
if (body.requires_shipping !== undefined) patch.requires_shipping = String(body.requires_shipping) === "1";
|
|
636
|
+
if (Object.keys(patch).length === 0) return _redirect(res, back + "?err=1");
|
|
637
|
+
var v = await catalog.variants.update(vid, patch);
|
|
638
|
+
if (!v) return _redirect(res, back + "?err=1");
|
|
639
|
+
} catch (e) {
|
|
640
|
+
if (!(e instanceof TypeError)) throw e;
|
|
641
|
+
return _redirect(res, back + "?err=1");
|
|
642
|
+
}
|
|
643
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".variant.update", outcome: "success", metadata: { id: vid } });
|
|
644
|
+
_redirect(res, back + "?saved=1");
|
|
645
|
+
},
|
|
646
|
+
));
|
|
647
|
+
|
|
453
648
|
router.delete("/admin/variants/:id", W("variant.delete", async function (req, res) {
|
|
454
649
|
var ok = await catalog.variants.delete(req.params.id);
|
|
455
650
|
if (!ok) return _problem(res, 404, "variant-not-found");
|
|
@@ -457,6 +652,56 @@ function mount(router, deps) {
|
|
|
457
652
|
return { id: req.params.id };
|
|
458
653
|
}));
|
|
459
654
|
|
|
655
|
+
// Browser confirm interstitial for variant delete — deleting a variant
|
|
656
|
+
// removes its prices + media via the FK cascade, so the console confirms
|
|
657
|
+
// first (the CSP forbids a client confirm() dialog). Reached by a GET
|
|
658
|
+
// link from the detail screen; bearer clients DELETE directly.
|
|
659
|
+
router.get("/admin/variants/:id/delete/confirm-page", _pageOrApi(true,
|
|
660
|
+
R(async function (_req, res) {
|
|
661
|
+
return _problem(res, 405, "use-canonical-endpoint", "DELETE /admin/variants/:id (or POST .../delete) directly for the JSON API");
|
|
662
|
+
}),
|
|
663
|
+
async function (req, res) {
|
|
664
|
+
var vid = req.params.id;
|
|
665
|
+
var v = null;
|
|
666
|
+
try { v = await catalog.variants.get(vid); }
|
|
667
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
668
|
+
var productId = v ? v.product_id : null;
|
|
669
|
+
var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
|
|
670
|
+
if (!v) return _redirect(res, back + "?err=1");
|
|
671
|
+
_sendHtml(res, 200, renderAdminConfirm({
|
|
672
|
+
shop_name: deps.shop_name, nav_available: navAvailable, active: "products",
|
|
673
|
+
heading: "Delete this variant?",
|
|
674
|
+
consequence: "Deleting the variant is permanent — its prices and any media attached to it are removed too.",
|
|
675
|
+
detail: "Variant: " + (v.title || v.sku) + " (" + v.sku + ").",
|
|
676
|
+
action: "/admin/variants/" + _htmlEscape(vid) + "/delete",
|
|
677
|
+
confirm_label: "Delete variant",
|
|
678
|
+
cancel_href: back,
|
|
679
|
+
}));
|
|
680
|
+
},
|
|
681
|
+
));
|
|
682
|
+
|
|
683
|
+
// Browser POST for variant delete (HTML forms can't DELETE). Bearer
|
|
684
|
+
// clients keep using DELETE /admin/variants/:id.
|
|
685
|
+
router.post("/admin/variants/:id/delete", _pageOrApi(false,
|
|
686
|
+
W("variant.delete", async function (req, res) {
|
|
687
|
+
var ok = await catalog.variants.delete(req.params.id);
|
|
688
|
+
if (!ok) return _problem(res, 404, "variant-not-found");
|
|
689
|
+
_json(res, 200, { ok: true });
|
|
690
|
+
return { id: req.params.id };
|
|
691
|
+
}),
|
|
692
|
+
async function (req, res) {
|
|
693
|
+
var vid = req.params.id;
|
|
694
|
+
var productId = await _variantProductId(vid);
|
|
695
|
+
var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
|
|
696
|
+
var ok = false;
|
|
697
|
+
try { ok = await catalog.variants.delete(vid); }
|
|
698
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
699
|
+
if (!ok) return _redirect(res, back + "?err=1");
|
|
700
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".variant.delete", outcome: "success", metadata: { id: vid } });
|
|
701
|
+
_redirect(res, back + "?saved=1");
|
|
702
|
+
},
|
|
703
|
+
));
|
|
704
|
+
|
|
460
705
|
// ---- prices ---------------------------------------------------------
|
|
461
706
|
|
|
462
707
|
router.post("/admin/variants/:id/prices", W("price.set", async function (req, res) {
|
|
@@ -465,6 +710,37 @@ function mount(router, deps) {
|
|
|
465
710
|
return p;
|
|
466
711
|
}));
|
|
467
712
|
|
|
713
|
+
// Browser POST alias for setting a price from the product detail screen.
|
|
714
|
+
// The amount comes in as a form string — coerce it with the strict
|
|
715
|
+
// integer reader (refuses "29.99" / "" / "50abc") before handing to the
|
|
716
|
+
// primitive. Bad input → ?err=1 notice on the product detail.
|
|
717
|
+
router.post("/admin/variants/:id/prices/set", _pageOrApi(false,
|
|
718
|
+
W("price.set", async function (req, res) {
|
|
719
|
+
var p = await catalog.prices.set(req.params.id, req.body || {});
|
|
720
|
+
_json(res, 201, p);
|
|
721
|
+
return p;
|
|
722
|
+
}),
|
|
723
|
+
async function (req, res) {
|
|
724
|
+
var vid = req.params.id;
|
|
725
|
+
var body = req.body || {};
|
|
726
|
+
var productId = await _variantProductId(vid);
|
|
727
|
+
var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
|
|
728
|
+
if (!productId) return _redirect(res, back + "?err=1");
|
|
729
|
+
try {
|
|
730
|
+
var amount = _strictMinorInt(body.amount_minor, "admin.price", "amount_minor");
|
|
731
|
+
await catalog.prices.set(vid, {
|
|
732
|
+
currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency,
|
|
733
|
+
amount_minor: amount,
|
|
734
|
+
});
|
|
735
|
+
} catch (e) {
|
|
736
|
+
if (!(e instanceof TypeError)) throw e;
|
|
737
|
+
return _redirect(res, back + "?err=1");
|
|
738
|
+
}
|
|
739
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".price.set", outcome: "success", metadata: { id: vid } });
|
|
740
|
+
_redirect(res, back + "?saved=1");
|
|
741
|
+
},
|
|
742
|
+
));
|
|
743
|
+
|
|
468
744
|
router.get("/admin/variants/:id/prices", R(async function (req, res) {
|
|
469
745
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
470
746
|
var currency = url && url.searchParams.get("currency");
|
|
@@ -607,6 +883,36 @@ function mount(router, deps) {
|
|
|
607
883
|
return m;
|
|
608
884
|
}));
|
|
609
885
|
|
|
886
|
+
// Browser POST alias: attach an existing R2 object to a product from the
|
|
887
|
+
// product detail screen. The product id comes from the path; the
|
|
888
|
+
// primitive validates the r2_key + content_type. Bad input → ?err=1.
|
|
889
|
+
router.post("/admin/products/:id/media/attach", _pageOrApi(false,
|
|
890
|
+
W("media.attach", async function (req, res) {
|
|
891
|
+
var body = Object.assign({}, req.body || {}, { product_id: req.params.id });
|
|
892
|
+
var m = await catalog.media.attach(body);
|
|
893
|
+
_json(res, 201, m);
|
|
894
|
+
return m;
|
|
895
|
+
}),
|
|
896
|
+
async function (req, res) {
|
|
897
|
+
var id = req.params.id;
|
|
898
|
+
var enc = encodeURIComponent(id);
|
|
899
|
+
var body = req.body || {};
|
|
900
|
+
try {
|
|
901
|
+
await catalog.media.attach({
|
|
902
|
+
product_id: id,
|
|
903
|
+
r2_key: typeof body.r2_key === "string" ? body.r2_key.trim() : body.r2_key,
|
|
904
|
+
content_type: typeof body.content_type === "string" ? body.content_type.trim() : body.content_type,
|
|
905
|
+
alt_text: typeof body.alt_text === "string" ? body.alt_text : undefined,
|
|
906
|
+
});
|
|
907
|
+
} catch (e) {
|
|
908
|
+
if (!(e instanceof TypeError)) throw e;
|
|
909
|
+
return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
910
|
+
}
|
|
911
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.attach", outcome: "success", metadata: { id: id } });
|
|
912
|
+
_redirect(res, "/admin/products/" + enc + "?saved=1");
|
|
913
|
+
},
|
|
914
|
+
));
|
|
915
|
+
|
|
610
916
|
// --- media upload (r2 bridge) ---------------------------------------
|
|
611
917
|
// POST /admin/media/upload — fetches `source_url` via b.httpClient
|
|
612
918
|
// (SSRF gate + size cap), uploads to R2 through the bridge, then
|
|
@@ -614,8 +920,13 @@ function mount(router, deps) {
|
|
|
614
920
|
// r2_bridge is wired (operator hasn't set D1_BRIDGE_URL +
|
|
615
921
|
// D1_BRIDGE_SECRET).
|
|
616
922
|
if (r2) {
|
|
617
|
-
|
|
618
|
-
|
|
923
|
+
// Fetch → store → attach, shared by the JSON upload route and the
|
|
924
|
+
// browser POST alias. Throws TypeError on bad input (mapped to 400);
|
|
925
|
+
// returns `{ status, code, detail }` for an operational failure the
|
|
926
|
+
// caller renders as a problem (JSON) or ?err=1 (browser), or `{ rec }`
|
|
927
|
+
// on success.
|
|
928
|
+
async function _performMediaUpload(body) {
|
|
929
|
+
body = body || {};
|
|
619
930
|
if (typeof body.source_url !== "string" || !body.source_url.length) {
|
|
620
931
|
throw new TypeError("admin.media.upload: body.source_url required");
|
|
621
932
|
}
|
|
@@ -641,11 +952,10 @@ function mount(router, deps) {
|
|
|
641
952
|
headers: { "accept": body.content_type + ",*/*;q=0.5" },
|
|
642
953
|
});
|
|
643
954
|
} catch (e) {
|
|
644
|
-
return
|
|
955
|
+
return { status: 502, code: "source-fetch-failed", detail: (e && e.message) || String(e) };
|
|
645
956
|
}
|
|
646
957
|
if (fetched.statusCode < 200 || fetched.statusCode >= 300) {
|
|
647
|
-
return
|
|
648
|
-
"source_url returned HTTP " + fetched.statusCode);
|
|
958
|
+
return { status: 502, code: "source-fetch-status", detail: "source_url returned HTTP " + fetched.statusCode };
|
|
649
959
|
}
|
|
650
960
|
var fetchedCT = String(fetched.headers && (fetched.headers["content-type"] || fetched.headers["Content-Type"]) || "");
|
|
651
961
|
// Loose match — the declared content_type must be a prefix of
|
|
@@ -655,13 +965,13 @@ function mount(router, deps) {
|
|
|
655
965
|
var declared = body.content_type.split(";")[0].trim().toLowerCase();
|
|
656
966
|
var served = fetchedCT.split(";")[0].trim().toLowerCase();
|
|
657
967
|
if (served && declared !== served) {
|
|
658
|
-
return
|
|
659
|
-
"source_url served `" + served + "` but operator declared `" + declared + "`"
|
|
968
|
+
return { status: 422, code: "content-type-mismatch",
|
|
969
|
+
detail: "source_url served `" + served + "` but operator declared `" + declared + "`" };
|
|
660
970
|
}
|
|
661
971
|
var buf = fetched.body && Buffer.isBuffer(fetched.body) ? fetched.body
|
|
662
972
|
: Buffer.from(fetched.body || "");
|
|
663
973
|
if (buf.length === 0) {
|
|
664
|
-
return
|
|
974
|
+
return { status: 422, code: "source-empty", detail: "source_url returned an empty body" };
|
|
665
975
|
}
|
|
666
976
|
// Generate the R2 key. The extension is inferred from the
|
|
667
977
|
// declared content-type so the operator can preview the asset
|
|
@@ -672,7 +982,7 @@ function mount(router, deps) {
|
|
|
672
982
|
try {
|
|
673
983
|
await r2.put(key, buf, body.content_type);
|
|
674
984
|
} catch (e) {
|
|
675
|
-
return
|
|
985
|
+
return { status: 502, code: "r2-upload-failed", detail: (e && e.message) || String(e) };
|
|
676
986
|
}
|
|
677
987
|
var m;
|
|
678
988
|
try {
|
|
@@ -690,15 +1000,42 @@ function mount(router, deps) {
|
|
|
690
1000
|
// The R2 write succeeded but the DB row didn't land — surface
|
|
691
1001
|
// the orphan key so the operator can reconcile or re-attach.
|
|
692
1002
|
var problem = e instanceof TypeError ? 400 : 500;
|
|
693
|
-
return
|
|
694
|
-
(e && e.message || String(e)) + " (orphan r2_key=" + key + ")"
|
|
1003
|
+
return { status: problem, code: "media-attach-failed",
|
|
1004
|
+
detail: (e && e.message || String(e)) + " (orphan r2_key=" + key + ")" };
|
|
695
1005
|
}
|
|
696
1006
|
// Expose the public asset URL alongside the media row so the
|
|
697
1007
|
// admin UI can preview without an extra round-trip.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1008
|
+
return { rec: Object.assign({}, m, { asset_url: assetPrefix + key }) };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
router.post("/admin/media/upload", W("media.upload", async function (req, res) {
|
|
1012
|
+
var out = await _performMediaUpload(req.body || {});
|
|
1013
|
+
if (out.rec) { _json(res, 201, out.rec); return out.rec; }
|
|
1014
|
+
return _problem(res, out.status, out.code, out.detail);
|
|
701
1015
|
}));
|
|
1016
|
+
|
|
1017
|
+
// Browser POST alias: upload-from-URL scoped to a product (id from the
|
|
1018
|
+
// path). On success PRGs back to the detail; an operational failure or
|
|
1019
|
+
// bad input lands a ?err=1 notice rather than a problem-details JSON.
|
|
1020
|
+
router.post("/admin/products/:id/media/upload", _pageOrApi(false,
|
|
1021
|
+
W("media.upload", async function (req, res) {
|
|
1022
|
+
var body = Object.assign({}, req.body || {}, { product_id: req.params.id });
|
|
1023
|
+
var out = await _performMediaUpload(body);
|
|
1024
|
+
if (out.rec) { _json(res, 201, out.rec); return out.rec; }
|
|
1025
|
+
return _problem(res, out.status, out.code, out.detail);
|
|
1026
|
+
}),
|
|
1027
|
+
async function (req, res) {
|
|
1028
|
+
var id = req.params.id;
|
|
1029
|
+
var enc = encodeURIComponent(id);
|
|
1030
|
+
var body = Object.assign({}, req.body || {}, { product_id: id });
|
|
1031
|
+
var out;
|
|
1032
|
+
try { out = await _performMediaUpload(body); }
|
|
1033
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/products/" + enc + "?err=1"); }
|
|
1034
|
+
if (!out.rec) return _redirect(res, "/admin/products/" + enc + "?err=1");
|
|
1035
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.upload", outcome: "success", metadata: { id: id } });
|
|
1036
|
+
_redirect(res, "/admin/products/" + enc + "?saved=1");
|
|
1037
|
+
},
|
|
1038
|
+
));
|
|
702
1039
|
}
|
|
703
1040
|
|
|
704
1041
|
router.delete("/admin/media/:id", W("media.delete", async function (req, res) {
|
|
@@ -708,6 +1045,57 @@ function mount(router, deps) {
|
|
|
708
1045
|
return { id: req.params.id };
|
|
709
1046
|
}));
|
|
710
1047
|
|
|
1048
|
+
// Browser confirm interstitial for media delete — removing a media row
|
|
1049
|
+
// detaches the asset from the product (the R2 object itself is left in
|
|
1050
|
+
// the bucket for the operator to reclaim), so the console confirms.
|
|
1051
|
+
router.get("/admin/media/:id/delete/confirm-page", _pageOrApi(true,
|
|
1052
|
+
R(async function (_req, res) {
|
|
1053
|
+
return _problem(res, 405, "use-canonical-endpoint", "DELETE /admin/media/:id (or POST .../delete) directly for the JSON API");
|
|
1054
|
+
}),
|
|
1055
|
+
async function (req, res) {
|
|
1056
|
+
var mid = req.params.id;
|
|
1057
|
+
var m = null;
|
|
1058
|
+
try { m = await catalog.media.get(mid); }
|
|
1059
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
1060
|
+
var productId = m ? m.product_id : null;
|
|
1061
|
+
var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
|
|
1062
|
+
if (!m) return _redirect(res, back + "?err=1");
|
|
1063
|
+
_sendHtml(res, 200, renderAdminConfirm({
|
|
1064
|
+
shop_name: deps.shop_name, nav_available: navAvailable, active: "products",
|
|
1065
|
+
heading: "Delete this media?",
|
|
1066
|
+
consequence: "This detaches the asset from the product. The stored object remains in your bucket — reclaim it there if no longer needed.",
|
|
1067
|
+
detail: m.r2_key ? "Key: " + m.r2_key : "This media row will be removed.",
|
|
1068
|
+
action: "/admin/media/" + _htmlEscape(mid) + "/delete",
|
|
1069
|
+
confirm_label: "Delete media",
|
|
1070
|
+
cancel_href: back,
|
|
1071
|
+
}));
|
|
1072
|
+
},
|
|
1073
|
+
));
|
|
1074
|
+
|
|
1075
|
+
// Browser POST for media delete (HTML forms can't DELETE).
|
|
1076
|
+
router.post("/admin/media/:id/delete", _pageOrApi(false,
|
|
1077
|
+
W("media.delete", async function (req, res) {
|
|
1078
|
+
var ok = await catalog.media.delete(req.params.id);
|
|
1079
|
+
if (!ok) return _problem(res, 404, "media-not-found");
|
|
1080
|
+
_json(res, 200, { ok: true });
|
|
1081
|
+
return { id: req.params.id };
|
|
1082
|
+
}),
|
|
1083
|
+
async function (req, res) {
|
|
1084
|
+
var mid = req.params.id;
|
|
1085
|
+
var m = null;
|
|
1086
|
+
try { m = await catalog.media.get(mid); }
|
|
1087
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
1088
|
+
var productId = m ? m.product_id : null;
|
|
1089
|
+
var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
|
|
1090
|
+
var ok = false;
|
|
1091
|
+
try { ok = await catalog.media.delete(mid); }
|
|
1092
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
1093
|
+
if (!ok) return _redirect(res, back + "?err=1");
|
|
1094
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.delete", outcome: "success", metadata: { id: mid } });
|
|
1095
|
+
_redirect(res, back + "?saved=1");
|
|
1096
|
+
},
|
|
1097
|
+
));
|
|
1098
|
+
|
|
711
1099
|
// ---- bulk catalog import --------------------------------------------
|
|
712
1100
|
|
|
713
1101
|
// POST /admin/catalog/import — Content-Type: text/csv body. The CSV
|
|
@@ -2719,6 +3107,620 @@ function mount(router, deps) {
|
|
|
2719
3107
|
return issued;
|
|
2720
3108
|
}
|
|
2721
3109
|
|
|
3110
|
+
// ---- tax rates ------------------------------------------------------
|
|
3111
|
+
//
|
|
3112
|
+
// Operator-managed per-jurisdiction rate table. `defineRate` keys on a
|
|
3113
|
+
// (jurisdiction, category, effective window) tuple; the console lists
|
|
3114
|
+
// by jurisdiction, creates a rate, edits the mutable columns (rate_bps
|
|
3115
|
+
// / effective_until / source), and archives. `bulkImport` is an
|
|
3116
|
+
// API-only path (operators hydrate a paid-feed snapshot from a script);
|
|
3117
|
+
// it has no console form because authoring dozens of rows by hand isn't
|
|
3118
|
+
// a console gesture — the single-rate create covers operator-by-hand
|
|
3119
|
+
// entry and the feed importer uses the bearer JSON surface.
|
|
3120
|
+
var taxRates = deps.taxRates || null;
|
|
3121
|
+
if (taxRates) {
|
|
3122
|
+
router.post("/admin/tax-rates", _pageOrApi(false,
|
|
3123
|
+
W("tax_rate.create", async function (req, res) {
|
|
3124
|
+
var rate = await taxRates.defineRate(_taxRateInput(req.body || {}));
|
|
3125
|
+
_json(res, 201, rate);
|
|
3126
|
+
return { id: rate.id };
|
|
3127
|
+
}),
|
|
3128
|
+
async function (req, res) {
|
|
3129
|
+
var body = req.body || {};
|
|
3130
|
+
var jurisdiction = typeof body.jurisdiction === "string" ? body.jurisdiction.trim().toUpperCase() : body.jurisdiction;
|
|
3131
|
+
try {
|
|
3132
|
+
await taxRates.defineRate(_taxRateInput(body));
|
|
3133
|
+
} catch (e) {
|
|
3134
|
+
if (!(e instanceof TypeError) && e.code !== "TAX_RATE_OVERLAP") throw e;
|
|
3135
|
+
var rows = await _taxRatesForBrowser(jurisdiction);
|
|
3136
|
+
return _sendHtml(res, 400, renderAdminTaxRates({
|
|
3137
|
+
shop_name: deps.shop_name, nav_available: navAvailable, rates: rows,
|
|
3138
|
+
jurisdiction: jurisdiction, sources: taxRates.SOURCES,
|
|
3139
|
+
notice: (e && e.message || "").replace(/^taxRates[.:]\s*/, ""),
|
|
3140
|
+
}));
|
|
3141
|
+
}
|
|
3142
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".tax_rate.create", outcome: "success" });
|
|
3143
|
+
_redirect(res, "/admin/tax-rates?jurisdiction=" + encodeURIComponent(jurisdiction) + "&created=1");
|
|
3144
|
+
},
|
|
3145
|
+
));
|
|
3146
|
+
|
|
3147
|
+
router.get("/admin/tax-rates", _pageOrApi(true,
|
|
3148
|
+
R(async function (req, res) {
|
|
3149
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3150
|
+
var j = url && url.searchParams.get("jurisdiction");
|
|
3151
|
+
if (!j) return _json(res, 200, { rows: [] });
|
|
3152
|
+
var includeExpired = url && url.searchParams.get("include_expired") === "1";
|
|
3153
|
+
var rows = await taxRates.listForJurisdiction({ jurisdiction: j, include_expired: includeExpired });
|
|
3154
|
+
_json(res, 200, { rows: rows });
|
|
3155
|
+
}),
|
|
3156
|
+
async function (req, res) {
|
|
3157
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3158
|
+
var j = url && url.searchParams.get("jurisdiction");
|
|
3159
|
+
var jurisdiction = j ? String(j).trim().toUpperCase() : null;
|
|
3160
|
+
var rows = await _taxRatesForBrowser(jurisdiction);
|
|
3161
|
+
_sendHtml(res, 200, renderAdminTaxRates({
|
|
3162
|
+
shop_name: deps.shop_name, nav_available: navAvailable, rates: rows,
|
|
3163
|
+
jurisdiction: jurisdiction, sources: taxRates.SOURCES,
|
|
3164
|
+
created: url && url.searchParams.get("created"),
|
|
3165
|
+
updated: url && url.searchParams.get("updated"),
|
|
3166
|
+
archived: url && url.searchParams.get("archived"),
|
|
3167
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the rate." : null,
|
|
3168
|
+
}));
|
|
3169
|
+
},
|
|
3170
|
+
));
|
|
3171
|
+
|
|
3172
|
+
// Patch the mutable columns (rate_bps / effective_until / source).
|
|
3173
|
+
router.patch("/admin/tax-rates/:id", W("tax_rate.update", async function (req, res) {
|
|
3174
|
+
var rate;
|
|
3175
|
+
try { rate = await taxRates.updateRate(req.params.id, req.body || {}); }
|
|
3176
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3177
|
+
if (!rate) return _problem(res, 404, "tax-rate-not-found");
|
|
3178
|
+
_json(res, 200, rate);
|
|
3179
|
+
return { id: rate.id };
|
|
3180
|
+
}));
|
|
3181
|
+
|
|
3182
|
+
// Browser POST alias for the edit (HTML can't PATCH). Re-aims at the
|
|
3183
|
+
// rate's jurisdiction list on success / failure.
|
|
3184
|
+
router.post("/admin/tax-rates/:id/edit", _pageOrApi(false,
|
|
3185
|
+
W("tax_rate.update", async function (req, res) {
|
|
3186
|
+
var rate;
|
|
3187
|
+
try { rate = await taxRates.updateRate(req.params.id, _taxRatePatch(req.body || {})); }
|
|
3188
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3189
|
+
if (!rate) return _problem(res, 404, "tax-rate-not-found");
|
|
3190
|
+
_json(res, 200, rate);
|
|
3191
|
+
return { id: rate.id };
|
|
3192
|
+
}),
|
|
3193
|
+
async function (req, res) {
|
|
3194
|
+
var body = req.body || {};
|
|
3195
|
+
var enc = "/admin/tax-rates" + (body.jurisdiction ? "?jurisdiction=" + encodeURIComponent(String(body.jurisdiction).trim().toUpperCase()) : "");
|
|
3196
|
+
var rate = null;
|
|
3197
|
+
try { rate = await taxRates.updateRate(req.params.id, _taxRatePatch(body)); }
|
|
3198
|
+
catch (e) { if (!(e instanceof TypeError) && e.code !== "TAX_RATE_OVERLAP" && e.code !== "TAX_RATE_ARCHIVED") throw e; }
|
|
3199
|
+
if (!rate) return _redirect(res, enc + (enc.indexOf("?") === -1 ? "?" : "&") + "err=1");
|
|
3200
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".tax_rate.update", outcome: "success", metadata: { id: req.params.id } });
|
|
3201
|
+
_redirect(res, enc + (enc.indexOf("?") === -1 ? "?" : "&") + "updated=1");
|
|
3202
|
+
},
|
|
3203
|
+
));
|
|
3204
|
+
|
|
3205
|
+
router.delete("/admin/tax-rates/:id", W("tax_rate.archive", async function (req, res) {
|
|
3206
|
+
var rate;
|
|
3207
|
+
try { rate = await taxRates.archiveRate(req.params.id, {}); }
|
|
3208
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3209
|
+
if (!rate) return _problem(res, 404, "tax-rate-not-found");
|
|
3210
|
+
_json(res, 200, rate);
|
|
3211
|
+
return { id: rate.id };
|
|
3212
|
+
}));
|
|
3213
|
+
|
|
3214
|
+
router.post("/admin/tax-rates/:id/archive", _pageOrApi(false,
|
|
3215
|
+
W("tax_rate.archive", async function (req, res) {
|
|
3216
|
+
var rate;
|
|
3217
|
+
try { rate = await taxRates.archiveRate(req.params.id, {}); }
|
|
3218
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3219
|
+
if (!rate) return _problem(res, 404, "tax-rate-not-found");
|
|
3220
|
+
_json(res, 200, rate);
|
|
3221
|
+
return { id: rate.id };
|
|
3222
|
+
}),
|
|
3223
|
+
async function (req, res) {
|
|
3224
|
+
var body = req.body || {};
|
|
3225
|
+
var enc = "/admin/tax-rates" + (body.jurisdiction ? "?jurisdiction=" + encodeURIComponent(String(body.jurisdiction).trim().toUpperCase()) : "");
|
|
3226
|
+
var rate = null;
|
|
3227
|
+
try { rate = await taxRates.archiveRate(req.params.id, {}); }
|
|
3228
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
3229
|
+
if (!rate) return _redirect(res, enc + (enc.indexOf("?") === -1 ? "?" : "&") + "err=1");
|
|
3230
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".tax_rate.archive", outcome: "success", metadata: { id: req.params.id } });
|
|
3231
|
+
_redirect(res, enc + (enc.indexOf("?") === -1 ? "?" : "&") + "archived=1");
|
|
3232
|
+
},
|
|
3233
|
+
));
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// Read a jurisdiction's rate history for the browser list. A null /
|
|
3237
|
+
// unset jurisdiction yields an empty list (the page shows the picker
|
|
3238
|
+
// form). A malformed jurisdiction throws TypeError in the primitive —
|
|
3239
|
+
// map that to an empty list so the page renders the notice, never 500.
|
|
3240
|
+
async function _taxRatesForBrowser(jurisdiction) {
|
|
3241
|
+
if (!jurisdiction) return [];
|
|
3242
|
+
try { return await taxRates.listForJurisdiction({ jurisdiction: jurisdiction, include_expired: true }); }
|
|
3243
|
+
catch (e) { if (e instanceof TypeError) return []; throw e; }
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
// Translate the create form / JSON body into a defineRate input.
|
|
3247
|
+
// Strict integer parsing for rate_bps + the two epoch-ms windows.
|
|
3248
|
+
function _taxRateInput(body) {
|
|
3249
|
+
var input = {
|
|
3250
|
+
jurisdiction: typeof body.jurisdiction === "string" ? body.jurisdiction.trim().toUpperCase() : body.jurisdiction,
|
|
3251
|
+
rate_bps: _strictMinorInt(body.rate_bps, "taxRates", "rate_bps"),
|
|
3252
|
+
source: typeof body.source === "string" && body.source ? body.source : "manual",
|
|
3253
|
+
};
|
|
3254
|
+
if (body.category != null && body.category !== "") input.category = String(body.category).trim();
|
|
3255
|
+
input.effective_from = (body.effective_from == null || body.effective_from === "")
|
|
3256
|
+
? Date.now()
|
|
3257
|
+
: _strictMinorInt(body.effective_from, "taxRates", "effective_from (epoch-ms)");
|
|
3258
|
+
if (body.effective_until != null && body.effective_until !== "") {
|
|
3259
|
+
input.effective_until = _strictMinorInt(body.effective_until, "taxRates", "effective_until (epoch-ms)");
|
|
3260
|
+
}
|
|
3261
|
+
return input;
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// Translate the edit form / JSON body into an updateRate patch. Only
|
|
3265
|
+
// the mutable columns are forwarded; absent fields are left untouched.
|
|
3266
|
+
function _taxRatePatch(body) {
|
|
3267
|
+
var patch = {};
|
|
3268
|
+
if (body.rate_bps != null && body.rate_bps !== "") patch.rate_bps = _strictMinorInt(body.rate_bps, "taxRates", "rate_bps");
|
|
3269
|
+
if (Object.prototype.hasOwnProperty.call(body, "effective_until")) {
|
|
3270
|
+
patch.effective_until = (body.effective_until == null || body.effective_until === "")
|
|
3271
|
+
? null
|
|
3272
|
+
: _strictMinorInt(body.effective_until, "taxRates", "effective_until (epoch-ms)");
|
|
3273
|
+
}
|
|
3274
|
+
if (body.source != null && body.source !== "") patch.source = String(body.source);
|
|
3275
|
+
return patch;
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
// ---- shipping zones -------------------------------------------------
|
|
3279
|
+
//
|
|
3280
|
+
// Operator-defined flat-rate / table-rate zones. The console create is
|
|
3281
|
+
// the common single-country, single-flat-service shape — country +
|
|
3282
|
+
// service label + flat amount + currency, optionally a region. The
|
|
3283
|
+
// primitive's full regions[] / rates[] vocabulary (weight + order-value
|
|
3284
|
+
// buckets, multi-region) is reachable via the bearer JSON contract; the
|
|
3285
|
+
// edit screen lets an operator paste the full regions / rates JSON to
|
|
3286
|
+
// mutate beyond the simple shape.
|
|
3287
|
+
var shippingZones = deps.shippingZones || null;
|
|
3288
|
+
if (shippingZones) {
|
|
3289
|
+
router.post("/admin/shipping", _pageOrApi(false,
|
|
3290
|
+
W("shipping_zone.create", async function (req, res) {
|
|
3291
|
+
var zone = await shippingZones.defineZone(_shippingZoneInput(req.body || {}));
|
|
3292
|
+
_json(res, 201, zone);
|
|
3293
|
+
return { id: zone.slug };
|
|
3294
|
+
}),
|
|
3295
|
+
async function (req, res) {
|
|
3296
|
+
try {
|
|
3297
|
+
await shippingZones.defineZone(_shippingZoneInput(req.body || {}));
|
|
3298
|
+
} catch (e) {
|
|
3299
|
+
if (!(e instanceof TypeError) && e.code !== "SHIPPING_ZONE_EXISTS") throw e;
|
|
3300
|
+
var rows = await shippingZones.listZones({});
|
|
3301
|
+
return _sendHtml(res, 400, renderAdminShipping({
|
|
3302
|
+
shop_name: deps.shop_name, nav_available: navAvailable, zones: rows,
|
|
3303
|
+
notice: (e && e.message || "").replace(/^shippingZones[.:]\s*/, ""),
|
|
3304
|
+
}));
|
|
3305
|
+
}
|
|
3306
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".shipping_zone.create", outcome: "success" });
|
|
3307
|
+
_redirect(res, "/admin/shipping?created=1");
|
|
3308
|
+
},
|
|
3309
|
+
));
|
|
3310
|
+
|
|
3311
|
+
router.get("/admin/shipping", _pageOrApi(true,
|
|
3312
|
+
R(async function (req, res) {
|
|
3313
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3314
|
+
var activeOnly = url && url.searchParams.get("active") === "1";
|
|
3315
|
+
var rows = await shippingZones.listZones(activeOnly ? { active_only: true } : {});
|
|
3316
|
+
_json(res, 200, { rows: rows });
|
|
3317
|
+
}),
|
|
3318
|
+
async function (req, res) {
|
|
3319
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3320
|
+
var rows = await shippingZones.listZones({});
|
|
3321
|
+
_sendHtml(res, 200, renderAdminShipping({
|
|
3322
|
+
shop_name: deps.shop_name, nav_available: navAvailable, zones: rows,
|
|
3323
|
+
created: url && url.searchParams.get("created"),
|
|
3324
|
+
updated: url && url.searchParams.get("updated"),
|
|
3325
|
+
archived: url && url.searchParams.get("archived"),
|
|
3326
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the zone." : null,
|
|
3327
|
+
}));
|
|
3328
|
+
},
|
|
3329
|
+
));
|
|
3330
|
+
|
|
3331
|
+
router.get("/admin/shipping/:slug", _pageOrApi(true,
|
|
3332
|
+
R(async function (req, res) {
|
|
3333
|
+
var zone;
|
|
3334
|
+
try { zone = await shippingZones.getZone(req.params.slug); }
|
|
3335
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 404, "shipping-zone-not-found", e.message); throw e; }
|
|
3336
|
+
if (!zone) return _problem(res, 404, "shipping-zone-not-found");
|
|
3337
|
+
_json(res, 200, zone);
|
|
3338
|
+
}),
|
|
3339
|
+
async function (req, res) {
|
|
3340
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3341
|
+
var zone;
|
|
3342
|
+
try { zone = await shippingZones.getZone(req.params.slug); }
|
|
3343
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; zone = null; }
|
|
3344
|
+
if (!zone) return _sendHtml(res, 404, renderAdminShipping({
|
|
3345
|
+
shop_name: deps.shop_name, nav_available: navAvailable, zones: [], notice: "Zone not found.",
|
|
3346
|
+
}));
|
|
3347
|
+
_sendHtml(res, 200, renderAdminShippingZone({
|
|
3348
|
+
shop_name: deps.shop_name, nav_available: navAvailable, zone: zone,
|
|
3349
|
+
updated: url && url.searchParams.get("updated"),
|
|
3350
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
3351
|
+
}));
|
|
3352
|
+
},
|
|
3353
|
+
));
|
|
3354
|
+
|
|
3355
|
+
router.patch("/admin/shipping/:slug", W("shipping_zone.update", async function (req, res) {
|
|
3356
|
+
var zone;
|
|
3357
|
+
try { zone = await shippingZones.updateZone(req.params.slug, req.body || {}); }
|
|
3358
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3359
|
+
if (!zone) return _problem(res, 404, "shipping-zone-not-found");
|
|
3360
|
+
_json(res, 200, zone);
|
|
3361
|
+
return { id: zone.slug };
|
|
3362
|
+
}));
|
|
3363
|
+
|
|
3364
|
+
// Browser edit alias. Mutates title + active (checkbox) and, when the
|
|
3365
|
+
// operator pastes JSON, regions / rates. Empty paste fields are left
|
|
3366
|
+
// untouched so a title-only edit doesn't wipe the rate table.
|
|
3367
|
+
router.post("/admin/shipping/:slug/edit", _pageOrApi(false,
|
|
3368
|
+
W("shipping_zone.update", async function (req, res) {
|
|
3369
|
+
var zone;
|
|
3370
|
+
try { zone = await shippingZones.updateZone(req.params.slug, _shippingZonePatch(req.body || {})); }
|
|
3371
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3372
|
+
if (!zone) return _problem(res, 404, "shipping-zone-not-found");
|
|
3373
|
+
_json(res, 200, zone);
|
|
3374
|
+
return { id: zone.slug };
|
|
3375
|
+
}),
|
|
3376
|
+
async function (req, res) {
|
|
3377
|
+
var slug = req.params.slug;
|
|
3378
|
+
var enc = encodeURIComponent(slug);
|
|
3379
|
+
var zone = null;
|
|
3380
|
+
try { zone = await shippingZones.updateZone(slug, _shippingZonePatch(req.body || {})); }
|
|
3381
|
+
catch (e) { if (!(e instanceof TypeError) && e.code !== "SHIPPING_ZONE_ARCHIVED") throw e; }
|
|
3382
|
+
if (!zone) return _redirect(res, "/admin/shipping/" + enc + "?err=1");
|
|
3383
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".shipping_zone.update", outcome: "success", metadata: { slug: slug } });
|
|
3384
|
+
_redirect(res, "/admin/shipping/" + enc + "?updated=1");
|
|
3385
|
+
},
|
|
3386
|
+
));
|
|
3387
|
+
|
|
3388
|
+
router.delete("/admin/shipping/:slug", W("shipping_zone.archive", async function (req, res) {
|
|
3389
|
+
var zone;
|
|
3390
|
+
try { zone = await shippingZones.archiveZone(req.params.slug); }
|
|
3391
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3392
|
+
if (!zone) return _problem(res, 404, "shipping-zone-not-found");
|
|
3393
|
+
_json(res, 200, zone);
|
|
3394
|
+
return { id: zone.slug };
|
|
3395
|
+
}));
|
|
3396
|
+
|
|
3397
|
+
router.post("/admin/shipping/:slug/archive", _pageOrApi(false,
|
|
3398
|
+
W("shipping_zone.archive", async function (req, res) {
|
|
3399
|
+
var zone;
|
|
3400
|
+
try { zone = await shippingZones.archiveZone(req.params.slug); }
|
|
3401
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3402
|
+
if (!zone) return _problem(res, 404, "shipping-zone-not-found");
|
|
3403
|
+
_json(res, 200, zone);
|
|
3404
|
+
return { id: zone.slug };
|
|
3405
|
+
}),
|
|
3406
|
+
async function (req, res) {
|
|
3407
|
+
var slug = req.params.slug;
|
|
3408
|
+
var zone = null;
|
|
3409
|
+
try { zone = await shippingZones.archiveZone(slug); }
|
|
3410
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
3411
|
+
if (!zone) return _redirect(res, "/admin/shipping?err=1");
|
|
3412
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".shipping_zone.archive", outcome: "success", metadata: { slug: slug } });
|
|
3413
|
+
_redirect(res, "/admin/shipping?archived=1");
|
|
3414
|
+
},
|
|
3415
|
+
));
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
// Translate the create form / JSON body into a defineZone input. The
|
|
3419
|
+
// console form is the single-country, single-flat-rate shape; the
|
|
3420
|
+
// bearer JSON path can pass the full regions[]/rates[] arrays directly,
|
|
3421
|
+
// so those pass through untouched when present.
|
|
3422
|
+
function _shippingZoneInput(body) {
|
|
3423
|
+
if (Array.isArray(body.regions) || Array.isArray(body.rates)) {
|
|
3424
|
+
// Full JSON shape from a bearer client — forward as-is.
|
|
3425
|
+
return body;
|
|
3426
|
+
}
|
|
3427
|
+
var country = typeof body.country === "string" ? body.country.trim().toUpperCase() : body.country;
|
|
3428
|
+
var region = (body.region != null && body.region !== "") ? String(body.region).trim().toUpperCase() : null;
|
|
3429
|
+
var regionEntry = { country: country };
|
|
3430
|
+
if (region) regionEntry.region = region;
|
|
3431
|
+
return {
|
|
3432
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
3433
|
+
title: body.title,
|
|
3434
|
+
active: body.active === "0" || body.active === "off" ? false : true,
|
|
3435
|
+
regions: [regionEntry],
|
|
3436
|
+
rates: [{
|
|
3437
|
+
rate_minor: _strictMinorInt(body.rate_minor, "shippingZones", "rate_minor"),
|
|
3438
|
+
currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency,
|
|
3439
|
+
service_label: body.service_label,
|
|
3440
|
+
}],
|
|
3441
|
+
};
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
// Translate the edit form / JSON body into an updateZone patch. Title
|
|
3445
|
+
// + active always forward; regions / rates only when the operator
|
|
3446
|
+
// pasted non-empty JSON (an empty paste leaves the existing table).
|
|
3447
|
+
function _shippingZonePatch(body) {
|
|
3448
|
+
var patch = {};
|
|
3449
|
+
if (Object.prototype.hasOwnProperty.call(body, "title") && body.title !== "") patch.title = body.title;
|
|
3450
|
+
// The active checkbox is present (="on"/"1") when checked, absent when
|
|
3451
|
+
// unchecked — but only treat it as a field when the form declared it
|
|
3452
|
+
// via the hidden marker so a partial JSON edit doesn't flip active.
|
|
3453
|
+
if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
|
|
3454
|
+
if (typeof body.regions_json === "string" && body.regions_json.trim() !== "") {
|
|
3455
|
+
patch.regions = JSON.parse(body.regions_json);
|
|
3456
|
+
} else if (Array.isArray(body.regions)) {
|
|
3457
|
+
patch.regions = body.regions;
|
|
3458
|
+
}
|
|
3459
|
+
if (typeof body.rates_json === "string" && body.rates_json.trim() !== "") {
|
|
3460
|
+
patch.rates = JSON.parse(body.rates_json);
|
|
3461
|
+
} else if (Array.isArray(body.rates)) {
|
|
3462
|
+
patch.rates = body.rates;
|
|
3463
|
+
}
|
|
3464
|
+
return patch;
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
// ---- automatic discounts + stacking policies ------------------------
|
|
3468
|
+
//
|
|
3469
|
+
// Two related concerns on one console screen. `autoDiscount` rules are
|
|
3470
|
+
// cart-level automatics (no code typed); `couponStacking` policies gate
|
|
3471
|
+
// which codes may combine. The screen lists + creates + archives both;
|
|
3472
|
+
// rule edit covers title / priority / active (the trigger + value JSON
|
|
3473
|
+
// vocabularies are re-set via the create-shape edit fields). Segment-
|
|
3474
|
+
// gated rules / per-customer caps / exclusions stay on the bearer JSON
|
|
3475
|
+
// surface — the console covers the operator-by-hand common case (a
|
|
3476
|
+
// trigger, a value, a priority); the richer JSON shapes pass through
|
|
3477
|
+
// the bearer create unchanged.
|
|
3478
|
+
var autoDiscount = deps.autoDiscount || null;
|
|
3479
|
+
var couponStacking = deps.couponStacking || null;
|
|
3480
|
+
if (autoDiscount) {
|
|
3481
|
+
router.post("/admin/discounts", _pageOrApi(false,
|
|
3482
|
+
W("auto_discount.create", async function (req, res) {
|
|
3483
|
+
var rule = await autoDiscount.defineRule(_discountInput(req.body || {}));
|
|
3484
|
+
_json(res, 201, rule);
|
|
3485
|
+
return { id: rule.slug };
|
|
3486
|
+
}),
|
|
3487
|
+
async function (req, res) {
|
|
3488
|
+
try {
|
|
3489
|
+
await autoDiscount.defineRule(_discountInput(req.body || {}));
|
|
3490
|
+
} catch (e) {
|
|
3491
|
+
if (!(e instanceof TypeError)) throw e;
|
|
3492
|
+
return _sendHtml(res, 400, await _renderDiscounts({
|
|
3493
|
+
notice: (e && e.message || "").replace(/^autoDiscount[.:]\s*/, ""),
|
|
3494
|
+
}));
|
|
3495
|
+
}
|
|
3496
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.create", outcome: "success" });
|
|
3497
|
+
_redirect(res, "/admin/discounts?created=1");
|
|
3498
|
+
},
|
|
3499
|
+
));
|
|
3500
|
+
|
|
3501
|
+
router.get("/admin/discounts", _pageOrApi(true,
|
|
3502
|
+
R(async function (req, res) {
|
|
3503
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3504
|
+
var activeOnly = url && url.searchParams.get("active") === "1";
|
|
3505
|
+
var rules = await autoDiscount.listRules(activeOnly ? { active_only: true, limit: 500 } : { limit: 500 });
|
|
3506
|
+
var policies = couponStacking ? await couponStacking.listPolicies({ limit: 200 }) : [];
|
|
3507
|
+
_json(res, 200, { rules: rules, policies: policies });
|
|
3508
|
+
}),
|
|
3509
|
+
async function (req, res) {
|
|
3510
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3511
|
+
_sendHtml(res, 200, await _renderDiscounts({
|
|
3512
|
+
created: url && url.searchParams.get("created"),
|
|
3513
|
+
updated: url && url.searchParams.get("updated"),
|
|
3514
|
+
archived: url && url.searchParams.get("archived"),
|
|
3515
|
+
policy_created: url && url.searchParams.get("policy_created"),
|
|
3516
|
+
policy_archived: url && url.searchParams.get("policy_archived"),
|
|
3517
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
3518
|
+
}));
|
|
3519
|
+
},
|
|
3520
|
+
));
|
|
3521
|
+
|
|
3522
|
+
router.patch("/admin/discounts/:slug", W("auto_discount.update", async function (req, res) {
|
|
3523
|
+
var rule;
|
|
3524
|
+
try { rule = await autoDiscount.updateRule(req.params.slug, req.body || {}); }
|
|
3525
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3526
|
+
_json(res, 200, rule);
|
|
3527
|
+
return { id: rule.slug };
|
|
3528
|
+
}));
|
|
3529
|
+
|
|
3530
|
+
router.post("/admin/discounts/:slug/edit", _pageOrApi(false,
|
|
3531
|
+
W("auto_discount.update", async function (req, res) {
|
|
3532
|
+
var rule;
|
|
3533
|
+
try { rule = await autoDiscount.updateRule(req.params.slug, _discountPatch(req.body || {})); }
|
|
3534
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3535
|
+
_json(res, 200, rule);
|
|
3536
|
+
return { id: rule.slug };
|
|
3537
|
+
}),
|
|
3538
|
+
async function (req, res) {
|
|
3539
|
+
try { await autoDiscount.updateRule(req.params.slug, _discountPatch(req.body || {})); }
|
|
3540
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/discounts?err=1"); }
|
|
3541
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.update", outcome: "success", metadata: { slug: req.params.slug } });
|
|
3542
|
+
_redirect(res, "/admin/discounts?updated=1");
|
|
3543
|
+
},
|
|
3544
|
+
));
|
|
3545
|
+
|
|
3546
|
+
router.delete("/admin/discounts/:slug", W("auto_discount.archive", async function (req, res) {
|
|
3547
|
+
var rule;
|
|
3548
|
+
try { rule = await autoDiscount.archiveRule(req.params.slug); }
|
|
3549
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3550
|
+
_json(res, 200, rule);
|
|
3551
|
+
return { id: rule.slug };
|
|
3552
|
+
}));
|
|
3553
|
+
|
|
3554
|
+
router.post("/admin/discounts/:slug/archive", _pageOrApi(false,
|
|
3555
|
+
W("auto_discount.archive", async function (req, res) {
|
|
3556
|
+
var rule;
|
|
3557
|
+
try { rule = await autoDiscount.archiveRule(req.params.slug); }
|
|
3558
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3559
|
+
_json(res, 200, rule);
|
|
3560
|
+
return { id: rule.slug };
|
|
3561
|
+
}),
|
|
3562
|
+
async function (req, res) {
|
|
3563
|
+
try { await autoDiscount.archiveRule(req.params.slug); }
|
|
3564
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/discounts?err=1"); }
|
|
3565
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.archive", outcome: "success", metadata: { slug: req.params.slug } });
|
|
3566
|
+
_redirect(res, "/admin/discounts?archived=1");
|
|
3567
|
+
},
|
|
3568
|
+
));
|
|
3569
|
+
|
|
3570
|
+
// Stacking-policy lifecycle — only mounted when the couponStacking
|
|
3571
|
+
// dep is wired alongside autoDiscount (the screen renders the policy
|
|
3572
|
+
// section conditionally too).
|
|
3573
|
+
if (couponStacking) {
|
|
3574
|
+
router.post("/admin/discounts/policies", _pageOrApi(false,
|
|
3575
|
+
W("coupon_policy.create", async function (req, res) {
|
|
3576
|
+
var policy = await couponStacking.definePolicy(_policyInput(req.body || {}));
|
|
3577
|
+
_json(res, 201, policy);
|
|
3578
|
+
return { id: policy.slug };
|
|
3579
|
+
}),
|
|
3580
|
+
async function (req, res) {
|
|
3581
|
+
try {
|
|
3582
|
+
await couponStacking.definePolicy(_policyInput(req.body || {}));
|
|
3583
|
+
} catch (e) {
|
|
3584
|
+
if (!(e instanceof TypeError)) throw e;
|
|
3585
|
+
return _sendHtml(res, 400, await _renderDiscounts({
|
|
3586
|
+
notice: (e && e.message || "").replace(/^couponStacking[.:]\s*/, ""),
|
|
3587
|
+
}));
|
|
3588
|
+
}
|
|
3589
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".coupon_policy.create", outcome: "success" });
|
|
3590
|
+
_redirect(res, "/admin/discounts?policy_created=1");
|
|
3591
|
+
},
|
|
3592
|
+
));
|
|
3593
|
+
|
|
3594
|
+
router.delete("/admin/discounts/policies/:slug", W("coupon_policy.archive", async function (req, res) {
|
|
3595
|
+
var policy;
|
|
3596
|
+
try { policy = await couponStacking.archivePolicy(req.params.slug); }
|
|
3597
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3598
|
+
_json(res, 200, policy);
|
|
3599
|
+
return { id: policy.slug };
|
|
3600
|
+
}));
|
|
3601
|
+
|
|
3602
|
+
router.post("/admin/discounts/policies/:slug/archive", _pageOrApi(false,
|
|
3603
|
+
W("coupon_policy.archive", async function (req, res) {
|
|
3604
|
+
var policy;
|
|
3605
|
+
try { policy = await couponStacking.archivePolicy(req.params.slug); }
|
|
3606
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3607
|
+
_json(res, 200, policy);
|
|
3608
|
+
return { id: policy.slug };
|
|
3609
|
+
}),
|
|
3610
|
+
async function (req, res) {
|
|
3611
|
+
try { await couponStacking.archivePolicy(req.params.slug); }
|
|
3612
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/discounts?err=1"); }
|
|
3613
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".coupon_policy.archive", outcome: "success", metadata: { slug: req.params.slug } });
|
|
3614
|
+
_redirect(res, "/admin/discounts?policy_archived=1");
|
|
3615
|
+
},
|
|
3616
|
+
));
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
// Render the discounts screen — gathers rules + (optional) stacking
|
|
3621
|
+
// policies, then hands them to the renderer with whatever banner flags
|
|
3622
|
+
// the caller passed.
|
|
3623
|
+
async function _renderDiscounts(flags) {
|
|
3624
|
+
flags = flags || {};
|
|
3625
|
+
var rules = await autoDiscount.listRules({ limit: 500 });
|
|
3626
|
+
var policies = couponStacking ? await couponStacking.listPolicies({ limit: 200 }) : [];
|
|
3627
|
+
return renderAdminDiscounts(Object.assign({
|
|
3628
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
3629
|
+
rules: rules, policies: policies, stacking_enabled: !!couponStacking,
|
|
3630
|
+
}, flags));
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
// Translate the create form / JSON body into a defineRule input. The
|
|
3634
|
+
// console covers a single trigger + a single value + priority; the
|
|
3635
|
+
// bearer JSON path can pass the full vocabulary (applies_to, segments,
|
|
3636
|
+
// exclusions, caps) directly, so a body already carrying object-shaped
|
|
3637
|
+
// trigger / value passes through untouched.
|
|
3638
|
+
function _discountInput(body) {
|
|
3639
|
+
if (body.trigger && typeof body.trigger === "object" && body.value && typeof body.value === "object") {
|
|
3640
|
+
return body; // full JSON shape from a bearer client
|
|
3641
|
+
}
|
|
3642
|
+
var input = {
|
|
3643
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
3644
|
+
title: body.title,
|
|
3645
|
+
trigger: _discountTrigger(body),
|
|
3646
|
+
value: _discountValue(body),
|
|
3647
|
+
};
|
|
3648
|
+
if (body.priority != null && body.priority !== "") {
|
|
3649
|
+
input.priority = _strictMinorInt(body.priority, "autoDiscount", "priority");
|
|
3650
|
+
}
|
|
3651
|
+
return input;
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
function _discountTrigger(body) {
|
|
3655
|
+
var kind = body.trigger_kind;
|
|
3656
|
+
if (kind === "item_count_min") {
|
|
3657
|
+
return { kind: "item_count_min", min_count: _strictMinorInt(body.trigger_min_count, "autoDiscount", "trigger min_count") };
|
|
3658
|
+
}
|
|
3659
|
+
if (kind === "sku_purchase") {
|
|
3660
|
+
var skus = String(body.trigger_skus || "").split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
3661
|
+
var t = { kind: "sku_purchase", skus: skus };
|
|
3662
|
+
if (body.trigger_min_quantity != null && body.trigger_min_quantity !== "") {
|
|
3663
|
+
t.min_quantity = _strictMinorInt(body.trigger_min_quantity, "autoDiscount", "trigger min_quantity");
|
|
3664
|
+
}
|
|
3665
|
+
return t;
|
|
3666
|
+
}
|
|
3667
|
+
// default: cart_total_min
|
|
3668
|
+
return { kind: "cart_total_min", min_minor: _strictMinorInt(body.trigger_min_minor, "autoDiscount", "trigger min_minor") };
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
function _discountValue(body) {
|
|
3672
|
+
var kind = body.value_kind;
|
|
3673
|
+
if (kind === "percent_off") {
|
|
3674
|
+
return { kind: "percent_off", basis_points: _strictMinorInt(body.value_basis_points, "autoDiscount", "value basis_points") };
|
|
3675
|
+
}
|
|
3676
|
+
if (kind === "amount_off_total" || kind === "amount_off_each") {
|
|
3677
|
+
return { kind: kind, minor: _strictMinorInt(body.value_minor, "autoDiscount", "value minor") };
|
|
3678
|
+
}
|
|
3679
|
+
if (kind === "bogo") {
|
|
3680
|
+
return {
|
|
3681
|
+
kind: "bogo",
|
|
3682
|
+
buy_qty: _strictMinorInt(body.value_buy_qty, "autoDiscount", "value buy_qty"),
|
|
3683
|
+
get_qty: _strictMinorInt(body.value_get_qty, "autoDiscount", "value get_qty"),
|
|
3684
|
+
};
|
|
3685
|
+
}
|
|
3686
|
+
return { kind: "free_shipping" };
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
// Translate the rule edit form into an updateRule patch — title /
|
|
3690
|
+
// priority / active. The trigger + value re-set go through the bearer
|
|
3691
|
+
// JSON PATCH for the full vocabulary; the console edit is the common
|
|
3692
|
+
// "rename / re-prioritise / pause" gesture.
|
|
3693
|
+
function _discountPatch(body) {
|
|
3694
|
+
var patch = {};
|
|
3695
|
+
if (Object.prototype.hasOwnProperty.call(body, "title") && body.title !== "") patch.title = body.title;
|
|
3696
|
+
if (body.priority != null && body.priority !== "") patch.priority = _strictMinorInt(body.priority, "autoDiscount", "priority");
|
|
3697
|
+
if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
|
|
3698
|
+
return patch;
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
// Translate the policy create form / JSON body into a definePolicy
|
|
3702
|
+
// input. The console exposes the common shape (max codes + the two
|
|
3703
|
+
// combine toggles + an order-min floor); exclusive_codes / segment
|
|
3704
|
+
// gating stay on the bearer JSON path.
|
|
3705
|
+
function _policyInput(body) {
|
|
3706
|
+
if (body.allow_combine && typeof body.allow_combine === "object") {
|
|
3707
|
+
return body; // full JSON shape from a bearer client
|
|
3708
|
+
}
|
|
3709
|
+
var input = {
|
|
3710
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
3711
|
+
title: body.title,
|
|
3712
|
+
max_codes_per_order: _strictMinorInt(body.max_codes_per_order, "couponStacking", "max_codes_per_order"),
|
|
3713
|
+
allow_combine: {
|
|
3714
|
+
with_quantity_discounts: (body.with_quantity_discounts === "on" || body.with_quantity_discounts === "1"),
|
|
3715
|
+
with_other_codes: (body.with_other_codes === "on" || body.with_other_codes === "1"),
|
|
3716
|
+
},
|
|
3717
|
+
};
|
|
3718
|
+
if (body.order_min_minor != null && body.order_min_minor !== "") {
|
|
3719
|
+
input.order_min_minor = _strictMinorInt(body.order_min_minor, "couponStacking", "order_min_minor");
|
|
3720
|
+
}
|
|
3721
|
+
return input;
|
|
3722
|
+
}
|
|
3723
|
+
|
|
2722
3724
|
// ---- admin web pages (browser session + setup wizard) ---------------
|
|
2723
3725
|
//
|
|
2724
3726
|
// The operator signs in by pasting the ADMIN_API_KEY once; that sets a
|
|
@@ -3037,6 +4039,9 @@ var ADMIN_NAV_ITEMS = [
|
|
|
3037
4039
|
{ key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
|
|
3038
4040
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
3039
4041
|
{ key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
|
|
4042
|
+
{ key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
|
|
4043
|
+
{ key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
|
|
4044
|
+
{ key: "shipping", href: "/admin/shipping", label: "Shipping", requires: "shippingZones" },
|
|
3040
4045
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
3041
4046
|
{ key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
|
|
3042
4047
|
{ key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
|
|
@@ -3238,10 +4243,13 @@ function renderAdminProducts(opts) {
|
|
|
3238
4243
|
var action = p.status === "archived"
|
|
3239
4244
|
? "<form method=\"post\" action=\"/admin/products/" + _htmlEscape(p.id) + "/restore\"><button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>"
|
|
3240
4245
|
: "<form method=\"post\" action=\"/admin/products/" + _htmlEscape(p.id) + "/archive\"><button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>";
|
|
3241
|
-
return "<tr><td><strong>" + _htmlEscape(p.title) + "</strong></td>" +
|
|
4246
|
+
return "<tr><td><a href=\"/admin/products/" + _htmlEscape(p.id) + "\"><strong>" + _htmlEscape(p.title) + "</strong></a></td>" +
|
|
3242
4247
|
"<td><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></td>" +
|
|
3243
4248
|
"<td><span class=\"status-pill " + cls + "\">" + _htmlEscape(p.status) + "</span></td>" +
|
|
3244
|
-
"<td
|
|
4249
|
+
"<td><div class=\"actions-row mt-0\">" +
|
|
4250
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/products/" + _htmlEscape(p.id) + "\">Manage</a>" +
|
|
4251
|
+
action +
|
|
4252
|
+
"</div></td></tr>";
|
|
3245
4253
|
}).join("");
|
|
3246
4254
|
var table = products.length
|
|
3247
4255
|
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Status</th><th scope=\"col\">Action</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
@@ -3878,6 +4886,354 @@ function renderAdminSubscriptionPlans(opts) {
|
|
|
3878
4886
|
// •••• fill; the plaintext bearer code is never stored and so never
|
|
3879
4887
|
// rendered here. Original (issued) + remaining balance + lifecycle
|
|
3880
4888
|
// status + issued date per row, newest first.
|
|
4889
|
+
// A <select> form field matching the _setupField shape. `options` is an
|
|
4890
|
+
// array of { value, label }; `selected` highlights the current value.
|
|
4891
|
+
function _selectField(label, name, options, selected, hint, extra) {
|
|
4892
|
+
var opts = (options || []).map(function (o) {
|
|
4893
|
+
return "<option value=\"" + _htmlEscape(o.value) + "\"" +
|
|
4894
|
+
(o.value === selected ? " selected" : "") + ">" + _htmlEscape(o.label) + "</option>";
|
|
4895
|
+
}).join("");
|
|
4896
|
+
return "<label class=\"form-field\"><span>" + _htmlEscape(label) + "</span>" +
|
|
4897
|
+
"<select name=\"" + _htmlEscape(name) + "\"" + (extra || "") + ">" + opts + "</select>" +
|
|
4898
|
+
(hint ? "<small>" + _htmlEscape(hint) + "</small>" : "") +
|
|
4899
|
+
"</label>";
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4902
|
+
// bps → human "X.XX%". 1 bp = 0.01%.
|
|
4903
|
+
function _fmtBps(bps) {
|
|
4904
|
+
var n = Number(bps);
|
|
4905
|
+
if (!isFinite(n)) return "—";
|
|
4906
|
+
return (n / 100).toFixed(2) + "%";
|
|
4907
|
+
}
|
|
4908
|
+
|
|
4909
|
+
function renderAdminTaxRates(opts) {
|
|
4910
|
+
opts = opts || {};
|
|
4911
|
+
var rows = opts.rates || [];
|
|
4912
|
+
var jurisdiction = opts.jurisdiction || "";
|
|
4913
|
+
var sources = opts.sources || ["manual"];
|
|
4914
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Rate created.</div>" : "";
|
|
4915
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Rate updated.</div>" : "";
|
|
4916
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Rate archived.</div>" : "";
|
|
4917
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
4918
|
+
|
|
4919
|
+
// Jurisdiction picker — the list is scoped to one jurisdiction at a
|
|
4920
|
+
// time (the lib's listForJurisdiction is the only list surface).
|
|
4921
|
+
var picker =
|
|
4922
|
+
"<div class=\"panel mw-34\">" +
|
|
4923
|
+
"<form method=\"get\" action=\"/admin/tax-rates\" class=\"form-inline\">" +
|
|
4924
|
+
"<label class=\"form-field\"><span>Jurisdiction</span>" +
|
|
4925
|
+
"<input type=\"text\" name=\"jurisdiction\" value=\"" + _htmlEscape(jurisdiction) + "\" placeholder=\"US or US-CA\" maxlength=\"6\" class=\"input-code\" required>" +
|
|
4926
|
+
"<small>ISO 3166-1 country, optionally -subdivision (e.g. US, US-CA, DE-BY).</small>" +
|
|
4927
|
+
"</label>" +
|
|
4928
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">View rates</button></div>" +
|
|
4929
|
+
"</form>" +
|
|
4930
|
+
"</div>";
|
|
4931
|
+
|
|
4932
|
+
var table = "";
|
|
4933
|
+
if (jurisdiction) {
|
|
4934
|
+
var bodyRows = rows.map(function (r) {
|
|
4935
|
+
var isArchived = r.archived_at != null;
|
|
4936
|
+
var until = r.effective_until == null ? "<span class=\"meta\">open</span>" : _htmlEscape(_fmtDate(Number(r.effective_until)));
|
|
4937
|
+
var editForm = isArchived ? "" :
|
|
4938
|
+
"<form method=\"post\" action=\"/admin/tax-rates/" + _htmlEscape(r.id) + "/edit\" class=\"form-inline\">" +
|
|
4939
|
+
"<input type=\"hidden\" name=\"jurisdiction\" value=\"" + _htmlEscape(jurisdiction) + "\">" +
|
|
4940
|
+
"<input type=\"number\" name=\"rate_bps\" value=\"" + _htmlEscape(String(r.rate_bps)) + "\" min=\"0\" max=\"10000\" class=\"input-sm\" aria-label=\"rate basis points\">" +
|
|
4941
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
|
|
4942
|
+
"</form> " +
|
|
4943
|
+
"<form method=\"post\" action=\"/admin/tax-rates/" + _htmlEscape(r.id) + "/archive\" class=\"form-inline\">" +
|
|
4944
|
+
"<input type=\"hidden\" name=\"jurisdiction\" value=\"" + _htmlEscape(jurisdiction) + "\">" +
|
|
4945
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button>" +
|
|
4946
|
+
"</form>";
|
|
4947
|
+
return "<tr>" +
|
|
4948
|
+
"<td>" + (r.category ? "<code>" + _htmlEscape(r.category) + "</code>" : "<span class=\"meta\">default</span>") + "</td>" +
|
|
4949
|
+
"<td class=\"num\"><strong>" + _htmlEscape(_fmtBps(r.rate_bps)) + "</strong> <span class=\"meta\">(" + _htmlEscape(String(r.rate_bps)) + " bps)</span></td>" +
|
|
4950
|
+
"<td>" + _htmlEscape(_fmtDate(Number(r.effective_from))) + "</td>" +
|
|
4951
|
+
"<td>" + until + "</td>" +
|
|
4952
|
+
"<td>" + _htmlEscape(r.source) + "</td>" +
|
|
4953
|
+
"<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "live") + "</span></td>" +
|
|
4954
|
+
"<td>" + editForm + "</td>" +
|
|
4955
|
+
"</tr>";
|
|
4956
|
+
}).join("");
|
|
4957
|
+
table = rows.length
|
|
4958
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Category</th><th scope=\"col\" class=\"num\">Rate</th><th scope=\"col\">From</th><th scope=\"col\">Until</th><th scope=\"col\">Source</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
4959
|
+
: "<p class=\"empty\">No rates for " + _htmlEscape(jurisdiction) + " yet.</p>";
|
|
4960
|
+
}
|
|
4961
|
+
|
|
4962
|
+
var sourceOpts = sources.map(function (s) { return { value: s, label: s }; });
|
|
4963
|
+
var createForm = jurisdiction
|
|
4964
|
+
? "<div class=\"panel mt mw-34\">" +
|
|
4965
|
+
"<h3 class=\"subhead\">Add a rate for " + _htmlEscape(jurisdiction) + "</h3>" +
|
|
4966
|
+
"<form method=\"post\" action=\"/admin/tax-rates\">" +
|
|
4967
|
+
"<input type=\"hidden\" name=\"jurisdiction\" value=\"" + _htmlEscape(jurisdiction) + "\">" +
|
|
4968
|
+
_setupField("Category (optional)", "category", "", "text", "Leave blank for the jurisdiction's default rate. Lowercase, e.g. food, books.", " maxlength=\"64\"") +
|
|
4969
|
+
_setupField("Rate (basis points)", "rate_bps", "", "number", "1 bp = 0.01%. 2000 = 20.00%.", " min=\"0\" max=\"10000\" required") +
|
|
4970
|
+
_setupField("Effective from (epoch-ms, optional)", "effective_from", "", "number", "When the rate starts. Blank = now.", " min=\"0\"") +
|
|
4971
|
+
_setupField("Effective until (epoch-ms, optional)", "effective_until", "", "number", "When the rate ends. Blank = open-ended.", " min=\"0\"") +
|
|
4972
|
+
_selectField("Source", "source", sourceOpts, "manual", "Who declared this rate.", "") +
|
|
4973
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add rate</button></div>" +
|
|
4974
|
+
"</form>" +
|
|
4975
|
+
"</div>"
|
|
4976
|
+
: "";
|
|
4977
|
+
|
|
4978
|
+
var body = "<section><h2>Tax rates</h2>" + created + updated + archived + notice + picker + table + createForm + "</section>";
|
|
4979
|
+
return _renderAdminShell(opts.shop_name, "Tax rates", body, "tax", opts.nav_available);
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
function renderAdminShipping(opts) {
|
|
4983
|
+
opts = opts || {};
|
|
4984
|
+
var rows = opts.zones || [];
|
|
4985
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Zone created.</div>" : "";
|
|
4986
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Zone updated.</div>" : "";
|
|
4987
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Zone archived.</div>" : "";
|
|
4988
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
4989
|
+
|
|
4990
|
+
var bodyRows = rows.map(function (z) {
|
|
4991
|
+
var isArchived = z.archived_at != null;
|
|
4992
|
+
var regionStr = (z.regions || []).map(function (r) {
|
|
4993
|
+
return r.region ? r.country + "-" + r.region : r.country;
|
|
4994
|
+
}).join(", ");
|
|
4995
|
+
var rateCount = (z.rates || []).length;
|
|
4996
|
+
var statusCls = isArchived ? "cancelled" : (z.active ? "paid" : "pending");
|
|
4997
|
+
var statusTxt = isArchived ? "archived" : (z.active ? "active" : "paused");
|
|
4998
|
+
return "<tr>" +
|
|
4999
|
+
"<td><a href=\"/admin/shipping/" + _htmlEscape(encodeURIComponent(z.slug)) + "\"><strong>" + _htmlEscape(z.title) + "</strong></a></td>" +
|
|
5000
|
+
"<td><code class=\"order-id\">" + _htmlEscape(z.slug) + "</code></td>" +
|
|
5001
|
+
"<td>" + _htmlEscape(regionStr) + "</td>" +
|
|
5002
|
+
"<td class=\"num\">" + _htmlEscape(String(rateCount)) + "</td>" +
|
|
5003
|
+
"<td><span class=\"status-pill " + statusCls + "\">" + _htmlEscape(statusTxt) + "</span></td>" +
|
|
5004
|
+
"<td><div class=\"actions-row\">" +
|
|
5005
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/shipping/" + _htmlEscape(encodeURIComponent(z.slug)) + "\">Manage</a>" +
|
|
5006
|
+
(isArchived ? "" :
|
|
5007
|
+
"<form method=\"post\" action=\"/admin/shipping/" + _htmlEscape(encodeURIComponent(z.slug)) + "/archive\" class=\"form-inline\">" +
|
|
5008
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
|
|
5009
|
+
"</div></td>" +
|
|
5010
|
+
"</tr>";
|
|
5011
|
+
}).join("");
|
|
5012
|
+
|
|
5013
|
+
var table = rows.length
|
|
5014
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Regions</th><th scope=\"col\" class=\"num\">Rates</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
5015
|
+
: "<p class=\"empty\">No shipping zones yet.</p>";
|
|
5016
|
+
|
|
5017
|
+
var createForm =
|
|
5018
|
+
"<div class=\"panel mt mw-40\">" +
|
|
5019
|
+
"<h3 class=\"subhead\">Add a zone</h3>" +
|
|
5020
|
+
"<p class=\"meta\">A flat-rate service for one destination. Add more rate rows / regions from the zone's manage screen.</p>" +
|
|
5021
|
+
"<form method=\"post\" action=\"/admin/shipping\">" +
|
|
5022
|
+
_setupField("Slug", "slug", "", "text", "Stable identifier, e.g. domestic-us.", " maxlength=\"64\" required") +
|
|
5023
|
+
_setupField("Title", "title", "", "text", "Operator-facing name, e.g. Domestic (US).", " maxlength=\"200\" required") +
|
|
5024
|
+
_setupField("Country", "country", "", "text", "ISO 3166-1 alpha-2, e.g. US.", " maxlength=\"2\" class=\"input-code\" required") +
|
|
5025
|
+
_setupField("Region (optional)", "region", "", "text", "ISO 3166-2 subdivision without country prefix, e.g. CA. Blank = whole country.", " maxlength=\"3\" class=\"input-code\"") +
|
|
5026
|
+
_setupField("Service label", "service_label", "", "text", "Shown at checkout, e.g. Standard.", " maxlength=\"128\" required") +
|
|
5027
|
+
_setupField("Rate (minor units)", "rate_minor", "", "number", "In the currency's smallest unit — 500 = $5.00.", " min=\"0\" required") +
|
|
5028
|
+
_setupField("Currency", "currency", "", "text", "3-letter ISO 4217, e.g. USD.", " maxlength=\"3\" class=\"input-code\" required") +
|
|
5029
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add zone</button></div>" +
|
|
5030
|
+
"</form>" +
|
|
5031
|
+
"</div>";
|
|
5032
|
+
|
|
5033
|
+
var body = "<section><h2>Shipping zones</h2>" + created + updated + archived + notice + table + createForm + "</section>";
|
|
5034
|
+
return _renderAdminShell(opts.shop_name, "Shipping zones", body, "shipping", opts.nav_available);
|
|
5035
|
+
}
|
|
5036
|
+
|
|
5037
|
+
function renderAdminShippingZone(opts) {
|
|
5038
|
+
opts = opts || {};
|
|
5039
|
+
var z = opts.zone;
|
|
5040
|
+
if (!z) {
|
|
5041
|
+
var nf = "<section><h2>Shipping zone</h2><p class=\"empty\">Zone not found.</p>" +
|
|
5042
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/shipping\">Back to shipping</a></div></section>";
|
|
5043
|
+
return _renderAdminShell(opts.shop_name, "Shipping zone", nf, "shipping", opts.nav_available);
|
|
5044
|
+
}
|
|
5045
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Zone updated.</div>" : "";
|
|
5046
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
5047
|
+
var enc = encodeURIComponent(z.slug);
|
|
5048
|
+
|
|
5049
|
+
var rateRows = (z.rates || []).map(function (r) {
|
|
5050
|
+
var bucket = [];
|
|
5051
|
+
if (r.min_weight_grams != null || r.max_weight_grams != null) {
|
|
5052
|
+
bucket.push((r.min_weight_grams || 0) + "–" + (r.max_weight_grams == null ? "∞" : r.max_weight_grams) + " g");
|
|
5053
|
+
}
|
|
5054
|
+
if (r.min_order_minor != null || r.max_order_minor != null) {
|
|
5055
|
+
bucket.push((r.min_order_minor || 0) + "–" + (r.max_order_minor == null ? "∞" : r.max_order_minor) + " minor");
|
|
5056
|
+
}
|
|
5057
|
+
return "<tr>" +
|
|
5058
|
+
"<td>" + _htmlEscape(r.service_label) + "</td>" +
|
|
5059
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.rate_minor, String(r.currency || "").toUpperCase())) + "</td>" +
|
|
5060
|
+
"<td>" + (bucket.length ? _htmlEscape(bucket.join(" · ")) : "<span class=\"meta\">any</span>") + "</td>" +
|
|
5061
|
+
"</tr>";
|
|
5062
|
+
}).join("");
|
|
5063
|
+
var rateTable = (z.rates || []).length
|
|
5064
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Service</th><th scope=\"col\" class=\"num\">Rate</th><th scope=\"col\">Bucket</th></tr></thead><tbody>" + rateRows + "</tbody></table></div>"
|
|
5065
|
+
: "<p class=\"empty\">No rate rows.</p>";
|
|
5066
|
+
|
|
5067
|
+
var head = "<p class=\"meta\"><a href=\"/admin/shipping\">← Shipping</a> · <code class=\"order-id\">" + _htmlEscape(z.slug) + "</code> · " +
|
|
5068
|
+
"<span class=\"status-pill " + (z.archived_at != null ? "cancelled" : (z.active ? "paid" : "pending")) + "\">" +
|
|
5069
|
+
(z.archived_at != null ? "archived" : (z.active ? "active" : "paused")) + "</span></p>";
|
|
5070
|
+
|
|
5071
|
+
// Edit form: title + active toggle always; regions / rates via JSON
|
|
5072
|
+
// paste for the full vocabulary (weight + order buckets, multi-region).
|
|
5073
|
+
var editForm =
|
|
5074
|
+
"<div class=\"panel mt mw-42\">" +
|
|
5075
|
+
"<h3 class=\"subhead\">Edit zone</h3>" +
|
|
5076
|
+
"<form method=\"post\" action=\"/admin/shipping/" + _htmlEscape(enc) + "/edit\">" +
|
|
5077
|
+
_setupField("Title", "title", z.title, "text", "", " maxlength=\"200\"") +
|
|
5078
|
+
"<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
|
|
5079
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (z.active ? " checked" : "") + "> Active (serve this zone at checkout)</label>" +
|
|
5080
|
+
"<label class=\"form-field\"><span>Regions JSON (optional)</span>" +
|
|
5081
|
+
"<textarea name=\"regions_json\" rows=\"3\" placeholder='[{\"country\":\"US\",\"region\":\"CA\"}]'></textarea>" +
|
|
5082
|
+
"<small>Paste to replace the regions array. Blank leaves it unchanged.</small></label>" +
|
|
5083
|
+
"<label class=\"form-field\"><span>Rates JSON (optional)</span>" +
|
|
5084
|
+
"<textarea name=\"rates_json\" rows=\"4\" placeholder='[{\"rate_minor\":500,\"currency\":\"USD\",\"service_label\":\"Standard\"}]'></textarea>" +
|
|
5085
|
+
"<small>Paste to replace the rate table. Blank leaves it unchanged.</small></label>" +
|
|
5086
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save zone</button>" +
|
|
5087
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/shipping\">Back</a></div>" +
|
|
5088
|
+
"</form>" +
|
|
5089
|
+
"</div>";
|
|
5090
|
+
|
|
5091
|
+
var body = "<section><h2>" + _htmlEscape(z.title) + "</h2>" + updated + notice + head +
|
|
5092
|
+
"<h3 class=\"subhead subhead--sp-lg\">Rates</h3>" + rateTable + editForm + "</section>";
|
|
5093
|
+
return _renderAdminShell(opts.shop_name, "Zone " + z.slug, body, "shipping", opts.nav_available);
|
|
5094
|
+
}
|
|
5095
|
+
|
|
5096
|
+
// Human one-liner for a rule's trigger / value JSON.
|
|
5097
|
+
function _fmtTrigger(t) {
|
|
5098
|
+
if (!t || !t.kind) return "—";
|
|
5099
|
+
if (t.kind === "cart_total_min") return "cart ≥ " + (t.min_minor || 0) + " minor";
|
|
5100
|
+
if (t.kind === "item_count_min") return "≥ " + (t.min_count || 0) + " items";
|
|
5101
|
+
if (t.kind === "sku_purchase") return "buys " + (t.skus || []).join("/") + (t.min_quantity > 1 ? " ×" + t.min_quantity : "");
|
|
5102
|
+
return t.kind;
|
|
5103
|
+
}
|
|
5104
|
+
function _fmtValue(v) {
|
|
5105
|
+
if (!v || !v.kind) return "—";
|
|
5106
|
+
if (v.kind === "percent_off") return (Number(v.basis_points) / 100).toFixed(2) + "% off";
|
|
5107
|
+
if (v.kind === "amount_off_total") return v.minor + " minor off total";
|
|
5108
|
+
if (v.kind === "amount_off_each") return v.minor + " minor off each";
|
|
5109
|
+
if (v.kind === "free_shipping") return "free shipping";
|
|
5110
|
+
if (v.kind === "bogo") return "buy " + v.buy_qty + " get " + v.get_qty;
|
|
5111
|
+
return v.kind;
|
|
5112
|
+
}
|
|
5113
|
+
|
|
5114
|
+
function renderAdminDiscounts(opts) {
|
|
5115
|
+
opts = opts || {};
|
|
5116
|
+
var rules = opts.rules || [];
|
|
5117
|
+
var policies = opts.policies || [];
|
|
5118
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Discount rule created.</div>" : "";
|
|
5119
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Rule updated.</div>" : "";
|
|
5120
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Rule archived.</div>" : "";
|
|
5121
|
+
var pCreated = opts.policy_created ? "<div class=\"banner banner--ok\">Stacking policy created.</div>" : "";
|
|
5122
|
+
var pArchived = opts.policy_archived ? "<div class=\"banner banner--ok\">Stacking policy archived.</div>" : "";
|
|
5123
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
5124
|
+
|
|
5125
|
+
var ruleRows = rules.map(function (r) {
|
|
5126
|
+
var isArchived = r.archived_at != null;
|
|
5127
|
+
var statusCls = isArchived ? "cancelled" : (r.active ? "paid" : "pending");
|
|
5128
|
+
var statusTxt = isArchived ? "archived" : (r.active ? "active" : "paused");
|
|
5129
|
+
var actions = isArchived ? "" :
|
|
5130
|
+
"<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/edit\" class=\"form-inline\">" +
|
|
5131
|
+
"<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
|
|
5132
|
+
"<input type=\"number\" name=\"priority\" value=\"" + _htmlEscape(String(r.priority)) + "\" min=\"0\" class=\"input-sm\" aria-label=\"priority\">" +
|
|
5133
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (r.active ? " checked" : "") + "> on</label>" +
|
|
5134
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
|
|
5135
|
+
"</form> " +
|
|
5136
|
+
"<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/archive\" class=\"form-inline\">" +
|
|
5137
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
|
|
5138
|
+
return "<tr>" +
|
|
5139
|
+
"<td><strong>" + _htmlEscape(r.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(r.slug) + "</code></td>" +
|
|
5140
|
+
"<td>" + _htmlEscape(_fmtTrigger(r.trigger)) + "</td>" +
|
|
5141
|
+
"<td>" + _htmlEscape(_fmtValue(r.value)) + "</td>" +
|
|
5142
|
+
"<td class=\"num\">" + _htmlEscape(String(r.priority)) + "</td>" +
|
|
5143
|
+
"<td><span class=\"status-pill " + statusCls + "\">" + _htmlEscape(statusTxt) + "</span></td>" +
|
|
5144
|
+
"<td>" + actions + "</td>" +
|
|
5145
|
+
"</tr>";
|
|
5146
|
+
}).join("");
|
|
5147
|
+
var ruleTable = rules.length
|
|
5148
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Rule</th><th scope=\"col\">Trigger</th><th scope=\"col\">Value</th><th scope=\"col\" class=\"num\">Priority</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + ruleRows + "</tbody></table></div>"
|
|
5149
|
+
: "<p class=\"empty\">No automatic discount rules yet.</p>";
|
|
5150
|
+
|
|
5151
|
+
var TRIGGERS = [
|
|
5152
|
+
{ value: "cart_total_min", label: "Cart total ≥ (minor)" },
|
|
5153
|
+
{ value: "item_count_min", label: "Item count ≥" },
|
|
5154
|
+
{ value: "sku_purchase", label: "SKU purchased" },
|
|
5155
|
+
];
|
|
5156
|
+
var VALUES = [
|
|
5157
|
+
{ value: "percent_off", label: "Percent off" },
|
|
5158
|
+
{ value: "amount_off_total", label: "Amount off total" },
|
|
5159
|
+
{ value: "amount_off_each", label: "Amount off each" },
|
|
5160
|
+
{ value: "free_shipping", label: "Free shipping" },
|
|
5161
|
+
{ value: "bogo", label: "Buy X get Y" },
|
|
5162
|
+
];
|
|
5163
|
+
// The create form is server-rendered with every trigger / value field
|
|
5164
|
+
// present; the operator fills the ones that apply to the kind they
|
|
5165
|
+
// pick (the lib validates the chosen kind's required fields). No client
|
|
5166
|
+
// JS toggling — the strict CSP forbids inline script, and the backend
|
|
5167
|
+
// is the validator.
|
|
5168
|
+
var createForm =
|
|
5169
|
+
"<div class=\"panel mt mw-42\">" +
|
|
5170
|
+
"<h3 class=\"subhead\">Add a rule</h3>" +
|
|
5171
|
+
"<form method=\"post\" action=\"/admin/discounts\">" +
|
|
5172
|
+
_setupField("Slug", "slug", "", "text", "Stable handle, e.g. free-ship-50.", " maxlength=\"80\" required") +
|
|
5173
|
+
_setupField("Title", "title", "", "text", "Operator-facing name.", " maxlength=\"200\" required") +
|
|
5174
|
+
_selectField("Trigger kind", "trigger_kind", TRIGGERS, "cart_total_min", "What fires the rule.", "") +
|
|
5175
|
+
_setupField("· Cart total min (minor)", "trigger_min_minor", "", "number", "For \"Cart total ≥\".", " min=\"0\"") +
|
|
5176
|
+
_setupField("· Item count min", "trigger_min_count", "", "number", "For \"Item count ≥\".", " min=\"1\"") +
|
|
5177
|
+
_setupField("· SKUs (comma-separated)", "trigger_skus", "", "text", "For \"SKU purchased\".", " maxlength=\"2000\"") +
|
|
5178
|
+
_setupField("· SKU min quantity", "trigger_min_quantity", "", "number", "For \"SKU purchased\" (default 1).", " min=\"1\"") +
|
|
5179
|
+
_selectField("Value kind", "value_kind", VALUES, "percent_off", "What the rule gives.", "") +
|
|
5180
|
+
_setupField("· Percent (basis points)", "value_basis_points", "", "number", "For \"Percent off\". 1000 = 10.00%.", " min=\"1\" max=\"10000\"") +
|
|
5181
|
+
_setupField("· Amount (minor)", "value_minor", "", "number", "For \"Amount off total / each\".", " min=\"1\"") +
|
|
5182
|
+
_setupField("· BOGO buy qty", "value_buy_qty", "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
|
|
5183
|
+
_setupField("· BOGO get qty", "value_get_qty", "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
|
|
5184
|
+
_setupField("Priority", "priority", "", "number", "Higher wins ties. Default 0.", " min=\"0\"") +
|
|
5185
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add rule</button></div>" +
|
|
5186
|
+
"</form>" +
|
|
5187
|
+
"</div>";
|
|
5188
|
+
|
|
5189
|
+
var policySection = "";
|
|
5190
|
+
if (opts.stacking_enabled) {
|
|
5191
|
+
var polRows = policies.map(function (p) {
|
|
5192
|
+
var isArchived = p.archived_at != null;
|
|
5193
|
+
var combine = [];
|
|
5194
|
+
if (p.allow_combine && p.allow_combine.with_other_codes) combine.push("codes");
|
|
5195
|
+
if (p.allow_combine && p.allow_combine.with_quantity_discounts) combine.push("qty discounts");
|
|
5196
|
+
var statusCls = isArchived ? "cancelled" : (p.active ? "paid" : "pending");
|
|
5197
|
+
var statusTxt = isArchived ? "archived" : (p.active ? "active" : "paused");
|
|
5198
|
+
var actions = isArchived ? "" :
|
|
5199
|
+
"<form method=\"post\" action=\"/admin/discounts/policies/" + _htmlEscape(encodeURIComponent(p.slug)) + "/archive\" class=\"form-inline\">" +
|
|
5200
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
|
|
5201
|
+
return "<tr>" +
|
|
5202
|
+
"<td><strong>" + _htmlEscape(p.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></td>" +
|
|
5203
|
+
"<td class=\"num\">" + _htmlEscape(String(p.max_codes_per_order)) + "</td>" +
|
|
5204
|
+
"<td>" + (combine.length ? _htmlEscape(combine.join(", ")) : "<span class=\"meta\">none</span>") + "</td>" +
|
|
5205
|
+
"<td class=\"num\">" + _htmlEscape(String(p.order_min_minor)) + "</td>" +
|
|
5206
|
+
"<td><span class=\"status-pill " + statusCls + "\">" + _htmlEscape(statusTxt) + "</span></td>" +
|
|
5207
|
+
"<td>" + actions + "</td>" +
|
|
5208
|
+
"</tr>";
|
|
5209
|
+
}).join("");
|
|
5210
|
+
var polTable = policies.length
|
|
5211
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Policy</th><th scope=\"col\" class=\"num\">Max codes</th><th scope=\"col\">Combines with</th><th scope=\"col\" class=\"num\">Order min</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + polRows + "</tbody></table></div>"
|
|
5212
|
+
: "<p class=\"empty\">No stacking policies yet — without one, only one code applies per order.</p>";
|
|
5213
|
+
var polForm =
|
|
5214
|
+
"<div class=\"panel mt mw-40\">" +
|
|
5215
|
+
"<h3 class=\"subhead\">Add a stacking policy</h3>" +
|
|
5216
|
+
"<form method=\"post\" action=\"/admin/discounts/policies\">" +
|
|
5217
|
+
_setupField("Slug", "slug", "", "text", "Stable handle, e.g. default-stack.", " maxlength=\"80\" required") +
|
|
5218
|
+
_setupField("Title", "title", "", "text", "Operator-facing name.", " maxlength=\"200\" required") +
|
|
5219
|
+
_setupField("Max codes per order", "max_codes_per_order", "", "number", "1–32.", " min=\"1\" max=\"32\" required") +
|
|
5220
|
+
_setupField("Order minimum (minor)", "order_min_minor", "", "number", "Subtotal floor; default 0.", " min=\"0\"") +
|
|
5221
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"with_other_codes\"> Allow combining multiple codes</label>" +
|
|
5222
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"with_quantity_discounts\"> Allow combining with quantity discounts</label>" +
|
|
5223
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add policy</button></div>" +
|
|
5224
|
+
"</form>" +
|
|
5225
|
+
"</div>";
|
|
5226
|
+
policySection = "<h3 class=\"subhead subhead--sp-lg\">Coupon stacking policies</h3>" +
|
|
5227
|
+
"<p class=\"meta\">Gate which codes (and quantity discounts) may combine on one order.</p>" +
|
|
5228
|
+
pCreated + pArchived + polTable + polForm;
|
|
5229
|
+
}
|
|
5230
|
+
|
|
5231
|
+
var body = "<section><h2>Discounts</h2>" + created + updated + archived + notice +
|
|
5232
|
+
"<p class=\"meta\">Automatic cart-level discounts — applied without a coupon code.</p>" +
|
|
5233
|
+
ruleTable + createForm + policySection + "</section>";
|
|
5234
|
+
return _renderAdminShell(opts.shop_name, "Discounts", body, "discounts", opts.nav_available);
|
|
5235
|
+
}
|
|
5236
|
+
|
|
3881
5237
|
function renderAdminGiftCards(opts) {
|
|
3882
5238
|
opts = opts || {};
|
|
3883
5239
|
var rows = opts.cards || [];
|
|
@@ -4618,6 +5974,209 @@ function renderAdminCollection(opts) {
|
|
|
4618
5974
|
return _renderAdminShell(opts.shop_name, "Collection " + col.slug, body, "collections", opts.nav_available);
|
|
4619
5975
|
}
|
|
4620
5976
|
|
|
5977
|
+
// Product detail / management screen — the console's full editor for a
|
|
5978
|
+
// single catalog product: its fields (slug / title / description /
|
|
5979
|
+
// status), its variants (create / edit / delete), each variant's price
|
|
5980
|
+
// (set, with the current price + an append-only price history) and the
|
|
5981
|
+
// product's media (list / attach-by-key / upload-by-URL when the R2
|
|
5982
|
+
// bridge is wired / delete). Mirrors `renderAdminCollection`: an edit
|
|
5983
|
+
// form posting to a /edit POST alias (HTML forms can't PATCH), inline
|
|
5984
|
+
// sub-entity tables with per-row forms, and destructive actions routed
|
|
5985
|
+
// through a server-rendered confirm interstitial (the CSP forbids a
|
|
5986
|
+
// client confirm() dialog).
|
|
5987
|
+
function renderAdminProduct(opts) {
|
|
5988
|
+
opts = opts || {};
|
|
5989
|
+
var p = opts.product;
|
|
5990
|
+
if (!p) {
|
|
5991
|
+
var nf = "<section><h2>Product</h2><p class=\"empty\">Product not found.</p>" +
|
|
5992
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/products\">Back to products</a></div></section>";
|
|
5993
|
+
return _renderAdminShell(opts.shop_name, "Product", nf, "products", opts.nav_available);
|
|
5994
|
+
}
|
|
5995
|
+
var variants = opts.variants || [];
|
|
5996
|
+
var mediaRows = opts.media || [];
|
|
5997
|
+
var pricesByVar = opts.prices_by_variant || {}; // { variantId: { currencies: [{ currency, current, history }] } }
|
|
5998
|
+
var assetPrefix = typeof opts.asset_prefix === "string" ? opts.asset_prefix : "/assets/";
|
|
5999
|
+
var uploadWired = !!opts.upload_available;
|
|
6000
|
+
var defaultCurrency = (typeof opts.default_currency === "string" && /^[A-Z]{3}$/.test(opts.default_currency)) ? opts.default_currency : "USD";
|
|
6001
|
+
var pid = _htmlEscape(p.id);
|
|
6002
|
+
|
|
6003
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
6004
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
6005
|
+
|
|
6006
|
+
// ---- product fields edit ---------------------------------------------
|
|
6007
|
+
var statusOpts = ["draft", "active", "archived"].map(function (s) {
|
|
6008
|
+
return "<option value=\"" + s + "\"" + (s === p.status ? " selected" : "") + ">" + s + "</option>";
|
|
6009
|
+
}).join("");
|
|
6010
|
+
var editForm =
|
|
6011
|
+
"<div class=\"panel mw-40\">" +
|
|
6012
|
+
"<h3 class=\"subhead\">Details</h3>" +
|
|
6013
|
+
"<form method=\"post\" action=\"/admin/products/" + pid + "/edit\">" +
|
|
6014
|
+
_setupField("Slug", "slug", p.slug, "text", "Lowercase, hyphenated — the storefront URL.", " maxlength=\"200\" required") +
|
|
6015
|
+
_setupField("Title", "title", p.title, "text", "", " maxlength=\"500\" required") +
|
|
6016
|
+
"<label class=\"form-field\"><span>Description</span>" +
|
|
6017
|
+
"<textarea name=\"description\" maxlength=\"100000\" rows=\"4\">" + _htmlEscape(p.description || "") + "</textarea></label>" +
|
|
6018
|
+
"<label class=\"form-field\"><span>Status</span><select name=\"status\">" + statusOpts + "</select></label>" +
|
|
6019
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save</button></div>" +
|
|
6020
|
+
"</form>" +
|
|
6021
|
+
"</div>";
|
|
6022
|
+
|
|
6023
|
+
// ---- variants table + per-variant price manager ----------------------
|
|
6024
|
+
var variantBlocks = variants.map(function (v) {
|
|
6025
|
+
var vid = _htmlEscape(v.id);
|
|
6026
|
+
var optionPairs = Object.keys(v.options || {}).map(function (k) {
|
|
6027
|
+
return _htmlEscape(k) + "=" + _htmlEscape(String(v.options[k]));
|
|
6028
|
+
}).join(", ");
|
|
6029
|
+
var optionsText = Object.keys(v.options || {}).map(function (k) {
|
|
6030
|
+
return k + "=" + String(v.options[k]);
|
|
6031
|
+
}).join(", ");
|
|
6032
|
+
|
|
6033
|
+
// Price rows — current + history per currency the variant is priced in.
|
|
6034
|
+
var priceModel = pricesByVar[v.id] || { currencies: [] };
|
|
6035
|
+
var priceCurrencyBlocks = (priceModel.currencies || []).map(function (pc) {
|
|
6036
|
+
var current = pc.current
|
|
6037
|
+
? "<strong>" + _htmlEscape(pricing.format(pc.current.amount_minor, pc.currency)) + "</strong>"
|
|
6038
|
+
: "<span class=\"u-mute\">no active price</span>";
|
|
6039
|
+
var histRows = (pc.history || []).map(function (h) {
|
|
6040
|
+
return "<tr>" +
|
|
6041
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(h.amount_minor, pc.currency)) + "</td>" +
|
|
6042
|
+
"<td>" + _htmlEscape(_fmtDate(h.effective_from)) + "</td>" +
|
|
6043
|
+
"<td>" + (h.effective_until == null ? "<span class=\"status-pill paid\">current</span>" : _htmlEscape(_fmtDate(h.effective_until))) + "</td>" +
|
|
6044
|
+
"</tr>";
|
|
6045
|
+
}).join("");
|
|
6046
|
+
var histTable = (pc.history && pc.history.length)
|
|
6047
|
+
? "<table><thead><tr><th scope=\"col\" class=\"num\">Amount</th><th scope=\"col\">From</th><th scope=\"col\">Until</th></tr></thead><tbody>" + histRows + "</tbody></table>"
|
|
6048
|
+
: "";
|
|
6049
|
+
return "<div class=\"m-04\">" +
|
|
6050
|
+
"<span class=\"u-mute\">" + _htmlEscape(pc.currency) + "</span> · " + current +
|
|
6051
|
+
histTable +
|
|
6052
|
+
"</div>";
|
|
6053
|
+
}).join("");
|
|
6054
|
+
var priceSection =
|
|
6055
|
+
"<div class=\"panel-sub\">" +
|
|
6056
|
+
"<h4 class=\"subhead subhead--sp\">Price</h4>" +
|
|
6057
|
+
(priceCurrencyBlocks || "<p class=\"empty\">No price set yet.</p>") +
|
|
6058
|
+
"<form method=\"post\" action=\"/admin/variants/" + vid + "/prices/set\" class=\"price-set-form\">" +
|
|
6059
|
+
"<div class=\"actions-row\">" +
|
|
6060
|
+
"<input type=\"text\" name=\"currency\" value=\"" + _htmlEscape(defaultCurrency) + "\" class=\"input-code\" aria-label=\"Currency\" maxlength=\"3\" required>" +
|
|
6061
|
+
"<input type=\"number\" name=\"amount_minor\" min=\"0\" step=\"1\" placeholder=\"amount (minor units)\" aria-label=\"Amount in minor units\" required>" +
|
|
6062
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Set price</button>" +
|
|
6063
|
+
"</div>" +
|
|
6064
|
+
"<small class=\"u-mute\">Amount in the currency's minor units — e.g. 2999 = $29.99. Setting a price is append-only; the prior price is retained in history.</small>" +
|
|
6065
|
+
"</form>" +
|
|
6066
|
+
"</div>";
|
|
6067
|
+
|
|
6068
|
+
var editVariant =
|
|
6069
|
+
"<form method=\"post\" action=\"/admin/variants/" + vid + "/edit\">" +
|
|
6070
|
+
"<div class=\"actions-row\">" +
|
|
6071
|
+
"<label class=\"form-field mb-0\"><span>SKU</span><input type=\"text\" name=\"sku\" value=\"" + _htmlEscape(v.sku) + "\" maxlength=\"128\" required></label>" +
|
|
6072
|
+
"<label class=\"form-field mb-0\"><span>Title</span><input type=\"text\" name=\"title\" value=\"" + _htmlEscape(v.title || "") + "\" maxlength=\"500\"></label>" +
|
|
6073
|
+
"</div>" +
|
|
6074
|
+
"<div class=\"actions-row\">" +
|
|
6075
|
+
"<label class=\"form-field mb-0\"><span>Options (k=v, comma-sep)</span><input type=\"text\" name=\"options\" value=\"" + _htmlEscape(optionsText) + "\" maxlength=\"2000\"></label>" +
|
|
6076
|
+
"<label class=\"form-field mb-0\"><span>Weight (g)</span><input type=\"number\" name=\"weight_grams\" value=\"" + _htmlEscape(String(v.weight_grams || 0)) + "\" min=\"0\" step=\"1\"></label>" +
|
|
6077
|
+
"</div>" +
|
|
6078
|
+
"<label class=\"form-field\"><span>Requires shipping</span><select name=\"requires_shipping\">" +
|
|
6079
|
+
"<option value=\"1\"" + (v.requires_shipping ? " selected" : "") + ">Yes (physical)</option>" +
|
|
6080
|
+
"<option value=\"0\"" + (v.requires_shipping ? "" : " selected") + ">No (digital)</option>" +
|
|
6081
|
+
"</select></label>" +
|
|
6082
|
+
"<div class=\"actions-row\">" +
|
|
6083
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Save variant</button>" +
|
|
6084
|
+
"<a class=\"btn btn--danger\" href=\"/admin/variants/" + vid + "/delete/confirm-page?product_id=" + pid + "\">Delete</a>" +
|
|
6085
|
+
"</div>" +
|
|
6086
|
+
"</form>";
|
|
6087
|
+
|
|
6088
|
+
return "<div class=\"panel mt-1\">" +
|
|
6089
|
+
"<div class=\"actions-row mt-0\">" +
|
|
6090
|
+
"<strong>" + _htmlEscape(v.title || v.sku) + "</strong>" +
|
|
6091
|
+
"<code class=\"order-id\">" + _htmlEscape(v.sku) + "</code>" +
|
|
6092
|
+
(optionPairs ? "<span class=\"u-mute\">" + optionPairs + "</span>" : "") +
|
|
6093
|
+
"</div>" +
|
|
6094
|
+
editVariant +
|
|
6095
|
+
priceSection +
|
|
6096
|
+
"</div>";
|
|
6097
|
+
}).join("");
|
|
6098
|
+
var variantsBody = variants.length ? variantBlocks : "<p class=\"empty\">No variants yet — add the first one below.</p>";
|
|
6099
|
+
|
|
6100
|
+
var addVariant =
|
|
6101
|
+
"<div class=\"panel mt-1 mw-40\">" +
|
|
6102
|
+
"<h3 class=\"subhead\">Add a variant</h3>" +
|
|
6103
|
+
"<form method=\"post\" action=\"/admin/products/" + pid + "/variants/create\">" +
|
|
6104
|
+
_setupField("SKU", "sku", "", "text", "Unique stock-keeping unit — letters, digits, . _ -", " maxlength=\"128\" required") +
|
|
6105
|
+
_setupField("Title", "title", "", "text", "Optional — e.g. “Large / Blue”.", " maxlength=\"500\"") +
|
|
6106
|
+
_setupField("Options", "options", "", "text", "Attribute map as k=v pairs, comma-separated — e.g. size=L, color=blue.", " maxlength=\"2000\"") +
|
|
6107
|
+
_setupField("Weight (grams)", "weight_grams", "0", "number", "Used for shipping rate calculation.", " min=\"0\" step=\"1\"") +
|
|
6108
|
+
"<label class=\"form-field\"><span>Requires shipping</span><select name=\"requires_shipping\">" +
|
|
6109
|
+
"<option value=\"1\">Yes (physical)</option><option value=\"0\">No (digital)</option>" +
|
|
6110
|
+
"</select></label>" +
|
|
6111
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add variant</button></div>" +
|
|
6112
|
+
"</form>" +
|
|
6113
|
+
"</div>";
|
|
6114
|
+
|
|
6115
|
+
var variantsSection = "<section class=\"mt\"><h3 class=\"fs-105\">Variants</h3>" + variantsBody + addVariant + "</section>";
|
|
6116
|
+
|
|
6117
|
+
// ---- media -----------------------------------------------------------
|
|
6118
|
+
var mediaCards = mediaRows.map(function (m) {
|
|
6119
|
+
var mid = _htmlEscape(m.id);
|
|
6120
|
+
var url = assetPrefix + m.r2_key;
|
|
6121
|
+
var isImage = /^image\//.test(m.content_type || "");
|
|
6122
|
+
var thumb = isImage
|
|
6123
|
+
? "<img class=\"media-thumb\" src=\"" + _htmlEscape(url) + "\" alt=\"" + _htmlEscape(m.alt_text || "") + "\" loading=\"lazy\">"
|
|
6124
|
+
: "<span class=\"media-thumb media-thumb--file\">" + _htmlEscape((m.content_type || "file").split("/")[0]) + "</span>";
|
|
6125
|
+
return "<div class=\"media-card\">" +
|
|
6126
|
+
thumb +
|
|
6127
|
+
"<code class=\"order-id\">" + _htmlEscape(m.r2_key) + "</code>" +
|
|
6128
|
+
"<span class=\"u-mute\">" + _htmlEscape(m.content_type || "") + (m.alt_text ? " · " + _htmlEscape(m.alt_text) : "") + "</span>" +
|
|
6129
|
+
"<a class=\"btn btn--danger\" href=\"/admin/media/" + mid + "/delete/confirm-page?product_id=" + pid + "\">Delete</a>" +
|
|
6130
|
+
"</div>";
|
|
6131
|
+
}).join("");
|
|
6132
|
+
var mediaGrid = mediaRows.length
|
|
6133
|
+
? "<div class=\"media-grid\">" + mediaCards + "</div>"
|
|
6134
|
+
: "<p class=\"empty\">No media attached yet.</p>";
|
|
6135
|
+
|
|
6136
|
+
var attachForm =
|
|
6137
|
+
"<div class=\"panel mt-1 mw-40\">" +
|
|
6138
|
+
"<h3 class=\"subhead\">Attach media by R2 key</h3>" +
|
|
6139
|
+
"<p class=\"meta\">Reference an object already in your media bucket. The key is its path under the bucket (no leading slash, no “..”).</p>" +
|
|
6140
|
+
"<form method=\"post\" action=\"/admin/products/" + pid + "/media/attach\">" +
|
|
6141
|
+
_setupField("R2 key", "r2_key", "", "text", "e.g. media/01j…png", " maxlength=\"1024\" required") +
|
|
6142
|
+
_setupField("Content type", "content_type", "", "text", "MIME type — e.g. image/png.", " maxlength=\"255\" required") +
|
|
6143
|
+
_setupField("Alt text", "alt_text", "", "text", "Describes the image for screen readers + SEO.", " maxlength=\"500\"") +
|
|
6144
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Attach media</button></div>" +
|
|
6145
|
+
"</form>" +
|
|
6146
|
+
"</div>";
|
|
6147
|
+
|
|
6148
|
+
var uploadForm = uploadWired
|
|
6149
|
+
? "<div class=\"panel mt-1 mw-40\">" +
|
|
6150
|
+
"<h3 class=\"subhead\">Upload media from a URL</h3>" +
|
|
6151
|
+
"<p class=\"meta\">Fetches the source (SSRF-gated), stores it in your bucket, and attaches it to this product in one step.</p>" +
|
|
6152
|
+
"<form method=\"post\" action=\"/admin/products/" + pid + "/media/upload\">" +
|
|
6153
|
+
_setupField("Source URL", "source_url", "", "url", "A public https URL to the asset bytes.", " maxlength=\"2000\" required") +
|
|
6154
|
+
_setupField("Content type", "content_type", "", "text", "MIME type the source serves — e.g. image/png.", " maxlength=\"255\" required") +
|
|
6155
|
+
_setupField("Alt text", "alt_text", "", "text", "Describes the image for screen readers + SEO.", " maxlength=\"500\"") +
|
|
6156
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Upload + attach</button></div>" +
|
|
6157
|
+
"</form>" +
|
|
6158
|
+
"</div>"
|
|
6159
|
+
: "";
|
|
6160
|
+
|
|
6161
|
+
var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + attachForm + uploadForm + "</section>";
|
|
6162
|
+
|
|
6163
|
+
// ---- head + assembly -------------------------------------------------
|
|
6164
|
+
var statusCls = p.status === "active" ? "paid" : (p.status === "archived" ? "refunded" : "pending");
|
|
6165
|
+
var archiveAction = p.status === "archived"
|
|
6166
|
+
? "<form method=\"post\" action=\"/admin/products/" + pid + "/restore\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>"
|
|
6167
|
+
: "<form method=\"post\" action=\"/admin/products/" + pid + "/archive\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>";
|
|
6168
|
+
var head =
|
|
6169
|
+
"<p class=\"meta\"><a href=\"/admin/products\">← Products</a> · " +
|
|
6170
|
+
"<code class=\"order-id\">" + _htmlEscape(p.slug) + "</code> · " +
|
|
6171
|
+
"<span class=\"status-pill " + statusCls + "\">" + _htmlEscape(p.status) + "</span>" +
|
|
6172
|
+
" · <a href=\"/products/" + _htmlEscape(encodeURIComponent(p.slug)) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront →</a></p>";
|
|
6173
|
+
|
|
6174
|
+
var body = "<section><h2>" + _htmlEscape(p.title) + "</h2>" + saved + notice + head +
|
|
6175
|
+
"<div class=\"actions-row mt-0\">" + archiveAction + "</div>" +
|
|
6176
|
+
editForm + "</section>" + variantsSection + mediaSection;
|
|
6177
|
+
return _renderAdminShell(opts.shop_name, "Product " + p.slug, body, "products", opts.nav_available);
|
|
6178
|
+
}
|
|
6179
|
+
|
|
4621
6180
|
module.exports = {
|
|
4622
6181
|
mount: mount,
|
|
4623
6182
|
AUDIT_NAMESPACE: AUDIT_NAMESPACE,
|
|
@@ -4627,6 +6186,7 @@ module.exports = {
|
|
|
4627
6186
|
renderAdminSetup: renderAdminSetup,
|
|
4628
6187
|
renderAdminIntegrations: renderAdminIntegrations,
|
|
4629
6188
|
renderAdminProducts: renderAdminProducts,
|
|
6189
|
+
renderAdminProduct: renderAdminProduct,
|
|
4630
6190
|
renderAdminInventory: renderAdminInventory,
|
|
4631
6191
|
renderAdminOrders: renderAdminOrders,
|
|
4632
6192
|
renderAdminOrder: renderAdminOrder,
|
|
@@ -4640,5 +6200,9 @@ module.exports = {
|
|
|
4640
6200
|
renderAdminCollection: renderAdminCollection,
|
|
4641
6201
|
renderAdminGiftCards: renderAdminGiftCards,
|
|
4642
6202
|
renderAdminGiftCard: renderAdminGiftCard,
|
|
6203
|
+
renderAdminTaxRates: renderAdminTaxRates,
|
|
6204
|
+
renderAdminShipping: renderAdminShipping,
|
|
6205
|
+
renderAdminShippingZone: renderAdminShippingZone,
|
|
6206
|
+
renderAdminDiscounts: renderAdminDiscounts,
|
|
4643
6207
|
renderAdminConfirm: renderAdminConfirm,
|
|
4644
6208
|
};
|