@blamejs/blamejs-shop 0.3.25 → 0.3.27

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.27 (2026-05-31) — **Shared product and page links now show a preview image, and category pages get breadcrumb rich results.** Social-share previews and search rich results were emitting relative image paths, which Facebook, Slack, Twitter, iMessage, and Google all drop — so a shared product or page link showed no preview image, and product rich results had no image. Every og:image, twitter:image, and Product/Article structured-data image is now a fully-qualified URL built from the page's own origin. Collection and category pages, which already showed an on-page breadcrumb, now also emit BreadcrumbList structured data so the trail can appear in search results, matching what product pages already did. And operator-supplied page keywords or announcement text containing a dollar-sign sequence now render literally instead of being garbled. **Fixed:** *Share and rich-result images are now absolute URLs* — The og:image, twitter:image, and the image in Product and blog-article structured data were relative paths, so social platforms and Google could not load them — a shared link showed no preview image. They are now fully-qualified against the page's origin on every page, in both the edge and container renderers, so shares unfurl with the right image and product rich results carry one. Images an operator already hosts at an absolute URL pass through unchanged. · *Breadcrumb structured data on collection and category pages* — Collection and category pages showed a breadcrumb trail on the page but did not expose it as BreadcrumbList structured data, so search engines could not render the trail in results. They now emit it with absolute URLs, the same way product pages already do. · *Dollar-sign sequences in page metadata render literally* — Page meta-keywords and announcement-bar text containing a dollar-sign sequence (for example a price written with a special character) could be garbled in the page head because of how the value was substituted into the template. The substitution now treats the value as literal text, so it renders exactly as entered.
12
+
13
+ - v0.3.26 (2026-05-31) — **Customers can raise support tickets and operators work them from a queue.** The support-ticket system had a complete backend — threaded messages, assignment, tags, and a status workflow — but no way in or out: a shopper could not open a ticket and an operator could not see one. Both sides are now wired. A logged-in customer can raise a ticket from their account (with an optional link to one of their own orders), see the thread, and reply while it is open. Operators get a Support queue in the console with an unassigned-triage view, the full thread for each ticket, and controls to reply (with an internal-note option that the customer never sees), assign, tag, and move the ticket through its status workflow. A customer only ever sees their own tickets, and replying to a closed ticket is refused. **Added:** *Customer support tickets* — A Support section in the account area lets a signed-in customer open a ticket (subject, message, category, and an optional reference to one of their own orders), list their tickets with status, read the full message thread, and reply while the ticket is open. Internal operator notes are never shown to the customer, a customer can only see and reply to their own tickets, and a reply to a closed ticket is refused. · *Support queue in the admin console* — A Support screen lists inbound tickets by status with an unassigned-triage view. Opening a ticket shows the full thread, the requester, the current status, the assignee, and tags. From there an operator can reply (optionally as an internal note), assign the ticket, tag it, and move it through its status workflow — in progress, waiting on the customer, resolved, closed, or reopened.
14
+
11
15
  - v0.3.25 (2026-05-30) — **Open an individual customer to see their orders and manage store credit, notes, and loyalty.** The customers list was view-only — there was no way to open a single customer. Each customer now has a detail screen showing their identity, recent orders (linked), store-credit balance with a grant/deduct control, loyalty balance and tier (with a link to adjust points), internal operator notes, and the segments they belong to. A store-credit grant or deduction requires a reason and is written to the store-credit ledger, an over-deduction is refused, and segment membership is shown read-only because it is computed from purchase behavior rather than set by hand. **Added:** *Customer detail screen* — Opening a customer from the roster now shows their record, recent orders, store-credit balance with a reason-gated grant/deduct (recorded in the store-credit ledger; an over-deduction is refused), their loyalty balance and tier with a link to adjust points, and an internal notes log you can add to. The segments a customer belongs to are listed read-only, since membership is derived from their behavior. Customer identity fields remain read-only by design.
12
16
 
13
17
  - v0.3.24 (2026-05-30) — **Manage the loyalty program from the admin console.** The loyalty program was visible to customers at /account/loyalty — their points balance, tier, the ways to earn, and the rewards — but there was no way to configure it from the console, so it could not actually be run. A new Loyalty admin screen now manages the whole program: create and edit the rules that award points (and archive ones that should no longer apply), build and edit the rewards catalog (a reward must be active to appear to customers), and grant or deduct a specific customer's points balance. A points adjustment requires a reason and is written to the loyalty ledger, so every manual change is recorded. **Added:** *Loyalty program administration* — A Loyalty screen in the admin console manages earn rules (the events that award points and how many), the rewards catalog (what points can be redeemed for, with per-kind values), and per-customer point adjustments. Only active, non-archived rules and rewards appear to customers. A manual grant or deduction requires a reason and is recorded in the loyalty ledger alongside automatic earn and redeem activity, and an over-deduction or invalid adjustment is rejected without changing the balance. This completes a program that previously could be shown to shoppers but not configured.
package/lib/admin.js CHANGED
@@ -508,6 +508,7 @@ function mount(router, deps) {
508
508
  var shippingLabels = deps.shippingLabels || null; // per-shipment carrier-label record disabled when absent
509
509
  var splitShipments = deps.splitShipments || null; // order split-shipment planner disabled when absent
510
510
  var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
511
+ var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
511
512
 
512
513
  // Which optional console sections are wired — gates their nav links so a
513
514
  // signed-in admin is never sent to a route that wasn't mounted. Passed
@@ -515,7 +516,7 @@ function mount(router, deps) {
515
516
  // `reports` is always present in the nav (read-only sales summary needs no
516
517
  // extra dep); its route mounts unconditionally and renders an unconfigured
517
518
  // notice when the salesReports primitive isn't wired.
518
- 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, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
519
+ 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, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets };
519
520
 
520
521
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
521
522
 
@@ -3578,6 +3579,220 @@ function mount(router, deps) {
3578
3579
  ));
3579
3580
  }
3580
3581
 
