@blamejs/blamejs-shop 0.3.33 → 0.3.35

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.35 (2026-05-31) — **Share a wishlist with a private link.** A customer's wishlist could only be viewed by the customer themselves. From the wishlist page a shopper can now create a shareable link and send it to someone — a friend buying a gift, for example — who opens it without needing an account and sees the wishlist's products. The link can be revoked at any time, after which it stops working. The shared page shows only the products: it does not reveal who owns the wishlist or any private notes on it, and it is not indexed by search engines. **Added:** *Shareable wishlist links* — The wishlist page now has a Share control that creates a private link to the wishlist. The full link is shown once, to copy and send; anyone with it can open the shared wishlist without an account and see its products, each linking to the product page. The owner sees their active links and can revoke any of them, which immediately stops the link from working. The shared view shows only the products — never the owner's identity or private notes — and is marked not to be indexed by search engines, so a personal wishlist stays private to whoever holds the link.
12
+
13
+ - v0.3.34 (2026-05-31) — **Edit, pin, and archive the internal notes on a customer.** Internal notes on a customer could only be added — a note with a typo or one that was no longer true could not be corrected or removed, which is a liability for notes like "do not ship to this address" or "allergic to X". The customer detail screen now lets you edit a note's text, pin important notes to the top, and archive notes that no longer apply (and restore them). Each action is limited to notes that belong to the customer being viewed. **Added:** *Edit, pin, and archive customer notes* — The notes panel on a customer's detail screen now supports the full lifecycle: edit a note's text to fix or update it, pin the notes that matter so they sort to the top, and archive notes that no longer apply, with a toggle to show archived notes and restore them. Previously notes were append-only, so a wrong or stale note could not be corrected or retired. Every edit, pin, and archive action is scoped to the customer being viewed, so a note can only be changed from the record it belongs to.
14
+
11
15
  - v0.3.33 (2026-05-31) — **Create and manage customer segments from the admin console.** Customer segments could be shown on a customer's profile but there was no way to define or manage them, so the feature could not actually be used. A new Segments screen in the admin console creates rule-based segments from recency, frequency, lifetime-order, spend, average-order-value, and refund-rate criteria, edits their rules, archives and restores them, and recomputes membership on demand — showing how many customers each segment now contains. Segment membership stays rule-derived: it is recalculated from order history, not assigned by hand. **Added:** *Customer segments administration* — A Segments screen in the admin console manages rule-based customer segments. Each segment is defined by RFM-style criteria — how recently and how often a customer orders, their lifetime order count and spend, average order value, and refund rate — and a list shows every segment with its rules, current member count, and status, filterable by active or archived. You can create and edit segments, archive and restore them, and recompute membership on demand, which re-evaluates the rules against order history and reports how many customers match. Membership is always derived from the rules, never assigned per customer.
12
16
 
13
17
  - v0.3.32 (2026-05-31) — **Collection pages now page through every product instead of stopping at 24.** A collection page showed only its first 24 products and provided no way to reach the rest, so any product past the 24th in a collection was silently unreachable. Collection pages now have previous/next pagination, matching the search results, so a collection of any size can be browsed in full. A shared link to a specific page keeps its position, while the page's canonical URL stays the plain collection address. **Fixed:** *Collection pages paginate* — A collection with more than 24 products was truncated at the first 24 with no way to see the others. Collection pages now carry previous/next pagination — the same control used on search results — so every product in a collection is reachable. The canonical URL remains the bare collection address on every page (so search engines see one collection page, not duplicates), while a shared link to a deeper page preserves its position.
package/lib/admin.js CHANGED
@@ -2674,8 +2674,13 @@ function mount(router, deps) {
2674
2674
  }
2675
2675
 
2676
2676
  var notes = [];
2677
+ var showArchivedNotes = flags.show_archived_notes === true;
2677
2678
  if (customerNotes) {
2678
- try { notes = (await customerNotes.notesForCustomer({ customer_id: customer.id, limit: 20 })).rows || []; }
2679
+ try {
2680
+ notes = (await customerNotes.notesForCustomer({
2681
+ customer_id: customer.id, limit: 50, include_archived: showArchivedNotes,
2682
+ })).rows || [];
2683
+ }
2679
2684
  catch (_e) { notes = []; }
2680
2685
  }
2681
2686
 
@@ -2698,6 +2703,7 @@ function mount(router, deps) {
2698
2703
  loyalty_link: !!deps.loyalty,
2699
2704
  can_notes: !!customerNotes,
2700
2705
  notes: notes,
2706
+ show_archived_notes: showArchivedNotes,
2701
2707
  can_segments: !!customerSegments,
2702
2708
  segments: segments,
2703
2709
  }, flags);
