@blamejs/blamejs-shop 0.2.14 → 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 +2 -0
- package/README.md +1 -1
- package/lib/admin.js +970 -1
- package/lib/asset-manifest.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.15 (2026-05-28) — **Configure tax, shipping, and discounts from the admin console.** Three commerce-configuration areas that previously existed only behind the JSON API now have admin console screens, so an operator can run a real store from the UI without scripting. Tax rates (per jurisdiction), shipping zones and rates, and discounts (automatic-discount rules plus coupon-stacking policies) each get a list / create / edit / archive screen, content-negotiated like the rest of the console (a bearer-token client still gets JSON). Their nav links appear only when the feature is wired. **Added:** *Tax rates screen* — `/admin/tax-rates` — pick a jurisdiction to see its rate history, create a rate (category + rate in basis points + effective window + source), edit the rate, and archive it. Overlap/archived conflicts surface as inline notices rather than errors. · *Shipping screen* — `/admin/shipping` — create a shipping zone (country/region + a flat-rate service) and manage it on a per-zone screen that lists its rates, edits the title/active state, and archives it; richer multi-region/multi-rate shapes remain available on the JSON path. · *Discounts screen* — `/admin/discounts` — create automatic-discount rules (cart-total / item-count / SKU triggers; percent-off / amount-off / free-shipping / BOGO values; priority), edit and archive them, plus manage coupon-stacking policies (max codes, combine toggles, order minimum).
|
|
12
|
+
|
|
11
13
|
- v0.2.14 (2026-05-28) — **Manage products end-to-end from the admin console — variants, prices, and images.** The admin console can now build a complete, sellable product without dropping to the JSON API. Previously creating a product only captured its name, slug, status, and description — there was no way to add a variant, set a price, or attach an image from the console, so a product made there was not sellable. Each product now has a management screen (reachable from the product list) with variant create/edit/delete, per-variant price setting plus price history, and media attach / upload / delete, with confirmation steps for deletes. The screen is content-negotiated: a bearer-token API client still gets JSON (now including the full product model), a signed-in browser gets the management UI. **Added:** *Product management screen in the admin console* — Each product's title in the admin product list now links to a detail screen where an operator can edit the product's fields, add/edit/remove variants, set a variant's price and see its price history, and attach/upload/remove product images — the full path to a sellable product, all from the console. Destructive actions (remove variant/image) go through a confirmation step. · *Catalog read helpers* — `prices.currencies(variantId)` (the distinct currencies a variant is priced in) and `media.get(id)` (single media row) were added to support the screen. **Changed:** *`GET /admin/products/:id` is content-negotiated* — It previously returned JSON only; it now returns the management screen to a signed-in browser and a fuller JSON model (product + variants + prices + media) to a bearer-token client. The existing variant/price/media write endpoints are unchanged in contract — the new browser forms route to the same handlers.
|
|
12
14
|
|
|
13
15
|
- v0.2.13 (2026-05-28) — **Product-grid and product-page visual polish.** Small visual refinements to the catalog grid and product page. Product tiles previously all carried the same fixed corner glow, so a grid of them read as one card stamped repeatedly; the glow position now rotates per tile so the grid looks like a designed set. The product page's buy box gets clearer elevation so it reads as a distinct purchase surface, and the fallback placeholder shown for a product with no image is now legible on the dark theme instead of a near-invisible mark. **Changed:** *Product tiles no longer share one identical glow* — Each product card's media glow now rotates through a set of positions per tile (matching the collection cards) instead of every tile glowing in the exact same corner, so a full grid reads as a designed set rather than a single card repeated. · *Clearer product-page buy box* — The product page's buy box gains a stronger border and a soft shadow so it stands out from the page as the purchase surface. · *Legible image-less placeholder* — The fallback artwork shown when a product has no image was nearly invisible on the dark canvas; its contrast is lifted (grid lines, shield detail, and caption) so it reads as an intentional placeholder. Applied identically on the edge and container render paths.
|
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
87
87
|
| **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
|
|
88
88
|
| **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
|
|
89
89
|
| **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
|
|
90
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, and
|
|
90
|
+
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
91
91
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
|
92
92
|
| **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
|
|
93
93
|
|
package/lib/admin.js
CHANGED
|
@@ -319,7 +319,7 @@ function mount(router, deps) {
|
|
|
319
319
|
// Which optional console sections are wired — gates their nav links so a
|
|
320
320
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
321
321
|
// into every authed render call as `nav_available`.
|
|
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 };
|
|
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 };
|
|
323
323
|
|
|
324
324
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
325
325
|
|
|
@@ -3107,6 +3107,620 @@ function mount(router, deps) {
|
|
|
3107
3107
|
return issued;
|
|
3108
3108
|
}
|
|
3109
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
|
+
|
|
3110
3724
|
// ---- admin web pages (browser session + setup wizard) ---------------
|
|
3111
3725
|
//
|
|
3112
3726
|
// The operator signs in by pasting the ADMIN_API_KEY once; that sets a
|
|
@@ -3425,6 +4039,9 @@ var ADMIN_NAV_ITEMS = [
|
|
|
3425
4039
|
{ key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
|
|
3426
4040
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
3427
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" },
|
|
3428
4045
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
3429
4046
|
{ key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
|
|
3430
4047
|
{ key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
|
|
@@ -4269,6 +4886,354 @@ function renderAdminSubscriptionPlans(opts) {
|
|
|
4269
4886
|
// •••• fill; the plaintext bearer code is never stored and so never
|
|
4270
4887
|
// rendered here. Original (issued) + remaining balance + lifecycle
|
|
4271
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
|
+
|
|
4272
5237
|
function renderAdminGiftCards(opts) {
|
|
4273
5238
|
opts = opts || {};
|
|
4274
5239
|
var rows = opts.cards || [];
|
|
@@ -5235,5 +6200,9 @@ module.exports = {
|
|
|
5235
6200
|
renderAdminCollection: renderAdminCollection,
|
|
5236
6201
|
renderAdminGiftCards: renderAdminGiftCards,
|
|
5237
6202
|
renderAdminGiftCard: renderAdminGiftCard,
|
|
6203
|
+
renderAdminTaxRates: renderAdminTaxRates,
|
|
6204
|
+
renderAdminShipping: renderAdminShipping,
|
|
6205
|
+
renderAdminShippingZone: renderAdminShippingZone,
|
|
6206
|
+
renderAdminDiscounts: renderAdminDiscounts,
|
|
5238
6207
|
renderAdminConfirm: renderAdminConfirm,
|
|
5239
6208
|
};
|
package/lib/asset-manifest.json
CHANGED
package/package.json
CHANGED