3582
+ // ---- support tickets ------------------------------------------------
3583
+ // The operator queue over the customer-service ticketing surface: a
3584
+ // status-filterable list (plus an unassigned-triage view via the
3585
+ // primitive's listUnassigned), a ticket detail with the full thread +
3586
+ // requester + assignee + tags, an operator reply, assign, tag, and the
3587
+ // FSM status transitions the module exposes (in_progress / waiting /
3588
+ // resolved / closed / reopened). Content-negotiated like the other
3589
+ // screens: bearer → JSON; signed-in browser → the HTML console.
3590
+ if (supportTickets) {
3591
+ var support = supportTickets;
3592
+
3593
+ // Resolve the ?status= chip into a list. The special "unassigned"
3594
+ // chip routes through listUnassigned; every real status routes through
3595
+ // the primitive's status-filtered list. A bad/absent chip falls back
3596
+ // to the whole board.
3597
+ async function _supportRows(filter) {
3598
+ if (filter === "unassigned") {
3599
+ return { rows: await support.listUnassigned({}), filter: "unassigned" };
3600
+ }
3601
+ if (filter && support.ALLOWED_STATUSES.indexOf(filter) !== -1) {
3602
+ return { rows: await support.list({ status_filter: filter, limit: support.MAX_LIST_LIMIT }), filter: filter };
3603
+ }
3604
+ return { rows: await support.list({ limit: support.MAX_LIST_LIMIT }), filter: "all" };
3605
+ }
3606
+
3607
+ router.get("/admin/support", _pageOrApi(true,
3608
+ R(async function (req, res) {
3609
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3610
+ var filter = (url && url.searchParams.get("status")) || null;
3611
+ var resolved = await _supportRows(filter);
3612
+ _json(res, 200, { rows: resolved.rows, status: resolved.filter });
3613
+ }),
3614
+ async function (req, res) {
3615
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3616
+ var filter = (url && url.searchParams.get("status")) || null;
3617
+ var resolved = await _supportRows(filter);
3618
+ _sendHtml(res, 200, renderAdminSupport({
3619
+ shop_name: deps.shop_name, nav_available: navAvailable,
3620
+ tickets: resolved.rows, status: resolved.filter,
3621
+ }));
3622
+ },
3623
+ ));
3624
+
3625
+ // Resolve a ticket by :id, surfacing a malformed / unknown id as a
3626
+ // 404 (the route is a defensive request-shape reader, never a 500).
3627
+ // Returns null after sending the 404 so the caller short-circuits.
3628
+ async function _supportTicketOr404(req, res, isJson) {
3629
+ var ticket;
3630
+ try { ticket = await support.get(req.params.id); }
3631
+ catch (e) {
3632
+ if (e instanceof TypeError) {
3633
+ if (isJson) { _problem(res, 404, "support-ticket-not-found"); return null; }
3634
+ _sendHtml(res, 404, renderAdminSupport({
3635
+ shop_name: deps.shop_name, nav_available: navAvailable, tickets: [], status: "all", notice: "Ticket not found.",
3636
+ }));
3637
+ return null;
3638
+ }
3639
+ throw e;
3640
+ }
3641
+ if (!ticket) {
3642
+ if (isJson) { _problem(res, 404, "support-ticket-not-found"); return null; }
3643
+ _sendHtml(res, 404, renderAdminSupport({
3644
+ shop_name: deps.shop_name, nav_available: navAvailable, tickets: [], status: "all", notice: "Ticket not found.",
3645
+ }));
3646
+ return null;
3647
+ }
3648
+ return ticket;
3649
+ }
3650
+
3651
+ router.get("/admin/support/:id", _pageOrApi(true,
3652
+ R(async function (req, res) {
3653
+ var ticket = await _supportTicketOr404(req, res, true); if (!ticket) return;
3654
+ var thread = await support.thread(ticket.id);
3655
+ _json(res, 200, thread);
3656
+ }),
3657
+ async function (req, res) {
3658
+ var ticket = await _supportTicketOr404(req, res, false); if (!ticket) return;
3659
+ var thread = await support.thread(ticket.id);
3660
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3661
+ _sendHtml(res, 200, renderAdminSupportTicket({
3662
+ shop_name: deps.shop_name,
3663
+ nav_available: navAvailable,
3664
+ ticket: thread.ticket,
3665
+ messages: thread.messages,
3666
+ transitions: support.ALLOWED_STATUSES,
3667
+ moved: url && url.searchParams.get("moved"),
3668
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this ticket." : null,
3669
+ }));
3670
+ },
3671
+ ));
3672
+
3673
+ // The browser side of a per-ticket action: run `opFn(id, body)`, then
3674
+ // PRG back to the detail. A bad shape (TypeError), an FSM refusal, a
3675
+ // closed-ticket reply, or a not-found row becomes an ?err=1 notice on
3676
+ // the detail, never a 500; anything else propagates to the wrapper.
3677
+ function _supportClientError(e) {
3678
+ if (!e) return false;
3679
+ if (e instanceof TypeError) return true;
3680
+ return e.code === "SUPPORT_TICKET_NOT_FOUND" ||
3681
+ e.code === "SUPPORT_TICKET_CLOSED" ||
3682
+ e.code === "SUPPORT_TICKET_TRANSITION_REFUSED";
3683
+ }
3684
+ function _supportAction(jsonHandler, auditEvent, opFn) {
3685
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
3686
+ var id = req.params.id;
3687
+ try { await opFn(id, req.body || {}); }
3688
+ catch (e) {
3689
+ if (_supportClientError(e)) {
3690
+ return _redirect(res, "/admin/support/" + encodeURIComponent(id) + "?err=1");
3691
+ }
3692
+ throw e;
3693
+ }
3694
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
3695
+ _redirect(res, "/admin/support/" + encodeURIComponent(id) + "?moved=1");
3696
+ });
3697
+ }
3698
+
3699
+ // Map a support primitive error to an API problem. A bad shape is a
3700
+ // 400, a not-found a 404, a closed-ticket reply / refused transition a
3701
+ // 409 — same classification the browser path uses, surfaced as
3702
+ // problem-details for the JSON contract.
3703
+ function _supportApiError(res, e) {
3704
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
3705
+ if (e && e.code === "SUPPORT_TICKET_NOT_FOUND") return _problem(res, 404, "support-ticket-not-found");
3706
+ if (e && e.code === "SUPPORT_TICKET_CLOSED") return _problem(res, 409, "conflict", e.message);
3707
+ if (e && e.code === "SUPPORT_TICKET_TRANSITION_REFUSED") return _problem(res, 409, "conflict", e.message);
3708
+ return null;
3709
+ }
3710
+
3711
+ // Operator reply — author "operator". An internal=1 note is supported
3712
+ // (operator-only; never shown to the customer) via the form's
3713
+ // checkbox.
3714
+ router.post("/admin/support/:id/reply", _supportAction(
3715
+ W("support.reply", async function (req, res) {
3716
+ var body = req.body || {};
3717
+ var ticket;
3718
+ try {
3719
+ ticket = await support.reply({
3720
+ ticket_id: req.params.id,
3721
+ author: "operator",
3722
+ body: body.body,
3723
+ internal: body.internal === "1" || body.internal === true,
3724
+ });
3725
+ } catch (e) {
3726
+ var mapped = _supportApiError(res, e); if (mapped !== null) return mapped;
3727
+ throw e;
3728
+ }
3729
+ _json(res, 200, ticket);
3730
+ return ticket;
3731
+ }),
3732
+ "support.reply",
3733
+ function (id, body) {
3734
+ return support.reply({
3735
+ ticket_id: id, author: "operator", body: body.body,
3736
+ internal: body.internal === "1" || body.internal === true,
3737
+ });
3738
+ },
3739
+ ));
3740
+
3741
+ // Assign the ticket to an operator (operator_id is a UUID — the
3742
+ // console seeds it from a hidden field carrying the signed-in
3743
+ // operator's id, or an explicit input).
3744
+ router.post("/admin/support/:id/assign", _supportAction(
3745
+ W("support.assign", async function (req, res) {
3746
+ var body = req.body || {};
3747
+ var ticket;
3748
+ try { ticket = await support.assign({ ticket_id: req.params.id, operator_id: body.operator_id }); }
3749
+ catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
3750
+ _json(res, 200, ticket);
3751
+ return ticket;
3752
+ }),
3753
+ "support.assign",
3754
+ function (id, body) { return support.assign({ ticket_id: id, operator_id: body.operator_id }); },
3755
+ ));
3756
+
3757
+ // Add a free-form tag.
3758
+ router.post("/admin/support/:id/tag", _supportAction(
3759
+ W("support.tag", async function (req, res) {
3760
+ var body = req.body || {};
3761
+ var ticket;
3762
+ try { ticket = await support.addTag({ ticket_id: req.params.id, tag: body.tag }); }
3763
+ catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
3764
+ _json(res, 200, ticket);
3765
+ return ticket;
3766
+ }),
3767
+ "support.tag",
3768
+ function (id, body) { return support.addTag({ ticket_id: id, tag: body.tag }); },
3769
+ ));
3770
+
3771
+ // FSM status transitions. Each posts to /admin/support/:id/<to_status>
3772
+ // for the edges the module's graph exposes; an illegal edge from the
3773
+ // current state is refused by the primitive (→ ?err=1 notice). The
3774
+ // `reopen` URL maps to the module's `reopened` status (the customer-
3775
+ // facing verb for a resolved ticket re-entering the workflow).
3776
+ function _supportTransition(verb, toStatus) {
3777
+ router.post("/admin/support/:id/" + verb, _supportAction(
3778
+ W("support." + verb, async function (req, res) {
3779
+ var ticket;
3780
+ try { ticket = await support.transition({ ticket_id: req.params.id, to_status: toStatus, reason: (req.body && req.body.reason) || undefined }); }
3781
+ catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
3782
+ _json(res, 200, ticket);
3783
+ return ticket;
3784
+ }),
3785
+ "support." + verb,
3786
+ function (id, body) { return support.transition({ ticket_id: id, to_status: toStatus, reason: (body && body.reason) || undefined }); },
3787
+ ));
3788
+ }
3789
+ _supportTransition("in-progress", "in_progress");
3790
+ _supportTransition("waiting-customer", "waiting_customer");
3791
+ _supportTransition("resolved", "resolved");
3792
+ _supportTransition("closed", "closed");
3793
+ _supportTransition("reopen", "reopened");
3794
+ }
3795
+
3581
3796
  // ---- config ---------------------------------------------------------