@@ -2716,8 +2722,13 @@ function mount(router, deps) {
2716
2722
  R(async function (req, res) {
2717
2723
  var c = await _resolveCustomer(req.params.id);
2718
2724
  if (!c) return _problem(res, 404, "customer-not-found");
2719
- // Bearer JSON: the customer record + its satellites (no HTML).
2720
- var model = await _customerDetailModel(c, {});
2725
+ // Bearer JSON: the customer record + its satellites (no HTML). A
2726
+ // tooling client can opt into the archived notes via
2727
+ // ?notes_archived=1, matching the browser detail-screen toggle.
2728
+ var apiUrl = req.url ? new URL(req.url, "http://localhost") : null;
2729
+ var model = await _customerDetailModel(c, {
2730
+ show_archived_notes: !!(apiUrl && apiUrl.searchParams.get("notes_archived") === "1"),
2731
+ });
2721
2732
  _json(res, 200, {
2722
2733
  customer: c,
2723
2734
  recent_orders: model.recent_orders,
@@ -2738,6 +2749,7 @@ function mount(router, deps) {
2738
2749
  saved: url && url.searchParams.get("saved"),
2739
2750
  credit_notice: url && url.searchParams.get("credit_err") ? url.searchParams.get("credit_err") : null,
2740
2751
  note_notice: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
2752
+ show_archived_notes: !!(url && url.searchParams.get("notes_archived") === "1"),
2741
2753
  };
2742
2754
  _sendHtml(res, 200, renderAdminCustomerDetail(await _customerDetailModel(c, flags)));
2743
2755
  },
@@ -2870,6 +2882,104 @@ function mount(router, deps) {
2870
2882
  _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) + "?saved=1");
2871
2883
  },
2872
2884
  ));
2885
+
2886
+ // ---- customer notes (edit / pin / archive lifecycle) ---------------
2887
+ // A note belongs to a customer. customerNotes' update / pin / archive
2888
+ // methods move a row by note id alone — they carry no notion of which
2889
+ // customer the operator is acting on — so every per-note write route
2890
+ // under :id MUST first assert the note belongs to THAT customer before
2891
+ // mutating it, or an operator on customer A's screen could edit / pin /
2892
+ // retire customer B's note by guessing its id (an IDOR). This mirrors
2893
+ // the media setPrimary (_mediaBelongsToProduct) + support thread
2894
+ // (_ownedTicket) scoping: load the note, refuse it unless its
2895
+ // customer_id matches the path id. A malformed note id throws a
2896
+ // TypeError inside getNote's UUID guard → a clean 400; a well-formed
2897
+ // unknown id (or one owned by a different customer) returns null /
2898
+ // false → a clean 404, with nothing written either way.
2899
+ async function _noteBelongsToCustomer(noteId, customerId) {
2900
+ var note;
2901
+ try { note = await customerNotes.getNote(noteId); }
2902
+ catch (e) { if (e instanceof TypeError) throw e; return false; }
2903
+ return (note && note.customer_id === customerId) ? note : false;
2904
+ }
2905
+
2906
+ // Map a per-note write to a content-negotiated outcome. `mutate(note)`
2907
+ // performs the customerNotes call (already ownership-checked); on the
2908
+ // bearer path it returns the updated note as JSON, on the browser path
2909
+ // it PRGs back to the detail screen with a saved / note_err banner.
2910
+ // A malformed note id is a clean 400 (bearer) / note_err redirect
2911
+ // (browser); a missing / cross-customer note is a clean 404 (bearer) /
2912
+ // note_err redirect (browser). Nothing is mutated on any refusal.
2913
+ function _noteWriteRoute(routePath, audit, mutate) {
2914
+ router.post(routePath, _pageOrApi(false,
2915
+ W(audit, async function (req, res) {
2916
+ var c = await _resolveCustomer(req.params.id);
2917
+ if (!c) return _problem(res, 404, "customer-not-found");
2918
+ var owned;
2919
+ try { owned = await _noteBelongsToCustomer(req.params.noteId, c.id); }
2920
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
2921
+ if (!owned) return _problem(res, 404, "note-not-found");
2922
+ var updated;
2923
+ try { updated = await mutate(owned, req.body || {}); }
2924
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
2925
+ _json(res, 200, updated);
2926
+ return { id: c.id };
2927
+ }),
2928
+ async function (req, res) {
2929
+ var c = await _resolveCustomer(req.params.id);
2930
+ if (!c) return _sendHtml(res, 404, renderAdminCustomers({
2931
+ shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
2932
+ }));
2933
+ var enc = encodeURIComponent(c.id);
2934
+ var owned;
2935
+ try { owned = await _noteBelongsToCustomer(req.params.noteId, c.id); }
2936
+ catch (e) {
2937
+ var nm = _safeNotice(e, audit);
2938
+ return _redirect(res, "/admin/customers/" + enc +
2939
+ "?note_err=" + encodeURIComponent(nm.message.replace(/^(?:admin|customerNotes)[.:]\s*/, "")));
2940
+ }
2941
+ if (!owned) {
2942
+ return _redirect(res, "/admin/customers/" + enc +
2943
+ "?note_err=" + encodeURIComponent("That note was not found on this customer."));
2944
+ }
2945
+ try { await mutate(owned, req.body || {}); }
2946
+ catch (e) {
2947
+ var n = _safeNotice(e, audit);
2948
+ return _redirect(res, "/admin/customers/" + enc +
2949
+ "?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|customerNotes)[.:]\s*/, "")));
2950
+ }
2951
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: c.id } });
2952
+ _redirect(res, "/admin/customers/" + enc + "?saved=1");
2953
+ },
2954
+ ));
2955
+ }
2956
+
2957
+ // Edit the note body / kind. The new text is required + length-capped by
2958
+ // the primitive's validator (empty / over-long body, bad kind → a clean
2959
+ // 4xx with nothing written); the kind is left unchanged when the form
2960
+ // omits it.
2961
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/edit", "customer.note.edit",
2962
+ function (owned, body) {
2963
+ var patch = { body: body.body };
2964
+ if (body.kind != null && body.kind !== "") patch.kind = body.kind;
2965
+ return customerNotes.updateNote(owned.id, patch);
2966
+ });
2967
+
2968
+ // Pin / unpin — a pinned note floats to the top of the customer's note
2969
+ // list (notesForCustomer orders pinned DESC). Idempotent at the
2970
+ // primitive.
2971
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/pin", "customer.note.pin",
2972
+ function (owned) { return customerNotes.pinNote(owned.id); });
2973
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/unpin", "customer.note.unpin",
2974
+ function (owned) { return customerNotes.unpinNote(owned.id); });
2975
+
2976
+ // Archive / unarchive — a soft-retire that drops the note from the
2977
+ // default (active) listing; unarchive brings it back. Both idempotent at
2978
+ // the primitive.
2979
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/archive", "customer.note.archive",
2980
+ function (owned) { return customerNotes.archiveNote(owned.id); });
2981
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/unarchive", "customer.note.unarchive",
2982
+ function (owned) { return customerNotes.unarchiveNote(owned.id); });
2873
2983
  }
