@blamejs/blamejs-shop 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/admin.js +143 -1
- package/lib/asset-manifest.json +3 -3
- package/lib/storefront.js +127 -4
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/middleware/body-parser.js +95 -49
- package/lib/vendor/blamejs/lib/template.js +159 -23
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs/lib/vendor/simplewebauthn-server.cjs +31 -28
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.17.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.18.json +18 -0
- package/lib/vendor/blamejs/test/00-primitives.js +144 -3
- 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.6 (2026-05-27) — **Business-hours page, a graceful unconfigured-checkout state, and the brand shield on the product page.** Three storefront changes. Operators can publish their open hours: define schedules (timezone + per-weekday open/close) from a new Hours screen in the admin console, and a public /hours page renders each as a weekly grid with a live open/closed status computed per request in the schedule's timezone. The cart now degrades gracefully when no payment provider is configured — instead of a checkout button that leads to a dead route, it shows a clear, disabled 'checkout isn't set up yet' notice, and a direct visit to /checkout returns a tidy 'unavailable' page rather than a 404. And the product page's post-quantum-checkout trust badge now uses the real brand shield mark (the site favicon) in place of a CSS approximation. **Added:** *Public /hours page* — A server-rendered page listing every active schedule as a weekday grid (closed days marked), with a live open/closed pill and the next transition ("Opens Monday at 09:00" / "Closes at 17:00"). Timezone-aware and computed per request, so it isn't cached stale. · *Admin console: Hours* — A new Hours screen (and matching JSON API) to create a schedule with a timezone and per-weekday open/close times (a blank day is closed), list schedules, and archive ones no longer in use. Holiday closures and one-off date exceptions — which override the weekly base — are available through the API. The screen appears only when the business-hours primitive is wired. **Changed:** *Cart and /checkout degrade gracefully without a payment provider* — When checkout isn't configured (no payment provider), the cart no longer shows a checkout button that leads nowhere — it shows a disabled control with a clear note that online checkout isn't set up yet and the cart is saved. A direct visit to /checkout now returns a tidy 503 'checkout unavailable' page instead of a 404. Configuring a payment provider restores the full cart → shipping → payment → confirmation flow with no other change. · *Product-page trust badge uses the brand shield* — The post-quantum-secured-checkout badge on the product page now renders the actual brand shield mark (the site icon) rather than a CSS-drawn hex approximation, so the badge matches the brand identity used elsewhere.
|
|
12
|
+
|
|
11
13
|
- v0.2.5 (2026-05-27) — **Customer surveys — token-gated NPS / CSAT / CES feedback with an admin rollup.** Operators can now collect structured post-experience feedback. Define a survey (NPS, CSAT, CES, or a custom open-ended prompt) from a new Surveys screen in the admin console, then issue a single-use invitation for a customer — the console shows the survey link once for you to send. The customer opens the link and answers without signing in; the token itself is the access. Responses roll up on the survey's detail page with the kind-appropriate score (NPS index, CSAT positive-rate, CES mean) plus a per-question breakdown. Surveys can be archived when a campaign ends; existing invitation links keep working. **Added:** *Token-gated survey page* — A `/survey/:token` page renders the survey's questions — a rating scale, single-select options, or a free-text box — and accepts one response per invitation. The single-use token is the access (no login), and the page degrades cleanly to a clear notice when a link is already used, expired, closed, or invalid. The required-answer check re-renders the form with a message rather than losing the visitor's work. · *Admin console: Surveys* — A new Surveys screen (and matching JSON API) to create a survey for the chosen kind (the console seeds the standard question set; fully custom question lists go through the API), issue a single-use invitation for a customer id (the link is revealed exactly once), read the response rollup with the kind's headline score and per-question results, and archive finished surveys. The screen appears only when the surveys primitive is wired.
|
|
12
14
|
|
|
13
15
|
- v0.2.4 (2026-05-27) — **Announcement bar — publish a sitewide promo or notice strip from the admin console.** Operators can now publish a sitewide announcement bar — a strip at the very top of every storefront page for a sale, a shipping notice, or an urgent alert. Manage it from a new Announcements screen in the admin console: set the message, an optional call-to-action link, a theme (urgency, promo, info, or success), the audience (everyone, signed-out visitors, or signed-in customers), an optional start/expiry schedule, and whether visitors can dismiss it. The highest-priority active announcement shows automatically (urgency outranks promo outranks info outranks success). The bar renders on both the edge and container paths so it appears on every page including the cached ones, and a dismissed bar stays hidden across navigation. Until an announcement is published, nothing changes. **Added:** *Sitewide announcement bar* — A page-top strip rendered on every storefront page (home, search, product, cart, blog, and the storefront chrome) in one of four themes. The single highest-priority active announcement for the viewer is shown, chosen by theme rank (urgency > promo > info > success). Audience targeting covers everyone, signed-out visitors, and signed-in customers, with an optional scheduled start and expiry. An optional call-to-action link is included; its URL is restricted to https:// or a site-relative path. · *Admin console: Announcements* — A new Announcements screen (and matching JSON API) to create announcements, filter the list to those active right now, and archive ones that are done. The screen appears in the console only when the announcement primitive is wired. · *Dismiss without a round-trip* — Dismissible announcements carry a close control. With JavaScript it's hidden in place and remembered in a local cookie so it doesn't reappear as you browse; with JavaScript off, a form submission records the dismissal and returns you to the page. The bar is always server-rendered identically for everyone, so a dismissed bar never leaks back in from a cached page — it's hidden per-visitor on the client.
|
package/lib/admin.js
CHANGED
|
@@ -300,7 +300,7 @@ function mount(router, deps) {
|
|
|
300
300
|
// Which optional console sections are wired — gates their nav links so a
|
|
301
301
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
302
302
|
// into every authed render call as `nav_available`.
|
|
303
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys };
|
|
303
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours };
|
|
304
304
|
|
|
305
305
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
306
306
|
|
|
@@ -2296,6 +2296,73 @@ function mount(router, deps) {
|
|
|
2296
2296
|
));
|
|
2297
2297
|
}
|
|
2298
2298
|
|
|
2299
|
+
// ---- business hours -------------------------------------------------
|
|
2300
|
+
// Define open/close schedules surfaced on the public /hours page. The
|
|
2301
|
+
// console create form takes a per-weekday open/close pair (blank = closed
|
|
2302
|
+
// that day); holidays + one-off exceptions are managed via the JSON API.
|
|
2303
|
+
// Content-negotiated like the other screens.
|
|
2304
|
+
if (deps.businessHours) {
|
|
2305
|
+
var hours = deps.businessHours;
|
|
2306
|
+
|
|
2307
|
+
router.get("/admin/hours", _pageOrApi(true,
|
|
2308
|
+
R(async function (req, res) {
|
|
2309
|
+
_json(res, 200, { rows: await hours.listSchedules() });
|
|
2310
|
+
}),
|
|
2311
|
+
async function (req, res) {
|
|
2312
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
2313
|
+
var rows = await hours.listSchedules();
|
|
2314
|
+
_sendHtml(res, 200, renderAdminHours({
|
|
2315
|
+
shop_name: deps.shop_name, nav_available: navAvailable, schedules: rows,
|
|
2316
|
+
created: url && url.searchParams.get("created"),
|
|
2317
|
+
archived: url && url.searchParams.get("archived"),
|
|
2318
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the schedule." : null,
|
|
2319
|
+
}));
|
|
2320
|
+
},
|
|
2321
|
+
));
|
|
2322
|
+
|
|
2323
|
+
router.post("/admin/hours", _pageOrApi(false,
|
|
2324
|
+
W("hours.define", async function (req, res) {
|
|
2325
|
+
var row = await hours.defineSchedule(req.body || {});
|
|
2326
|
+
_json(res, 201, row);
|
|
2327
|
+
return { id: row.slug };
|
|
2328
|
+
}),
|
|
2329
|
+
async function (req, res) {
|
|
2330
|
+
try {
|
|
2331
|
+
await hours.defineSchedule(_scheduleFromForm(req.body || {}));
|
|
2332
|
+
} catch (e) {
|
|
2333
|
+
if (!(e instanceof TypeError)) throw e;
|
|
2334
|
+
var rows = await hours.listSchedules();
|
|
2335
|
+
return _sendHtml(res, 400, renderAdminHours({
|
|
2336
|
+
shop_name: deps.shop_name, nav_available: navAvailable, schedules: rows,
|
|
2337
|
+
notice: (e.message || "Couldn't save that schedule.").replace(/^businessHours[.:]\s*/, ""),
|
|
2338
|
+
}));
|
|
2339
|
+
}
|
|
2340
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".hours.define", outcome: "success" });
|
|
2341
|
+
_redirect(res, "/admin/hours?created=1");
|
|
2342
|
+
},
|
|
2343
|
+
));
|
|
2344
|
+
|
|
2345
|
+
router.post("/admin/hours/:slug/archive", _pageOrApi(false,
|
|
2346
|
+
W("hours.archive", async function (req, res) {
|
|
2347
|
+
var row;
|
|
2348
|
+
try { row = await hours.archiveSchedule(req.params.slug); }
|
|
2349
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
2350
|
+
if (!row) return _problem(res, 404, "schedule-not-found");
|
|
2351
|
+
_json(res, 200, row);
|
|
2352
|
+
return { id: row.slug };
|
|
2353
|
+
}),
|
|
2354
|
+
async function (req, res) {
|
|
2355
|
+
var slug = req.params.slug;
|
|
2356
|
+
var row = null;
|
|
2357
|
+
try { row = await hours.archiveSchedule(slug); }
|
|
2358
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
2359
|
+
if (!row) return _redirect(res, "/admin/hours?err=1");
|
|
2360
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".hours.archive", outcome: "success", metadata: { slug: slug } });
|
|
2361
|
+
_redirect(res, "/admin/hours?archived=1");
|
|
2362
|
+
},
|
|
2363
|
+
));
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2299
2366
|
// ---- analytics ------------------------------------------------------
|
|
2300
2367
|
|
|
2301
2368
|
var analytics = deps.analytics || null;
|
|
@@ -2972,6 +3039,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
2972
3039
|
{ key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
|
|
2973
3040
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
2974
3041
|
{ key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
|
|
3042
|
+
{ key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
|
|
2975
3043
|
{ key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
|
|
2976
3044
|
{ key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
|
|
2977
3045
|
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
@@ -4345,6 +4413,80 @@ function renderAdminSurveyDetail(opts) {
|
|
|
4345
4413
|
return _renderAdminShell(opts.shop_name, survey.title, bodyHtml, "surveys", opts.nav_available);
|
|
4346
4414
|
}
|
|
4347
4415
|
|
|
4416
|
+
// Build a defineSchedule input from the per-weekday open/close form fields
|
|
4417
|
+
// (d0_open/d0_close .. d6_open/d6_close). A day with both a start and end
|
|
4418
|
+
// time becomes a weekly_hours entry; a blank day is closed. Sun=0.
|
|
4419
|
+
function _scheduleFromForm(body) {
|
|
4420
|
+
body = body || {};
|
|
4421
|
+
var weekly = [];
|
|
4422
|
+
for (var d = 0; d < 7; d += 1) {
|
|
4423
|
+
var open = body["d" + d + "_open"];
|
|
4424
|
+
var close = body["d" + d + "_close"];
|
|
4425
|
+
if (typeof open === "string" && open.trim() && typeof close === "string" && close.trim()) {
|
|
4426
|
+
weekly.push({ day: d, open: open.trim(), close: close.trim() });
|
|
4427
|
+
}
|
|
4428
|
+
}
|
|
4429
|
+
return {
|
|
4430
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
4431
|
+
timezone: body.timezone,
|
|
4432
|
+
weekly_hours: weekly,
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
function renderAdminHours(opts) {
|
|
4437
|
+
opts = opts || {};
|
|
4438
|
+
var rows = opts.schedules || [];
|
|
4439
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Schedule saved.</div>" : "";
|
|
4440
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Schedule archived.</div>" : "";
|
|
4441
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
4442
|
+
var DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
4443
|
+
|
|
4444
|
+
var bodyRows = rows.map(function (s) {
|
|
4445
|
+
var isArchived = s.archived_at != null;
|
|
4446
|
+
var enc = _htmlEscape(encodeURIComponent(s.slug));
|
|
4447
|
+
var openDays = ((s.weekly_hours || []).reduce(function (set, w) { set[w.day] = 1; return set; }, {}));
|
|
4448
|
+
var dayCount = Object.keys(openDays).length;
|
|
4449
|
+
return "<tr>" +
|
|
4450
|
+
"<td><code class=\"order-id\">" + _htmlEscape(s.slug) + "</code></td>" +
|
|
4451
|
+
"<td>" + _htmlEscape(s.timezone) + "</td>" +
|
|
4452
|
+
"<td class=\"num\">" + dayCount + " open day(s)</td>" +
|
|
4453
|
+
"<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
|
|
4454
|
+
"<td><div class=\"actions-row\">" +
|
|
4455
|
+
(isArchived ? "" :
|
|
4456
|
+
"<form method=\"post\" action=\"/admin/hours/" + enc + "/archive\" class=\"form-inline\">" +
|
|
4457
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
|
|
4458
|
+
"</div></td>" +
|
|
4459
|
+
"</tr>";
|
|
4460
|
+
}).join("");
|
|
4461
|
+
|
|
4462
|
+
var table = rows.length
|
|
4463
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Schedule</th><th scope=\"col\">Timezone</th><th scope=\"col\" class=\"num\">Days</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
4464
|
+
: "<p class=\"empty\">No schedules yet.</p>";
|
|
4465
|
+
|
|
4466
|
+
var dayFields = DOW.map(function (label, d) {
|
|
4467
|
+
return "<div class=\"actions-row\">" +
|
|
4468
|
+
"<span class=\"u-mute\">" + label + "</span>" +
|
|
4469
|
+
"<input type=\"time\" name=\"d" + d + "_open\" aria-label=\"" + label + " open\">" +
|
|
4470
|
+
"<input type=\"time\" name=\"d" + d + "_close\" aria-label=\"" + label + " close\">" +
|
|
4471
|
+
"</div>";
|
|
4472
|
+
}).join("");
|
|
4473
|
+
|
|
4474
|
+
var createForm =
|
|
4475
|
+
"<div class=\"panel mt mw-42\">" +
|
|
4476
|
+
"<h3 class=\"subhead\">Create a schedule</h3>" +
|
|
4477
|
+
"<p class=\"meta\">Set an open + close time per weekday (leave a day blank to mark it closed). Times are in the schedule's timezone. Holidays + one-off exceptions are managed via the API.</p>" +
|
|
4478
|
+
"<form method=\"post\" action=\"/admin/hours\">" +
|
|
4479
|
+
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — e.g. support or store.", " maxlength=\"64\" required") +
|
|
4480
|
+
_setupField("Timezone", "timezone", "", "text", "IANA name, e.g. America/New_York.", " maxlength=\"64\" required") +
|
|
4481
|
+
"<fieldset class=\"box\"><legend class=\"legend-sm\">Weekly hours (open / close)</legend>" + dayFields + "</fieldset>" +
|
|
4482
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create schedule</button></div>" +
|
|
4483
|
+
"</form>" +
|
|
4484
|
+
"</div>";
|
|
4485
|
+
|
|
4486
|
+
var bodyHtml = "<section><h2>Business hours</h2>" + created + archived + notice + table + createForm + "</section>";
|
|
4487
|
+
return _renderAdminShell(opts.shop_name, "Hours", bodyHtml, "hours", opts.nav_available);
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4348
4490
|
function renderAdminCollection(opts) {
|
|
4349
4491
|
opts = opts || {};
|
|
4350
4492
|
var col = opts.collection;
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.6",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-FgqvXcZygYkJjjOtXAeu9xi6AtYqkchPnJKcS0OeHm8lynhd9r4RfdpoT7P7j7/l",
|
|
6
6
|
"fingerprinted": "css/admin.afd7b964c1f6fe1b.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-rjXOXDbgXWPBR/7YqM07j7oAN9Gx9SIY31GUdViVAFG/Tw4HD519zbVvXmYEpUrz",
|
|
10
|
+
"fingerprinted": "css/main.6825424436cea4c0.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
package/lib/storefront.js
CHANGED
|
@@ -1274,7 +1274,7 @@ function _buildBuyBox(variants, escAttr) {
|
|
|
1274
1274
|
|
|
1275
1275
|
var trustLine =
|
|
1276
1276
|
"<div class=\"pdp__meta\">\n" +
|
|
1277
|
-
" <span class=\"pdp__badge\"><
|
|
1277
|
+
" <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" +
|
|
1278
1278
|
" </div>";
|
|
1279
1279
|
|
|
1280
1280
|
// Many variants → keep the compact table (still a per-row add form).
|
|
@@ -3788,8 +3788,7 @@ var CART_PAGE =
|
|
|
3788
3788
|
" <div><dt>Subtotal</dt><dd>{{subtotal}}</dd></div>\n" +
|
|
3789
3789
|
" <div class=\"totals-list__grand\"><dt>Total</dt><dd>{{total}}</dd></div>\n" +
|
|
3790
3790
|
" </dl>\n" +
|
|
3791
|
-
"
|
|
3792
|
-
" <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n" +
|
|
3791
|
+
"RAW_CHECKOUT_CTA" +
|
|
3793
3792
|
" </aside>\n" +
|
|
3794
3793
|
" </div>\n" +
|
|
3795
3794
|
"</section>\n";
|
|
@@ -3886,11 +3885,21 @@ function renderCart(opts) {
|
|
|
3886
3885
|
product_url: l.product_url,
|
|
3887
3886
|
}).replace("RAW_CART_LINE_THUMB", thumb).replace("RAW_CART_LINE_SAVE", saveBtn);
|
|
3888
3887
|
}).join("");
|
|
3888
|
+
// Checkout CTA — only a live button when checkout is actually wired
|
|
3889
|
+
// (Stripe configured). Absent that, render a clear, disabled "not set
|
|
3890
|
+
// up" notice instead of a link that 404s. Backward-compatible: callers
|
|
3891
|
+
// that don't pass the flag (older tests) keep the button.
|
|
3892
|
+
var checkoutAvailable = opts.checkout_available !== false;
|
|
3893
|
+
var checkoutCta = checkoutAvailable
|
|
3894
|
+
? " <a href=\"/checkout\" class=\"btn-primary cart-page__checkout\">Continue to checkout <span aria-hidden=\"true\">→</span></a>\n" +
|
|
3895
|
+
" <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n"
|
|
3896
|
+
: " <button type=\"button\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Checkout unavailable</button>\n" +
|
|
3897
|
+
" <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";
|
|
3889
3898
|
body = _render(CART_PAGE, {
|
|
3890
3899
|
line_rows: "RAW_LINES",
|
|
3891
3900
|
subtotal: subtotal,
|
|
3892
3901
|
total: total,
|
|
3893
|
-
}).replace("RAW_LINES", rows);
|
|
3902
|
+
}).replace("RAW_LINES", rows).replace("RAW_CHECKOUT_CTA", checkoutCta);
|
|
3894
3903
|
}
|
|
3895
3904
|
return _wrap(Object.assign({
|
|
3896
3905
|
title: "Cart",
|
|
@@ -4735,6 +4744,67 @@ function renderSurveyPage(opts) {
|
|
|
4735
4744
|
});
|
|
4736
4745
|
}
|
|
4737
4746
|
|
|
4747
|
+
// ---- business-hours page -----------------------------------------------
|
|
4748
|
+
|
|
4749
|
+
var _DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
4750
|
+
|
|
4751
|
+
// The public /hours page. `opts.schedules` is an array of resolved
|
|
4752
|
+
// schedules: { summary: { slug, timezone, days[7] }, is_open, next_open,
|
|
4753
|
+
// next_close }. Renders each as a card with a live open/closed pill, the
|
|
4754
|
+
// next transition, and the weekly grid. An empty list renders a friendly
|
|
4755
|
+
// "not published yet" notice.
|
|
4756
|
+
function renderHoursPage(opts) {
|
|
4757
|
+
opts = opts || {};
|
|
4758
|
+
var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
|
|
4759
|
+
var schedules = opts.schedules || [];
|
|
4760
|
+
var body;
|
|
4761
|
+
if (!schedules.length) {
|
|
4762
|
+
body =
|
|
4763
|
+
"<section class=\"hours-page\"><div class=\"hours-page__inner hours-page__inner--msg\">" +
|
|
4764
|
+
"<h1 class=\"hours-page__title\">Hours</h1>" +
|
|
4765
|
+
"<p class=\"hours-page__lede\">Our hours haven't been published yet.</p>" +
|
|
4766
|
+
"<a class=\"btn-ghost\" href=\"/\">Back to the shop</a>" +
|
|
4767
|
+
"</div></section>";
|
|
4768
|
+
} else {
|
|
4769
|
+
var cards = schedules.map(function (s) {
|
|
4770
|
+
var pill = s.is_open
|
|
4771
|
+
? "<span class=\"hours-status hours-status--open\">Open now</span>"
|
|
4772
|
+
: "<span class=\"hours-status hours-status--closed\">Closed</span>";
|
|
4773
|
+
var next = "";
|
|
4774
|
+
if (s.is_open && s.next_close) next = "Closes at " + esc(s.next_close.close);
|
|
4775
|
+
else if (!s.is_open && s.next_open) next = "Opens " + esc(s.next_open.date) + " at " + esc(s.next_open.open);
|
|
4776
|
+
var rows = "";
|
|
4777
|
+
var days = (s.summary && s.summary.days) || [];
|
|
4778
|
+
for (var d = 0; d < 7; d += 1) {
|
|
4779
|
+
var wins = days[d] || [];
|
|
4780
|
+
var hrs = wins.length
|
|
4781
|
+
? wins.map(function (w) { return esc(w.open) + "–" + esc(w.close); }).join(", ")
|
|
4782
|
+
: "<span class=\"hours-table__closed\">Closed</span>";
|
|
4783
|
+
rows += "<tr><th scope=\"row\">" + _DOW_NAMES[d] + "</th><td>" + hrs + "</td></tr>";
|
|
4784
|
+
}
|
|
4785
|
+
return "<div class=\"hours-card\">" +
|
|
4786
|
+
"<div class=\"hours-card__head\"><h2>" + esc(s.summary.slug) + "</h2>" + pill + "</div>" +
|
|
4787
|
+
(next ? "<p class=\"hours-card__next\">" + next + "</p>" : "") +
|
|
4788
|
+
"<table class=\"hours-table\"><tbody>" + rows + "</tbody></table>" +
|
|
4789
|
+
"<p class=\"hours-card__tz\">Times shown in " + esc(s.summary.timezone) + ".</p>" +
|
|
4790
|
+
"</div>";
|
|
4791
|
+
}).join("");
|
|
4792
|
+
body =
|
|
4793
|
+
"<section class=\"hours-page\"><div class=\"hours-page__inner\">" +
|
|
4794
|
+
"<p class=\"eyebrow\">When we're around</p>" +
|
|
4795
|
+
"<h1 class=\"hours-page__title\">Hours</h1>" +
|
|
4796
|
+
"<div class=\"hours-grid\">" + cards + "</div>" +
|
|
4797
|
+
"</div></section>";
|
|
4798
|
+
}
|
|
4799
|
+
return _wrap({
|
|
4800
|
+
title: "Hours",
|
|
4801
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
4802
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
4803
|
+
theme_css: opts.theme_css,
|
|
4804
|
+
body: body,
|
|
4805
|
+
});
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4738
4808
|
function mount(router, deps) {
|
|
4739
4809
|
if (!router || typeof router.get !== "function") throw new TypeError("storefront.mount: router with .get() required");
|
|
4740
4810
|
if (!deps || !deps.catalog || !deps.cart) throw new TypeError("storefront.mount: deps.catalog + deps.cart required");
|
|
@@ -5212,6 +5282,34 @@ function mount(router, deps) {
|
|
|
5212
5282
|
});
|
|
5213
5283
|
}
|
|
5214
5284
|
|
|
5285
|
+
// ---- business hours (public /hours page) ----------------------------
|
|
5286
|
+
// Lists every active schedule with its weekly grid + a live open/closed
|
|
5287
|
+
// status computed at request time in the schedule's timezone. Container-
|
|
5288
|
+
// rendered (the status is time-of-day dependent, so it isn't edge-cached).
|
|
5289
|
+
// Resilient: a missing table / no schedules renders the "not published"
|
|
5290
|
+
// notice, never a 500.
|
|
5291
|
+
if (deps.businessHours) {
|
|
5292
|
+
router.get("/hours", async function (req, res) {
|
|
5293
|
+
var themeCss = (deps.theme && deps.theme.assetUrl) ? deps.theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL;
|
|
5294
|
+
var shopName = (deps.config && deps.config.shop_name) || "blamejs.shop";
|
|
5295
|
+
var resolved = [];
|
|
5296
|
+
try {
|
|
5297
|
+
var schedules = await deps.businessHours.listSchedules();
|
|
5298
|
+
var active = (schedules || []).filter(function (s) { return s.archived_at == null; });
|
|
5299
|
+
var now = Date.now();
|
|
5300
|
+
for (var i = 0; i < active.length; i += 1) {
|
|
5301
|
+
var slug = active[i].slug;
|
|
5302
|
+
var summary = await deps.businessHours.weekSummary({ slug: slug });
|
|
5303
|
+
var isOpen = await deps.businessHours.isOpenAt({ slug: slug, when: now });
|
|
5304
|
+
var nextOpen = isOpen ? null : await deps.businessHours.nextOpenAt({ slug: slug, when: now });
|
|
5305
|
+
var nextClose = isOpen ? await deps.businessHours.nextCloseAt({ slug: slug, when: now }) : null;
|
|
5306
|
+
if (summary) resolved.push({ summary: summary, is_open: isOpen, next_open: nextOpen, next_close: nextClose });
|
|
5307
|
+
}
|
|
5308
|
+
} catch (_e) { resolved = []; /* table absent / read failure → "not published" */ }
|
|
5309
|
+
_send(res, 200, renderHoursPage({ schedules: resolved, shop_name: shopName, theme_css: themeCss }));
|
|
5310
|
+
});
|
|
5311
|
+
}
|
|
5312
|
+
|
|
5215
5313
|
// Persist a locale choice. A GET form (works with JS off) from the
|
|
5216
5314
|
// footer switcher submits `lang` (the chosen tag) + `to` (the path to
|
|
5217
5315
|
// return to). We validate the tag against the active locale set, set
|
|
@@ -6178,6 +6276,7 @@ function mount(router, deps) {
|
|
|
6178
6276
|
totals: totals,
|
|
6179
6277
|
product_lookup: productLookup,
|
|
6180
6278
|
can_save: !!(deps.saveForLater && deps.customers),
|
|
6279
|
+
checkout_available: !!(deps.checkout && deps.order),
|
|
6181
6280
|
shop_name: shopName,
|
|
6182
6281
|
theme: theme,
|
|
6183
6282
|
}, ccy)));
|
|
@@ -6194,6 +6293,30 @@ function mount(router, deps) {
|
|
|
6194
6293
|
// routes only register when supplied. This lets the framework boot
|
|
6195
6294
|
// in pure-storefront mode (catalog + cart only) for stores that
|
|
6196
6295
|
// are still configuring payment.
|
|
6296
|
+
//
|
|
6297
|
+
// When checkout ISN'T wired (no payment provider configured), mount a
|
|
6298
|
+
// graceful placeholder at /checkout so the cart link or a direct hit
|
|
6299
|
+
// renders a clear "not set up yet" page instead of a 404. The cart CTA
|
|
6300
|
+
// is already gated to a disabled notice (renderCart checkout_available),
|
|
6301
|
+
// but a direct navigation still needs a real response. GET + POST both
|
|
6302
|
+
// land here (a stale form POST shouldn't 404 either).
|
|
6303
|
+
if (!(deps.checkout && deps.order)) {
|
|
6304
|
+
var _checkoutUnavailable = function (req, res) {
|
|
6305
|
+
var body =
|
|
6306
|
+
"<section class=\"cart-page cart-page--empty\"><div class=\"cart-empty\"><div class=\"cart-empty__card\">" +
|
|
6307
|
+
"<p class=\"eyebrow cart-empty__eyebrow\">Checkout</p>" +
|
|
6308
|
+
"<h1 class=\"cart-empty__title\">Checkout isn't available yet</h1>" +
|
|
6309
|
+
"<p class=\"cart-empty__lede\">Online payments aren't set up for this store yet, so orders can't be completed right now. Your cart is saved — please check back soon.</p>" +
|
|
6310
|
+
"<div class=\"cart-empty__cta\"><a class=\"btn-primary\" href=\"/cart\">Back to cart</a> <a class=\"btn-ghost\" href=\"/\">Keep browsing</a></div>" +
|
|
6311
|
+
"</div></div></section>";
|
|
6312
|
+
// 503: the capability is unconfigured, not the request's fault — keeps
|
|
6313
|
+
// the page out of search indexes + signals "temporarily unavailable".
|
|
6314
|
+
_send(res, 503, _wrap({ title: "Checkout", shop_name: shopName, theme: theme, cart_count: 0, body: body }));
|
|
6315
|
+
};
|
|
6316
|
+
router.get("/checkout", _checkoutUnavailable);
|
|
6317
|
+
router.post("/checkout", _checkoutUnavailable);
|
|
6318
|
+
}
|
|
6319
|
+
|
|
6197
6320
|
if (deps.checkout && deps.order) {
|
|
6198
6321
|
router.get("/checkout", async function (req, res) {
|
|
6199
6322
|
var sid = _readSidCookie(req);
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.13.
|
|
7
|
-
"tag": "v0.13.
|
|
6
|
+
"version": "0.13.18",
|
|
7
|
+
"tag": "v0.13.18",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.18 (2026-05-27) — **`bodyParser` multipart can buffer uploads in memory — no tmp directory for serverless / read-only filesystems.** The multipart/form-data sub-parser previously streamed every file part to a tmp directory on disk (os.tmpdir() by default), which fails on a read-only or ephemeral serverless filesystem. A new multipart.storage option selects where file parts land: "disk" (default, unchanged — req.files[].path points at a tmp file cleaned up on response end) or "memory" (req.files[].buffer holds the assembled bytes, with no filesystem access at all). Both modes enforce the same per-file (fileSize), per-field, and total-request (totalSize) caps, so memory mode adds no new memory-exhaustion surface. The file object shape is stable across both modes — disk sets path with buffer null, memory sets buffer with path null — so a handler branches on whichever is non-null. An invalid storage value is rejected when the middleware is constructed. **Added:** *`bodyParser` multipart `storage: "memory"` — buffer uploads in RAM instead of a tmp directory* — `b.middleware.bodyParser({ multipart: { storage: "memory" } })` buffers each uploaded file part in memory and exposes it as `req.files[].buffer` (a Buffer), with no `os.tmpdir()` write and no tmp-file cleanup — the read-only / serverless path. The default `storage: "disk"` is unchanged: file parts stream to a tmp file, `req.files[].path` points at it, and it is removed when the response finishes. Both modes apply the existing `fileSize` / per-field `maxBytes` / `totalSize` caps and SHA3-512 hash each part during streaming, so memory mode is bounded by the same limits and adds no new DoS surface. The `req.files[]` shape is stable across modes (disk: `path` set, `buffer` null; memory: `buffer` set, `path` null). A `storage` value other than `"disk"` or `"memory"` throws a `TypeError` at construction.
|
|
12
|
+
|
|
13
|
+
- v0.13.17 (2026-05-27) — **Template engine can render from a string with no views directory — for serverless / read-only filesystems.** b.template.create previously required a viewsDir that exists on disk, and rendering always read the template (and its layout/partials) from that directory — unusable on a read-only or ephemeral serverless filesystem where the templates aren't on disk. The engine now accepts a source string directly: viewsDir is optional, and the returned engine exposes renderString(source, data?, opts?) and compileString(source, opts?) that compile and render from a string with no disk read. {% extends %} and {{> partial}} in a string source resolve through an operator-supplied opts.resolve(name) -> string callback (without it, an extends throws a clear error and a missing partial inlines empty, matching the file path). The same HTML-escaping, expression grammar, and extends/partial-depth caps apply. The file-backed render / compile / precompileAll still work exactly as before when a viewsDir is configured, and now refuse with a clear error when one isn't. **Added:** *`engine.renderString` / `engine.compileString` — render templates from a string, no viewsDir* — `b.template.create({})` (no `viewsDir`) returns a string-only engine; `renderString(source, data?, { resolve })` and `compileString(source, { resolve })` compile and render from a source string with zero filesystem access — the read-only / serverless path. `{% extends %}` and `{{> partial}}` resolve through `opts.resolve(name) -> string`. The HTML escaping, grammar, and depth caps are identical to the file path. When a `viewsDir` IS configured, `render`/`compile`/`precompileAll` behave exactly as before; without one they refuse with `viewsDir not configured`. `renderString(source, { resolve })` may omit the data argument — an opts object carrying a function `resolve` is recognized as opts, not data. **Security:** *Vendored `@simplewebauthn/server` refreshed 13.3.0 → 13.3.1* — The vendored WebAuthn server bundle (`b.auth.passkey`'s registration/authentication verification) is refreshed to the latest upstream patch, with the MANIFEST version, CPE, and SHA-256 integrity hashes updated and the bundle re-verified.
|
|
14
|
+
|
|
11
15
|
- v0.13.16 (2026-05-27) — **`b.mail.agent` docs now describe the facade accurately, and not-yet-wired verbs point to the primitive to use.** b.mail.agent's module documentation claimed it was "the standardization contract for every mail protocol" that JMAP / IMAP / POP3 all route through — but no protocol server actually dispatches through the agent (the framework's own JMAP EmailSubmission handler composes b.mail.send.deliver directly), and the compose / send / reply / forward, sieve.list / sieve.activate, identity / vacation / mdn.* and export / job / import verbs throw mail-agent/not-implemented. The docs are corrected to describe what the agent is: a mailbox-access facade (RBAC + posture + audit + dispatch around a mail store) whose read surface plus the mailbox-mutation and Sieve-upload methods are wired, with the remaining verbs not yet routed through it. Those verbs' error message now names the underlying primitive to compose directly (b.mail.send.deliver, b.mail.sieve, b.mailMdn, …) instead of citing a version tag that had long passed. The public WIRED_AT export (a method→version map that no longer reflected reality) is replaced by COMPOSE_HINT (a method→primitive-to-compose map). No behaviour change: the same methods are wired or throw exactly as before. **Changed:** *`b.mail.agent` documentation corrected; not-implemented errors point to the primitive to compose* — The `@module` / `@card` no longer claim the agent is the universal protocol-dispatch contract — it's documented as a mailbox-access facade with a wired read + mutation + Sieve-upload surface, and the compose/send/identity/vacation/MDN/export verbs documented as not yet routed through it (compose the underlying primitive directly until a protocol server adopts the agent). The `mail-agent/not-implemented` error now names that primitive (e.g. `b.mail.send.deliver`) rather than a passed version tag. **Removed:** *`b.mail.agent.WIRED_AT` export replaced by `COMPOSE_HINT`* — The `WIRED_AT` export mapped each method to a framework version that was supposed to "light it up" — versions that have all shipped without the wiring, so the map was misleading. It is replaced by `COMPOSE_HINT`, mapping each not-yet-wired method to the primitive an operator composes directly. Operators reading `b.mail.agent.WIRED_AT` should read `b.mail.agent.COMPOSE_HINT` instead (pre-1.0: no compatibility shim).
|
|
12
16
|
|
|
13
17
|
- v0.13.15 (2026-05-27) — **Corrected more source citations and made deferred/reserved options honest in their docs.** A second accuracy pass over source threat-annotations and option docs. Three citation corrections: the base64url strict-decode guard cited CVE-2022-0235 (which is actually a node-fetch cookie-leak, unrelated) — it now names the weakness class it defends (CWE-347 / CWE-1286 signature canonicalization); the glob consecutive-wildcard ReDoS cap cited the wrong library (the CVE-2026-26996 ReDoS is minimatch, not picomatch — the adjacent picomatch one is CVE-2026-33671); and CVE-2026-32178 is reframed to the CWE-138 header-injection-spoofing class the public record actually documents (and dropped from the end-of-data SMTP-smuggling list, which is a different class). Several options/statuses are now honest about not-yet-implemented surface: b.archive.read.zip.fromTrustedStream is marked experimental (its methods throw and its options aren't honored yet — the example now shows the supported buffer-then-random-access path); b.acme revokeCert's useCertKey / certPrivateKey are marked reserved (the cert-key path throws; account-key signing is the supported default); and a stale message claiming passkey break-glass factors were a future feature is removed (passkeys are a live allowed factor). No runtime behaviour changes beyond message/doc text. **Changed:** *Deferred / reserved surface now documented honestly* — `b.archive.read.zip.fromTrustedStream` is marked `experimental` — its `inspect`/`entries`/`extract` throw and its `bombPolicy`/`audit` options aren't honored yet; the documented example now shows the supported path (buffer the stream, then use the random-access reader). `b.acme` `revokeCert`'s `useCertKey` / `certPrivateKey` options are marked reserved (the cert-key-signed-revocation path throws; account-key signing, the default, covers mainstream CAs). A `b.breakGlass` policy error and comment that called passkey factors a future feature are corrected — passkeys are a live allowed factor. **Fixed:** *Corrected misattributed CVE citations in source threat-annotations* — `b.crypto.fromBase64Url`'s strict-decode guard cited CVE-2022-0235 (a node-fetch header-leak, unrelated to base64/JWT decoding); it now cites the weakness class it actually defends — CWE-347 / CWE-1286 signature canonicalization. `b.guardRegex`'s consecutive-`*` cap attributed CVE-2026-26996 to picomatch; that ReDoS is in minimatch (the picomatch ReDoS it also defends is CVE-2026-33671) — the library name is corrected. CVE-2026-32178 is reframed to the CWE-138 header-injection spoofing class the public advisory documents, and removed from the end-of-data SMTP-smuggling trio (a distinct class). No behaviour change — the defenses are unchanged.
|
|
@@ -125,7 +125,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
125
125
|
- Rate-limit
|
|
126
126
|
- Security headers with `Permissions-Policy` defaults denying storage-access / browsing-topics / private-aggregation / controlled-frame
|
|
127
127
|
- CSP nonce
|
|
128
|
-
- Body parser
|
|
128
|
+
- Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
|
|
129
129
|
- Compression
|
|
130
130
|
- SSE
|
|
131
131
|
- Request log
|