3582
3797
 
3583
3798
  var config = deps.config || null;
@@ -7881,6 +8096,7 @@ var ADMIN_NAV_ITEMS = [
7881
8096
  { key: "reports", href: "/admin/reports", label: "Reports" },
7882
8097
  { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
7883
8098
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
8099
+ { key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
7884
8100
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
7885
8101
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
7886
8102
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
@@ -9360,6 +9576,165 @@ function renderAdminReturn(opts) {
9360
9576
  return _renderAdminShell(opts.shop_name, "Return " + (r.rma_code || r.id.slice(0, 8)), body, "returns", opts.nav_available);
9361
9577
  }
9362
9578
 
9579
+ // The support-ticket queue chips: every FSM status the module exposes,
9580
+ // plus "all" (the whole board) and an "unassigned" triage view.
9581
+ var SUPPORT_STATUS_FILTERS = [
9582
+ "all", "unassigned", "new", "in_progress", "waiting_customer", "resolved", "closed", "reopened",
9583
+ ];
9584
+
9585
+ function _supportPillClass(status) {
9586
+ if (status === "resolved") return "paid";
9587
+ if (status === "closed") return "cancelled";
9588
+ if (status === "new" || status === "reopened") return "pending";
9589
+ return ""; // in_progress / waiting_customer use the default pill
9590
+ }
9591
+
9592
+ function _supportPriorityClass(priority) {
9593
+ return priority === "urgent" || priority === "high" ? "cancelled"
9594
+ : priority === "low" ? "" : "pending";
9595
+ }
9596
+
9597
+ // Operator queue. A status / unassigned chip set filters the board; each
9598
+ // row links to the ticket detail. Renders the requester (email is stored
9599
+ // hash-only, so the row shows the short hash), the status + priority
9600
+ // pills, the assignee state, and when it was opened.
9601
+ function renderAdminSupport(opts) {
9602
+ opts = opts || {};
9603
+ var tickets = opts.tickets || [];
9604
+ var active = opts.status || "all";
9605
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
9606
+
9607
+ var chips = "<div class=\"order-filters\">" +
9608
+ SUPPORT_STATUS_FILTERS.map(function (s) {
9609
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/support?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
9610
+ }).join("") +
9611
+ "</div>";
9612
+
9613
+ var rows = tickets.map(function (t) {
9614
+ var assignee = t.assigned_operator_id ? _htmlEscape(String(t.assigned_operator_id).slice(0, 8)) : "<span class=\"meta\">unassigned</span>";
9615
+ return "<tr>" +
9616
+ "<td><a class=\"order-id\" href=\"/admin/support/" + _htmlEscape(t.id) + "\">" + _htmlEscape(t.subject) + "</a></td>" +
9617
+ "<td>" + _htmlEscape(t.category) + "</td>" +
9618
+ "<td><span class=\"status-pill " + _supportPillClass(t.status) + "\">" + _htmlEscape(t.status) + "</span></td>" +
9619
+ "<td><span class=\"status-pill " + _supportPriorityClass(t.priority) + "\">" + _htmlEscape(t.priority) + "</span></td>" +
9620
+ "<td>" + assignee + "</td>" +
9621
+ "<td>" + _htmlEscape(_fmtDate(t.opened_at)) + "</td>" +
9622
+ "</tr>";
9623
+ }).join("");
9624
+
9625
+ var table = tickets.length
9626
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Subject</th><th scope=\"col\">Category</th><th scope=\"col\">Status</th><th scope=\"col\">Priority</th><th scope=\"col\">Assignee</th><th scope=\"col\">Opened</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
9627
+ : "<p class=\"empty\">No “" + _htmlEscape(active) + "” tickets.</p>";
9628
+
9629
+ var body = "<section><h2>Support</h2>" + notice + chips + table + "</section>";
9630
+ return _renderAdminShell(opts.shop_name, "Support", body, "support", opts.nav_available);
9631
+ }
9632
+
9633
+ // Ticket detail — the full thread (customer + operator + system + internal
9634
+ // notes), the requester / status / assignee / tags, and the action forms:
9635
+ // operator reply (with an internal-note toggle), assign, tag, and the FSM
9636
+ // status transitions legal from the current state.
9637
+ function renderAdminSupportTicket(opts) {
9638
+ opts = opts || {};
9639
+ var t = opts.ticket;
9640
+ var messages = opts.messages || [];
9641
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Ticket updated.</div>" : "";
9642
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
9643
+
9644
+ var thread = messages.map(function (m) {
9645
+ var who = m.author === "operator" ? "Operator" : m.author === "system" ? "System" : "Customer";
9646
+ var internalTag = Number(m.internal) === 1 ? " <span class=\"status-pill pending\">internal</span>" : "";
9647
+ return "<li class=\"support-msg support-msg--" + _htmlEscape(String(m.author)) + "\">" +
9648
+ "<p class=\"meta\">" + _htmlEscape(who) + internalTag + " · " + _htmlEscape(_fmtDate(m.created_at)) + "</p>" +
9649
+ "<p class=\"support-msg__body\">" + _htmlEscape(String(m.body)).replace(/\n/g, "<br>") + "</p>" +
9650
+ "</li>";
9651
+ }).join("");
9652
+ var threadHtml = messages.length
9653
+ ? "<ul class=\"support-thread\">" + thread + "</ul>"
9654
+ : "<p class=\"empty\">No messages.</p>";
9655
+
9656
+ var tags = (t.tags || []).map(function (tag) {
9657
+ return "<span class=\"status-pill\">" + _htmlEscape(tag) + "</span>";
9658
+ }).join(" ") || "<span class=\"meta\">none</span>";
9659
+
9660
+ function _field(label, value) {
9661
+ return "<p><span class=\"meta\">" + _htmlEscape(label) + "</span><br>" + (value ? _htmlEscape(String(value)) : "<span class=\"meta\">—</span>") + "</p>";
9662
+ }
9663
+
9664
+ // Legal FSM edges from the current status drive which transition
9665
+ // buttons render. Mirrors the module's TRANSITIONS graph.
9666
+ var EDGES = {
9667
+ "new": ["in-progress", "closed"],
9668
+ "in_progress": ["waiting-customer", "resolved", "closed"],
9669
+ "waiting_customer": ["in-progress", "resolved", "closed"],
9670
+ "resolved": ["reopen", "closed"],
9671
+ "reopened": ["in-progress", "closed"],
9672
+ "closed": [],
9673
+ };
9674
+ var TRANSITION_LABELS = {
9675
+ "in-progress": "Mark in progress", "waiting-customer": "Wait on customer",
9676
+ "resolved": "Resolve", "closed": "Close", "reopen": "Reopen",
9677
+ };
9678
+ var transitionForms = (EDGES[t.status] || []).map(function (verb) {
9679
+ return "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/" + verb + "\" class=\"form-inline\">" +
9680
+ "<button class=\"btn" + (verb === "closed" ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(TRANSITION_LABELS[verb] || verb) + "</button>" +
9681
+ "</form>";
9682
+ }).join("");
9683
+ var transitions = transitionForms
9684
+ ? "<div class=\"order-actions\">" + transitionForms + "</div>"
9685
+ : "<span class=\"meta\">This ticket is closed — no further status changes.</span>";
9686
+
9687
+ var replyForm = t.status === "closed"
9688
+ ? "<span class=\"meta\">Closed tickets can't take a reply.</span>"
9689
+ : "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/reply\" class=\"return-action\">" +
9690
+ "<h4>Reply</h4>" +
9691
+ "<label class=\"form-field\"><span>Message</span><textarea name=\"body\" rows=\"4\" maxlength=\"8000\" required></textarea></label>" +
9692
+ "<label class=\"form-field form-field--inline\"><input type=\"checkbox\" name=\"internal\" value=\"1\"> <span>Internal note (not shown to the customer)</span></label>" +
9693
+ "<button class=\"btn\" type=\"submit\">Send reply</button>" +
9694
+ "</form>";
9695
+
9696
+ var assignForm =
9697
+ "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/assign\" class=\"return-action\">" +
9698
+ "<h4>Assign</h4>" +
9699
+ _setupField("Operator id (UUID)", "operator_id", t.assigned_operator_id || "", "text", "The operator to own this ticket.", " required") +
9700
+ "<button class=\"btn\" type=\"submit\">Assign</button>" +
9701
+ "</form>";
9702
+
9703
+ var tagForm =
9704
+ "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/tag\" class=\"return-action\">" +
9705
+ "<h4>Add tag</h4>" +
9706
+ _setupField("Tag", "tag", "", "text", "", " maxlength=\"32\" required") +
9707
+ "<button class=\"btn\" type=\"submit\">Add tag</button>" +
9708
+ "</form>";
9709
+
9710
+ var body =
9711
+ "<section class=\"mw-48\">" +
9712
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/support\">&larr; Support</a></div>" +
9713
+ "<h2>" + _htmlEscape(t.subject) + " " +
9714
+ "<span class=\"status-pill " + _supportPillClass(t.status) + "\">" + _htmlEscape(t.status) + "</span></h2>" +
9715
+ "<p class=\"meta\">Opened " + _htmlEscape(_fmtDate(t.opened_at)) +
9716
+ " · priority " + _htmlEscape(t.priority) +
9717
+ (t.order_id ? " · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(t.order_id) + "\">" + _htmlEscape(String(t.order_id).slice(0, 8)) + "</a>" : "") +
9718
+ "</p>" +
9719
+ moved + notice +
9720
+ "<div class=\"two-col\">" +
9721
+ "<div class=\"panel\"><h3 class=\"subhead\">Conversation</h3>" + threadHtml + "</div>" +
9722
+ "<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
9723
+ _field("Category", t.category) +
9724
+ _field("Requester", t.customer_email_hash ? String(t.customer_email_hash).slice(0, 12) + "…" : null) +
9725
+ _field("Customer id", t.customer_id ? String(t.customer_id).slice(0, 8) : null) +
9726
+ _field("Assignee", t.assigned_operator_id ? String(t.assigned_operator_id).slice(0, 8) : null) +
9727
+ "<p><span class=\"meta\">Tags</span><br>" + tags + "</p>" +
9728
+ "</div>" +
9729
+ "</div>" +
9730
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Status</h3>" + transitions + "</div>" +
9731
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
9732
+ "<div class=\"return-actions\">" + replyForm + assignForm + tagForm + "</div>" +
9733
+ "</div>" +
9734
+ "</section>";
9735
+ return _renderAdminShell(opts.shop_name, "Ticket", body, "support", opts.nav_available);
9736
+ }
9737
+
9363
9738
  // The review states an operator can filter the moderation queue by.
9364
9739
  var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
9365
9740
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.25",
2
+ "version": "0.3.27",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/storefront.js CHANGED
@@ -693,7 +693,6 @@ function _wrap(opts) {
693
693
  var ogType = opts.og_type || "website";
694
694
  var ogTitle = opts.og_title || (opts.title ? opts.title + " — " + shopName : shopName);
695
695
  var ogDescription = opts.og_description || "Open-source ecommerce framework built on blamejs. Server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.";
696
- var ogImage = opts.og_image || "/assets/brand/logo.png";
697
696
  // Absolute request URL drives both `og:url` (full URL incl. query) and
698
697
  // the canonical link (query stripped — the canonical names the page,
699
698
  // not the filtered/sorted view). Renderers thread `opts.canonical_url`
@@ -702,6 +701,14 @@ function _wrap(opts) {
702
701
  // rather than emit a bogus host-less URL.
703
702
  var canonicalUrl = opts.canonical_url || "";
704
703
  var ogUrl = opts.og_url || canonicalUrl;
704
+ // og:image / twitter:image carry a FULLY-QUALIFIED URL — a relative
705
+ // `/assets/...` (the brand-logo default, or a product hero) is dropped by
706
+ // every social-share crawler (Facebook / Slack / Twitter / iMessage) and
707
+ // by Google's rich result. Absolutize against the page origin so the
708
+ // share preview resolves; an operator-hosted `http(s)://` image passes
709
+ // through unchanged. Every container page funnels through `_wrap`, so
710
+ // this is the single absolutization site for the storefront's meta tags.
711
+ var ogImage = _absolutizeOgImage(opts.og_image || "/assets/brand/logo.png", canonicalUrl, shopName);
705
712
  // Multi-currency display switcher — populated only when the operator
706
713
  // configured >1 display currency (opts.currency_options). The block is
707
714
  // empty otherwise, so a single-currency store renders unchanged.
@@ -796,12 +803,18 @@ function _wrap(opts) {
796
803
  var assembled = _render(LAYOUT, vars)
797
804
  .replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
798
805
  .replace("RAW_ROBOTS_META", robotsMeta)
799
- .replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
800
806
  .replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
801
807
  .replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
802
808
  .replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
803
809
  .replace("RAW_CURRENCY_SWITCHER", switcherHtml)
804
810
  .replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "");
811
+ // The announcement bar carries operator-supplied message text (HTML-
812
+ // escaped, but `$` is not an escaped character), so splice it via the
813
+ // replacer-function helper — a `$&` / `` $` `` / `$N` in the message must
814
+ // land literally, not trigger `String.replace`'s dollar substitution.
815
+ // Matches the edge renderers' `spliceRaw` so the dual-render stays
816
+ // byte-consistent under a `$`-bearing announcement. See `_spliceRaw`.
817
+ assembled = _spliceRaw(assembled, "RAW_ANNOUNCEMENT_BAR", announcementBarHtml);
805
818
  // The body is RAW HTML (already rendered + escaped at the
806
819
  // per-fragment level). The placeholder swap is post-render so the
807
820
  // outer renderer's HTML-escape doesn't double-escape the inner
@@ -1204,6 +1217,30 @@ function _absoluteBase(canonicalUrl, shopName) {
1204
1217
  return "https://" + String(shopName || "blamejs.shop").replace(/^https?:\/\//, "");
1205
1218
  }
1206
1219
 
1220
+ // Absolutize an og:image / twitter:image / JSON-LD image value against the
1221
+ // page origin. A relative `/assets/...` path (the brand-logo default, or a
1222
+ // hero R2 key joined onto the asset prefix) becomes `<origin>/assets/...`
1223
+ // so every social-share crawler and rich-result fetch resolves it — a
1224
+ // relative path is dropped by Facebook / Slack / Twitter / iMessage and by
1225
+ // Google's product rich result. An already-absolute `http(s)://` value is
1226
+ // left unchanged (an operator-hosted image), and a value that is neither a
1227
+ // `/`-rooted path nor an absolute URL is returned as-is (nothing safe to
1228
+ // prefix). Absolutizes only against a reliable origin: with a canonical URL
1229
+ // the request origin is used; without one, the shop-name host is used ONLY
1230
+ // when it is usable as a host (no whitespace) — a display-name shop such as
1231
+ // "Test Shop" would otherwise emit an invalid "https://Test Shop/..." URL,
1232
+ // so the path is left relative (it still resolves against the page on a
1233
+ // crawler fetch). Mirrors the edge's worker/render/_lib.js `absolutizeOgImage`
1234
+ // so the two substrates emit identical absolute image URLs.
1235
+ function _absolutizeOgImage(value, canonicalUrl, shopName) {
1236
+ var v = (value == null) ? "" : String(value);
1237
+ if (/^https?:\/\//i.test(v)) return v;
1238
+ if (v.charAt(0) !== "/") return v;
1239
+ var hasCanonical = typeof canonicalUrl === "string" && canonicalUrl.length > 0;
1240
+ if (!hasCanonical && /\s/.test(String(shopName == null ? "" : shopName))) return v;
1241
+ return _absoluteBase(canonicalUrl, shopName) + v;
1242
+ }
1243
+
1207
1244
  // Schema.org Organization + WebSite JSON-LD for the home page. Shared by
1208
1245
  // the container `renderHome` and mirrored by the edge
1209
1246
  // `worker/render/home.js` — keep the two byte-identical. The base URL is
@@ -2860,9 +2897,25 @@ function renderCollection(opts) {
2860
2897
  "</header>" +
2861
2898
  grid +
2862
2899
  "</section>";
2900
+ // BreadcrumbList JSON-LD mirroring the on-page `<nav class="breadcrumb">`
2901
+ // trail (Shop → Collections → this collection). Google's result panel
2902
+ // renders the trail above the title; the `item` URLs are absolute so the
2903
+ // structured data is fully-qualified. Mirrors the PDP's breadcrumb shape.
2904
+ var shopName = opts.shop_name || "blamejs.shop";
2905
+ var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
2906
+ var breadcrumbJsonLd = _jsonLdScript({
2907
+ "@context": "https://schema.org",
2908
+ "@type": "BreadcrumbList",
2909
+ "itemListElement": [
2910
+ { "@type": "ListItem", "position": 1, "name": "Shop", "item": absoluteBase + "/" },
2911
+ { "@type": "ListItem", "position": 2, "name": "Collections", "item": absoluteBase + "/collections" },
2912
+ { "@type": "ListItem", "position": 3, "name": col.title, "item": absoluteBase + "/collections/" + col.slug },
2913
+ ],
2914
+ });
2863
2915
  return _wrap({
2864
- title: col.title, shop_name: opts.shop_name || "blamejs.shop",
2865
- cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
2916
+ title: col.title, shop_name: shopName,
2917
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css,
2918
+ body: body + breadcrumbJsonLd,
2866
2919
  canonical_url: opts.canonical_url, og_url: opts.og_url,
2867
2920
  });
2868
2921
  }
@@ -2971,15 +3024,40 @@ function renderCategory(opts) {
2971
3024
  "</section>";
2972
3025
  // Per-page meta description: the category's own description when set,
2973
3026
  // otherwise a "Shop {category}…" pitch.
3027
+ var shopName = opts.shop_name || "blamejs.shop";
2974
3028
  var catMetaDescription = (cat.description && String(cat.description).trim().length)
2975
3029
  ? String(cat.description)
2976
- : ("Shop " + cat.title + " at " + (opts.shop_name || "blamejs.shop") + ".");
3030
+ : ("Shop " + cat.title + " at " + shopName + ".");
3031
+ // BreadcrumbList JSON-LD mirroring the on-page `<nav class="breadcrumb">`
3032
+ // chain (Shop → Categories → …root→current). Google's result panel
3033
+ // renders the trail above the title; the `item` URLs are absolute so the
3034
+ // structured data is fully-qualified. The breadcrumb chain's last entry
3035
+ // is the current category (rendered as plain text on-page), included here
3036
+ // as the trailing list item with its own URL. Mirrors the PDP shape.
3037
+ var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
3038
+ var crumbItems = [
3039
+ { "@type": "ListItem", "position": 1, "name": "Shop", "item": absoluteBase + "/" },
3040
+ { "@type": "ListItem", "position": 2, "name": "Categories", "item": absoluteBase + "/categories" },
3041
+ ];
3042
+ for (var bi = 0; bi < crumbs.length; bi += 1) {
3043
+ crumbItems.push({
3044
+ "@type": "ListItem",
3045
+ "position": bi + 3,
3046
+ "name": crumbs[bi].title,
3047
+ "item": absoluteBase + "/categories/" + crumbs[bi].slug,
3048
+ });
3049
+ }
3050
+ var breadcrumbJsonLd = _jsonLdScript({
3051
+ "@context": "https://schema.org",
3052
+ "@type": "BreadcrumbList",
3053
+ "itemListElement": crumbItems,
3054
+ });
2977
3055
  return _wrap({
2978
- title: cat.title, shop_name: opts.shop_name || "blamejs.shop",
3056
+ title: cat.title, shop_name: shopName,
2979
3057
  cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css,
2980
3058
  og_description: catMetaDescription,
2981
3059
  canonical_url: opts.canonical_url, og_url: opts.og_url,
2982
- body: body,
3060
+ body: body + breadcrumbJsonLd,
2983
3061
  });
2984
3062
  }
2985
3063
 
@@ -3158,6 +3236,215 @@ function renderReturns(opts) {
3158
3236
  });
3159
3237
  }
3160
3238
 
3239
+ // Support ticket — the categories a shopper can pick when raising one,
3240
+ // and the human label for each. The values mirror the support-tickets
3241
+ // module's ALLOWED_CATEGORIES; the backend validates the submitted value
3242
+ // against its own allow-list, so a forged value is refused there.
3243
+ var SUPPORT_CATEGORIES = [
3244
+ ["pre_sale", "Pre-sale question"],
3245
+ ["order_issue", "Problem with an order"],
3246
+ ["shipping", "Shipping / delivery"],
3247
+ ["billing", "Billing"],
3248
+ ["refund", "Refund"],
3249
+ ["account", "My account"],
3250
+ ["complaint", "Complaint"],
3251
+ ["feature_request", "Feature request"],
3252
+ ["other", "Something else"],
3253
+ ];
3254
+
3255
+ // Customer-facing status word for a ticket. The module's status enum is
3256
+ // operator vocabulary (new / in_progress / waiting_customer / resolved /
3257
+ // closed / reopened); these are the shopper-facing phrasings.
3258
+ var SUPPORT_STATUS_LABELS = {
3259
+ "new": "Received",
3260
+ "in_progress": "In progress",
3261
+ "waiting_customer": "Awaiting your reply",
3262
+ "resolved": "Resolved",
3263
+ "closed": "Closed",
3264
+ "reopened": "Reopened",
3265
+ };
3266
+
3267
+ function _supportStatusBadge(status) {
3268
+ var esc = b.template.escapeHtml;
3269
+ var label = SUPPORT_STATUS_LABELS[status] || status;
3270
+ return "<span class=\"return-status return-status--" + esc(String(status)) + "\">" +
3271
+ esc(String(label)) + "</span>";
3272
+ }
3273
+
3274
+ function _supportCategoryLabel(value) {
3275
+ for (var i = 0; i < SUPPORT_CATEGORIES.length; i += 1) {
3276
+ if (SUPPORT_CATEGORIES[i][0] === value) return SUPPORT_CATEGORIES[i][1];
3277
+ }
3278
+ return value;
3279
+ }
3280
+
3281
+ // The signed-in customer's support-ticket list. `opts.tickets` is the
3282
+ // ownership-scoped set (already filtered to this customer); each links to
3283
+ // its thread view.
3284
+ function renderSupportList(opts) {
3285
+ var esc = b.template.escapeHtml;
3286
+ var tickets = opts.tickets || [];
3287
+ var rowsHtml = "";
3288
+ for (var i = 0; i < tickets.length; i += 1) {
3289
+ var t = tickets[i];
3290
+ var date = t.opened_at ? new Date(Number(t.opened_at)).toISOString().slice(0, 10) : "";
3291
+ rowsHtml +=
3292
+ "<li class=\"return-card\">" +
3293
+ "<div class=\"return-card__head\">" +
3294
+ "<a class=\"return-card__rma\" href=\"/account/support/" + esc(String(t.id)) + "\">" + esc(String(t.subject)) + "</a>" +
3295
+ _supportStatusBadge(t.status) +
3296
+ "</div>" +
3297
+ "<p class=\"return-card__meta\">" + esc(_supportCategoryLabel(t.category)) +
3298
+ (date ? " &middot; <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
3299
+ "</p>" +
3300
+ "</li>";
3301
+ }
3302
+ var inner = rowsHtml
3303
+ ? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
3304
+ : "<div class=\"account-empty\">" +
3305
+ "<p class=\"account-empty__lede\">No support tickets yet. Raise one and we'll get back to you.</p>" +
3306
+ "</div>";
3307
+ var notice = "";
3308
+ if (opts.created) {
3309
+ notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Your ticket has been raised — we'll reply here.</p>";
3310
+ }
3311
+ var body =
3312
+ "<section class=\"account-returns\">" +
3313
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3314
+ "<li><a href=\"/account\">Account</a></li>" +
3315
+ "<li aria-current=\"page\">Support</li>" +
3316
+ "</ol></nav>" +
3317
+ "<header class=\"account-recently-viewed__head\">" +
3318
+ "<h1 class=\"account-returns__title\">Support</h1>" +
3319
+ "<a class=\"btn-primary\" href=\"/account/support/new\">Raise a ticket</a>" +
3320
+ "</header>" +
3321
+ notice +
3322
+ inner +
3323
+ "</section>";
3324
+ return _wrap({
3325
+ title: "Support",
3326
+ shop_name: opts.shop_name || "blamejs.shop",
3327
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3328
+ theme_css: opts.theme_css,
3329
+ body: body,
3330
+ });
3331
+ }
3332
+
3333
+ // The new-ticket form. `opts.orders` is the signed-in customer's recent
3334
+ // orders so they can attach one (optional). `opts.values` re-fills the
3335
+ // form after a validation bounce; `opts.notice` is the error to show.
3336
+ function renderSupportNew(opts) {
3337
+ var esc = b.template.escapeHtml;
3338
+ var v = opts.values || {};
3339
+ var orders = opts.orders || [];
3340
+ var categoryOpts = SUPPORT_CATEGORIES.map(function (c) {
3341
+ var sel = v.category === c[0] ? " selected" : "";
3342
+ return "<option value=\"" + esc(c[0]) + "\"" + sel + ">" + esc(c[1]) + "</option>";
3343
+ }).join("");
3344
+ var orderOptions = "<option value=\"\">No specific order</option>" +
3345
+ orders.map(function (o) {
3346
+ var sel = v.order_id === o.id ? " selected" : "";
3347
+ var when = o.created_at ? new Date(Number(o.created_at)).toISOString().slice(0, 10) : "";
3348
+ return "<option value=\"" + esc(String(o.id)) + "\"" + sel + ">" +
3349
+ esc(String(o.id).slice(0, 8)) + (when ? " · " + esc(when) : "") + "</option>";
3350
+ }).join("");
3351
+ var orderField = orders.length
3352
+ ? "<label class=\"form-field\"><span class=\"form-field__label\">Related order (optional)</span>" +
3353
+ "<select name=\"order_id\">" + orderOptions + "</select></label>"
3354
+ : "";
3355
+ var notice = opts.notice
3356
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
3357
+ : "";
3358
+ var body =
3359
+ "<section class=\"return-form-page\">" +
3360
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3361
+ "<li><a href=\"/account\">Account</a></li>" +
3362
+ "<li><a href=\"/account/support\">Support</a></li>" +
3363
+ "<li aria-current=\"page\">Raise a ticket</li>" +
3364
+ "</ol></nav>" +
3365
+ "<h1 class=\"return-form-page__title\">Raise a support ticket</h1>" +
3366
+ notice +
3367
+ "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/support\">" +
3368
+ "<label class=\"form-field\"><span class=\"form-field__label\">Email for our reply</span>" +
3369
+ "<input type=\"email\" name=\"customer_email\" value=\"" + esc(v.customer_email == null ? "" : String(v.customer_email)) + "\" required autocomplete=\"email\" maxlength=\"254\"></label>" +
3370
+ "<label class=\"form-field\"><span class=\"form-field__label\">Category</span>" +
3371
+ "<select name=\"category\" required>" + categoryOpts + "</select></label>" +
3372
+ orderField +
3373
+ "<label class=\"form-field\"><span class=\"form-field__label\">Subject</span>" +
3374
+ "<input type=\"text\" name=\"subject\" value=\"" + esc(v.subject == null ? "" : String(v.subject)) + "\" required maxlength=\"200\"></label>" +
3375
+ "<label class=\"form-field\"><span class=\"form-field__label\">How can we help?</span>" +
3376
+ "<textarea name=\"body\" required maxlength=\"8000\" rows=\"6\">" + esc(v.body == null ? "" : String(v.body)) + "</textarea></label>" +
3377
+ "<button type=\"submit\" class=\"btn-primary\">Send</button>" +
3378
+ "</form>" +
3379
+ "</section>";
3380
+ return _wrap({
3381
+ title: "Raise a ticket",
3382
+ shop_name: opts.shop_name || "blamejs.shop",
3383
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3384
+ theme_css: opts.theme_css,
3385
+ body: body,
3386
+ });
3387
+ }
3388
+
3389
+ // One ticket's thread view. `opts.ticket` is the row, `opts.messages` the
3390
+ // thread in created_at ASC. Internal operator notes (internal=1) are
3391
+ // filtered out by the route before rendering — they never reach the
3392
+ // customer. A non-closed ticket shows the reply box.
3393
+ function renderSupportTicket(opts) {
3394
+ var esc = b.template.escapeHtml;
3395
+ var t = opts.ticket;
3396
+ var messages = opts.messages || [];
3397
+ var msgHtml = "";
3398
+ for (var i = 0; i < messages.length; i += 1) {
3399
+ var m = messages[i];
3400
+ var who = m.author === "operator" ? "Support" : m.author === "system" ? "System" : "You";
3401
+ var when = m.created_at ? new Date(Number(m.created_at)).toISOString().slice(0, 16).replace("T", " ") : "";
3402
+ msgHtml +=
3403
+ "<li class=\"support-msg support-msg--" + esc(String(m.author)) + "\">" +
3404
+ "<p class=\"support-msg__who\">" + esc(who) +
3405
+ (when ? " <time class=\"support-msg__when\" datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") + "</p>" +
3406
+ "<p class=\"support-msg__body\">" + esc(String(m.body)).replace(/\n/g, "<br>") + "</p>" +
3407
+ "</li>";
3408
+ }
3409
+ var notice = opts.notice
3410
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
3411
+ : "";
3412
+ var replyOk = opts.replied
3413
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">Your reply has been added.</p>"
3414
+ : "";
3415
+ var closed = t.status === "closed";
3416
+ var replyForm = closed
3417
+ ? "<p class=\"meta\">This ticket is closed. Raise a new one if you still need help.</p>"
3418
+ : "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/support/" + esc(String(t.id)) + "/reply\">" +
3419
+ "<label class=\"form-field\"><span class=\"form-field__label\">Add a reply</span>" +
3420
+ "<textarea name=\"body\" required maxlength=\"8000\" rows=\"5\"></textarea></label>" +
3421
+ "<button type=\"submit\" class=\"btn-primary\">Send reply</button>" +
3422
+ "</form>";
3423
+ var body =
3424
+ "<section class=\"account-returns\">" +
3425
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
3426
+ "<li><a href=\"/account\">Account</a></li>" +
3427
+ "<li><a href=\"/account/support\">Support</a></li>" +
3428
+ "<li aria-current=\"page\">Ticket</li>" +
3429
+ "</ol></nav>" +
3430
+ "<header class=\"account-recently-viewed__head\">" +
3431
+ "<h1 class=\"account-returns__title\">" + esc(String(t.subject)) + "</h1>" +
3432
+ _supportStatusBadge(t.status) +
3433
+ "</header>" +
3434
+ "<p class=\"return-card__meta\">" + esc(_supportCategoryLabel(t.category)) + "</p>" +
3435
+ replyOk + notice +
3436
+ "<ul class=\"support-thread\">" + msgHtml + "</ul>" +
3437
+ replyForm +
3438
+ "</section>";
3439
+ return _wrap({
3440
+ title: String(t.subject),
3441
+ shop_name: opts.shop_name || "blamejs.shop",
3442
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
3443
+ theme_css: opts.theme_css,
3444
+ body: body,
3445
+ });
3446
+ }
3447
+
3161
3448
  // Loyalty transaction-type pill — reuses the `pdp__badge` class the
3162
3449
  // theme already styles. The type is one of the ledger's closed enum
3163
3450
  // (earn / redeem / expire / adjust / tier-bonus).
@@ -3982,12 +4269,21 @@ function renderProduct(opts) {
3982
4269
  // unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
3983
4270
  // the default shop-level description + brand logo.
3984
4271
  var heroMedia = (opts.media && opts.media[0]) || null;
3985
- var ogImage = heroMedia ? ((opts.asset_prefix || "/assets/") + heroMedia.r2_key) : "/assets/brand/logo.png";
3986
4272
  // Absolute base for the BreadcrumbList `item` URLs — derived from the
3987
4273
  // PDP's own canonical (origin stripped of the /products/slug path) so
3988
4274
  // the structured-data trail carries fully-qualified URLs. Falls back to
3989
4275
  // the shop-name host when the renderer is called without a request URL.
3990
4276
  var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
4277
+ // og:image / twitter:image / the Product JSON-LD `image` all carry a
4278
+ // FULLY-QUALIFIED URL — a relative hero path (or the brand-logo default)
4279
+ // is dropped by social-share crawlers and by Google's product rich
4280
+ // result. Absolutize once here so the JSON-LD `image` (built below,
4281
+ // before `_wrap`) and the meta tags (`_wrap` re-runs the idempotent
4282
+ // absolutizer) both carry the resolved URL.
4283
+ var ogImage = _absolutizeOgImage(
4284
+ heroMedia ? ((opts.asset_prefix || "/assets/") + heroMedia.r2_key) : "/assets/brand/logo.png",
4285
+ opts.canonical_url, shopName
4286
+ );
3991
4287
 
3992
4288
  // Product + AggregateOffer JSON-LD, with AggregateRating folded in
3993
4289
  // when published reviews exist. Kept byte-compatible with the edge
@@ -5978,6 +6274,7 @@ var ACCOUNT_DASH_PAGE =
5978
6274
  " <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
5979
6275
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
5980
6276
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
6277
+ " <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
5981
6278
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
5982
6279
  " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
5983
6280
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
@@ -10531,6 +10828,212 @@ function mount(router, deps) {
10531
10828
  });
10532
10829
  }
10533
10830
 
10831
+ // Support tickets — the signed-in customer raises a ticket, lists
10832
+ // their own, reads a thread, and replies. EVERY route is login-gated
10833
+ // AND scoped to the session customer's id: the support primitive
10834
+ // stores `customer_id` on each ticket, so a ticket whose customer_id
10835
+ // doesn't match the signed-in shopper is a 404 (never a cross-customer
10836
+ // reveal — the IDOR defense). The raw email is never on disk, so the
10837
+ // intake form collects a reply-to address (the backend validates +
10838
+ // hashes it); the list / view / reply paths key on the session
10839
+ // customer_id alone, independent of any email.
10840
+ if (deps.supportTickets) {
10841
+ var support = deps.supportTickets;
10842
+
10843
+ function _supportAuth(req, res) {
10844
+ var auth;
10845
+ try { auth = _currentCustomer(req); }
10846
+ catch (e) {
10847
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
10848
+ throw e;
10849
+ }
10850
+ if (!auth) {
10851
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
10852
+ res.end ? res.end() : res.send("");
10853
+ return null;
10854
+ }
10855
+ return auth;
10856
+ }
10857
+
10858
+ // Load the ticket named in :id and confirm it belongs to the
10859
+ // signed-in customer. A malformed id (guardUuid TypeError from
10860
+ // support.get), a missing ticket, or a ticket owned by someone else
10861
+ // ALL return 404 — never a 500, never a leak of another customer's
10862
+ // ticket. This single helper is the ownership gate every per-ticket
10863
+ // route funnels through.
10864
+ async function _ownedTicket(req, res, auth) {
10865
+ var ticket;
10866
+ try { ticket = await support.get(req.params && req.params.id); }
10867
+ catch (e) {
10868
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
10869
+ throw e;
10870
+ }
10871
+ if (!ticket || ticket.customer_id !== auth.customer_id) {
10872
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
10873
+ return null;
10874
+ }
10875
+ return ticket;
10876
+ }
10877
+
10878
+ // The customer's own ticket list, scoped to their session customer_id.
10879
+ router.get("/account/support", async function (req, res) {
10880
+ var auth = _supportAuth(req, res); if (!auth) return;
10881
+ var tickets = await support.listByCustomerId(auth.customer_id, { limit: 100 });
10882
+ var cartCount = await _cartCountForReq(req);
10883
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
10884
+ _send(res, 200, renderSupportList({
10885
+ tickets: tickets,
10886
+ created: url && url.searchParams.get("ok") === "1",
10887
+ shop_name: shopName,
10888
+ cart_count: cartCount,
10889
+ }));
10890
+ });
10891
+
10892
+ // The new-ticket form. Offers the customer's recent orders as an
10893
+ // optional attachment.
10894
+ router.get("/account/support/new", async function (req, res) {
10895
+ var auth = _supportAuth(req, res); if (!auth) return;
10896
+ var orders = [];
10897
+ if (deps.order) {
10898
+ try {
10899
+ var page = await deps.order.listForCustomer(auth.customer_id, { limit: 20 });
10900
+ orders = page.rows;
10901
+ } catch (_e) { orders = []; }
10902
+ }
10903
+ var cartCount = await _cartCountForReq(req);
10904
+ _send(res, 200, renderSupportNew({
10905
+ orders: orders,
10906
+ shop_name: shopName,
10907
+ cart_count: cartCount,
10908
+ }));
10909
+ });
10910
+
10911
+ // Create the ticket as the session customer. customer_id is pinned
10912
+ // to the session — never read off the form — so a shopper can only
10913
+ // ever open a ticket against their own account. An optional order_id
10914
+ // is accepted only after confirming the order belongs to this
10915
+ // customer (so a forged id can't attach a stranger's order); a
10916
+ // non-owned / unknown order is dropped silently rather than failing
10917
+ // the ticket. The backend validates subject / body / category /
10918
+ // email shape and surfaces a TypeError on bad input as a clean
10919
+ // re-render, never a 500.
10920
+ router.post("/account/support", async function (req, res) {
10921
+ var auth = _supportAuth(req, res); if (!auth) return;
10922
+ var body = req.body || {};
10923
+ var cartCount = await _cartCountForReq(req);
10924
+
10925
+ async function _orders() {
10926
+ if (!deps.order) return [];
10927
+ try { return (await deps.order.listForCustomer(auth.customer_id, { limit: 20 })).rows; }
10928
+ catch (_e) { return []; }
10929
+ }
10930
+
10931
+ // Only attach an order the requesting customer actually owns.
10932
+ var orderId;
10933
+ if (body.order_id && deps.order) {
10934
+ try {
10935
+ var ord = await deps.order.get(body.order_id);
10936
+ if (ord && ord.customer_id === auth.customer_id) orderId = ord.id;
10937
+ } catch (_e) { /* malformed / unknown order id — attach nothing */ }
10938
+ }
10939
+
10940
+ var opened;
10941
+ try {
10942
+ opened = await support.open({
10943
+ customer_id: auth.customer_id,
10944
+ customer_email: body.customer_email,
10945
+ subject: body.subject,
10946
+ body: body.body,
10947
+ category: body.category,
10948
+ order_id: orderId,
10949
+ });
10950
+ } catch (e) {
10951
+ if (e instanceof TypeError) {
10952
+ return _send(res, 400, renderSupportNew({
10953
+ orders: await _orders(),
10954
+ values: body,
10955
+ notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your ticket and try again.",
10956
+ shop_name: shopName,
10957
+ cart_count: cartCount,
10958
+ }));
10959
+ }
10960
+ throw e;
10961
+ }
10962
+ res.status(303);
10963
+ res.setHeader && res.setHeader("location", "/account/support/" + encodeURIComponent(opened.id) + "?ok=1");
10964
+ return res.end ? res.end() : res.send("");
10965
+ });
10966
+
10967
+ // One ticket's thread. Ownership-scoped (foreign / unknown → 404).
10968
+ // Internal operator notes are filtered out before render — the
10969
+ // customer never sees an internal=1 message.
10970
+ router.get("/account/support/:id", async function (req, res) {
10971
+ var auth = _supportAuth(req, res); if (!auth) return;
10972
+ var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
10973
+ var thread = await support.thread(ticket.id);
10974
+ var visible = (thread.messages || []).filter(function (m) { return Number(m.internal) !== 1; });
10975
+ var cartCount = await _cartCountForReq(req);
10976
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
10977
+ _send(res, 200, renderSupportTicket({
10978
+ ticket: thread.ticket,
10979
+ messages: visible,
10980
+ replied: url && url.searchParams.get("ok") === "1",
10981
+ shop_name: shopName,
10982
+ cart_count: cartCount,
10983
+ }));
10984
+ });
10985
+
10986
+ // Append a customer reply. Ownership-scoped; refused on a closed
10987
+ // ticket (the backend rejects a closed-ticket reply with a typed
10988
+ // SUPPORT_TICKET_CLOSED error — surfaced as a 409 re-render, never a
10989
+ // 500). author is pinned to "customer".
10990
+ router.post("/account/support/:id/reply", async function (req, res) {
10991
+ var auth = _supportAuth(req, res); if (!auth) return;
10992
+ var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
10993
+ var body = req.body || {};
10994
+ var cartCount = await _cartCountForReq(req);
10995
+
10996
+ // A closed ticket can't take a reply — short-circuit to a
10997
+ // re-render with a notice rather than calling the backend (which
10998
+ // would also refuse, but this keeps the message customer-readable).
10999
+ if (ticket.status === "closed") {
11000
+ var closedThread = await support.thread(ticket.id);
11001
+ return _send(res, 409, renderSupportTicket({
11002
+ ticket: closedThread.ticket,
11003
+ messages: (closedThread.messages || []).filter(function (m) { return Number(m.internal) !== 1; }),
11004
+ notice: "This ticket is closed — raise a new one if you still need help.",
11005
+ shop_name: shopName,
11006
+ cart_count: cartCount,
11007
+ }));
11008
+ }
11009
+
11010
+ try {
11011
+ await support.reply({
11012
+ ticket_id: ticket.id,
11013
+ author: "customer",
11014
+ author_id: auth.customer_id,
11015
+ body: body.body,
11016
+ });
11017
+ } catch (e) {
11018
+ if (e instanceof TypeError || (e && e.code === "SUPPORT_TICKET_CLOSED")) {
11019
+ var againThread = await support.thread(ticket.id);
11020
+ var status = (e && e.code === "SUPPORT_TICKET_CLOSED") ? 409 : 400;
11021
+ return _send(res, status, renderSupportTicket({
11022
+ ticket: againThread.ticket,
11023
+ messages: (againThread.messages || []).filter(function (m) { return Number(m.internal) !== 1; }),
11024
+ notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your reply and try again.",
11025
+ shop_name: shopName,
11026
+ cart_count: cartCount,
11027
+ }));
11028
+ }
11029
+ throw e;
11030
+ }
11031
+ res.status(303);
11032
+ res.setHeader && res.setHeader("location", "/account/support/" + encodeURIComponent(ticket.id) + "?ok=1");
11033
+ return res.end ? res.end() : res.send("");
11034
+ });
11035
+ }
11036
+
10534
11037
  // Loyalty — the signed-in customer's points balance + tier, the
10535
11038
  // earn/redeem ledger, how points are earned, and (when a reward
10536
11039
  // catalog + redemption primitive are wired) a redeem-a-reward
@@ -11750,6 +12253,10 @@ module.exports = {
11750
12253
  renderHome: renderHome,
11751
12254
  renderSearch: renderSearch,
11752
12255
  renderProduct: renderProduct,
12256
+ renderCollectionList: renderCollectionList,
12257
+ renderCollection: renderCollection,
12258
+ renderCategoryIndex: renderCategoryIndex,
12259
+ renderCategory: renderCategory,
11753
12260
  renderCart: renderCart,
11754
12261
  renderCheckoutForm: renderCheckoutForm,
11755
12262
  renderCheckoutError: renderCheckoutError,
@@ -755,6 +755,60 @@ function create(opts) {
755
755
  return r.rows.map(_hydrate);
756
756
  },
757
757
 
758
+ // Customer-account-scoped list — every ticket whose `customer_id`
759
+ // matches, newest-opened first, optionally narrowed to one status.
760
+ // Keyed on the row's `customer_id` (NOT the email hash) so the
761
+ // storefront, which holds only the session customer id (the raw
762
+ // email is never on disk), can list + ownership-gate a signed-in
763
+ // shopper's tickets without re-deriving the email hash. Composes the
764
+ // same id / status / limit validators as the rest of the surface.
765
+ listByCustomerId: async function (customerId, listOpts) {
766
+ customerId = _uuid(customerId, "customer_id");
767
+ listOpts = listOpts || {};
768
+ var limit = _limit(listOpts.limit);
769
+ var where = ["customer_id = ?1"];
770
+ var params = [customerId];
771
+ var idx = 2;
772
+ if (listOpts.status_filter != null) {
773
+ where.push("status = ?" + idx);
774
+ params.push(_status(listOpts.status_filter, "status_filter"));
775
+ idx += 1;
776
+ }
777
+ params.push(limit);
778
+ var sql = "SELECT * FROM support_tickets WHERE " + where.join(" AND ") +
779
+ " ORDER BY opened_at DESC, id DESC LIMIT ?" + idx;
780
+ var r = await query(sql, params);
781
+ return r.rows.map(_hydrate);
782
+ },
783
+
784
+ // Operator queue across every assignment state — newest-stale first
785
+ // (oldest last_action_at), optionally narrowed to one status. The
786
+ // status filter drives the admin console's status chips; absent a
787
+ // filter it surfaces the whole board (every status, including
788
+ // resolved / closed) so the operator can audit history, capped at
789
+ // `limit`.
790
+ list: async function (listOpts) {
791
+ listOpts = listOpts || {};
792
+ var limit = _limit(listOpts.limit);
793
+ var where = [];
794
+ var params = [];
795
+ var idx = 1;
796
+ if (listOpts.status_filter != null) {
797
+ where.push("status = ?" + idx);
798
+ params.push(_status(listOpts.status_filter, "status_filter"));
799
+ idx += 1;
800
+ }
801
+ params.push(limit);
802
+ var sql = "SELECT * FROM support_tickets" +
803
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
804
+ " ORDER BY CASE priority " +
805
+ " WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 " +
806
+ " WHEN 'normal' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, " +
807
+ "last_action_at ASC LIMIT ?" + idx;
808
+ var r = await query(sql, params);
809
+ return r.rows.map(_hydrate);
810
+ },
811
+
758
812
  // Full thread view — ticket + every message in created_at ASC
759
813
  // order so the caller can render the conversation top-to-bottom.
760
814
  thread: async function (ticketId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
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": {