2874
2984
  }
2875
2985
 
@@ -9925,25 +10035,56 @@ function renderAdminCustomerDetail(opts) {
9925
10035
  "<p class=\"empty\">Customer notes aren't wired in this deployment.</p></div>";
9926
10036
  } else {
9927
10037
  var noteNotice = opts.note_notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.note_notice) + "</div>" : "";
10038
+ var showArchived = opts.show_archived_notes === true;
10039
+ // A <select name="kind"> with the active kind preselected. Reused by the
10040
+ // add form and each note's inline edit form.
10041
+ function _noteKindSelect(active) {
10042
+ return ["general", "preference", "escalation", "warning", "billing"].map(function (k) {
10043
+ return "<option value=\"" + k + "\"" + (k === active ? " selected" : "") + ">" + k + "</option>";
10044
+ }).join("");
10045
+ }
9928
10046
  var noteRows = (opts.notes || []).map(function (n) {
9929
- return "<li><div class=\"note-meta\">" + _htmlEscape(n.kind) + " · " + _htmlEscape(_fmtDate(n.created_at)) + "</div>" +
9930
- "<div class=\"note-body\">" + _htmlEscape(n.body) + "</div></li>";
10047
+ var nenc = _htmlEscape(encodeURIComponent(n.id));
10048
+ var base = "/admin/customers/" + _htmlEscape(enc) + "/notes/" + nenc;
10049
+ var isPinned = Number(n.pinned) === 1;
10050
+ var isArchived = n.archived_at != null;
10051
+ var pills =
10052
+ (isPinned ? "<span class=\"status-pill paid\">Pinned</span>" : "") +
10053
+ (isArchived ? "<span class=\"status-pill cancelled\">Archived</span>" : "");
10054
+ // Inline edit form — kind + body, prefilled with the note's current
10055
+ // values; the primitive re-validates an empty / over-long body.
10056
+ var editForm = "<details class=\"note-edit\"><summary class=\"btn btn--ghost\">Edit</summary>" +
10057
+ "<form method=\"post\" action=\"" + base + "/edit\">" +
10058
+ "<label class=\"form-field\"><span>Kind</span>" +
10059
+ "<select name=\"kind\">" + _noteKindSelect(n.kind) + "</select></label>" +
10060
+ "<label class=\"form-field\"><span>Note</span>" +
10061
+ "<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\">" + _htmlEscape(n.body) + "</textarea></label>" +
10062
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save note</button></div>" +
10063
+ "</form></details>";
10064
+ var pinForm = "<form method=\"post\" action=\"" + base + (isPinned ? "/unpin" : "/pin") + "\">" +
10065
+ "<button class=\"btn btn--ghost\" type=\"submit\">" + (isPinned ? "Unpin" : "Pin") + "</button></form>";
10066
+ var archiveForm = "<form method=\"post\" action=\"" + base + (isArchived ? "/unarchive" : "/archive") + "\">" +
10067
+ "<button class=\"btn btn--ghost\" type=\"submit\">" + (isArchived ? "Unarchive" : "Archive") + "</button></form>";
10068
+ return "<li class=\"note-item\">" +
10069
+ "<div class=\"note-meta\">" + _htmlEscape(n.kind) + " · " + _htmlEscape(_fmtDate(n.created_at)) +
10070
+ (pills ? " " + pills : "") + "</div>" +
10071
+ "<div class=\"note-body\">" + _htmlEscape(n.body) + "</div>" +
10072
+ "<div class=\"actions-row\">" + editForm + pinForm + archiveForm + "</div>" +
10073
+ "</li>";
9931
10074
  }).join("");
