@blamejs/blamejs-shop 0.2.27 → 0.2.29
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/lib/admin.js +427 -0
- package/lib/asset-manifest.json +3 -3
- package/lib/storefront.js +396 -32
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.29 (2026-05-29) — **Show the real total before checkout and truthful stock on every product.** Two storefront truthfulness fixes. The cart and checkout now show the real total before payment — subtotal plus estimated tax, shipping, and any discount, all totalled — computed by the same engine that runs the actual charge, so the figure shown matches what will be charged (labelled estimated until an address narrows the tax and shipping, then exact; a destination with no matching rule reads 'calculated at checkout' rather than a wrong number). Cart lines also surface real stock state. On product pages, the buy box now reflects real per-variant inventory: an out-of-stock variant disables Add to cart with an honest message, and a low-stock variant shows 'Only N left'. **Fixed:** *The cart and checkout show your real total before you pay* — Previously the cart total was just the subtotal and checkout showed no tax/shipping until the payment step. Both now render subtotal + estimated tax + estimated shipping + discount + total, computed from the same tax/shipping primitives as the Stripe charge — the displayed estimate equals the confirmed order total to the cent. Figures are labelled 'estimated' until a shipping address is entered (then exact); a destination with no matching tax or shipping rule reads 'calculated at checkout', and the subtotal stays honest. Cart lines now also show out-of-stock / low-stock state. · *Product availability reflects real inventory* — The product buy box now reads real per-variant stock: an out-of-stock variant disables 'Add to cart' and shows an out-of-stock message (in the badge and the JSON-LD), and a variant at or below the configured low-stock threshold shows 'Only N left'. The add-to-cart control is no longer enabled for items that can't be bought.
|
|
12
|
+
|
|
13
|
+
- v0.2.28 (2026-05-29) — **Sales reports and printable receipts in the admin console.** The admin console gains a Reports screen and printable order documents. Reports aggregates a sales and revenue summary over a date range you choose — order count, gross and net revenue, refunds, average order value, an order-status funnel, a by-day revenue table, and top products — with a CSV export of the daily series. Every order detail page now offers a printable receipt and a printable packing slip (print-optimized, carrying your shop name and contact), so you can pull a revenue report or print an order without querying the database directly. **Added:** *Reports screen* — `/admin/reports` aggregates a sales/revenue summary over a selectable date range: order count, gross and net revenue, refunds and refund rate, average order value, an order-status funnel, a by-day revenue table, and top products. The daily series exports to CSV. The screen reads only — it never writes. · *Printable receipts and packing slips* — `GET /admin/orders/:id/receipt` and `GET /admin/orders/:id/packing-slip` render self-contained, print-optimized order documents (lines, totals, ship-to, tracking barcode) with your shop name and contact in the masthead. Both are linked from a Documents panel on the admin order detail screen.
|
|
14
|
+
|
|
11
15
|
- v0.2.27 (2026-05-29) — **Upload product images directly from the admin console.** The admin product media manager can now take an image file uploaded straight from your device, in addition to attaching one by URL. On the product detail screen, pick a PNG, JPEG, WebP, GIF, AVIF, or SVG and it streams to object storage and attaches to the product (or a specific variant) in one step. Every upload is validated by both its declared content type and its actual magic bytes — a mismatched or disguised file is refused — and capped at 10 MiB. The upload surface is only present when object storage is configured, so a store without it sees no change. **Added:** *Direct image-file upload in the admin product media manager* — A file picker on the admin product detail screen accepts a local image (`multipart/form-data`) alongside the existing attach-by-URL field. The file is content-type- and magic-byte-validated (png/jpeg/webp/gif/avif/svg), size-capped at 10 MiB, streamed to the object-storage bridge, and attached to the product or variant. JSON API: `POST /admin/products/:id/media/upload-file` and `POST /admin/media/upload-file`. The routes mount only when the object-storage bridge is configured.
|
|
12
16
|
|
|
13
17
|
- v0.2.26 (2026-05-29) — **Update the vendored blamejs runtime to v0.13.44.** The bundled blamejs runtime moves from v0.13.0 to v0.13.44, carrying a run of upstream security and reliability fixes through the crypto, storage, and middleware layers the shop is built on, with no API or configuration changes. Highlights: DNSSEC validation now bounds the work spent on colliding keys and signatures (KeyTrap / NSEC3 resource-exhaustion defense); a sealed database column that fails to unseal now returns null instead of risking forged or cross-row ciphertext; the encrypted database gains a temporary-storage free-space guard and a shutdown watchdog that preserves the final flush on exit; S/MIME chain verification binds the leaf certificate to the verifying signer key; archive extraction refuses Windows reserved and alternate-data-stream path names; and the replay-nonce store is memory-capped and fails closed under flood. **Changed:** *Database reliability under low temporary storage* — The encrypted database now refuses growth-writes with a clear error when its temporary storage drops below a safety headroom (rather than risking corruption), and a shutdown watchdog preserves the final database flush if a shutdown phase hangs. Both harden the container restart path. **Security:** *Bundled blamejs runtime updated to v0.13.44* — Pulls in the upstream security fixes accumulated since v0.13.0: DNSSEC KeyTrap / NSEC3 work caps, sealed-column null-on-unseal-failure (no forged or cross-row ciphertext), S/MIME leaf-to-signer binding, archive-extraction rejection of Windows reserved / alternate-data-stream names, static-file sibling-prefix path containment, and a memory-capped, fail-closed replay-nonce store. No API or configuration changes are required.
|
package/lib/admin.js
CHANGED
|
@@ -371,10 +371,16 @@ function mount(router, deps) {
|
|
|
371
371
|
var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
|
|
372
372
|
var customers = deps.customers || null; // read-only customers console disabled when absent
|
|
373
373
|
var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
|
|
374
|
+
var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
|
|
375
|
+
var printReceipts = deps.printReceipts || null; // order receipt document disabled when absent
|
|
376
|
+
var packingSlips = deps.packingSlips || null; // order packing-slip document disabled when absent
|
|
374
377
|
|
|
375
378
|
// Which optional console sections are wired — gates their nav links so a
|
|
376
379
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
377
380
|
// into every authed render call as `nav_available`.
|
|
381
|
+
// `reports` is always present in the nav (read-only sales summary needs no
|
|
382
|
+
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
383
|
+
// notice when the salesReports primitive isn't wired.
|
|
378
384
|
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount };
|
|
379
385
|
|
|
380
386
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
@@ -1379,6 +1385,10 @@ function mount(router, deps) {
|
|
|
1379
1385
|
// Shipment/tracking panel only renders when the tracking primitive
|
|
1380
1386
|
// is wired; the carrier + status enums drive its form selects.
|
|
1381
1387
|
can_track: !!orderTracking,
|
|
1388
|
+
// Printable-document links — shown only when the render primitive is
|
|
1389
|
+
// wired (its route is mounted only then).
|
|
1390
|
+
can_receipt: !!printReceipts,
|
|
1391
|
+
can_packing_slip: !!packingSlips,
|
|
1382
1392
|
shipments: shipments,
|
|
1383
1393
|
carriers: orderTracking ? orderTracking.CARRIERS : null,
|
|
1384
1394
|
statuses: orderTracking ? orderTracking.STATUSES : null,
|
|
@@ -3160,6 +3170,288 @@ function mount(router, deps) {
|
|
|
3160
3170
|
));
|
|
3161
3171
|
}
|
|
3162
3172
|
|
|
3173
|
+
// ---- reporting ------------------------------------------------------
|
|
3174
|
+
//
|
|
3175
|
+
// A sales/revenue report over a selectable date range: order count,
|
|
3176
|
+
// gross/net revenue, refunds, AOV, top products, broken down by day and
|
|
3177
|
+
// by status. Aggregated from the salesReports primitive (pure read-only
|
|
3178
|
+
// SQL over orders/order_lines). The route mounts unconditionally so the
|
|
3179
|
+
// always-present "Reports" nav link never points at a missing route; when
|
|
3180
|
+
// the salesReports primitive isn't wired it renders an unconfigured
|
|
3181
|
+
// notice. CSV export of the by-day series is exposed via `?format=csv`.
|
|
3182
|
+
//
|
|
3183
|
+
// Date range: `from`/`to` are epoch-ms query params (defaults to the last
|
|
3184
|
+
// 30 days). A malformed range re-renders the page with a 400 + the
|
|
3185
|
+
// validator's message rather than crashing — the report is config/entry
|
|
3186
|
+
// tier, so a bad operator-typed range surfaces as a correction, not a 500.
|
|
3187
|
+
|
|
3188
|
+
// Parse a "YYYY-MM-DD" calendar-date param to the epoch-ms at UTC
|
|
3189
|
+
// midnight. Returns null when the param is absent; throws TypeError on a
|
|
3190
|
+
// malformed value so the config/entry-tier 400 path catches it. The
|
|
3191
|
+
// browser date <input> submits this shape; the JSON API can use either
|
|
3192
|
+
// this or the raw epoch-ms `from`/`to`.
|
|
3193
|
+
function _parseDateParam(str, label) {
|
|
3194
|
+
if (str == null || str === "") return null;
|
|
3195
|
+
if (typeof str !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(str)) {
|
|
3196
|
+
throw new TypeError("admin: " + label + " must be a YYYY-MM-DD date");
|
|
3197
|
+
}
|
|
3198
|
+
var ms = Date.parse(str + "T00:00:00Z");
|
|
3199
|
+
if (!isFinite(ms)) throw new TypeError("admin: " + label + " is not a valid date");
|
|
3200
|
+
return ms;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// Resolve the report window from the query string. Returns
|
|
3204
|
+
// `{ from, to }` (epoch-ms) or throws TypeError on a malformed value.
|
|
3205
|
+
// Accepts either the raw epoch-ms `from`/`to` pair (machine clients) or
|
|
3206
|
+
// the calendar-date `from-date`/`to-date` pair (the browser date inputs);
|
|
3207
|
+
// the epoch-ms form wins when both are present. Defaults: to = now,
|
|
3208
|
+
// from = now - 30d. The salesReports primitive re-validates (from < to,
|
|
3209
|
+
// span ≤ 1y), so this only coerces the shape.
|
|
3210
|
+
function _reportWindow(url) {
|
|
3211
|
+
var from = _parseEpochMs(url && url.searchParams.get("from"), "from");
|
|
3212
|
+
var to = _parseEpochMs(url && url.searchParams.get("to"), "to");
|
|
3213
|
+
if (from == null) from = _parseDateParam(url && url.searchParams.get("from-date"), "from");
|
|
3214
|
+
// `to-date` is the inclusive end day — advance to the next UTC midnight
|
|
3215
|
+
// so the window covers the whole selected day (salesReports treats `to`
|
|
3216
|
+
// as exclusive: `updated_at < to`).
|
|
3217
|
+
if (to == null) {
|
|
3218
|
+
var toDate = _parseDateParam(url && url.searchParams.get("to-date"), "to");
|
|
3219
|
+
if (toDate != null) to = toDate + b.constants.TIME.days(1);
|
|
3220
|
+
}
|
|
3221
|
+
var now = Date.now();
|
|
3222
|
+
return {
|
|
3223
|
+
to: to == null ? now : to,
|
|
3224
|
+
from: from == null ? (now - b.constants.TIME.days(30)) : from,
|
|
3225
|
+
};
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
// Aggregate the report payload from the salesReports primitive. Composes
|
|
3229
|
+
// the by-day revenue series, AOV, refund rate, the status funnel, and the
|
|
3230
|
+
// top products into one object the HTML + CSV + JSON surfaces all read.
|
|
3231
|
+
async function _buildReport(win) {
|
|
3232
|
+
var byDay = await salesReports.revenueByDay({ from: win.from, to: win.to });
|
|
3233
|
+
var aov = await salesReports.aov({ from: win.from, to: win.to });
|
|
3234
|
+
var refund = await salesReports.refundRate({ from: win.from, to: win.to });
|
|
3235
|
+
var funnel = await salesReports.funnel({ from: win.from, to: win.to });
|
|
3236
|
+
var top = await salesReports.topProducts({ from: win.from, to: win.to, limit: 10 });
|
|
3237
|
+
|
|
3238
|
+
// Headline totals from the by-day series — gross/net/refunds summed
|
|
3239
|
+
// across every currency bucket in the window. The per-currency split
|
|
3240
|
+
// stays on the by-day rows for the operator who needs it; the headline
|
|
3241
|
+
// is the single-number summary a dashboard leads with.
|
|
3242
|
+
var orderCount = 0, gross = 0, net = 0, refunds = 0;
|
|
3243
|
+
var currency = (aov && aov.currency) || "USD";
|
|
3244
|
+
for (var i = 0; i < byDay.length; i += 1) {
|
|
3245
|
+
var row = byDay[i];
|
|
3246
|
+
orderCount += row.order_count;
|
|
3247
|
+
gross += row.gross_revenue_minor;
|
|
3248
|
+
net += row.net_revenue_minor;
|
|
3249
|
+
refunds += row.refund_total_minor;
|
|
3250
|
+
}
|
|
3251
|
+
return {
|
|
3252
|
+
from: win.from,
|
|
3253
|
+
to: win.to,
|
|
3254
|
+
currency: currency,
|
|
3255
|
+
order_count: orderCount,
|
|
3256
|
+
gross_revenue_minor: gross,
|
|
3257
|
+
net_revenue_minor: net,
|
|
3258
|
+
refund_total_minor: refunds,
|
|
3259
|
+
aov_minor: (aov && aov.aov_minor) || 0,
|
|
3260
|
+
refund_rate_bps: (refund && refund.refund_rate_bps) || 0,
|
|
3261
|
+
by_day: byDay,
|
|
3262
|
+
by_status: funnel,
|
|
3263
|
+
top_products: (top && top.rows) || [],
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
// CSV body for the by-day revenue series. Composes `b.csv.stringify`
|
|
3268
|
+
// (RFC 4180) — the cell values are integer aggregates + ISO date buckets
|
|
3269
|
+
// + 3-letter currency codes, all framework-internal (never raw
|
|
3270
|
+
// operator/customer prose), so the RFC-4180 quoting is sufficient here;
|
|
3271
|
+
// there is no untrusted free-text column that would require b.guardCsv's
|
|
3272
|
+
// formula-injection defenses.
|
|
3273
|
+
function _reportCsv(report) {
|
|
3274
|
+
var rows = report.by_day.map(function (r) {
|
|
3275
|
+
return {
|
|
3276
|
+
date: r.bucket_start,
|
|
3277
|
+
currency: r.currency,
|
|
3278
|
+
order_count: r.order_count,
|
|
3279
|
+
gross_revenue_minor: r.gross_revenue_minor,
|
|
3280
|
+
net_revenue_minor: r.net_revenue_minor,
|
|
3281
|
+
refund_total_minor: r.refund_total_minor,
|
|
3282
|
+
};
|
|
3283
|
+
});
|
|
3284
|
+
return b.csv.stringify(rows, {
|
|
3285
|
+
columns: ["date", "currency", "order_count", "gross_revenue_minor", "net_revenue_minor", "refund_total_minor"],
|
|
3286
|
+
header: true,
|
|
3287
|
+
eol: "\n",
|
|
3288
|
+
});
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
router.get("/admin/reports", _pageOrApi(true,
|
|
3292
|
+
R(async function (req, res) {
|
|
3293
|
+
if (!salesReports) return _problem(res, 503, "reporting-unconfigured", "the salesReports primitive is not wired");
|
|
3294
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3295
|
+
// A malformed from/to surfaces as a 400 problem (config/entry tier).
|
|
3296
|
+
var win;
|
|
3297
|
+
try { win = _reportWindow(url); }
|
|
3298
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3299
|
+
var report;
|
|
3300
|
+
try { report = await _buildReport(win); }
|
|
3301
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
3302
|
+
if (url && url.searchParams.get("format") === "csv") {
|
|
3303
|
+
res.status(200);
|
|
3304
|
+
if (res.setHeader) {
|
|
3305
|
+
res.setHeader("content-type", "text/csv; charset=utf-8");
|
|
3306
|
+
res.setHeader("content-disposition", "attachment; filename=\"sales-report.csv\"");
|
|
3307
|
+
}
|
|
3308
|
+
var csv = _reportCsv(report);
|
|
3309
|
+
if (res.end) res.end(csv); else res.send(csv);
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
_json(res, 200, report);
|
|
3313
|
+
}),
|
|
3314
|
+
async function (req, res) {
|
|
3315
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3316
|
+
if (!salesReports) {
|
|
3317
|
+
return _sendHtml(res, 200, renderAdminReports({
|
|
3318
|
+
shop_name: deps.shop_name, nav_available: navAvailable, report: null,
|
|
3319
|
+
notice: "Reporting isn't configured on this deployment.",
|
|
3320
|
+
}));
|
|
3321
|
+
}
|
|
3322
|
+
var win, notice = null;
|
|
3323
|
+
try { win = _reportWindow(url); }
|
|
3324
|
+
catch (e) {
|
|
3325
|
+
if (!(e instanceof TypeError)) throw e;
|
|
3326
|
+
// Bad range → re-render with the default window + a correction
|
|
3327
|
+
// notice (config/entry tier: the operator fixes the typo).
|
|
3328
|
+
win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
|
|
3329
|
+
notice = e.message.replace(/^admin:\s*/, "");
|
|
3330
|
+
}
|
|
3331
|
+
// CSV download from the browser surface too (a link, not a fetch).
|
|
3332
|
+
if (url && url.searchParams.get("format") === "csv") {
|
|
3333
|
+
var report0;
|
|
3334
|
+
try { report0 = await _buildReport(win); }
|
|
3335
|
+
catch (e2) { if (!(e2 instanceof TypeError)) throw e2; report0 = null; }
|
|
3336
|
+
if (report0) {
|
|
3337
|
+
res.status(200);
|
|
3338
|
+
if (res.setHeader) {
|
|
3339
|
+
res.setHeader("content-type", "text/csv; charset=utf-8");
|
|
3340
|
+
res.setHeader("content-disposition", "attachment; filename=\"sales-report.csv\"");
|
|
3341
|
+
}
|
|
3342
|
+
var csv0 = _reportCsv(report0);
|
|
3343
|
+
if (res.end) res.end(csv0); else res.send(csv0);
|
|
3344
|
+
return;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
var report;
|
|
3348
|
+
try { report = await _buildReport(win); }
|
|
3349
|
+
catch (e3) {
|
|
3350
|
+
if (!(e3 instanceof TypeError)) throw e3;
|
|
3351
|
+
win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
|
|
3352
|
+
notice = e3.message.replace(/^salesReports:\s*/, "");
|
|
3353
|
+
report = await _buildReport(win);
|
|
3354
|
+
}
|
|
3355
|
+
_sendHtml(res, 200, renderAdminReports({
|
|
3356
|
+
shop_name: deps.shop_name, nav_available: navAvailable, report: report, notice: notice,
|
|
3357
|
+
}));
|
|
3358
|
+
},
|
|
3359
|
+
));
|
|
3360
|
+
|
|
3361
|
+
// ---- printable order documents --------------------------------------
|
|
3362
|
+
//
|
|
3363
|
+
// Operator-facing receipt + packing slip for an order. These are the
|
|
3364
|
+
// warehouse/fulfilment paper trail (the customer's own order page is a
|
|
3365
|
+
// separate storefront surface — untouched here). Each renders clean,
|
|
3366
|
+
// self-contained, print-optimized HTML the operator prints or pipes to a
|
|
3367
|
+
// PDF. Mounted only when the corresponding render primitive is wired.
|
|
3368
|
+
//
|
|
3369
|
+
// Failure modes: a malformed order id (TypeError from the primitive's id
|
|
3370
|
+
// reader) → 404; a missing order → 404. Only TypeError is swallowed to a
|
|
3371
|
+
// 404 — any other error propagates to the 500 path.
|
|
3372
|
+
|
|
3373
|
+
// The receipt + packing-slip render primitives emit a self-contained
|
|
3374
|
+
// document driven purely by the order; the shop's own name + contact live
|
|
3375
|
+
// in shop_config, so we read them here and inject a small masthead at the
|
|
3376
|
+
// top of the document `<body>`. Both renderers emit a literal "<body>\n"
|
|
3377
|
+
// marker; the header slots in right after it. When config is unwired or
|
|
3378
|
+
// the keys are unset, the document renders without a masthead (the order
|
|
3379
|
+
// data alone is still a complete, valid document).
|
|
3380
|
+
async function _shopMastheadHtml() {
|
|
3381
|
+
if (!deps.config) return "";
|
|
3382
|
+
var name = null, contact = null;
|
|
3383
|
+
try {
|
|
3384
|
+
name = await deps.config.get("shop.name", deps.shop_name || null);
|
|
3385
|
+
// The setup wizard persists the operator's contact under
|
|
3386
|
+
// "shop.contact_email"; fall back to the conventional
|
|
3387
|
+
// "shop.support_email" key the config module documents.
|
|
3388
|
+
contact = await deps.config.get("shop.contact_email", null);
|
|
3389
|
+
if (contact == null) contact = await deps.config.get("shop.support_email", null);
|
|
3390
|
+
} catch (_e) {
|
|
3391
|
+
// Config read failure must not break the print document — degrade to
|
|
3392
|
+
// no masthead rather than 500-ing the operator's print job.
|
|
3393
|
+
return "";
|
|
3394
|
+
}
|
|
3395
|
+
if (!name && !contact) return "";
|
|
3396
|
+
var parts = "";
|
|
3397
|
+
if (name) parts += "<strong>" + _htmlEscape(String(name)) + "</strong>";
|
|
3398
|
+
if (contact) parts += (name ? "<br>" : "") + _htmlEscape(String(contact));
|
|
3399
|
+
// Inline style is acceptable here: this document is served standalone
|
|
3400
|
+
// (not under the admin console's strict style-src CSP) and is built for
|
|
3401
|
+
// printing, where an external stylesheet round-trip is undesirable.
|
|
3402
|
+
return "<div style=\"margin-bottom:8mm;font-size:11pt;\">" + parts + "</div>\n";
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
function _injectMasthead(html, masthead) {
|
|
3406
|
+
if (!masthead) return html;
|
|
3407
|
+
var marker = "<body>\n";
|
|
3408
|
+
var at = html.indexOf(marker);
|
|
3409
|
+
if (at === -1) return html; // renderer changed shape — emit the doc unaltered
|
|
3410
|
+
return html.slice(0, at + marker.length) + masthead + html.slice(at + marker.length);
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
if (printReceipts) {
|
|
3414
|
+
router.get("/admin/orders/:id/receipt", async function (req, res) {
|
|
3415
|
+
if (!_htmlAuthed(req, expectedToken)) {
|
|
3416
|
+
if (req.method === "GET" || !req.method) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
|
|
3417
|
+
return _problem(res, 401, "unauthorized");
|
|
3418
|
+
}
|
|
3419
|
+
var html;
|
|
3420
|
+
try {
|
|
3421
|
+
html = await printReceipts.htmlPdf({ order_id: req.params.id });
|
|
3422
|
+
} catch (e) {
|
|
3423
|
+
if (e instanceof TypeError) {
|
|
3424
|
+
return _sendHtml(res, 404, renderAdminOrders({
|
|
3425
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
3426
|
+
}));
|
|
3427
|
+
}
|
|
3428
|
+
return _problem(res, 500, "internal-error", (e && e.message) || String(e));
|
|
3429
|
+
}
|
|
3430
|
+
_sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
|
|
3431
|
+
});
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
if (packingSlips) {
|
|
3435
|
+
router.get("/admin/orders/:id/packing-slip", async function (req, res) {
|
|
3436
|
+
if (!_htmlAuthed(req, expectedToken)) {
|
|
3437
|
+
if (req.method === "GET" || !req.method) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
|
|
3438
|
+
return _problem(res, 401, "unauthorized");
|
|
3439
|
+
}
|
|
3440
|
+
var html;
|
|
3441
|
+
try {
|
|
3442
|
+
html = await packingSlips.renderHtml({ order_id: req.params.id });
|
|
3443
|
+
} catch (e) {
|
|
3444
|
+
if (e instanceof TypeError) {
|
|
3445
|
+
return _sendHtml(res, 404, renderAdminOrders({
|
|
3446
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
3447
|
+
}));
|
|
3448
|
+
}
|
|
3449
|
+
return _problem(res, 500, "internal-error", (e && e.message) || String(e));
|
|
3450
|
+
}
|
|
3451
|
+
_sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
|
|
3452
|
+
});
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3163
3455
|
// ---- analytics ------------------------------------------------------
|
|
3164
3456
|
|
|
3165
3457
|
var analytics = deps.analytics || null;
|
|
@@ -4505,6 +4797,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
4505
4797
|
{ key: "products", href: "/admin/products", label: "Products" },
|
|
4506
4798
|
{ key: "inventory", href: "/admin/inventory", label: "Inventory" },
|
|
4507
4799
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
4800
|
+
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
4508
4801
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
4509
4802
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
4510
4803
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
@@ -4793,6 +5086,123 @@ function renderAdminOrders(opts) {
|
|
|
4793
5086
|
return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
|
|
4794
5087
|
}
|
|
4795
5088
|
|
|
5089
|
+
// epoch-ms → "YYYY-MM-DD" for a date <input>'s value. Mirrors `_fmtDate`'s
|
|
5090
|
+
// guard so a bad value never throws inside the template.
|
|
5091
|
+
function _dateInputValue(v) {
|
|
5092
|
+
var n = typeof v === "number" ? v : Date.parse(v);
|
|
5093
|
+
if (!isFinite(n)) return "";
|
|
5094
|
+
return new Date(n).toISOString().slice(0, 10);
|
|
5095
|
+
}
|
|
5096
|
+
|
|
5097
|
+
// Sales/revenue report screen. Renders the headline totals (orders,
|
|
5098
|
+
// gross/net revenue, refunds, AOV, refund rate), the order-status funnel,
|
|
5099
|
+
// a by-day revenue table, and the top products — all for the selected
|
|
5100
|
+
// date range. A date-range form lets the operator retune the window; a CSV
|
|
5101
|
+
// link exports the by-day series. `opts.report` is null when reporting
|
|
5102
|
+
// isn't configured (renders the notice only).
|
|
5103
|
+
function renderAdminReports(opts) {
|
|
5104
|
+
opts = opts || {};
|
|
5105
|
+
var report = opts.report;
|
|
5106
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
5107
|
+
|
|
5108
|
+
if (!report) {
|
|
5109
|
+
var emptyBody = "<section><h2>Reports</h2>" + notice +
|
|
5110
|
+
"<p class=\"empty\">No sales report available.</p></section>";
|
|
5111
|
+
return _renderAdminShell(opts.shop_name, "Reports", emptyBody, "reports", opts.nav_available);
|
|
5112
|
+
}
|
|
5113
|
+
|
|
5114
|
+
var fromVal = _dateInputValue(report.from);
|
|
5115
|
+
// The window's `to` is exclusive (next UTC midnight after the operator's
|
|
5116
|
+
// chosen end day); render the inclusive end day back into the date input
|
|
5117
|
+
// by stepping one day back.
|
|
5118
|
+
var toVal = _dateInputValue(report.to - b.constants.TIME.days(1));
|
|
5119
|
+
|
|
5120
|
+
// Date-range form (GET so the window lives in the URL — bookmarkable +
|
|
5121
|
+
// shareable). The browser submits calendar-date params; the CSV link
|
|
5122
|
+
// carries the same window as raw epoch-ms.
|
|
5123
|
+
var csvHref = "/admin/reports?format=csv" +
|
|
5124
|
+
"&from=" + encodeURIComponent(String(report.from)) +
|
|
5125
|
+
"&to=" + encodeURIComponent(String(report.to));
|
|
5126
|
+
var rangeForm =
|
|
5127
|
+
"<form method=\"get\" action=\"/admin/reports\" class=\"order-filters\">" +
|
|
5128
|
+
"<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" value=\"" + _htmlEscape(fromVal) + "\"></label>" +
|
|
5129
|
+
"<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" value=\"" + _htmlEscape(toVal) + "\"></label>" +
|
|
5130
|
+
"<button class=\"btn\" type=\"submit\">Apply</button>" +
|
|
5131
|
+
"<a class=\"btn btn--ghost\" href=\"" + _htmlEscape(csvHref) + "\">Export CSV</a>" +
|
|
5132
|
+
"</form>";
|
|
5133
|
+
|
|
5134
|
+
// Headline stat cards. Money formats through the same pricing helper the
|
|
5135
|
+
// dashboard + order pages use, in the report's headline currency.
|
|
5136
|
+
var cur = report.currency;
|
|
5137
|
+
var statsBlock =
|
|
5138
|
+
"<section><h2>Summary</h2><div class=\"stat-grid\">" +
|
|
5139
|
+
_statCard("Orders", String(report.order_count)) +
|
|
5140
|
+
_statCard("Gross revenue", pricing.format(report.gross_revenue_minor, cur)) +
|
|
5141
|
+
_statCard("Net revenue", pricing.format(report.net_revenue_minor, cur)) +
|
|
5142
|
+
_statCard("Refunds", pricing.format(report.refund_total_minor, cur)) +
|
|
5143
|
+
_statCard("Avg order value", pricing.format(report.aov_minor, cur)) +
|
|
5144
|
+
_statCard("Refund rate", (report.refund_rate_bps / 100).toFixed(2) + "%") +
|
|
5145
|
+
"</div></section>";
|
|
5146
|
+
|
|
5147
|
+
// Order-status funnel — each milestone counts orders that reached at
|
|
5148
|
+
// least that stage within the window.
|
|
5149
|
+
var f = report.by_status || {};
|
|
5150
|
+
var funnelRows = [
|
|
5151
|
+
["Checkout started", f.checkout_started],
|
|
5152
|
+
["Payment intent created", f.payment_intent_created],
|
|
5153
|
+
["Paid", f.paid],
|
|
5154
|
+
["Fulfilled", f.fulfilled],
|
|
5155
|
+
["Refunded", f.refunded],
|
|
5156
|
+
].map(function (pair) {
|
|
5157
|
+
return "<tr><td>" + _htmlEscape(pair[0]) + "</td><td class=\"num\">" + _htmlEscape(String(pair[1] == null ? 0 : pair[1])) + "</td></tr>";
|
|
5158
|
+
}).join("");
|
|
5159
|
+
var funnelBlock =
|
|
5160
|
+
"<section><h2>Order status</h2><div class=\"panel\">" +
|
|
5161
|
+
"<table><thead><tr><th scope=\"col\">Stage</th><th scope=\"col\" class=\"num\">Orders</th></tr></thead><tbody>" + funnelRows + "</tbody></table>" +
|
|
5162
|
+
"</div></section>";
|
|
5163
|
+
|
|
5164
|
+
// Top products by gross revenue across the window.
|
|
5165
|
+
var top = report.top_products || [];
|
|
5166
|
+
var topRows = top.length
|
|
5167
|
+
? top.map(function (r) {
|
|
5168
|
+
return "<tr><td>" + _htmlEscape(r.sku) + "</td>" +
|
|
5169
|
+
"<td class=\"num\">" + _htmlEscape(String(r.units_sold)) + "</td>" +
|
|
5170
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.gross_revenue_minor, r.currency)) + "</td></tr>";
|
|
5171
|
+
}).join("")
|
|
5172
|
+
: "<tr><td colspan=\"3\" class=\"empty\">No sales in this window.</td></tr>";
|
|
5173
|
+
var topBlock =
|
|
5174
|
+
"<section><h2>Top products</h2><div class=\"panel\">" +
|
|
5175
|
+
"<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Units</th><th scope=\"col\" class=\"num\">Revenue</th></tr></thead><tbody>" + topRows + "</tbody></table>" +
|
|
5176
|
+
"</div></section>";
|
|
5177
|
+
|
|
5178
|
+
// By-day revenue series — one row per (day, currency) bucket.
|
|
5179
|
+
var byDay = report.by_day || [];
|
|
5180
|
+
var dayRows = byDay.length
|
|
5181
|
+
? byDay.map(function (r) {
|
|
5182
|
+
return "<tr>" +
|
|
5183
|
+
"<td>" + _htmlEscape(r.bucket_start) + "</td>" +
|
|
5184
|
+
"<td>" + _htmlEscape(r.currency) + "</td>" +
|
|
5185
|
+
"<td class=\"num\">" + _htmlEscape(String(r.order_count)) + "</td>" +
|
|
5186
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.gross_revenue_minor, r.currency)) + "</td>" +
|
|
5187
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.net_revenue_minor, r.currency)) + "</td>" +
|
|
5188
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(r.refund_total_minor, r.currency)) + "</td>" +
|
|
5189
|
+
"</tr>";
|
|
5190
|
+
}).join("")
|
|
5191
|
+
: "<tr><td colspan=\"6\" class=\"empty\">No sales in this window.</td></tr>";
|
|
5192
|
+
var dayBlock =
|
|
5193
|
+
"<section><h2>By day</h2><div class=\"panel\">" +
|
|
5194
|
+
"<table><thead><tr><th scope=\"col\">Date</th><th scope=\"col\">Currency</th><th scope=\"col\" class=\"num\">Orders</th><th scope=\"col\" class=\"num\">Gross</th><th scope=\"col\" class=\"num\">Net</th><th scope=\"col\" class=\"num\">Refunds</th></tr></thead><tbody>" + dayRows + "</tbody></table>" +
|
|
5195
|
+
"</div></section>";
|
|
5196
|
+
|
|
5197
|
+
var body =
|
|
5198
|
+
"<section><h2>Reports</h2>" + notice +
|
|
5199
|
+
"<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(report.from)) + " → " + _htmlEscape(_fmtDate(report.to)) + "</p>" +
|
|
5200
|
+
rangeForm +
|
|
5201
|
+
"</section>" +
|
|
5202
|
+
statsBlock + funnelBlock + topBlock + dayBlock;
|
|
5203
|
+
return _renderAdminShell(opts.shop_name, "Reports", body, "reports", opts.nav_available);
|
|
5204
|
+
}
|
|
5205
|
+
|
|
4796
5206
|
function renderAdminOrder(opts) {
|
|
4797
5207
|
opts = opts || {};
|
|
4798
5208
|
var o = opts.order;
|
|
@@ -4854,6 +5264,22 @@ function renderAdminOrder(opts) {
|
|
|
4854
5264
|
}).filter(Boolean).join(" ");
|
|
4855
5265
|
var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
|
|
4856
5266
|
|
|
5267
|
+
// Printable operator documents — a receipt + a packing slip, each opening
|
|
5268
|
+
// the print-optimized HTML in a new tab. Rendered only when the matching
|
|
5269
|
+
// render primitive is wired (the routes mount only then).
|
|
5270
|
+
var docLinks = [];
|
|
5271
|
+
if (opts.can_receipt) {
|
|
5272
|
+
docLinks.push("<a class=\"btn btn--ghost\" href=\"/admin/orders/" + _htmlEscape(o.id) + "/receipt\" target=\"_blank\" rel=\"noopener\">Print receipt</a>");
|
|
5273
|
+
}
|
|
5274
|
+
if (opts.can_packing_slip) {
|
|
5275
|
+
docLinks.push("<a class=\"btn btn--ghost\" href=\"/admin/orders/" + _htmlEscape(o.id) + "/packing-slip\" target=\"_blank\" rel=\"noopener\">Print packing slip</a>");
|
|
5276
|
+
}
|
|
5277
|
+
var documentsPanel = docLinks.length
|
|
5278
|
+
? "<div class=\"panel mt\"><h3 class=\"subhead\">Documents</h3>" +
|
|
5279
|
+
"<div class=\"order-actions\">" + docLinks.join(" ") + "</div>" +
|
|
5280
|
+
"</div>"
|
|
5281
|
+
: "";
|
|
5282
|
+
|
|
4857
5283
|
// Shipment + tracking panel. Renders only when the tracking primitive is
|
|
4858
5284
|
// wired (`can_track`). For each existing shipment: the carrier, status,
|
|
4859
5285
|
// tracking number (linked to the carrier's public URL when known), and
|
|
@@ -4938,6 +5364,7 @@ function renderAdminOrder(opts) {
|
|
|
4938
5364
|
"<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
|
|
4939
5365
|
"<div class=\"order-actions\">" + actions + "</div>" +
|
|
4940
5366
|
"</div>" +
|
|
5367
|
+
documentsPanel +
|
|
4941
5368
|
trackingPanel +
|
|
4942
5369
|
"</section>";
|
|
4943
5370
|
return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.29",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
|
|
6
6
|
"fingerprinted": "css/admin.72c808274ebcefd9.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-fnCp5krUbHLkDXg+zRbl1QWTkjKB4VJuu+gyfkn+7t/fsWqiKildlVjrs00p/bno",
|
|
10
|
+
"fingerprinted": "css/main.f9ddd7f75f5f7d09.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
package/lib/storefront.js
CHANGED
|
@@ -1409,10 +1409,15 @@ var VARIANT_ROW =
|
|
|
1409
1409
|
// keeps a per-row add form, so the same endpoint contract holds.
|
|
1410
1410
|
// `variants` is the pre-formatted array [{ id, sku, title, price }]
|
|
1411
1411
|
// the renderers already build; `escAttr` is the path's HTML escaper.
|
|
1412
|
-
//
|
|
1412
|
+
// `availability` is the resolved `{ in_stock }` shape — when it reports
|
|
1413
|
+
// out of stock the add-to-cart control renders disabled with an honest
|
|
1414
|
+
// message instead of an active button, so the storefront never invites a
|
|
1415
|
+
// purchase the cart-hold path would reject. Mirrored byte-for-byte by
|
|
1416
|
+
// worker/render/product.js#_buildBuyBox.
|
|
1413
1417
|
var BUYBOX_CHIP_LIMIT = 12;
|
|
1414
1418
|
|
|
1415
|
-
function _buildBuyBox(variants, escAttr) {
|
|
1419
|
+
function _buildBuyBox(variants, escAttr, availability) {
|
|
1420
|
+
var inStock = !availability || availability.in_stock !== false;
|
|
1416
1421
|
if (!variants || variants.length === 0) {
|
|
1417
1422
|
return "<div class=\"pdp__variants\">\n" +
|
|
1418
1423
|
" <h2 class=\"pdp__variants-title\">Choose a variant</h2>\n" +
|
|
@@ -1430,10 +1435,27 @@ function _buildBuyBox(variants, escAttr) {
|
|
|
1430
1435
|
" <span class=\"pdp__badge\"><img class=\"pdp__badge-mark\" src=\"/assets/brand/favicon.svg\" alt=\"\" aria-hidden=\"true\" width=\"22\" height=\"22\"> Post-quantum secured checkout · ML-KEM-1024 key agreement · ML-DSA-65 receipt signature.</span>\n" +
|
|
1431
1436
|
" </div>";
|
|
1432
1437
|
|
|
1438
|
+
// Out-of-stock add control: a disabled button + honest message,
|
|
1439
|
+
// reused across all three buy-box shapes so a sold-out product never
|
|
1440
|
+
// offers an active "add to cart" anywhere on the PDP.
|
|
1441
|
+
var soldOutBtn =
|
|
1442
|
+
"<button type=\"submit\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Out of stock</button>\n" +
|
|
1443
|
+
" <p class=\"pdp__soldout-note\" role=\"status\">This item is currently out of stock.</p>";
|
|
1444
|
+
var soldOutRowBtn =
|
|
1445
|
+
"<button type=\"submit\" class=\"btn-primary btn-primary--sm\" disabled aria-disabled=\"true\">Out of stock</button>";
|
|
1446
|
+
|
|
1433
1447
|
// Many variants → keep the compact table (still a per-row add form).
|
|
1434
1448
|
if (variants.length > BUYBOX_CHIP_LIMIT) {
|
|
1435
1449
|
var rows = variants.map(function (v) {
|
|
1436
|
-
|
|
1450
|
+
var row = _render(VARIANT_ROW, { title: v.title, sku: v.sku, price: v.price, variant_id: v.id });
|
|
1451
|
+
// When the product is out of stock, swap each per-row add button for
|
|
1452
|
+
// the disabled control so no row offers an active purchase.
|
|
1453
|
+
if (!inStock) {
|
|
1454
|
+
row = row.replace(
|
|
1455
|
+
"<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Add to cart</button>",
|
|
1456
|
+
soldOutRowBtn);
|
|
1457
|
+
}
|
|
1458
|
+
return row;
|
|
1437
1459
|
}).join("");
|
|
1438
1460
|
return "<div class=\"pdp__variants\">\n" +
|
|
1439
1461
|
" <h2 class=\"pdp__variants-title\">Choose a variant</h2>\n" +
|
|
@@ -1471,13 +1493,17 @@ function _buildBuyBox(variants, escAttr) {
|
|
|
1471
1493
|
" <div class=\"pdp__meta\">" + chips + "</div>\n" +
|
|
1472
1494
|
" </fieldset>";
|
|
1473
1495
|
|
|
1496
|
+
var addControl = inStock
|
|
1497
|
+
? "<button type=\"submit\" class=\"btn-primary cart-page__checkout\">$ add to cart</button>"
|
|
1498
|
+
: soldOutBtn;
|
|
1499
|
+
|
|
1474
1500
|
return "<div class=\"pdp__buybox\">\n" +
|
|
1475
1501
|
" <p class=\"featured-product__price\">" + escAttr(lead.price) + "</p>\n" +
|
|
1476
1502
|
" <form method=\"post\" action=\"/cart/lines\">\n" +
|
|
1477
1503
|
" " + variantBlock + "\n" +
|
|
1478
1504
|
" <label class=\"pdp__variants-title\" for=\"buybox-qty\">Quantity</label>\n" +
|
|
1479
1505
|
" <input id=\"buybox-qty\" type=\"number\" name=\"qty\" value=\"1\" min=\"1\" max=\"99\" class=\"variant-row__qty\" aria-label=\"Quantity\">\n" +
|
|
1480
|
-
"
|
|
1506
|
+
" " + addControl + "\n" +
|
|
1481
1507
|
" </form>\n" +
|
|
1482
1508
|
" </div>\n" +
|
|
1483
1509
|
" " + trustLine;
|
|
@@ -1489,16 +1515,22 @@ function _buildBuyBox(variants, escAttr) {
|
|
|
1489
1515
|
// (the never-block-on-missing-inventory stance the cart-hold path already
|
|
1490
1516
|
// takes). `requires_shipping` is true if ANY variant ships physically —
|
|
1491
1517
|
// an all-digital product (`requires_shipping = 0` on every variant)
|
|
1492
|
-
// suppresses the "Ships in 1–2 business days" line.
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1518
|
+
// suppresses the "Ships in 1–2 business days" line. `low_stock` is the
|
|
1519
|
+
// smallest still-buyable count across the tracked variants when that count
|
|
1520
|
+
// sits at or below the variant's operator-configured `low_stock_threshold`
|
|
1521
|
+
// (null when no tracked variant is low, or no threshold is set) — drives
|
|
1522
|
+
// the honest "Only N left" nudge without hardcoding a global threshold.
|
|
1523
|
+
// Returns a normalised `{ in_stock, requires_shipping, low_stock }` so the
|
|
1524
|
+
// two render paths drive the badge + CTA + JSON-LD from the same shape.
|
|
1525
|
+
// Defensive request-shape reader: missing/garbage inputs resolve to the
|
|
1526
|
+
// available + physical default.
|
|
1496
1527
|
function _resolveAvailability(variants, inventoryBySku) {
|
|
1497
1528
|
variants = Array.isArray(variants) ? variants : [];
|
|
1498
1529
|
var inv = (inventoryBySku && typeof inventoryBySku === "object") ? inventoryBySku : null;
|
|
1499
1530
|
var anyTracked = false;
|
|
1500
1531
|
var anyInStock = false;
|
|
1501
1532
|
var requiresShipping = false;
|
|
1533
|
+
var lowStock = null;
|
|
1502
1534
|
for (var i = 0; i < variants.length; i += 1) {
|
|
1503
1535
|
var v = variants[i] || {};
|
|
1504
1536
|
// requires_shipping defaults to physical (true) unless the column is
|
|
@@ -1512,6 +1544,14 @@ function _resolveAvailability(variants, inventoryBySku) {
|
|
|
1512
1544
|
var row = inv[v.sku];
|
|
1513
1545
|
var available = row ? (Number(row.stock_on_hand) - Number(row.stock_held)) : 0;
|
|
1514
1546
|
if (available > 0) anyInStock = true;
|
|
1547
|
+
// Low-stock nudge: only when this variant is still buyable AND the
|
|
1548
|
+
// operator set a threshold AND the count is at or below it. Track the
|
|
1549
|
+
// smallest such count so a multi-variant product nudges on its
|
|
1550
|
+
// scarcest in-stock variant.
|
|
1551
|
+
var threshold = row ? Number(row.low_stock_threshold) : NaN;
|
|
1552
|
+
if (available > 0 && Number.isFinite(threshold) && available <= threshold) {
|
|
1553
|
+
if (lowStock === null || available < lowStock) lowStock = available;
|
|
1554
|
+
}
|
|
1515
1555
|
}
|
|
1516
1556
|
}
|
|
1517
1557
|
return {
|
|
@@ -1519,6 +1559,9 @@ function _resolveAvailability(variants, inventoryBySku) {
|
|
|
1519
1559
|
// so the product reads as in stock (never-block stance).
|
|
1520
1560
|
in_stock: anyTracked ? anyInStock : true,
|
|
1521
1561
|
requires_shipping: variants.length === 0 ? true : requiresShipping,
|
|
1562
|
+
// Only surface a low-stock count when the product is actually in stock
|
|
1563
|
+
// — an out-of-stock product never shows "Only N left".
|
|
1564
|
+
low_stock: (anyTracked ? anyInStock : true) ? lowStock : null,
|
|
1522
1565
|
};
|
|
1523
1566
|
}
|
|
1524
1567
|
|
|
@@ -1527,9 +1570,17 @@ function _resolveAvailability(variants, inventoryBySku) {
|
|
|
1527
1570
|
// for-byte by worker/render/product.js#_buildAvailability.
|
|
1528
1571
|
function _buildAvailability(availability) {
|
|
1529
1572
|
var a = availability || { in_stock: true, requires_shipping: true };
|
|
1530
|
-
var
|
|
1531
|
-
|
|
1532
|
-
|
|
1573
|
+
var low = Number(a.low_stock);
|
|
1574
|
+
var stockBadge;
|
|
1575
|
+
if (!a.in_stock) {
|
|
1576
|
+
stockBadge = "<span class=\"pdp__badge pdp__badge--out\">Out of stock</span>";
|
|
1577
|
+
} else if (Number.isFinite(low) && low > 0) {
|
|
1578
|
+
// In stock but running low — an honest scarcity nudge driven by the
|
|
1579
|
+
// operator's configured threshold, not a hardcoded number.
|
|
1580
|
+
stockBadge = "<span class=\"pdp__badge pdp__badge--low\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> Only " + low + " left</span>";
|
|
1581
|
+
} else {
|
|
1582
|
+
stockBadge = "<span class=\"pdp__badge pdp__badge--ok\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> In stock</span>";
|
|
1583
|
+
}
|
|
1533
1584
|
// The "Ships in 1–2 business days" line only applies to a physical good;
|
|
1534
1585
|
// an all-digital product suppresses it (nothing ships).
|
|
1535
1586
|
var shipBadge = a.requires_shipping
|
|
@@ -3668,7 +3719,7 @@ function renderProduct(opts) {
|
|
|
3668
3719
|
// loads ({ sku: { stock_on_hand, stock_held } }); absent it, the
|
|
3669
3720
|
// product reads as in stock (never-block-on-missing-inventory stance).
|
|
3670
3721
|
var availability = _resolveAvailability(variants, opts.inventory);
|
|
3671
|
-
var buyboxHtml = _buildBuyBox(rendered, b.template.escapeHtml);
|
|
3722
|
+
var buyboxHtml = _buildBuyBox(rendered, b.template.escapeHtml, availability);
|
|
3672
3723
|
var availabilityHtml = _buildAvailability(availability);
|
|
3673
3724
|
var shippingNoteHtml = _pdpShippingNote(availability);
|
|
3674
3725
|
var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
|
|
@@ -3814,6 +3865,7 @@ var CART_LINE_EDITABLE =
|
|
|
3814
3865
|
" <span class=\"cart-line__product-meta\">\n" +
|
|
3815
3866
|
" <span class=\"cart-line__product-title\">{{product_title}}</span>\n" +
|
|
3816
3867
|
" <code class=\"cart-line__sku-chip\">{{sku}}</code>\n" +
|
|
3868
|
+
"RAW_CART_LINE_STOCK" +
|
|
3817
3869
|
" </span>\n" +
|
|
3818
3870
|
" </a>\n" +
|
|
3819
3871
|
" </td>\n" +
|
|
@@ -3885,10 +3937,12 @@ function _shipToFromBody(body) {
|
|
|
3885
3937
|
|
|
3886
3938
|
// Checkout mirrors the cart's two-column shell: the shipping form on the
|
|
3887
3939
|
// left, a sticky order-summary rail on the right. The summary lists the
|
|
3888
|
-
// cart line items +
|
|
3889
|
-
//
|
|
3890
|
-
//
|
|
3891
|
-
// the
|
|
3940
|
+
// cart line items + a full Subtotal → tax → shipping → discount → Total
|
|
3941
|
+
// breakdown, computed from the same tax/shipping primitives the charge
|
|
3942
|
+
// runs through (estimated against the destination until the shopper
|
|
3943
|
+
// submits an address; exact on the POST re-render of an entered address).
|
|
3944
|
+
// An "Edit cart" link near the summary lets the shopper change quantities
|
|
3945
|
+
// without losing form data.
|
|
3892
3946
|
var CHECKOUT_PAGE =
|
|
3893
3947
|
"<section class=\"checkout-page\">\n" +
|
|
3894
3948
|
" <header class=\"section-head\">\n" +
|
|
@@ -3912,9 +3966,9 @@ var CHECKOUT_PAGE =
|
|
|
3912
3966
|
" </div>\n" +
|
|
3913
3967
|
" <ul class=\"checkout-page__lines\">RAW_SUMMARY_LINES</ul>\n" +
|
|
3914
3968
|
" <dl class=\"totals-list\">\n" +
|
|
3915
|
-
"
|
|
3969
|
+
"RAW_TOTALS_ROWS" +
|
|
3916
3970
|
" </dl>\n" +
|
|
3917
|
-
"
|
|
3971
|
+
"RAW_SUMMARY_NOTE" +
|
|
3918
3972
|
" </aside>\n" +
|
|
3919
3973
|
" </div>\n" +
|
|
3920
3974
|
"</section>\n";
|
|
@@ -3973,13 +4027,25 @@ function renderCheckoutForm(opts) {
|
|
|
3973
4027
|
var shopName = opts.shop_name || "blamejs.shop";
|
|
3974
4028
|
var assetPrefix = opts.asset_prefix || "/assets/";
|
|
3975
4029
|
var lookup = opts.product_lookup || {};
|
|
3976
|
-
|
|
4030
|
+
// Full totals breakdown when the route bundled one (`totals_detail` from
|
|
4031
|
+
// _estimateCartTotals); else a subtotal-only fallback so an un-wired
|
|
4032
|
+
// checkout still renders an honest Subtotal. Checkout formats in the
|
|
4033
|
+
// order's own currency (no display-currency conversion at the pay step).
|
|
4034
|
+
var detail = opts.totals_detail || {
|
|
4035
|
+
totals: totals, estimated: true, tax_resolved: false, shipping_resolved: false, shipping_label: null,
|
|
4036
|
+
};
|
|
4037
|
+
var dTotals = detail.totals || totals;
|
|
4038
|
+
var subtotal = pricing.format(dTotals.subtotal_minor, dTotals.currency);
|
|
4039
|
+
var grandTotal = pricing.format(
|
|
4040
|
+
dTotals.grand_total_minor == null ? dTotals.subtotal_minor : dTotals.grand_total_minor,
|
|
4041
|
+
dTotals.currency);
|
|
3977
4042
|
if (opts.theme) {
|
|
3978
4043
|
return opts.theme.render("checkout", {
|
|
3979
4044
|
title: "Checkout",
|
|
3980
4045
|
shop_name: shopName,
|
|
3981
4046
|
cart_count: lines.length,
|
|
3982
4047
|
subtotal: subtotal,
|
|
4048
|
+
total: grandTotal,
|
|
3983
4049
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
3984
4050
|
});
|
|
3985
4051
|
}
|
|
@@ -3990,13 +4056,21 @@ function renderCheckoutForm(opts) {
|
|
|
3990
4056
|
var summaryLines = lines.map(function (l) {
|
|
3991
4057
|
return _checkoutSummaryLine(l, lookup, assetPrefix, pricing.format);
|
|
3992
4058
|
}).join("");
|
|
4059
|
+
var totalsRows = _buildCartTotalsRows(detail, pricing.format, subtotal, grandTotal);
|
|
4060
|
+
// Honest summary microcopy: an estimate finalizes at the address step;
|
|
4061
|
+
// an entered address reads as the exact total. Mirrors the cart CTA note.
|
|
4062
|
+
var summaryNote = detail.estimated
|
|
4063
|
+
? " <p class=\"cart-page__note\">Estimated tax and shipping for your destination; the exact total is confirmed when you continue. Payment runs through Stripe.</p>\n"
|
|
4064
|
+
: " <p class=\"cart-page__note\">Total includes tax and shipping for the address above. Payment runs through Stripe.</p>\n";
|
|
3993
4065
|
// A coded gift-card / loyalty error from a rejected POST re-renders the
|
|
3994
4066
|
// form with the message inline (role="alert") rather than dead-ending on
|
|
3995
4067
|
// a separate error page — the shopper fixes the bad code in place.
|
|
3996
4068
|
var inlineError = opts.inline_error
|
|
3997
4069
|
? "<p class=\"auth-form__message auth-form__message--err\" role=\"alert\">" + b.template.escapeHtml(String(opts.inline_error)) + "</p>"
|
|
3998
4070
|
: "";
|
|
3999
|
-
var body = _render(CHECKOUT_PAGE, {
|
|
4071
|
+
var body = _render(CHECKOUT_PAGE, {})
|
|
4072
|
+
.replace("RAW_TOTALS_ROWS", totalsRows)
|
|
4073
|
+
.replace("RAW_SUMMARY_NOTE", summaryNote)
|
|
4000
4074
|
.replace("RAW_INLINE_ERROR", inlineError)
|
|
4001
4075
|
.replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill))
|
|
4002
4076
|
.replace("RAW_SUMMARY_LINES", summaryLines);
|
|
@@ -4544,8 +4618,7 @@ var CART_PAGE =
|
|
|
4544
4618
|
" <aside class=\"cart-page__summary\">\n" +
|
|
4545
4619
|
" <h2 class=\"pdp__variants-title\">Order summary</h2>\n" +
|
|
4546
4620
|
" <dl class=\"totals-list\">\n" +
|
|
4547
|
-
"
|
|
4548
|
-
" <div class=\"totals-list__grand\"><dt>Total</dt><dd>{{total}}</dd></div>\n" +
|
|
4621
|
+
"RAW_TOTALS_ROWS" +
|
|
4549
4622
|
" </dl>\n" +
|
|
4550
4623
|
"RAW_CHECKOUT_CTA" +
|
|
4551
4624
|
" </aside>\n" +
|
|
@@ -4618,6 +4691,57 @@ function renderCheckoutError(opts) {
|
|
|
4618
4691
|
});
|
|
4619
4692
|
}
|
|
4620
4693
|
|
|
4694
|
+
// Build the order-summary `<dl>` rows shared by the cart + checkout
|
|
4695
|
+
// totals lists. Renders Subtotal, an optional discount, tax, shipping,
|
|
4696
|
+
// and the grand Total — composing the real tax/shipping/discount figures
|
|
4697
|
+
// the pricing primitive computed for the destination. Each of tax +
|
|
4698
|
+
// shipping is shown in one of two honest states:
|
|
4699
|
+
//
|
|
4700
|
+
// - a real amount, under an "Estimated tax" / "Estimated shipping" /
|
|
4701
|
+
// "Estimated total" label while the destination isn't yet confirmed
|
|
4702
|
+
// by the shopper (so the figure is never read as the final charge);
|
|
4703
|
+
// - "Calculated at checkout" when no rule/zone matched the destination
|
|
4704
|
+
// (a labelled deferral, never a fabricated 0).
|
|
4705
|
+
//
|
|
4706
|
+
// `fmt` is the per-request price formatter (display-currency aware). When
|
|
4707
|
+
// `t.estimated` is false (the checkout POST re-render of an entered
|
|
4708
|
+
// address) the labels drop the "Estimated" prefix and the figures read
|
|
4709
|
+
// as exact. `subtotalStr`/`totalStr` are pre-formatted so the caller
|
|
4710
|
+
// controls display-currency conversion.
|
|
4711
|
+
function _buildCartTotalsRows(t, fmt, subtotalStr, totalStr) {
|
|
4712
|
+
var totals = t.totals;
|
|
4713
|
+
var estimated = !!t.estimated;
|
|
4714
|
+
var rows = " <div><dt>Subtotal</dt><dd>" + subtotalStr + "</dd></div>\n";
|
|
4715
|
+
if (totals.discount_minor > 0) {
|
|
4716
|
+
rows += " <div class=\"totals-list__discount\"><dt>Discount</dt><dd>−" +
|
|
4717
|
+
fmt(totals.discount_minor, totals.currency) + "</dd></div>\n";
|
|
4718
|
+
}
|
|
4719
|
+
// Tax row.
|
|
4720
|
+
var taxLabel = estimated ? "Estimated tax" : "Tax";
|
|
4721
|
+
if (t.tax_resolved) {
|
|
4722
|
+
rows += " <div><dt>" + taxLabel + "</dt><dd>" + fmt(totals.tax_minor, totals.currency) + "</dd></div>\n";
|
|
4723
|
+
} else if (totals.tax_minor > 0) {
|
|
4724
|
+
// A non-zero tax with no jurisdiction match still reflects a real
|
|
4725
|
+
// computed figure — show it under the estimate label.
|
|
4726
|
+
rows += " <div><dt>" + taxLabel + "</dt><dd>" + fmt(totals.tax_minor, totals.currency) + "</dd></div>\n";
|
|
4727
|
+
} else {
|
|
4728
|
+
rows += " <div class=\"totals-list__pending\"><dt>Tax</dt><dd>Calculated at checkout</dd></div>\n";
|
|
4729
|
+
}
|
|
4730
|
+
// Shipping row.
|
|
4731
|
+
var shipLabel = estimated ? "Estimated shipping" : "Shipping";
|
|
4732
|
+
if (t.shipping_resolved) {
|
|
4733
|
+
var shipValue = totals.shipping_minor === 0 ? "Free" : fmt(totals.shipping_minor, totals.currency);
|
|
4734
|
+
rows += " <div><dt>" + shipLabel + "</dt><dd>" + shipValue + "</dd></div>\n";
|
|
4735
|
+
} else {
|
|
4736
|
+
rows += " <div class=\"totals-list__pending\"><dt>Shipping</dt><dd>Calculated at checkout</dd></div>\n";
|
|
4737
|
+
}
|
|
4738
|
+
// Grand total. On an estimate, label it so the figure is never read as
|
|
4739
|
+
// the committed charge before the address step.
|
|
4740
|
+
var totalLabel = estimated ? "Estimated total" : "Total";
|
|
4741
|
+
rows += " <div class=\"totals-list__grand\"><dt>" + totalLabel + "</dt><dd>" + totalStr + "</dd></div>\n";
|
|
4742
|
+
return rows;
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4621
4745
|
function renderCart(opts) {
|
|
4622
4746
|
if (!opts) throw new TypeError("storefront.renderCart: opts required");
|
|
4623
4747
|
var lines = opts.lines || [];
|
|
@@ -4628,6 +4752,10 @@ function renderCart(opts) {
|
|
|
4628
4752
|
// route handler bundles it in. Lines without an entry render with
|
|
4629
4753
|
// a dashed-placeholder tile + the SKU as the fallback title.
|
|
4630
4754
|
var lookup = opts.product_lookup || {};
|
|
4755
|
+
// Per-line stock state map (variant_id → "out" | "low" | "ok"), bundled
|
|
4756
|
+
// by the route handler. Absent for back-compat callers / the edge empty
|
|
4757
|
+
// cart — those lines render with no badge (the never-block stance).
|
|
4758
|
+
var lineStock = opts.line_stock || {};
|
|
4631
4759
|
var fmt = _priceFormatter(opts);
|
|
4632
4760
|
var rendered = lines.map(function (l) {
|
|
4633
4761
|
var match = lookup[l.variant_id] || null;
|
|
@@ -4645,6 +4773,7 @@ function renderCart(opts) {
|
|
|
4645
4773
|
product_url: prod ? ("/products/" + prod.slug) : "#",
|
|
4646
4774
|
image_url: imageUrl,
|
|
4647
4775
|
image_alt: imageAlt,
|
|
4776
|
+
stock: lineStock[l.variant_id] || "ok",
|
|
4648
4777
|
};
|
|
4649
4778
|
});
|
|
4650
4779
|
var subtotal = fmt(totals.subtotal_minor, totals.currency);
|
|
@@ -4678,6 +4807,16 @@ function renderCart(opts) {
|
|
|
4678
4807
|
var saveBtn = canSave
|
|
4679
4808
|
? "<form method=\"post\" action=\"/cart/lines/" + _escAttr(l.id) + "/save\"><button type=\"submit\" class=\"cart-line__btn cart-line__btn--save\">Save for later</button></form>"
|
|
4680
4809
|
: "";
|
|
4810
|
+
// Real per-line availability badge — never a hardcoded "in stock".
|
|
4811
|
+
// Out-of-stock + low-stock surface a status pill so the shopper sees
|
|
4812
|
+
// the truth before they commit; an "ok" line shows nothing (the
|
|
4813
|
+
// implied default). `role="status"` so a screen reader announces it.
|
|
4814
|
+
var stockBadge = "";
|
|
4815
|
+
if (l.stock === "out") {
|
|
4816
|
+
stockBadge = " <span class=\"cart-line__stock cart-line__stock--out\" role=\"status\">Out of stock</span>\n";
|
|
4817
|
+
} else if (l.stock === "low") {
|
|
4818
|
+
stockBadge = " <span class=\"cart-line__stock cart-line__stock--low\" role=\"status\">Low stock</span>\n";
|
|
4819
|
+
}
|
|
4681
4820
|
return _render(CART_LINE_EDITABLE, {
|
|
4682
4821
|
sku: l.sku,
|
|
4683
4822
|
qty: l.qty,
|
|
@@ -4686,27 +4825,55 @@ function renderCart(opts) {
|
|
|
4686
4825
|
line_id: l.id,
|
|
4687
4826
|
product_title: l.product_title,
|
|
4688
4827
|
product_url: l.product_url,
|
|
4689
|
-
}).replace("RAW_CART_LINE_THUMB", thumb).replace("RAW_CART_LINE_SAVE", saveBtn);
|
|
4828
|
+
}).replace("RAW_CART_LINE_THUMB", thumb).replace("RAW_CART_LINE_STOCK", stockBadge).replace("RAW_CART_LINE_SAVE", saveBtn);
|
|
4690
4829
|
}).join("");
|
|
4691
4830
|
// Checkout CTA — only a live button when checkout is actually wired
|
|
4692
4831
|
// (Stripe configured). Absent that, render a clear, disabled "not set
|
|
4693
4832
|
// up" notice instead of a link that 404s. Backward-compatible: callers
|
|
4694
4833
|
// that don't pass the flag (older tests) keep the button.
|
|
4695
4834
|
var checkoutAvailable = opts.checkout_available !== false;
|
|
4835
|
+
// Truthful CTA note. When the route bundled an estimated totals
|
|
4836
|
+
// breakdown, say the figures are an estimate that finalizes once the
|
|
4837
|
+
// shipping address is entered — never the stale "calculated on the next
|
|
4838
|
+
// step" (which implied the cart total was just the subtotal). Without a
|
|
4839
|
+
// breakdown (back-compat caller), keep the prior note.
|
|
4840
|
+
var ctaNote;
|
|
4841
|
+
if (opts.totals_detail && opts.totals_detail.estimated) {
|
|
4842
|
+
ctaNote = " <p class=\"cart-page__note\">Estimated tax and shipping shown above; the exact total is confirmed once you enter your shipping address. Payment runs through Stripe.</p>\n";
|
|
4843
|
+
} else if (opts.totals_detail) {
|
|
4844
|
+
ctaNote = " <p class=\"cart-page__note\">Total includes tax and shipping for your address. Payment runs through Stripe.</p>\n";
|
|
4845
|
+
} else {
|
|
4846
|
+
ctaNote = " <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n";
|
|
4847
|
+
}
|
|
4696
4848
|
var checkoutCta = checkoutAvailable
|
|
4697
4849
|
? " <a href=\"/checkout\" class=\"btn-primary cart-page__checkout\">Continue to checkout <span aria-hidden=\"true\">→</span></a>\n" +
|
|
4698
|
-
|
|
4850
|
+
ctaNote
|
|
4699
4851
|
: " <button type=\"button\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Checkout unavailable</button>\n" +
|
|
4700
4852
|
" <p class=\"cart-page__note cart-page__note--warn\" role=\"status\">Online checkout isn't set up for this store yet — payments aren't configured. Your cart is saved; please check back soon.</p>\n";
|
|
4701
4853
|
// Post-add confirmation banner — rendered only on the `?added=1`
|
|
4702
4854
|
// redirect from POST /cart/lines so the shopper gets explicit feedback
|
|
4703
4855
|
// their item landed (the audit found the silent 303 left no cue).
|
|
4704
4856
|
var notice = opts.added ? CART_ADDED_NOTICE : "";
|
|
4857
|
+
// The order-summary rows. When the route bundled a totals breakdown
|
|
4858
|
+
// (`totals_detail` from _estimateCartTotals), render the full Subtotal
|
|
4859
|
+
// → tax → shipping → discount → Total list with the destination's real
|
|
4860
|
+
// computed figures (estimate-labelled until the address step). Absent
|
|
4861
|
+
// that — a back-compat caller passing only `opts.totals` — fall back to
|
|
4862
|
+
// the bare Subtotal + Total list (byte-identical to the prior shape).
|
|
4863
|
+
var totalsRows;
|
|
4864
|
+
if (opts.totals_detail) {
|
|
4865
|
+
totalsRows = _buildCartTotalsRows(opts.totals_detail, fmt, subtotal, total);
|
|
4866
|
+
} else {
|
|
4867
|
+
totalsRows =
|
|
4868
|
+
" <div><dt>Subtotal</dt><dd>" + subtotal + "</dd></div>\n" +
|
|
4869
|
+
" <div class=\"totals-list__grand\"><dt>Total</dt><dd>" + total + "</dd></div>\n";
|
|
4870
|
+
}
|
|
4705
4871
|
body = _render(CART_PAGE, {
|
|
4706
4872
|
line_rows: "RAW_LINES",
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4873
|
+
}).replace("RAW_LINES", rows)
|
|
4874
|
+
.replace("RAW_TOTALS_ROWS", totalsRows)
|
|
4875
|
+
.replace("RAW_CHECKOUT_CTA", checkoutCta)
|
|
4876
|
+
.replace("RAW_CART_NOTICE", notice);
|
|
4710
4877
|
}
|
|
4711
4878
|
return _wrap(Object.assign({
|
|
4712
4879
|
title: "Cart",
|
|
@@ -6834,6 +7001,177 @@ function mount(router, deps) {
|
|
|
6834
7001
|
return out;
|
|
6835
7002
|
}
|
|
6836
7003
|
|
|
7004
|
+
// Resolve the destination the cart/checkout totals estimate against,
|
|
7005
|
+
// most-specific first: (1) the signed-in customer's default shipping
|
|
7006
|
+
// address, (2) the operator's `shop.estimate_destination` config, (3) a
|
|
7007
|
+
// bare `{ country: "US" }`. Every read is best-effort — a missing
|
|
7008
|
+
// address table, an unmigrated config row, or a malformed saved value
|
|
7009
|
+
// degrades to the next fallback rather than 500-ing the cart. The
|
|
7010
|
+
// returned shape is the `ship_to` the tax/shipping primitives consume,
|
|
7011
|
+
// plus `from_saved` so the renderer can say "estimated for your saved
|
|
7012
|
+
// address" vs "estimated for <country>". A garbage country code from
|
|
7013
|
+
// any source is dropped (→ the US default) so the estimate never throws
|
|
7014
|
+
// inside the primitive's strict validators.
|
|
7015
|
+
async function _estimateDestination(req) {
|
|
7016
|
+
function _normalize(d, fromSaved) {
|
|
7017
|
+
if (!d || typeof d !== "object") return null;
|
|
7018
|
+
var country = typeof d.country === "string" ? d.country.toUpperCase() : "";
|
|
7019
|
+
if (!/^[A-Z]{2}$/.test(country)) return null;
|
|
7020
|
+
var state = (typeof d.state === "string" && /^[A-Za-z0-9]{1,5}$/.test(d.state))
|
|
7021
|
+
? d.state.toUpperCase() : undefined;
|
|
7022
|
+
var postal = (typeof d.postal === "string" && /^[A-Za-z0-9 -]{1,16}$/.test(d.postal))
|
|
7023
|
+
? d.postal : undefined;
|
|
7024
|
+
return { ship_to: { country: country, state: state, postal: postal }, from_saved: !!fromSaved };
|
|
7025
|
+
}
|
|
7026
|
+
// (1) Signed-in customer's default shipping address.
|
|
7027
|
+
var coAuth = _currentCustomerEnv(req);
|
|
7028
|
+
if (deps.addresses && coAuth) {
|
|
7029
|
+
try {
|
|
7030
|
+
var rows = await deps.addresses.listForCustomer(coAuth.customer_id, { limit: 50 });
|
|
7031
|
+
var pick = null;
|
|
7032
|
+
for (var ai = 0; ai < rows.length; ai += 1) {
|
|
7033
|
+
if (Number(rows[ai].is_default_shipping) === 1) { pick = rows[ai]; break; }
|
|
7034
|
+
}
|
|
7035
|
+
if (!pick && rows.length) pick = rows[0];
|
|
7036
|
+
if (pick) {
|
|
7037
|
+
var n = _normalize({
|
|
7038
|
+
country: pick.country,
|
|
7039
|
+
// Saved `region` is free-text ("California"); _normalize only
|
|
7040
|
+
// carries it when it already matches the subdivision-code shape.
|
|
7041
|
+
state: pick.region,
|
|
7042
|
+
postal: pick.postal_code,
|
|
7043
|
+
}, true);
|
|
7044
|
+
if (n) return n;
|
|
7045
|
+
}
|
|
7046
|
+
} catch (_e) { /* fall through to config / default */ }
|
|
7047
|
+
}
|
|
7048
|
+
// (2) Operator-configured default destination.
|
|
7049
|
+
if (deps.config && typeof deps.config.get === "function") {
|
|
7050
|
+
try {
|
|
7051
|
+
var cfg = await deps.config.get("shop.estimate_destination", null);
|
|
7052
|
+
var c2 = _normalize(cfg, false);
|
|
7053
|
+
if (c2) return c2;
|
|
7054
|
+
} catch (_e) { /* fall through to the US default */ }
|
|
7055
|
+
}
|
|
7056
|
+
// (3) Bare US default — the storefront's documented estimate locale.
|
|
7057
|
+
return { ship_to: { country: "US" }, from_saved: false };
|
|
7058
|
+
}
|
|
7059
|
+
|
|
7060
|
+
// Compute the cart/checkout totals the shopper sees BEFORE paying.
|
|
7061
|
+
// Composes the SAME tax + shipping primitives the charge runs through
|
|
7062
|
+
// (via checkout.quote, which prices tax against the post-discount
|
|
7063
|
+
// subtotal + picks shipping rates for the destination), so the
|
|
7064
|
+
// displayed grand total agrees with what Stripe is later asked to
|
|
7065
|
+
// charge for that destination. Returns:
|
|
7066
|
+
//
|
|
7067
|
+
// {
|
|
7068
|
+
// totals, // pricing.totals() breakdown (always present)
|
|
7069
|
+
// estimated, // true when tax/shipping are an estimate
|
|
7070
|
+
// // (destination not yet confirmed by the shopper)
|
|
7071
|
+
// tax_resolved, // a tax rule matched the destination
|
|
7072
|
+
// shipping_resolved, // a shipping service priced the destination
|
|
7073
|
+
// shipping_label, // the chosen estimate service's label, or null
|
|
7074
|
+
// destination, // { ship_to, from_saved } the estimate used
|
|
7075
|
+
// }
|
|
7076
|
+
//
|
|
7077
|
+
// `opts.confirmed` marks a destination the shopper actually entered
|
|
7078
|
+
// (the checkout POST re-render) so the figures read as exact, not an
|
|
7079
|
+
// estimate. Degrades gracefully at every step: no checkout dep, a
|
|
7080
|
+
// quote failure, or no shipping zone match all fall back to a
|
|
7081
|
+
// subtotal-only breakdown with tax/shipping flagged unresolved — the
|
|
7082
|
+
// subtotal is always honest, and the renderer labels the rest
|
|
7083
|
+
// "calculated at checkout" rather than fabricating a number.
|
|
7084
|
+
async function _estimateCartTotals(req, c, lines, opts) {
|
|
7085
|
+
opts = opts || {};
|
|
7086
|
+
var base = pricing.totals(c, lines, {}); // subtotal-only, always valid
|
|
7087
|
+
var result = {
|
|
7088
|
+
totals: base,
|
|
7089
|
+
estimated: !opts.confirmed,
|
|
7090
|
+
tax_resolved: false,
|
|
7091
|
+
shipping_resolved: false,
|
|
7092
|
+
shipping_label: null,
|
|
7093
|
+
destination: null,
|
|
7094
|
+
};
|
|
7095
|
+
if (!deps.checkout || typeof deps.checkout.quote !== "function") return result;
|
|
7096
|
+
var dest = opts.ship_to
|
|
7097
|
+
? { ship_to: opts.ship_to, from_saved: false }
|
|
7098
|
+
: await _estimateDestination(req);
|
|
7099
|
+
result.destination = dest;
|
|
7100
|
+
try {
|
|
7101
|
+
// quote() without a selected_shipping_id returns the tax row + ALL
|
|
7102
|
+
// available shipping services without throwing on selection; we pick
|
|
7103
|
+
// the cheapest as the estimate so the shopper sees the lowest real
|
|
7104
|
+
// shipping figure for the destination (they choose the exact service
|
|
7105
|
+
// at the address step).
|
|
7106
|
+
var quote = await deps.checkout.quote({
|
|
7107
|
+
cart_id: c.id,
|
|
7108
|
+
ship_to: dest.ship_to,
|
|
7109
|
+
});
|
|
7110
|
+
var taxMinor = quote.totals.tax_minor;
|
|
7111
|
+
result.tax_resolved = quote.tax_rate_bps > 0 ||
|
|
7112
|
+
(quote.tax_jurisdiction && quote.tax_jurisdiction !== "fallback");
|
|
7113
|
+
var rates = Array.isArray(quote.shipping_rates) ? quote.shipping_rates : [];
|
|
7114
|
+
var cheapest = null;
|
|
7115
|
+
for (var i = 0; i < rates.length; i += 1) {
|
|
7116
|
+
if (cheapest === null || rates[i].amount_minor < cheapest.amount_minor) cheapest = rates[i];
|
|
7117
|
+
}
|
|
7118
|
+
var shippingMinor = 0;
|
|
7119
|
+
if (cheapest) {
|
|
7120
|
+
shippingMinor = cheapest.amount_minor;
|
|
7121
|
+
result.shipping_resolved = true;
|
|
7122
|
+
result.shipping_label = cheapest.label;
|
|
7123
|
+
}
|
|
7124
|
+
result.totals = pricing.totals(c, lines, {
|
|
7125
|
+
tax_minor: taxMinor,
|
|
7126
|
+
shipping_minor: shippingMinor,
|
|
7127
|
+
discount_minor: quote.totals.discount_minor || 0,
|
|
7128
|
+
});
|
|
7129
|
+
} catch (_e) {
|
|
7130
|
+
// Quote failed (cart not active, primitive error) — keep the honest
|
|
7131
|
+
// subtotal-only breakdown; the renderer shows "calculated at
|
|
7132
|
+
// checkout" for the unresolved lines.
|
|
7133
|
+
return result;
|
|
7134
|
+
}
|
|
7135
|
+
return result;
|
|
7136
|
+
}
|
|
7137
|
+
|
|
7138
|
+
// Per-line stock truth for the cart: maps each line's variant SKU to its
|
|
7139
|
+
// availability state so the cart can say "Low stock" / "Out of stock"
|
|
7140
|
+
// instead of an implied always-buyable. Best-effort — a SKU with no
|
|
7141
|
+
// inventory row (or a read failure) is treated as available, matching the
|
|
7142
|
+
// storefront's never-block-on-missing-inventory stance. Returns a map
|
|
7143
|
+
// keyed by variant_id → "out" | "low" | "ok". Low is the operator's
|
|
7144
|
+
// configured low-stock threshold (`shop.low_stock_threshold`, default 5)
|
|
7145
|
+
// applied to available-on-hand (stock_on_hand − stock_held).
|
|
7146
|
+
async function _cartLineStock(lines) {
|
|
7147
|
+
var out = {};
|
|
7148
|
+
if (!deps.catalog || !deps.catalog.inventory || typeof deps.catalog.inventory.get !== "function") {
|
|
7149
|
+
return out;
|
|
7150
|
+
}
|
|
7151
|
+
var threshold = 5;
|
|
7152
|
+
if (deps.config && typeof deps.config.get === "function") {
|
|
7153
|
+
try {
|
|
7154
|
+
var t = await deps.config.get("shop.low_stock_threshold", 5);
|
|
7155
|
+
if (Number.isInteger(t) && t >= 0) threshold = t;
|
|
7156
|
+
} catch (_e) { /* keep the default */ }
|
|
7157
|
+
}
|
|
7158
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
7159
|
+
var vId = lines[i].variant_id;
|
|
7160
|
+
if (Object.prototype.hasOwnProperty.call(out, vId)) continue;
|
|
7161
|
+
var sku = lines[i].sku;
|
|
7162
|
+
if (!sku) { out[vId] = "ok"; continue; }
|
|
7163
|
+
try {
|
|
7164
|
+
var inv = await deps.catalog.inventory.get(sku);
|
|
7165
|
+
if (!inv) { out[vId] = "ok"; continue; }
|
|
7166
|
+
var avail = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
|
|
7167
|
+
out[vId] = avail <= 0 ? "out" : (avail <= threshold ? "low" : "ok");
|
|
7168
|
+
} catch (_e) {
|
|
7169
|
+
out[vId] = "ok"; // drop-silent — a read failure never blocks the cart
|
|
7170
|
+
}
|
|
7171
|
+
}
|
|
7172
|
+
return out;
|
|
7173
|
+
}
|
|
7174
|
+
|
|
6837
7175
|
// POST /currency — set (or clear) the visitor's display-currency choice.
|
|
6838
7176
|
// Registered only when multi-currency display is wired. Display-only:
|
|
6839
7177
|
// this NEVER touches the cart / order / payment currency — it sets the
|
|
@@ -7506,7 +7844,16 @@ function mount(router, deps) {
|
|
|
7506
7844
|
// Recomputed every render (idempotent); the stored snapshot is never
|
|
7507
7845
|
// mutated, so changing a line's quantity re-prices it automatically.
|
|
7508
7846
|
var lines = await _repriceCartLines(rawLines);
|
|
7509
|
-
|
|
7847
|
+
// Real total before pay: compose the same tax + shipping primitives the
|
|
7848
|
+
// charge runs through (estimated against the shopper's saved/default
|
|
7849
|
+
// destination until they confirm an address at checkout). Falls back to
|
|
7850
|
+
// a subtotal-only breakdown — with tax/shipping labelled "calculated at
|
|
7851
|
+
// checkout" — when checkout isn't wired or no zone matches.
|
|
7852
|
+
var totalsDetail = await _estimateCartTotals(req, c, lines, {});
|
|
7853
|
+
var totals = totalsDetail.totals;
|
|
7854
|
+
// Truthful per-line stock state (out / low / ok) so the cart never
|
|
7855
|
+
// implies a sold-out line is buyable.
|
|
7856
|
+
var lineStock = await _cartLineStock(lines);
|
|
7510
7857
|
// Build the variant_id → { product, hero_media } lookup the
|
|
7511
7858
|
// renderer uses to decorate each line with a thumbnail + title.
|
|
7512
7859
|
// Cache by variant_id so a cart with the same variant twice
|
|
@@ -7527,6 +7874,8 @@ function mount(router, deps) {
|
|
|
7527
7874
|
_send(res, 200, renderCart(Object.assign({
|
|
7528
7875
|
lines: lines,
|
|
7529
7876
|
totals: totals,
|
|
7877
|
+
totals_detail: totalsDetail,
|
|
7878
|
+
line_stock: lineStock,
|
|
7530
7879
|
product_lookup: productLookup,
|
|
7531
7880
|
can_save: !!(deps.saveForLater && deps.customers),
|
|
7532
7881
|
checkout_available: !!(deps.checkout && deps.order),
|
|
@@ -7578,8 +7927,18 @@ function mount(router, deps) {
|
|
|
7578
7927
|
// catch (so a rejected gift-card / loyalty code re-renders the same
|
|
7579
7928
|
// form inline instead of dead-ending). `inlineError` is the optional
|
|
7580
7929
|
// message to surface at the top of the form.
|
|
7581
|
-
async function _checkoutRenderOpts(req, c, lines, inlineError) {
|
|
7582
|
-
|
|
7930
|
+
async function _checkoutRenderOpts(req, c, lines, inlineError, confirmedShipTo) {
|
|
7931
|
+
// Full totals breakdown the summary renders. When the shopper has
|
|
7932
|
+
// entered an address (the POST re-render passes `confirmedShipTo`),
|
|
7933
|
+
// compute the EXACT total against it (estimated:false); otherwise
|
|
7934
|
+
// estimate against the saved/default destination so the summary
|
|
7935
|
+
// still shows a real Subtotal → tax → shipping → Total rather than a
|
|
7936
|
+
// bare subtotal. Composes the same tax/shipping primitives the charge
|
|
7937
|
+
// runs through (via checkout.quote inside _estimateCartTotals).
|
|
7938
|
+
var totalsDetail = await _estimateCartTotals(req, c, lines, confirmedShipTo
|
|
7939
|
+
? { ship_to: confirmedShipTo, confirmed: true }
|
|
7940
|
+
: {});
|
|
7941
|
+
var totals = totalsDetail.totals;
|
|
7583
7942
|
// variant_id → { product, hero_media } lookup for the summary
|
|
7584
7943
|
// thumbnails + titles — same shape (and caching) the cart route uses.
|
|
7585
7944
|
var checkoutLookup = {};
|
|
@@ -7630,7 +7989,8 @@ function mount(router, deps) {
|
|
|
7630
7989
|
} catch (_e) { prefill = null; }
|
|
7631
7990
|
}
|
|
7632
7991
|
return {
|
|
7633
|
-
lines: lines, totals: totals,
|
|
7992
|
+
lines: lines, totals: totals, totals_detail: totalsDetail,
|
|
7993
|
+
shop_name: shopName, theme: theme,
|
|
7634
7994
|
product_lookup: checkoutLookup,
|
|
7635
7995
|
paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
|
|
7636
7996
|
loyalty_balance: loyaltyBalance,
|
|
@@ -7739,7 +8099,11 @@ function mount(router, deps) {
|
|
|
7739
8099
|
try {
|
|
7740
8100
|
var coLines = await _repriceCartLines(await deps.cart.listLines(c.id));
|
|
7741
8101
|
if (coLines.length) {
|
|
7742
|
-
|
|
8102
|
+
// The shopper already entered an address on this POST — re-price
|
|
8103
|
+
// the summary against it (exact, not estimated) so the inline
|
|
8104
|
+
// re-render shows the same total the confirm path computed.
|
|
8105
|
+
var confirmedTo = (shipTo && /^[A-Z]{2}$/.test(shipTo.country)) ? shipTo : null;
|
|
8106
|
+
return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, coLines, msg, confirmedTo)));
|
|
7743
8107
|
}
|
|
7744
8108
|
} catch (_re) { /* fall through to the styled error page */ }
|
|
7745
8109
|
}
|
package/package.json
CHANGED