9932
- notesPanel = "<div class=\"panel\"><h3 class=\"subhead\">Notes</h3>" +
10075
+ var archivedToggle = showArchived
10076
+ ? "<a class=\"btn btn--ghost\" href=\"/admin/customers/" + _htmlEscape(enc) + "\">Hide archived</a>"
10077
+ : "<a class=\"btn btn--ghost\" href=\"/admin/customers/" + _htmlEscape(enc) + "?notes_archived=1\">Show archived</a>";
10078
+ notesPanel = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Notes</h3>" + archivedToggle + "</div>" +
9933
10079
  noteNotice +
9934
- "<p class=\"meta\">Operator-only annotations on this customer. Never shown to the customer.</p>" +
10080
+ "<p class=\"meta\">Operator-only annotations on this customer. Never shown to the customer. " +
10081
+ "Pin the notes every shift should see first; archive a stale or wrong note to retire it from the active list.</p>" +
9935
10082
  ((opts.notes || []).length
9936
10083
  ? "<ul class=\"note-list\">" + noteRows + "</ul>"
9937
- : "<p class=\"empty\">No notes yet.</p>") +
10084
+ : "<p class=\"empty\">" + (showArchived ? "No notes (including archived)." : "No notes yet.") + "</p>") +
9938
10085
  "<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/notes\">" +
9939
10086
  "<label class=\"form-field\"><span>Kind</span>" +
9940
- "<select name=\"kind\">" +
9941
- "<option value=\"general\">general</option>" +
9942
- "<option value=\"preference\">preference</option>" +
9943
- "<option value=\"escalation\">escalation</option>" +
9944
- "<option value=\"warning\">warning</option>" +
9945
- "<option value=\"billing\">billing</option>" +
9946
- "</select></label>" +
10087
+ "<select name=\"kind\">" + _noteKindSelect("general") + "</select></label>" +
9947
10088
  "<label class=\"form-field\"><span>Note</span>" +
9948
10089
  "<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\" placeholder=\"e.g. VIP — comp shipping where possible\"></textarea></label>" +
9949
10090
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add note</button></div>" +
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.33",
2
+ "version": "0.3.35",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/storefront.js CHANGED
@@ -2488,6 +2488,12 @@ function renderWishlist(opts) {
2488
2488
  var notice = okMsg
2489
2489
  ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(okMsg) + "</p>"
2490
2490
  : "";
2491
+ // The owner's share panel — a "Share this wishlist" control plus the
2492
+ // list of active share links each with a Revoke action. The route
2493
+ // builds the panel HTML (it owns the share-link reads); absent it (the
2494
+ // sharing primitive isn't wired, or a unit test calling the renderer
2495
+ // directly) the panel is empty so the page renders unchanged.
2496
+ var sharePanel = opts.share_panel || "";
2491
2497
  var body =
2492
2498
  "<section class=\"account-wishlist\">" +
2493
2499
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -2496,6 +2502,7 @@ function renderWishlist(opts) {
2496
2502
  "</ol></nav>" +
2497
2503
  "<h1 class=\"account-wishlist__title\">Saved items</h1>" +
2498
2504
  notice +
2505
+ sharePanel +
2499
2506
  inner +
2500
2507
  "</section>";
2501
2508
  return _wrap({
@@ -2507,6 +2514,136 @@ function renderWishlist(opts) {
2507
2514
  });
2508
2515
  }
2509
2516
 
2517
+ // The owner-side wishlist share panel (mounts on /account/wishlist). It
2518
+ // renders a "Create a share link" control plus the owner's active share
2519
+ // links, each with a status + view count and a Revoke form. `opts.shares`
2520
+ // is the owner's links from `listSharesForOwner` (already scoped to the
2521
+ // session customer by the route). A revoked / expired link still appears
2522
+ // (greyed, no Revoke) so the owner sees the history; only an active link
2523
+ // carries a Revoke control.
2524
+ //
2525
+ // The plaintext share token is surfaced EXACTLY ONCE — at create time, via
2526
+ // `opts.fresh_url` (the full public URL of the just-minted link). The list
2527
+ // rows never carry the URL because the token isn't persisted; the owner
2528
+ // copies the link from this one-time confirmation. `opts.notice` surfaces a
2529
+ // created/revoked confirmation via role="status".
2530
+ var WISHLIST_SHARE_NOTICES = {
2531
+ created: "Share link created. Copy it below — it's shown only once.",
2532
+ revoked: "Share link revoked. The link no longer works.",
2533
+ };
2534
+ function _wishlistSharePanel(opts) {
2535
+ opts = opts || {};
2536
+ var esc = b.template.escapeHtml;
2537
+ var shares = opts.shares || [];
2538
+ var nowTs = Date.now();
2539
+ var rows = "";
2540
+ for (var i = 0; i < shares.length; i += 1) {
2541
+ var s = shares[i];
2542
+ var revoked = s.revoked_at != null;
2543
+ var expired = !revoked && s.expires_at != null && Number(s.expires_at) < nowTs;
2544
+ var inactive = revoked || expired;
2545
+ var statusLabel = revoked ? "Revoked" : (expired ? "Expired" : "Active");
2546
+ var statusCls = revoked ? "wishlist-share__status--revoked"
2547
+ : (expired ? "wishlist-share__status--expired" : "wishlist-share__status--active");
2548
+ var revokeForm = inactive
2549
+ ? ""
2550
+ : "<form class=\"wishlist-share__revoke\" method=\"post\" action=\"/wishlist/share/" + esc(s.id) + "/revoke\">" +
2551
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Revoke</button>" +
2552
+ "</form>";
2553
+ var viewN = Number(s.view_count) || 0;
2554
+ rows +=
2555
+ "<li class=\"wishlist-share" + (inactive ? " wishlist-share--inactive" : "") + "\">" +
2556
+ "<div class=\"wishlist-share__head\">" +
2557
+ "<span class=\"wishlist-share__status " + statusCls + "\">" + esc(statusLabel) + "</span>" +
2558
+ "<span class=\"wishlist-share__views\">" + esc(String(viewN)) + " view" + (viewN === 1 ? "" : "s") + "</span>" +
2559
+ "</div>" +
2560
+ revokeForm +
2561
+ "</li>";
2562
+ }
2563
+ var list = rows ? "<ul class=\"wishlist-share__list\">" + rows + "</ul>" : "";
2564
+ var noticeMsg = WISHLIST_SHARE_NOTICES[opts.notice];
2565
+ var notice = noticeMsg
2566
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
2567
+ : "";
2568
+ // The one-time URL block — present only on the create confirmation. A
2569
+ // read-only text field the owner can copy; it never re-renders after the
2570
+ // owner navigates away (the token isn't persisted).
2571
+ var freshUrl = opts.fresh_url
2572
+ ? "<div class=\"wishlist-share__fresh\">" +
2573
+ "<label class=\"wishlist-share__fresh-label\" for=\"wishlist-share-url\">Your share link</label>" +
2574
+ "<input id=\"wishlist-share-url\" class=\"wishlist-share__url\" type=\"text\" readonly value=\"" + esc(opts.fresh_url) + "\" " +
2575
+ "aria-label=\"Shareable wishlist link\" onfocus=\"this.select()\">" +
2576
+ "</div>"
2577
+ : "";
2578
+ return "<section class=\"wishlist-share-panel\" aria-labelledby=\"wishlist-share-heading\">" +
2579
+ "<h2 id=\"wishlist-share-heading\" class=\"wishlist-share-panel__title\">Share this wishlist</h2>" +
2580
+ "<p class=\"wishlist-share-panel__lede\">Create a link so friends and family can see what you're hoping for. The link shows your saved products only — it never reveals your account.</p>" +
2581
+ notice +
2582
+ freshUrl +
2583
+ "<form class=\"wishlist-share-panel__create\" method=\"post\" action=\"/wishlist/share\">" +
2584
+ "<button type=\"submit\" class=\"btn-secondary\">Create a share link</button>" +
2585
+ "</form>" +
2586
+ list +
2587
+ "</section>";
2588
+ }
2589
+
2590
+ // Public, no-auth shared-wishlist page (`GET /wishlist/shared/:token`). It
2591
+ // renders ONLY the shared product cards (title, image, link to each PDP) —
2592
+ // the owner's identity, the per-entry private notes, and the owner customer
2593
+ // id are NEVER emitted, mirroring what the primitive's `viewShared` is
2594
+ // trusted to surface to a giver. `opts.entries` is the `viewShared` entry
2595
+ // list resolved to products; `opts.title` is the share's display title.
2596
+ // Carries a noindex robots directive — a personal shared wishlist is not a
2597
+ // page search engines should index. An unknown / revoked / expired token
2598
+ // never reaches this renderer (the route 404s first via renderNotFound).
2599
+ function renderSharedWishlist(opts) {
2600
+ opts = opts || {};
2601
+ var esc = b.template.escapeHtml;
2602
+ var items = opts.items || [];
2603
+ var prefix = opts.asset_prefix || "/assets/";
2604
+ var heading = (opts.title && String(opts.title).length) ? opts.title : "A shared wishlist";
2605
+ var rowsHtml = "";
2606
+ for (var i = 0; i < items.length; i += 1) {
2607
+ var it = items[i];
2608
+ if (!it.product) continue; // archived/deleted product → drop the card (no owner data to leak)
2609
+ var slug = esc(it.product.slug);
2610
+ var thumb = it.hero_media
2611
+ ? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || it.product.title) + "\" loading=\"lazy\">"
2612
+ : "<span class=\"wishlist-item__mark\" aria-hidden=\"true\">" + esc((it.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
2613
+ rowsHtml +=
2614
+ "<li class=\"wishlist-item\">" +
2615
+ "<a class=\"wishlist-item__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
2616
+ "<div class=\"wishlist-item__body\">" +
2617
+ "<a class=\"wishlist-item__title\" href=\"/products/" + slug + "\">" + esc(it.product.title) + "</a>" +
2618
+ "<a class=\"wishlist-item__view card-link\" href=\"/products/" + slug + "\">View product →</a>" +
2619
+ "</div>" +
2620
+ "</li>";
2621
+ }
2622
+ var inner = rowsHtml
2623
+ ? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
2624
+ : "<div class=\"account-empty\">" +
2625
+ "<p class=\"account-empty__lede\">This wishlist is empty right now — nothing's been saved yet.</p>" +
2626
+ "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
2627
+ "</div>";
2628
+ var body =
2629
+ "<section class=\"account-wishlist\">" +
2630
+ "<p class=\"eyebrow\">Shared with you</p>" +
2631
+ "<h1 class=\"account-wishlist__title\">" + esc(heading) + "</h1>" +
2632
+ inner +
2633
+ "</section>";
2634
+ return _wrap({
2635
+ title: heading,
2636
+ shop_name: opts.shop_name || "blamejs.shop",
2637
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
2638
+ theme_css: opts.theme_css,
2639
+ // A personal shared wishlist is not search-index material — keep it
2640
+ // out of the index. `noindex` maps to noindex,nofollow in _wrap; the
2641
+ // page needs no canonical (a crawler won't index it to dedupe).
2642
+ robots: "noindex",
2643
+ body: body,
2644
+ });
2645
+ }
2646
+
2510
2647
  // Compare page notice banner — surfaced after a toggle / a full-basket
2511
2648
  // refusal. The route passes a short message key; unknown keys render
2512
2649
  // nothing so a forged `?notice=` query can't inject arbitrary copy.
@@ -10695,13 +10832,221 @@ function mount(router, deps) {
10695
10832
  }
10696
10833
  var cartCount = await _cartCountForReq(req);
10697
10834
  var wlUrl = req.url ? new URL(req.url, "http://localhost") : null;
10835
+ // Owner share panel — only when the sharing primitive is wired.
10836
+ // The share-link list read is SCOPED to the session customer's id,
10837
+ // so the owner only ever sees their own links. A reads failure
10838
+ // (missing table on a fresh deploy) degrades to no panel rather
10839
+ // than 500-ing the page.
10840
+ var sharePanel = "";
10841
+ if (deps.wishlistSharing) {
10842
+ // Scoped to the session customer — the panel read keys on the
10843
+ // signed-in customer's id, so the owner only ever sees their own
10844
+ // links (this list surface carries no path id, so no IDOR).
10845
+ var sessionOwnerId = auth.customer_id;
10846
+ var ownShares = [];
10847
+ try { ownShares = await deps.wishlistSharing.listSharesForOwner(sessionOwnerId); }
10848
+ catch (_e) { ownShares = []; }
10849
+ sharePanel = _wishlistSharePanel({
10850
+ shares: ownShares,
10851
+ notice: wlUrl ? wlUrl.searchParams.get("share") : null,
10852
+ });
10853
+ }
10698
10854
  _send(res, 200, renderWishlist({
10699
10855
  items: items,
10700
10856
  notice: wlUrl ? wlUrl.searchParams.get("ok") : null,
10857
+ share_panel: sharePanel,
10858
+ shop_name: shopName,
10859
+ cart_count: cartCount,
10860
+ asset_prefix: deps.asset_prefix || "/assets/",
10861
+ }));
10862
+ });
10863
+ }
10864
+
10865
+ // ---- wishlist sharing -----------------------------------------------
10866
+ //
10867
+ // Owner-scoped share links on the customer's own wishlist, plus the
10868
+ // public, no-auth shared view a giver opens. Mounts only when the
10869
+ // sharing primitive is wired (it composes the wishlist primitive, so a
10870
+ // store with sharing also has the solo wishlist above).
10871
+ //
10872
+ // Owner side (gated on the session customer, CSRF-protected by the
10873
+ // container form chokepoint): create a share link, see active links,
10874
+ // revoke a link. Every owner action is scoped to the session customer —
10875
+ // the create keys on the signed-in customer's id; the revoke loads the
10876
+ // session customer's links by their id and refuses a link that isn't
10877
+ // the session customer's (clean 404, no IDOR).
10878
+ //
10879
+ // Public side (NO auth): `GET /wishlist/shared/:token` resolves the
10880
+ // wishlist ONLY through `viewShared(token)` — never by a guessable
10881
+ // wishlist/customer id — renders the saved products (redacting the
10882
+ // owner's identity + private notes), records the view, and 404s an
10883
+ // unknown / revoked / expired token. noindex (a personal wishlist isn't
10884
+ // index material).
10885
+ if (deps.wishlistSharing) {
10886
+ // Build the owner's share panel from links scoped to the session
10887
+ // customer. `opts.fresh_url` (the one-time URL of a just-created link)
10888
+ // and `opts.notice` (created / revoked) thread through to the panel.
10889
+ // A reads failure (missing table on a fresh deploy) degrades to a
10890
+ // panel with no list rather than throwing.
10891
+ async function _buildSharePanel(customerId, opts) {
10892
+ opts = opts || {};
10893
+ var shareRows = [];
10894
+ try { shareRows = await deps.wishlistSharing.listSharesForOwner(customerId); }
10895
+ catch (_e) { shareRows = []; }
10896
+ return _wishlistSharePanel({
10897
+ shares: shareRows,
10898
+ notice: opts.notice || null,
10899
+ fresh_url: opts.fresh_url || null,
10900
+ });
10901
+ }
10902
+
10903
+ // Re-render the /account/wishlist page (used by the create POST so the
10904
+ // one-time share URL is shown inline, and on a revoke). Resolves the
10905
+ // same saved-items list the GET route renders.
10906
+ async function _renderWishlistWithPanel(req, res, customerId, panelOpts) {
10907
+ var page = await deps.wishlist.listForCustomer(customerId, { limit: 50 });
10908
+ var items = [];
10909
+ for (var i = 0; i < page.rows.length; i += 1) {
10910
+ var entry = page.rows[i];
10911
+ var product = null;
10912
+ try { product = await deps.catalog.products.get(entry.product_id); } catch (_e) { product = null; }
10913
+ if (!product) { items.push({ product: null, product_id: entry.product_id }); continue; }
10914
+ var media = await deps.catalog.media.listForProduct(product.id);
10915
+ items.push({ product: product, hero_media: media.length ? media[0] : null });
10916
+ }
10917
+ var cartCount = await _cartCountForReq(req);
10918
+ var panel = await _buildSharePanel(customerId, panelOpts || {});
10919
+ _send(res, 200, renderWishlist({
10920
+ items: items,
10921
+ share_panel: panel,
10701
10922
  shop_name: shopName,
10702
10923
  cart_count: cartCount,
10703
10924
  asset_prefix: deps.asset_prefix || "/assets/",
10704
10925
  }));
10926
+ }
10927
+
10928
+ // POST /wishlist/share — mint a share link for the SESSION customer's
10929
+ // wishlist. The link is owner-scoped (keyed on `auth.customer_id`);
10930
+ // the plaintext token is returned once and surfaced inline as the
10931
+ // public URL the owner copies (never persisted). `unlisted` is the
10932
+ // default privacy: link-bearer access, no friends-graph requirement,
10933
+ // not advertised. The page re-renders directly (200) rather than a
10934
+ // redirect so the one-time URL is shown.
10935
+ router.post("/wishlist/share", async function (req, res) {
10936
+ var auth;
10937
+ try { auth = _currentCustomer(req); }
10938
+ catch (e) {
10939
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
10940
+ throw e;
10941
+ }
10942
+ if (!auth) {
10943
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
10944
+ return res.end ? res.end() : res.send("");
10945
+ }
10946
+ var created;
10947
+ try {
10948
+ created = await deps.wishlistSharing.createShareLink({
10949
+ owner_customer_id: auth.customer_id,
10950
+ privacy: "unlisted",
10951
+ });
10952
+ } catch (e) {
10953
+ res.status(e instanceof TypeError ? 400 : 500);
10954
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
10955
+ }
10956
+ // The full public URL the giver opens. Built from this request's
10957
+ // ORIGIN (scheme + host) so the one-time link is a correct absolute
10958
+ // URL — this POST lands on /wishlist/share, so trimming a path off
10959
+ // the canonical URL would mangle the link (and the token shows once).
10960
+ var shareOrigin = "";
10961
+ try { shareOrigin = new URL(_requestUrls(req).canonical_url).origin; }
10962
+ catch (_e) { shareOrigin = ""; /* unparseable — fall back to a host-relative link */ }
10963
+ var freshUrl = shareOrigin + "/wishlist/shared/" + encodeURIComponent(created.plaintext_token);
10964
+ await _renderWishlistWithPanel(req, res, auth.customer_id, { notice: "created", fresh_url: freshUrl });
10965
+ });
10966
+
10967
+ // POST /wishlist/share/:share_id/revoke — revoke one of the SESSION
10968
+ // customer's share links. The primitive's revokeShareLink moves a link
10969
+ // by id alone (no owner notion), so the route owns the ownership
10970
+ // decision: it loads the session customer's own links and refuses a
10971
+ // share_id that isn't among them (clean 404). Without that scope, any
10972
+ // signed-in shopper could revoke another customer's link by guessing
10973
+ // its id (IDOR).
10974
+ router.post("/wishlist/share/:share_id/revoke", async function (req, res) {
10975
+ var auth;
10976
+ try { auth = _currentCustomer(req); }
10977
+ catch (e) {
10978
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
10979
+ throw e;
10980
+ }
10981
+ if (!auth) {
10982
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
10983
+ return res.end ? res.end() : res.send("");
10984
+ }
10985
+ var shareId = (req.params && req.params.share_id) || "";
10986
+ // Ownership scope: the link must belong to the session customer.
10987
+ var owned = [];
10988
+ try { owned = await deps.wishlistSharing.listSharesForOwner(auth.customer_id); }
10989
+ catch (_e) { owned = []; }
10990
+ var match = null;
10991
+ for (var i = 0; i < owned.length; i += 1) {
10992
+ if (owned[i].id === shareId) { match = owned[i]; break; }
10993
+ }
10994
+ if (!match) {
10995
+ // Unknown id, malformed id, or a link owned by someone else all
10996
+ // resolve the same way — a clean 404, no act, no leak.
10997
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
10998
+ }
10999
+ try {
11000
+ await deps.wishlistSharing.revokeShareLink({ link_id: shareId, reason: "owner revoked from account" });
11001
+ } catch (e) {
11002
+ res.status(e instanceof TypeError ? 400 : 500);
11003
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
11004
+ }
11005
+ res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?share=revoked");
11006
+ return res.end ? res.end() : res.send("");
11007
+ });
11008
+
11009
+ // GET /wishlist/shared/:token — the public, no-auth shared view. The
11010
+ // wishlist is resolved ONLY via viewShared(token); a giver with the
11011
+ // link sees the saved products (with the owner identity + private
11012
+ // notes redacted — the renderer emits product cards only). An unknown
11013
+ // / revoked / expired token 404s exactly like an unknown route. The
11014
+ // view is recorded best-effort (analytics for the owner) and never
11015
+ // blocks the render. noindex.
11016
+ router.get("/wishlist/shared/:token", async function (req, res) {
11017
+ var token = (req.params && req.params.token) || "";
11018
+ var view = null;
11019
+ try {
11020
+ view = await deps.wishlistSharing.viewShared({ token: token });
11021
+ } catch (_e) {
11022
+ // A malformed token (TypeError) and a not-found / revoked / expired
11023
+ // token (coded errors) all render the same 404 — no oracle on why.
11024
+ return _send(res, 404, renderNotFound({ shop_name: shopName, cart_count: await _cartCountForReq(req) }));
11025
+ }
11026
+ // Record the open for the owner's view counter. Best-effort: a
11027
+ // record failure never breaks the giver's page.
11028
+ try { await deps.wishlistSharing.recordView({ token: token }); }
11029
+ catch (_e) { /* drop-silent — analytics, not the render path */ }
11030
+ // Resolve each shared entry to its product + hero image. The entry's
11031
+ // owner customer_id + private notes are NOT carried into the view
11032
+ // (the renderer emits product cards only) so the giver never sees
11033
+ // the owner's identity.
11034
+ var items = [];
11035
+ var entries = (view && view.entries) || [];
11036
+ for (var i = 0; i < entries.length; i += 1) {
11037
+ var product = null;
11038
+ try { product = await deps.catalog.products.get(entries[i].product_id); } catch (_e) { product = null; }
11039
+ if (!product) continue;
11040
+ var media = await deps.catalog.media.listForProduct(product.id);
11041
+ items.push({ product: product, hero_media: media.length ? media[0] : null });
11042
+ }
11043
+ _send(res, 200, renderSharedWishlist({
11044
+ items: items,
11045
+ title: (view && view.share && view.share.title) || "A shared wishlist",
11046
+ shop_name: shopName,
11047
+ cart_count: await _cartCountForReq(req),
11048
+ asset_prefix: deps.asset_prefix || "/assets/",
11049
+ }));
10705
11050
  });
10706
11051
  }
10707
11052
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.33",
3
+ "version": "0.3.35",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {