@blamejs/blamejs-shop 0.4.10 → 0.4.12

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.4.x
10
10
 
11
+ - v0.4.12 (2026-06-05) — **Blog posts render their Markdown, plural search queries match again, and the search-ranking metrics view gets the impression and click data it was built to read.** Four search-and-content fixes. Published blog posts were showing their raw Markdown source — headings, bold, and links rendered as literal text — even though the authoring screen has always promised Markdown; the edge now renders it through the same sanitized renderer the post primitive uses, so the live page matches the editor's promise. The query stemmer over-stripped plurals ending in a vowel plus "es" ("tees", "shoes", "does"), so those searches missed their synonym groups and matched unrelated products; it now strips those down to the right singular. The search-ranking event log finally has production writers — every search records an impression and every result click records a click against the active weight set, so the admin search-metrics view shows real click-through and conversion instead of an empty table. And the analytics search funnel now applies the same hygiene the autocomplete log does, so "Popular searches" and the analytics top-terms report agree. **Added:** *Search-ranking impressions and clicks are recorded, so the admin metrics view has data* — The search-ranking feature ships a weight-set metrics view that reads an event log to report click-through, conversion, and click-to-purchase per weight set — but nothing ever wrote those events, so the view was always empty. Two writers are now wired. Every search records one impression against the active weight set (a drop-silent, fire-and-forget write that never affects the search response). Every search result link carries a `?from=search` marker plus the query it was ranked for; when a shopper follows one to a product page, that page records a click against the active weight set. Both work with JavaScript off — the click signal is read server-side from the link, with no tracking beacon. Facet narrowing is recorded the same way, so the facet-usage rollup reflects real use. Purchase attribution is deliberately left out of this release: tying a completed order back to the search that led to it needs a session-to-order link the event schema doesn't carry, so the metrics view reports impressions, clicks, and the click-through ratio from real traffic while purchase counts and conversion stay at zero until that linkage ships. **Fixed:** *Blog posts render Markdown instead of showing the raw source* — A published post's body is authored in Markdown — the editor labels the field "Body (Markdown)" and the post primitive's renderer turns headings, lists, blockquotes, rules, inline code, bold, italic, and https-or-rooted links into HTML. But the edge-served public post page painted the body as plain escaped text, so a shopper saw literal `## Heading` and `**bold**` markers rather than formatted copy. The edge now renders the body through the same sanitized renderer the primitive uses, so the live page matches what the editor previews and the authoring screen promises. The security posture is unchanged: every operator-authored byte is HTML-escaped, raw HTML never passes through (any `<` lands as `&lt;`), and link URLs are restricted to https or `/`-rooted paths — a `<script>` or `javascript:` link in a post body renders inert. · *Plural search queries ending in a vowel plus "es" match again* — The query stemmer tried its `-es` plural rule before its `-s` rule, so a word ending in a vowel plus "es" was over-stripped: "tees" became "te", "shoes" became "sho", "does" became "do". Two things broke as a result — the over-stripped term no longer matched its operator-curated synonym group (so a search for "tees" missed a "tee"/"t-shirt" grouping), and the truncated term fed a broad substring match that surfaced unrelated products. The stemmer now strips the full `-es` only when the stem ends in a sibilant ("boxes" → "box", "dishes" → "dish", "churches" → "church") and takes the bare `-s` otherwise ("tees" → "tee", "shoes" → "shoe", "does" → "doe"). The edge search mirror carries the identical rule, so the result is the same whether the page is served from the edge cache or the container. · *The analytics search funnel and the autocomplete log apply the same query hygiene* — Two search sinks recorded the typed query with different cleaning. The autocomplete query log stripped control bytes and lowercased before writing; the analytics funnel that feeds the top-search-terms report trimmed only — so a control byte could reach the analytics table raw, and the two reports counted differently. The storefront now normalizes the query once (strip control bytes, trim, lowercase) and hands the same value to both sinks. The analytics top-terms report also groups case-insensitively, so a term typed as "Hat" and "hat" collapses into one row — folding any mixed-case history written before this — matching how "Popular searches" has always counted. The consent gate on analytics recording is unchanged.
12
+
13
+ - v0.4.11 (2026-06-05) — **Search autocomplete: a live suggestions dropdown in the header, plus an admin screen to curate it.** Typing in the storefront search box now opens an autocomplete dropdown with matching products, what other shoppers are searching for, and operator-curated featured links. A new admin screen pins those featured suggestions and surfaces a popular-searches report so you can see demand and spot terms that return nothing. The search box keeps working with JavaScript off — the dropdown is a progressive enhancement on top of the plain form. **Added:** *Search autocomplete in the storefront header* — As a shopper types in the header search box, a dropdown opens beneath it with up to three groups: matching products (linking straight to the product page), popular recent searches (click one to run it), and operator-curated featured suggestions (linking wherever you point them). It's keyboard-navigable — arrow keys move through the list, Enter picks, Escape dismisses — and announces itself to screen readers as a combobox. The dropdown is served from a cacheable JSON endpoint that carries no per-visitor data, and it's a pure enhancement: with JavaScript off, or before the data loads, the box stays an ordinary search form that submits to the results page. · *Search-suggestions admin screen* — A new Search suggestions screen under the admin console lets you curate featured suggestions: pin a typed prefix (for example, prefix "free" surfaces "Free shipping over $50"), set its destination link, priority, and an optional active window, then edit the priority or status inline or remove it. Below the curation table, a read-only Popular searches view reports what shoppers have typed over the last 30 days — each term's search count, its zero-result share, and when it was last seen — so a high zero-result term flags a stock or naming gap worth closing. Every search a shopper runs is logged for this report (the visitor's session identifier is hashed before storage), and entries older than 90 days are pruned automatically.
14
+
11
15
  - v0.4.10 (2026-06-05) — **The Promo banners console is back in the admin nav.** The promo-banners management screen introduced in 0.4.4 was fully built and mounted, but the admin nav never showed its link: the nav filters items against an availability map, and the map was missing the one key the Promo banners item checks, so the link was filtered off every page and the screen was reachable only by typing /admin/promo-banners. The key is in the map now, and a contract test pins every nav item's gate key to the map so a future screen can't ship undiscoverable the same way. **Added:** *Nav gate keys are contract-tested* — A test now parses every nav item's gate key against the availability map and fails the suite on any mismatch, in either direction of drift — a new screen whose key is forgotten can't ship with a permanently hidden link again. **Fixed:** *Promo banners nav link renders* — The admin nav gates each item on an availability map keyed by the deps the console was mounted with. The Promo banners item checked a key the map never defined, which reads as permanently unavailable — so the link was hidden on every authenticated admin page even though the screen itself mounted and worked. Operators see the link again wherever the promo-banners primitive is wired.
12
16
 
13
17
  - v0.4.9 (2026-06-05) — **Scheduled jobs actually run now, the cart's discount-code entry renders, and low-stock alerts reach the admin console.** Three shipped features were unreachable in a deployed store because the production composition never connected them. The worker's scheduled ticks and inventory events carry no browser headers, so the bot guard rejected every one of them before the shared-secret check ran — cart recovery, back-in-stock scans, wishlist alerts and digests, abandoned-checkout reaping, and customer-portal expiry never executed on a deployment fronted by the bundled worker. The cart page's discount-code entry and the low-stock alert pipeline were similarly disconnected: each renders or mounts only when the server hands the backing engine to the surface, and the server never did. All three are wired now, and the boot test gains a full-composition pass that fails the build when a gated surface stops reaching the wire. **Added:** *Boot test proves the full composition on the wire* — The integration boot test now boots `server.js` twice: once bare (the container health-check contract, unchanged) and once against an in-memory stand-in for the worker's SQL bridge, which mounts the full catalog + cart + storefront + admin composition exactly as a deployment runs it. The bridged pass adds to cart through the production middleware stack, applies a discount code end to end (the entry renders, the code resolves, the applied chip persists), proves the worker-shaped scheduled tick reaches its handler, and drives a real stock mutation — an admin restock on a SKU under its threshold — into a persisted low-stock alert row without ever touching the intake endpoint directly. A feature gated on a dependency the server forgets to hand over now fails this gate instead of shipping invisible — the failure mode behind all three fixes above. **Fixed:** *Internal scheduled and event endpoints are no longer rejected by the bot guard* — The worker calls the container's internal endpoints (`/_/cart-recovery-tick`, `/_/stock-alert-sweep`, `/_/wishlist-alerts-sweep`, `/_/wishlist-digest-sweep`, `/_/customer-portal-expire`, `/_/stale-order-reap`, `/_/low-stock-alert`) with no browser fingerprint, and the bot guard's missing-header heuristic returned 403 before each handler's constant-time shared-secret check ever ran. Because the worker fires these calls without reading the response, the failures were invisible: on a deployment fronted by the bundled worker, none of these jobs had ever executed — abandoned checkouts kept their stock holds, recovery and wishlist passes never ran, and expired portal grants were never cleaned. These paths now skip the browser-fingerprint heuristics only; every one of them still refuses callers without the shared bridge secret, and `/_/health` keeps its bot-guard coverage unchanged. · *The cart page's discount-code entry renders* — The "Have a discount code?" block and its `POST /cart/coupon` / `POST /cart/coupon/remove` routes mount only when the storefront receives the discount engine, and the server never passed it — so the entry introduced in 0.3.69 was invisible in a deployed store even though checkout already honoured codes submitted through its own field. The engine is now threaded to the storefront: shoppers can apply and remove codes on the cart, and the applied code carries through the same quote-and-confirm path checkout always used. · *Low-stock alerts flow end to end* — Crossing a SKU's low-stock threshold produced no alert, for two independent reasons. The catalog's stock-mutating operations — the checkout hold, a release, a decrement, an admin restock — expose an observer hook for exactly this, and the server never connected it, so the threshold check simply never ran. And the worker-side path (the inventory lock posting the alert to the container) had no `/_/low-stock-alert` endpoint to receive it, so even a directly-posted alert was dropped: no `inventory_alerts` row, no `inventory.low_stock` webhook, and a `/admin/inventory/alerts` screen that never mounted. Both halves are wired now: every stock mutation that leaves a SKU under its configured threshold writes the alert row, fans out the webhook through the shared dispatcher, and emits the warning log line; the intake endpoint exists (secret-gated, like the other internal endpoints); and the admin alert-history screen is reachable. · *Stray editor artifact removed from release-notes/* — A temporary file accidentally committed alongside the 0.3.74 notes is gone from the repository.
package/README.md CHANGED
@@ -91,6 +91,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
91
91
  | **`lib/category-navigation.js`** | Hierarchical category tree surfaced as public browse pages. `GET /categories` lists the active top-level categories as a card grid; `GET /categories/:slug` renders one category — its title and optional description, a breadcrumb chain from the catalog root down to the current category, an optional hero image, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived / unpublished categories drop out of every surface. Public, no sign-in; an unknown, archived, or malformed slug is a 404 (never a 500), and a category with no children renders a graceful empty state. Linked from the footer on every page. The tree itself (define / move / reorder / archive, with cycle defense bounded by `MAX_TREE_DEPTH`) is operator-managed through the primitive's write API. |
92
92
  | **`lib/search-facets.js`** | Filterable search. A search result page renders facet groups — collection, price range, in-stock — as server-rendered controls; selecting one narrows the results and rides the URL query string (`?q=…&collection=…&in_stock=1`), counts beside each option reflect the current result set, facets combine across groups, and active filters show as removable chips with a clear-all path that survives result pagination. All filtering is server-side from the query string (no client JS); unknown facet keys, out-of-range prices, and garbage values are ignored rather than erroring; an empty filtered result shows a clear-filters state. Runs identically at the edge worker and the container — the edge reads the catalog and facet registry straight from D1, missing-table-resilient. |
93
93
  | **`lib/search-synonyms.js`** | Typo-tolerant, synonym-aware query matching. Before the catalog is searched, the query is expanded through an operator-curated vocabulary — synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal — and the page shows a "Showing results for" note when the query was corrected or expanded. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. Shared rewrite instance; runs identically at the edge and the container. |
94
+ | **`lib/search-suggestions.js`** | Search-box autocomplete. As a shopper types, the header search box opens a dropdown (`GET /search/suggestions`, a cacheable JSON endpoint carrying no per-visitor data) with up to three groups: matching products, popular recent searches, and operator-curated featured suggestions pinned to a typed prefix. The dropdown is keyboard-navigable and announces as a combobox; it's a progressive enhancement — JS-off and the pre-load state both fall back to the plain search form. Every search a shopper runs is logged for the admin popular-searches report (the session identifier is hashed before storage; entries past 90 days are pruned automatically). Operators curate the featured rows and read the report from the admin Search suggestions screen; an unsafe link scheme is refused at write time. |
94
95
  | **`lib/cookie-consent.js`** | GDPR/ePrivacy cookie consent. Every page carries a banner until the visitor decides — accept all, reject non-essential, or manage categories — with the choice in a sealed first-party cookie and recorded in a consent ledger for the audit trail. Strictly-necessary cookies are always on; analytics/marketing are opt-in and default-deny, and a `DNT`/`Sec-GPC` header forces non-essential to denied regardless of stored opt-in. `POST /consent` sets the choice (safe-redirect back); `GET /cookies` is the preferences page (footer-linked). A server-side gating hook renders a category's script only when granted. Banner + form are server-rendered (work with JS off; the island only hides the banner once decided) and identical at the edge and container. |
95
96
  | **`lib/currency-display.js`** | Multi-currency display. Shoppers pick a display currency from the footer switcher (`POST /currency`, sealed cookie, 303 back); product pages, the grid, search, and the cart then show prices converted into it via operator-set FX rates (stored as integer basis points, banker's rounding), formatted per the currency's locale. Conversion is display-only — the cart line and the charged amount stay in the base currency, and a "Prices shown in <display>; you'll be charged in <base>" disclosure appears whenever converting. An optional per-currency rounding rule (composed with `lib/currency-rounding.js`) snaps converted prices to a display increment (e.g. CHF 0.05). Every failure mode — no rate on file, an expired rate, a de-listed currency, a tampered cookie — falls back to base-currency display, never a broken / `NaN` price. Base + enabled list come from `SHOP_BASE_CURRENCY` / `SHOP_CURRENCIES` (or `shop.base_currency` / `shop.currencies` config). Switcher + conversions are server-rendered (work with JS off) and identical at the edge and container. |
96
97
  | **`lib/translations.js`** | Storefront localization. The UI chrome (nav, search, newsletter band, footer) renders in the visitor's locale, resolved identically at the edge and container: `?lang=`, then a first-party cookie, then `Accept-Language`, then the operator default. A footer locale switcher (languages shown by their autonyms) persists the choice and 303s back; `GET /locale` sets the cookie. Right-to-left languages set the document `dir`. Strings layer the operator's translation rows over a built-in English baseline — a missing key falls back to English, never a raw placeholder. Enable it by seeding a locale policy (default + supported locales) via `localeRouter`; with none seeded the storefront renders the English baseline and shows no switcher. `SHOP_DEFAULT_LOCALE` sets the edge default and `SHOP_LOCALES` (the supported list) lets the edge forward an `Accept-Language`-preferred non-default locale to the container instead of caching the default; an explicit cookie/`?lang=` choice is always container-served. Server-rendered (works with JS off), byte-identical edge/container. |
@@ -98,7 +99,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
98
99
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
99
100
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
100
101
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
101
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, tracks new SKUs, and opens the low-stock alert history (`/admin/inventory/alerts`) — each alert row is written when a checkout decrement crosses a SKU's threshold, alongside an `inventory.low_stock` webhook to subscribed endpoints; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer, plus a customer-service notes thread per order (internal or customer-visible, pinnable, resolvable); **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); each customer opens to an aggregated activity timeline (orders, loyalty, wishlist, reviews, support) read from the tables those primitives already populate; customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Carts, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
102
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, tracks new SKUs, and opens the low-stock alert history (`/admin/inventory/alerts`) — each alert row is written when a checkout decrement crosses a SKU's threshold, alongside an `inventory.low_stock` webhook to subscribed endpoints; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer, plus a customer-service notes thread per order (internal or customer-visible, pinnable, resolvable); **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); each customer opens to an aggregated activity timeline (orders, loyalty, wishlist, reviews, support) read from the tables those primitives already populate; customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. **Search suggestions** (`/admin/search-suggestions`) curates the storefront autocomplete — pin a featured link to a typed prefix, set its priority / status / active window, edit or remove it inline — and surfaces a read-only popular-searches report (each term's 30-day count, zero-result share, and last-seen) so an unmatched term flags a stock or naming gap. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Search suggestions, Carts, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
102
103
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
103
104
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
104
105
 
package/SECURITY.md CHANGED
@@ -195,6 +195,18 @@ node -e "
195
195
  Redemption decrements with an atomic `balance >= amount` SQL guard
196
196
  keyed on the order id, so concurrent or replayed checkouts can never
197
197
  overdraw a card or apply more than the remaining balance.
198
+ - **The search-query log is privacy-bounded, and curated suggestions
199
+ can't stage an XSS.** Every storefront search a shopper runs is logged
200
+ to power the admin popular-searches report, but the visitor's session
201
+ identifier is hashed (`b.crypto.namespaceHash`) before storage — the
202
+ log holds no recoverable customer-side identifier — and rows older than
203
+ 90 days are pruned automatically by a self-gated daily sweep. Operator-
204
+ curated featured suggestions refuse a `javascript:` / `data:` /
205
+ `vbscript:` link scheme at write time, and both the autocomplete
206
+ dropdown (built entirely with `textContent`, never `innerHTML`) and the
207
+ admin report (every echoed query / display field escaped) treat the
208
+ logged query text as untrusted — a shopper can't smuggle markup into an
209
+ operator dashboard through the search box.
198
210
  - **Abandoned checkouts don't strand stock forever.** Checkout reserves
199
211
  stock with an atomic conditional hold before charging; an order whose
200
212
  buyer abandons the payment sheet (or whose PaymentIntent expires) would
package/lib/admin.js CHANGED
@@ -571,6 +571,7 @@ function mount(router, deps) {
571
571
  var clickAndCollect = deps.clickAndCollect || null; // pickup-locations CRUD + pickup queue console disabled when absent
572
572
  var giftOptions = deps.giftOptions || null; // gift-wrap catalog console disabled when absent
573
573
  var searchRanking = deps.searchRanking || null; // search-ranking weight-set + pin console disabled when absent
574
+ var searchSuggestions = deps.searchSuggestions || null; // featured-suggestion curation + popular-searches view disabled when absent
574
575
  var trustBadges = deps.trustBadges || null; // trust-badge authoring console disabled when absent
575
576
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
576
577
  // Read-only activity log at /admin/audit. Defaults ON — the framework
@@ -592,7 +593,7 @@ function mount(router, deps) {
592
593
  // `reports` is always present in the nav (read-only sales summary needs no
593
594
  // extra dep); its route mounts unconditionally and renders an unconfigured
594
595
  // notice when the salesReports primitive isn't wired.
595
- var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart };
596
+ var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart };
596
597
 
597
598
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
598
599
 
@@ -6200,6 +6201,112 @@ function mount(router, deps) {
6200
6201
  ));
6201
6202
  }
6202
6203
 
6204
+ // ---- search suggestions ---------------------------------------------
6205
+ // Operator curation for the header autocomplete dropdown: featured
6206
+ // suggestions (typing "free" surfaces "Free shipping over $50") plus a
6207
+ // read-only "Popular searches" view over the 30-day query log. Content-
6208
+ // negotiated like the other console screens — bearer → JSON; signed-in
6209
+ // browser → the HTML table + create form.
6210
+ if (deps.searchSuggestions) {
6211
+ var suggestions = deps.searchSuggestions;
6212
+
6213
+ router.get("/admin/search-suggestions", _pageOrApi(true,
6214
+ R(async function (req, res) {
6215
+ var featured = await _listFeaturedSuggestions(suggestions);
6216
+ var popular = await suggestions.popularQueries({ limit: 50 });
6217
+ _json(res, 200, { featured: featured, popular: popular });
6218
+ }),
6219
+ async function (req, res) {
6220
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6221
+ var popular = [];
6222
+ var featured = [];
6223
+ try { popular = await suggestions.popularQueries({ limit: 50 }); }
6224
+ catch (_e) { popular = []; }
6225
+ try { featured = await _listFeaturedSuggestions(suggestions); }
6226
+ catch (_e2) { featured = []; }
6227
+ _sendHtml(res, 200, renderAdminSearchSuggestions({
6228
+ shop_name: deps.shop_name, nav_available: navAvailable,
6229
+ featured: featured, popular: popular,
6230
+ created: url && url.searchParams.get("created"),
6231
+ updated: url && url.searchParams.get("updated"),
6232
+ deleted: url && url.searchParams.get("deleted"),
6233
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the suggestion." : null,
6234
+ }));
6235
+ },
6236
+ ));
6237
+
6238
+ router.post("/admin/search-suggestions", _pageOrApi(false,
6239
+ W("search_suggestion.add", async function (req, res) {
6240
+ var row;
6241
+ try { row = await suggestions.addFeatured(req.body || {}); }
6242
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6243
+ _json(res, 201, row);
6244
+ return { id: row.id };
6245
+ }),
6246
+ async function (req, res) {
6247
+ try {
6248
+ await suggestions.addFeatured(_featuredSuggestionFromForm(req.body || {}));
6249
+ } catch (e) {
6250
+ var n = _safeNotice(e, "search_suggestion.add");
6251
+ var featured = [];
6252
+ var popular = [];
6253
+ try { featured = await _listFeaturedSuggestions(suggestions); } catch (_e) { featured = []; }
6254
+ try { popular = await suggestions.popularQueries({ limit: 50 }); } catch (_e2) { popular = []; }
6255
+ return _sendHtml(res, n.status, renderAdminSearchSuggestions({
6256
+ shop_name: deps.shop_name, nav_available: navAvailable,
6257
+ featured: featured, popular: popular,
6258
+ notice: n.message.replace(/^searchSuggestions[.:]\s*/, ""),
6259
+ }));
6260
+ }
6261
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_suggestion.add", outcome: "success" });
6262
+ _redirect(res, "/admin/search-suggestions?created=1");
6263
+ },
6264
+ ));
6265
+
6266
+ router.post("/admin/search-suggestions/:id/edit", _pageOrApi(false,
6267
+ W("search_suggestion.update", async function (req, res) {
6268
+ var row;
6269
+ try { row = await suggestions.updateFeatured(req.params.id, _featuredSuggestionPatchFromForm(req.body || {})); }
6270
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6271
+ if (!row) return _problem(res, 404, "search-suggestion-not-found");
6272
+ _json(res, 200, row);
6273
+ return { id: row.id };
6274
+ }),
6275
+ async function (req, res) {
6276
+ var id = req.params.id;
6277
+ try {
6278
+ var row = await suggestions.updateFeatured(id, _featuredSuggestionPatchFromForm(req.body || {}));
6279
+ if (!row) return _redirect(res, "/admin/search-suggestions?err=1");
6280
+ } catch (e) {
6281
+ if (!(e instanceof TypeError)) throw e;
6282
+ return _redirect(res, "/admin/search-suggestions?err=1");
6283
+ }
6284
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_suggestion.update", outcome: "success", metadata: { id: id } });
6285
+ _redirect(res, "/admin/search-suggestions?updated=1");
6286
+ },
6287
+ ));
6288
+
6289
+ router.post("/admin/search-suggestions/:id/delete", _pageOrApi(false,
6290
+ W("search_suggestion.delete", async function (req, res) {
6291
+ var out;
6292
+ try { out = await suggestions.deleteFeatured(req.params.id); }
6293
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6294
+ if (!out.removed) return _problem(res, 404, "search-suggestion-not-found");
6295
+ _json(res, 200, out);
6296
+ return { id: req.params.id };
6297
+ }),
6298
+ async function (req, res) {
6299
+ var id = req.params.id;
6300
+ var out = { removed: false };
6301
+ try { out = await suggestions.deleteFeatured(id); }
6302
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
6303
+ if (!out.removed) return _redirect(res, "/admin/search-suggestions?err=1");
6304
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_suggestion.delete", outcome: "success", metadata: { id: id } });
6305
+ _redirect(res, "/admin/search-suggestions?deleted=1");
6306
+ },
6307
+ ));
6308
+ }
6309
+
6203
6310
  // ---- promo banners --------------------------------------------------
6204
6311
  // Operator-authored marketing banners at six fixed storefront placements.
6205
6312
  // Content-negotiated like the other console screens: bearer → the JSON
@@ -11878,6 +11985,7 @@ var ADMIN_NAV_ITEMS = [
11878
11985
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
11879
11986
  { key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
11880
11987
  { key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
11988
+ { key: "search-suggestions", href: "/admin/search-suggestions", label: "Search suggestions", requires: "searchSuggestions" },
11881
11989
  { key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
11882
11990
  { key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
11883
11991
  { key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
@@ -16640,6 +16748,56 @@ function _announcementPatchFromForm(body) {
16640
16748
  return patch;
16641
16749
  }
16642
16750
 
16751
+ // ---- search-suggestion form coercion --------------------------------
16752
+
16753
+ // Read every featured suggestion for the curation table — through the
16754
+ // injected instance's listFeatured (the primitive's suggest() is prefix-
16755
+ // scoped + active-only; the console needs the full set), so a
16756
+ // composition that created the primitive with a custom query handle
16757
+ // reads the same rows the write surface (addFeatured / updateFeatured /
16758
+ // deleteFeatured) writes.
16759
+ function _listFeaturedSuggestions(searchSuggestions) {
16760
+ return searchSuggestions.listFeatured({});
16761
+ }
16762
+
16763
+ // Coerce the create form into the shape searchSuggestions.addFeatured
16764
+ // expects: prefix + display_text + link_url, an optional integer
16765
+ // priority, optional epoch schedule bounds, and a status. Blank optional
16766
+ // fields drop out so the primitive applies its own defaults (priority 0,
16767
+ // starts_at now, status active). The primitive validates the result
16768
+ // (control bytes, length caps, refused link schemes, expires>starts) and
16769
+ // throws a TypeError on a bad shape, which the browser path degrades to a
16770
+ // clean err notice and the bearer path to a 400.
16771
+ function _featuredSuggestionFromForm(body) {
16772
+ body = body || {};
16773
+ var out = {
16774
+ prefix: typeof body.prefix === "string" ? body.prefix.trim() : body.prefix,
16775
+ display_text: typeof body.display_text === "string" ? body.display_text.trim() : body.display_text,
16776
+ link_url: typeof body.link_url === "string" ? body.link_url.trim() : body.link_url,
16777
+ };
16778
+ if (body.priority != null && String(body.priority).trim() !== "") {
16779
+ out.priority = _strictMinorInt(body.priority, "searchSuggestions.addFeatured", "priority");
16780
+ }
16781
+ var sa = _epochFromForm(body.starts_at); if (sa != null) out.starts_at = sa;
16782
+ var ea = _epochFromForm(body.expires_at); if (ea != null) out.expires_at = ea;
16783
+ if (body.status != null && body.status !== "") out.status = body.status;
16784
+ return out;
16785
+ }
16786
+
16787
+ // Coerce the inline edit form into an updateFeatured patch. The console's
16788
+ // per-row form only exposes the two cheap-to-flip columns — priority and
16789
+ // status — so the patch carries just those; the primitive's whitelist
16790
+ // validates each before it touches the row.
16791
+ function _featuredSuggestionPatchFromForm(body) {
16792
+ body = body || {};
16793
+ var patch = {};
16794
+ if (body.priority != null && String(body.priority).trim() !== "") {
16795
+ patch.priority = _strictMinorInt(body.priority, "searchSuggestions.updateFeatured", "priority");
16796
+ }
16797
+ if (body.status != null && body.status !== "") patch.status = body.status;
16798
+ return patch;
16799
+ }
16800
+
16643
16801
  // ---- search-ranking form coercion -----------------------------------
16644
16802
 
16645
16803
  // `?from`/`?to` ms-epoch query params for the metrics rollup → integer, or a
@@ -16903,6 +17061,104 @@ function renderAdminAnnouncements(opts) {
16903
17061
  return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
16904
17062
  }
16905
17063
 
17064
+ // Search-suggestions curation screen: the featured-suggestion table (with
17065
+ // a per-row inline priority/status edit + a delete), a create form, and a
17066
+ // read-only "Popular searches" view over the recent query log. Every
17067
+ // echoed field — prefix, display text, link, query text — is operator- or
17068
+ // shopper-authored, so it runs through _htmlEscape on the way out.
17069
+ function renderAdminSearchSuggestions(opts) {
17070
+ opts = opts || {};
17071
+ var featured = opts.featured || [];
17072
+ var popular = opts.popular || [];
17073
+ var created = opts.created ? "<div class=\"banner banner--ok\">Suggestion saved.</div>" : "";
17074
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Suggestion updated.</div>" : "";
17075
+ var deleted = opts.deleted ? "<div class=\"banner banner--ok\">Suggestion removed.</div>" : "";
17076
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
17077
+
17078
+ function _statusOpts(selected) {
17079
+ return ["active", "draft", "expired"].map(function (s) {
17080
+ return "<option value=\"" + s + "\"" + (s === selected ? " selected" : "") + ">" + s + "</option>";
17081
+ }).join("");
17082
+ }
17083
+
17084
+ function _window(f) {
17085
+ var from = f.starts_at != null ? _datetimeLocalValue(Number(f.starts_at)).replace("T", " ") : "—";
17086
+ var to = f.expires_at != null ? _datetimeLocalValue(Number(f.expires_at)).replace("T", " ") : "open";
17087
+ return _htmlEscape(from + " → " + to);
17088
+ }
17089
+
17090
+ var featuredRows = featured.map(function (f) {
17091
+ var enc = encodeURIComponent(f.id);
17092
+ var statusClass = f.status === "active" ? "paid" : (f.status === "expired" ? "cancelled" : "");
17093
+ return "<tr>" +
17094
+ "<td><code class=\"order-id\">" + _htmlEscape(f.prefix) + "</code></td>" +
17095
+ "<td>" + _htmlEscape(f.display_text) + "</td>" +
17096
+ "<td><a href=\"" + _htmlEscape(f.link_url) + "\" rel=\"noopener noreferrer\">" + _htmlEscape(f.link_url) + "</a></td>" +
17097
+ "<td>" + _window(f) + "</td>" +
17098
+ "<td>" +
17099
+ "<form method=\"post\" action=\"/admin/search-suggestions/" + _htmlEscape(enc) + "/edit\" class=\"form-inline\">" +
17100
+ "<input type=\"number\" name=\"priority\" value=\"" + _htmlEscape(String(f.priority)) + "\" min=\"0\" max=\"1000000\" class=\"input-narrow\" aria-label=\"Priority\">" +
17101
+ "<select name=\"status\" aria-label=\"Status\">" + _statusOpts(f.status) + "</select>" +
17102
+ "<button class=\"btn\" type=\"submit\">Save</button>" +
17103
+ "</form>" +
17104
+ "</td>" +
17105
+ "<td><span class=\"status-pill " + statusClass + "\">" + _htmlEscape(f.status) + "</span></td>" +
17106
+ "<td>" +
17107
+ "<form method=\"post\" action=\"/admin/search-suggestions/" + _htmlEscape(enc) + "/delete\" class=\"form-inline\">" +
17108
+ "<button class=\"btn btn--danger\" type=\"submit\">Delete</button>" +
17109
+ "</form>" +
17110
+ "</td>" +
17111
+ "</tr>";
17112
+ }).join("");
17113
+
17114
+ var featuredTable = featured.length
17115
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr>" +
17116
+ "<th scope=\"col\">Prefix</th><th scope=\"col\">Display text</th><th scope=\"col\">Link</th>" +
17117
+ "<th scope=\"col\">Window</th><th scope=\"col\">Priority / status</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th>" +
17118
+ "</tr></thead><tbody>" + featuredRows + "</tbody></table>") + "</div>"
17119
+ : "<p class=\"empty\">No featured suggestions yet. Add one below to surface a curated link as shoppers type.</p>";
17120
+
17121
+ var createForm =
17122
+ "<div class=\"panel mt mw-40\">" +
17123
+ "<h3 class=\"subhead\">Add a featured suggestion</h3>" +
17124
+ "<p class=\"meta\">Pin a curated link to a typed prefix — e.g. prefix &ldquo;free&rdquo; surfaces &ldquo;Free shipping over $50&rdquo;. Higher priority wins when more than one matches. Leave the schedule blank for an always-on suggestion.</p>" +
17125
+ "<form method=\"post\" action=\"/admin/search-suggestions\">" +
17126
+ _setupField("Prefix", "prefix", "", "text", "Lowercased on save. The leading characters a shopper types.", " maxlength=\"100\" required") +
17127
+ _setupField("Display text", "display_text", "", "text", "What the dropdown row reads.", " maxlength=\"200\" required") +
17128
+ _setupField("Link URL", "link_url", "", "text", "Where the row links. https:// or a /-rooted path — javascript:/data: are refused.", " maxlength=\"2048\" required") +
17129
+ _setupField("Priority (optional)", "priority", "", "number", "Higher shows first. Defaults to 0.", " min=\"0\" max=\"1000000\"") +
17130
+ "<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\"></label>" +
17131
+ "<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\"></label>" +
17132
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add suggestion</button></div>" +
17133
+ "</form>" +
17134
+ "</div>";
17135
+
17136
+ var popularRows = popular.map(function (p) {
17137
+ var share = Math.round((Number(p.zero_result_share) || 0) * 100);
17138
+ var lastSeen = p.last_seen != null ? _datetimeLocalValue(Number(p.last_seen)).replace("T", " ") : "—";
17139
+ return "<tr>" +
17140
+ "<td>" + _htmlEscape(p.query_normalized) + "</td>" +
17141
+ "<td>" + _htmlEscape(String(Number(p.count) || 0)) + "</td>" +
17142
+ "<td>" + _htmlEscape(String(share)) + "%</td>" +
17143
+ "<td>" + _htmlEscape(lastSeen) + "</td>" +
17144
+ "</tr>";
17145
+ }).join("");
17146
+
17147
+ var popularTable = popular.length
17148
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr>" +
17149
+ "<th scope=\"col\">Query</th><th scope=\"col\">Count</th><th scope=\"col\">Zero-result share</th><th scope=\"col\">Last seen</th>" +
17150
+ "</tr></thead><tbody>" + popularRows + "</tbody></table>") + "</div>"
17151
+ : "<p class=\"empty\">No searches logged yet.</p>";
17152
+
17153
+ var bodyHtml = "<section><h2>Search suggestions</h2>" + created + updated + deleted + notice +
17154
+ featuredTable + createForm +
17155
+ "<h3 class=\"subhead mt\">Popular searches (last 30 days)</h3>" +
17156
+ "<p class=\"meta\">What shoppers are typing into the search box. A high zero-result share is a stock or naming gap worth closing.</p>" +
17157
+ popularTable +
17158
+ "</section>";
17159
+ return _renderAdminShell(opts.shop_name, "Search suggestions", bodyHtml, "search-suggestions", opts.nav_available);
17160
+ }
17161
+
16906
17162
  // ---- promo-banner form coercion -------------------------------------
16907
17163
 
16908
17164
  var _PROMO_PLACEMENT_LABELS = {
package/lib/analytics.js CHANGED
@@ -492,18 +492,26 @@ function create(opts) {
492
492
  // GROUPs by the denormalised column so the query never decodes
493
493
  // the JSON payload. Default limit 10; max 100 (same envelope as
494
494
  // `topSKUs`).
495
+ //
496
+ // GROUPs + sorts on `lower(search_q)` so "Tee" and "tee" collapse
497
+ // into one row, matching how the autocomplete "Popular searches"
498
+ // aggregate (search-suggestions, which lowercases on write)
499
+ // counts them. New writes already arrive lowercased from the
500
+ // storefront record site; lowering at aggregate time folds any
501
+ // mixed-case history written before that. ASCII queries only, so
502
+ // SQLite's built-in `lower()` (ASCII-only) is sufficient.
495
503
  topSearchTerms: async function (windowOpts) {
496
504
  var w = _resolveEventWindow(windowOpts);
497
505
  var limit = (windowOpts && windowOpts.limit) == null ? 10 : windowOpts.limit;
498
506
  _limit(limit, "limit");
499
507
  var r = await query(
500
- "SELECT search_q AS search_q, COUNT(*) AS count " +
508
+ "SELECT lower(search_q) AS search_q, COUNT(*) AS count " +
501
509
  " FROM analytics_events " +
502
510
  " WHERE event_type = 'search_query' " +
503
511
  " AND search_q IS NOT NULL " +
504
512
  " AND occurred_at >= ?1 AND occurred_at < ?2 " +
505
- " GROUP BY search_q " +
506
- " ORDER BY count DESC, search_q ASC " +
513
+ " GROUP BY lower(search_q) " +
514
+ " ORDER BY count DESC, lower(search_q) ASC " +
507
515
  " LIMIT ?3",
508
516
  [w.from, w.to, limit],
509
517
  );
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.4.10",
2
+ "version": "0.4.12",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
6
6
  "fingerprinted": "css/admin.44eb97700c660798.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-fxUZj7xaROivK6yjixSJavibw/WXXmUgjxS4mSG2PXavX/j2GZrsi9uquxvcJjTa",
10
- "fingerprinted": "css/main.0117fe12d12ac55b.css"
9
+ "integrity": "sha384-nRqjlZAYU5a0XVp9OWxcVw3zzZpj6vPcj+5XRf4byAgK49fg8W5QFx8vFLm40ldA",
10
+ "fingerprinted": "css/main.4eb7c53f87817566.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
@@ -52,6 +52,10 @@
52
52
  "js/saved-card.js": {
53
53
  "integrity": "sha384-Kaj6n+Any4rwCH2lyREHoq30MrAZtEd/fTa+tDnIrMJ4zO01YWRhW5TTujcYyuVn",
54
54
  "fingerprinted": "js/saved-card.d7fd750d746dbe4d.js"
55
+ },
56
+ "js/search-suggest.js": {
57
+ "integrity": "sha384-Hw1PLzUPkH0rjuz/4wTjrK+jAVqfGAVuTHNPmZcwbqUvJAuFTk7DjbuAFRHyliuc",
58
+ "fingerprinted": "js/search-suggest.f25cfda1fed825bf.js"
55
59
  }
56
60
  }
57
61
  }
@@ -51,6 +51,10 @@
51
51
  * the id didn't exist.
52
52
  * - `deleteFeatured(id)` — operator-only. Returns
53
53
  * `{ removed: boolean }`.
54
+ * - `listFeatured({ limit?, offset? })` — operator-only curation
55
+ * read: every featured row regardless of status or schedule
56
+ * window, priority-then-recency ordered (suggest() is the
57
+ * shopper-scoped read; this is the console's).
54
58
  * - `popularQueries({ from?, to?, limit? })` — operator dashboard
55
59
  * aggregate. Returns `[ { query_normalized, count, last_seen,
56
60
  * zero_result_share } ]` sorted by count desc.
@@ -463,6 +467,37 @@ function create(opts) {
463
467
  return { removed: Number(r.rowCount || 0) > 0 };
464
468
  },
465
469
 
470
+ // The curation-console read: every featured row regardless of status
471
+ // or schedule window (suggest() answers the shopper and is prefix-
472
+ // scoped + active-only; an operator managing the set needs the full
473
+ // list). Ordered the way an operator scans it — priority first, then
474
+ // most-recently-touched. Reads through the instance's own query
475
+ // handle, so a composition that injected a custom handle sees the
476
+ // same rows it writes.
477
+ listFeatured: async function (listOpts) {
478
+ listOpts = listOpts || {};
479
+ // Console-read bounds — wider than the shopper-facing MAX_LIMIT
480
+ // (the autocomplete answers with 5; an operator pages the full
481
+ // curated set).
482
+ var limit = listOpts.limit == null ? 500 : listOpts.limit;
483
+ var offset = listOpts.offset == null ? 0 : listOpts.offset;
484
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) {
485
+ throw new TypeError("searchSuggestions.listFeatured: limit must be 1...1000");
486
+ }
487
+ if (!Number.isInteger(offset) || offset < 0) {
488
+ throw new TypeError("searchSuggestions.listFeatured: offset must be a non-negative integer");
489
+ }
490
+ var r = await query(
491
+ "SELECT id, prefix, display_text, link_url, priority, " +
492
+ "starts_at, expires_at, status, created_at, updated_at " +
493
+ "FROM featured_search_suggestions " +
494
+ "ORDER BY priority DESC, updated_at DESC, id DESC " +
495
+ "LIMIT ?1 OFFSET ?2",
496
+ [limit, offset],
497
+ );
498
+ return r.rows;
499
+ },
500
+
466
501
  popularQueries: async function (popOpts) {
467
502
  popOpts = popOpts || {};
468
503
  var now = Date.now();
@@ -276,7 +276,21 @@ function _stem(token) {
276
276
  if (token.length >= 5 && token.slice(-3) === "ies") return token.slice(0, -3) + "y";
277
277
  if (token.length >= 5 && token.slice(-3) === "ing") return token.slice(0, -3);
278
278
  if (token.length >= 4 && token.slice(-2) === "ed") return token.slice(0, -2);
279
- if (token.length >= 4 && token.slice(-2) === "es") return token.slice(0, -2);
279
+ // `-es` is only a true plural suffix when the stem ends in a sibilant
280
+ // (`s`/`x`/`z`/`ch`/`sh`): boxes -> box, dishes -> dish, churches ->
281
+ // church. For vowel-final stems the singular keeps a trailing `e` and
282
+ // the plural is a bare `-s` — stripping `-es` there over-strips
283
+ // (tees -> te, shoes -> sho, does -> do). Strip `-es` only for the
284
+ // sibilant case; everything else falls through to the bare `-s` rule
285
+ // (tees -> tee, shoes -> shoe, does -> doe).
286
+ if (token.length >= 4 && token.slice(-2) === "es") {
287
+ var esStem = token.slice(0, -2);
288
+ var esLast = esStem.slice(-1);
289
+ var esLast2 = esStem.slice(-2);
290
+ if (esLast === "s" || esLast === "x" || esLast === "z" || esLast2 === "ch" || esLast2 === "sh") {
291
+ return esStem;
292
+ }
293
+ }
280
294
  if (token.length >= 4 && token.slice(-1) === "s") return token.slice(0, -1);
281
295
  return token;
282
296
  }
package/lib/storefront.js CHANGED
@@ -305,7 +305,7 @@ var LAYOUT =
305
305
  " <div class=\"site-search__inner\">\n" +
306
306
  " <label for=\"site-search-q\" class=\"skip-link\">{{search_label}}</label>\n" +
307
307
  " <svg class=\"site-search__icon\" viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M21 21l-4.35-4.35M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg>\n" +
308
- " <input id=\"site-search-q\" type=\"search\" name=\"q\" value=\"{{search_q}}\" placeholder=\"{{search_placeholder}}\" autocomplete=\"off\" spellcheck=\"false\" maxlength=\"200\">\n" +
308
+ " <input id=\"site-search-q\" type=\"search\" name=\"q\" value=\"{{search_q}}\" placeholder=\"{{search_placeholder}}\" autocomplete=\"off\" spellcheck=\"false\" maxlength=\"200\" data-suggest=\"/search/suggestions\" role=\"combobox\" aria-expanded=\"false\" aria-autocomplete=\"list\" aria-controls=\"site-search-suggest\">\n" +
309
309
  " <button type=\"submit\">{{search_submit}}</button>\n" +
310
310
  " </div>\n" +
311
311
  " </form>\n" +
@@ -386,6 +386,7 @@ var LAYOUT =
386
386
  CONSENT_BANNER +
387
387
  "RAW_CONSENT_SCRIPT" +
388
388
  "RAW_CART_COUNT_SCRIPT" +
389
+ "RAW_SEARCH_SUGGEST_SCRIPT" +
389
390
  "RAW_ANNOUNCEMENT_SCRIPT" +
390
391
  "</body>\n" +
391
392
  "</html>\n";
@@ -1041,6 +1042,7 @@ function _wrap(opts) {
1041
1042
  .replace("RAW_HREFLANG", hreflangHtml)
1042
1043
  .replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
1043
1044
  .replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
1045
+ .replace("RAW_SEARCH_SUGGEST_SCRIPT", _islandScript("search-suggest.js", { id: "search-suggest-island" }))
1044
1046
  .replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
1045
1047
  .replace("RAW_CURRENCY_SWITCHER", switcherHtml)
1046
1048
  .replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "");
@@ -1680,6 +1682,16 @@ function _renderSearchChips(facets, filters, q) {
1680
1682
  // exist). Mirrors the edge renderer's SEARCH_PAGE_SIZE.
1681
1683
  var SEARCH_PAGE_SIZE = 24;
1682
1684
 
1685
+ // Search-query-log retention + the self-gating sweep cadence. Every
1686
+ // /search hit logs a row (drop-silent) for the admin "Popular searches"
1687
+ // view; rows older than the retention window are pruned by a sweep that
1688
+ // runs at most once a day, gated by a module-level timestamp (the same
1689
+ // self-gating shape server.js uses for the stock-alert sweep) so a busy
1690
+ // search page doesn't issue a DELETE on every request.
1691
+ var SEARCH_QUERY_RETENTION_MS = b.constants.TIME.days(90);
1692
+ var SEARCH_QUERY_CLEANUP_EVERY_MS = b.constants.TIME.days(1);
1693
+ var _lastSearchQueryCleanupAt = 0;
1694
+
1683
1695
  // `/search?...` URL for a specific results page — the query + active
1684
1696
  // filters carried forward (so paging preserves the facet state) with a
1685
1697
  // `page` param appended for any page past the first. Page 1 omits the
@@ -1842,6 +1854,9 @@ var SEARCH_MAX_FACET_KEYS = 32;
1842
1854
  var SEARCH_MAX_FACET_VALUES = 64;
1843
1855
  var SEARCH_MAX_VALUE_LEN = 256;
1844
1856
  var SEARCH_CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
1857
+ // Global twin for strip-all replaces (the non-global one above is used for
1858
+ // presence tests; `.replace` needs the `g` flag to clear every byte).
1859
+ var SEARCH_CONTROL_BYTE_RE_G = /[\x00-\x1f\x7f]/g;
1845
1860
 
1846
1861
  // Parse `?key=value` repeats off a parsed URL into the
1847
1862
  // `{ facetKey: [value, ...] }` shape the searchFacets primitive
@@ -1930,13 +1945,20 @@ function renderSearch(opts) {
1930
1945
  } else {
1931
1946
  var assetPrefix = opts.asset_prefix || "/assets/";
1932
1947
  var fmt = _priceFormatter(opts);
1948
+ // `?from=search&sq=<query>` marks the click as originating in a ranked
1949
+ // result list. The PDP route reads it to log a click event against the
1950
+ // active ranking weight set (the impression/click signals the admin
1951
+ // search-metrics view aggregates); `sq` carries the query the list was
1952
+ // ranked for, which recordSearchEvent requires. Works with JS off; no
1953
+ // beacon. Mirrored byte-for-byte by worker/render/search.js.
1954
+ var resultLinkSuffix = "?from=search&sq=" + encodeURIComponent(qTrim.slice(0, 200));
1933
1955
  var cards = products.map(function (p) {
1934
1956
  var priceStr = p.starting_price_minor != null
1935
1957
  ? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
1936
1958
  : "—";
1937
1959
  var imageUrl = p.hero_media ? assetPrefix + p.hero_media.r2_key : null;
1938
1960
  var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
1939
- return _buildProductCard({ title: p.title, price: priceStr, slug: p.slug, image_url: imageUrl, image_alt: imageAlt });
1961
+ return _buildProductCard({ title: p.title, price: priceStr, slug: p.slug + resultLinkSuffix, image_url: imageUrl, image_alt: imageAlt });
1940
1962
  }).join("\n");
1941
1963
  resultsInner = "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
1942
1964
  }
@@ -10649,6 +10671,88 @@ function mount(router, deps) {
10649
10671
  } catch (_e) { /* drop-silent — analytics is supplementary to every request */ }
10650
10672
  }
10651
10673
 
10674
+ // Search-ranking impression. Resolves the active weight set, then logs a
10675
+ // single list-level impression against it. The metrics view aggregates
10676
+ // impressions per weights_slug to compute CTR / conversion, so the slug
10677
+ // is the join key; query is denormalised for operator inspection. No
10678
+ // active set → no-op (recordSearchEvent would throw SEARCH_WEIGHTS_NOT
10679
+ // _FOUND, which the catch swallows). HOT PATH: fire-and-forget +
10680
+ // drop-silent — a metrics write must never affect the search response.
10681
+ function _recordSearchImpression(req, term) {
10682
+ if (!deps.searchRanking) return;
10683
+ try {
10684
+ var sid = null;
10685
+ try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
10686
+ Promise.resolve(deps.searchRanking.activeWeights())
10687
+ .then(function (active) {
10688
+ if (!active || !active.slug) return; // ranking not configured → nothing to attribute
10689
+ var ev = { query: term, event_type: "impression", weights_slug: active.slug };
10690
+ if (sid) ev.session_id = sid;
10691
+ return deps.searchRanking.recordSearchEvent(ev);
10692
+ })
10693
+ .catch(function () { /* drop-silent — impression bump never fails the search */ });
10694
+ } catch (_e) { /* drop-silent — supplementary to every search */ }
10695
+ }
10696
+
10697
+ // Search-ranking click. Logged from the PDP route when the inbound link
10698
+ // carried `?from=search`. Attributes the click to whatever weight set is
10699
+ // active at click time — the same set new impressions log against — so
10700
+ // CTR stays coherent (an operator rarely flips the active set mid-session;
10701
+ // if they do, both new impressions and new clicks follow the new set).
10702
+ // `productId` is denormalised for inspection; the metrics view keys on the
10703
+ // weights_slug + time window. HOT PATH: fire-and-forget + drop-silent.
10704
+ function _recordSearchClick(req, term, productId) {
10705
+ if (!deps.searchRanking) return;
10706
+ // recordSearchEvent requires a non-empty query; a click that lost its
10707
+ // `sq` marker can't be attributed, so skip rather than fire a call that
10708
+ // would throw.
10709
+ if (typeof term !== "string" || term.trim().length === 0) return;
10710
+ try {
10711
+ var sid = null;
10712
+ try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
10713
+ Promise.resolve(deps.searchRanking.activeWeights())
10714
+ .then(function (active) {
10715
+ if (!active || !active.slug) return;
10716
+ var ev = { query: term, event_type: "click", weights_slug: active.slug };
10717
+ if (productId) ev.product_id = productId;
10718
+ if (sid) ev.session_id = sid;
10719
+ return deps.searchRanking.recordSearchEvent(ev);
10720
+ })
10721
+ .catch(function () { /* drop-silent — click bump never fails the PDP */ });
10722
+ } catch (_e) { /* drop-silent — supplementary to every PDP view */ }
10723
+ }
10724
+
10725
+ // Facet-usage events. One per applied facet value the shopper narrowed
10726
+ // by, so the admin facet-usage rollup reflects real use. `filters` is the
10727
+ // `{ key: [value, ...] }` shape the search handler already parsed +
10728
+ // validated against the live facet defs. HOT PATH: fire-and-forget +
10729
+ // drop-silent — a usage write must never affect the search response.
10730
+ function _recordFacetUses(req, filters) {
10731
+ if (!deps.searchFacets) return;
10732
+ try {
10733
+ var sid = null;
10734
+ try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
10735
+ var sessionId = sid || "anon";
10736
+ // searchFacets is a per-request factory (bound to a catalog); the
10737
+ // usage write needs no catalog, so an instance with an empty list
10738
+ // adapter is enough for recordFacetUse.
10739
+ var sf = deps.searchFacets({ list: function () { return Promise.resolve({ rows: [] }); } });
10740
+ var keys = Object.keys(filters);
10741
+ for (var k = 0; k < keys.length; k += 1) {
10742
+ var values = filters[keys[k]];
10743
+ if (!Array.isArray(values)) continue;
10744
+ for (var v = 0; v < values.length; v += 1) {
10745
+ (function (key, value) {
10746
+ try {
10747
+ Promise.resolve(sf.recordFacetUse({ key: key, value: value, session_id: sessionId }))
10748
+ .catch(function () { /* drop-silent — usage bump never fails the search */ });
10749
+ } catch (_e) { /* drop-silent — skip a value that fails validation */ }
10750
+ })(keys[k], values[v]);
10751
+ }
10752
+ }
10753
+ } catch (_e) { /* drop-silent — supplementary to every search */ }
10754
+ }
10755
+
10652
10756
  // Resolve the active trust badges for a container-only placement and
10653
10757
  // concatenate each one's sanitized renderHtml. Fires an impression per
10654
10758
  // rendered badge (fire-and-forget — the counter is drop-silent on the hot
@@ -12341,15 +12445,106 @@ function mount(router, deps) {
12341
12445
  shop_name: shopName,
12342
12446
  cart_count: cartCount,
12343
12447
  }, _requestUrls(req), ccy)));
12448
+ // One hygiene pass for every search sink. The autocomplete query log
12449
+ // (searchSuggestions.recordQuery) strips control bytes + lowercases
12450
+ // before it writes; the analytics funnel must see the SAME shape so
12451
+ // "Popular searches" and the analytics "top search terms" aggregate
12452
+ // the same rows. Strip control bytes, trim, lowercase once here — a
12453
+ // query that was only control bytes / whitespace collapses to "" and
12454
+ // every sink below skips it.
12455
+ var searchTerm = q.replace(SEARCH_CONTROL_BYTE_RE_G, "").trim().toLowerCase();
12344
12456
  // Consent-gated funnel event — feeds the top-search-terms aggregate.
12345
- // Only a real (non-empty) query counts; the typed term is bounded to
12346
- // 200 chars above (recordEvent caps search_q at 256). Container-served
12457
+ // Only a real (non-empty) query counts; the term is bounded to 200
12458
+ // chars above (recordEvent caps search_q at 256). Container-served
12347
12459
  // only; fire-and-forget + drop-silent.
12348
- if (q.trim().length > 0) {
12349
- _recordAnalyticsEvent(req, { event_type: "search_query", search_q: q.trim() });
12460
+ if (searchTerm.length > 0) {
12461
+ _recordAnalyticsEvent(req, { event_type: "search_query", search_q: searchTerm });
12462
+ }
12463
+ // Log the query for the admin "Popular searches" view + the autocomplete
12464
+ // popular-terms group. Observability sink — drop-silent by design: a
12465
+ // logging hiccup must never fail the search the shopper just ran. The
12466
+ // session id is the well-shaped sid cookie or "anon" (the primitive
12467
+ // hashes it before any write, so neither value is recoverable). Only a
12468
+ // real (non-empty) query is logged. Alongside it, a self-gated retention
12469
+ // sweep runs at most once a day to prune rows past the 90-day window.
12470
+ if (deps.searchSuggestions && searchTerm.length > 0) {
12471
+ try {
12472
+ deps.searchSuggestions.recordQuery({
12473
+ q: searchTerm,
12474
+ session_id: _readSidCookie(req) || "anon",
12475
+ result_count: totalCount,
12476
+ }).catch(function () { /* drop-silent — logging never fails the search */ });
12477
+ } catch (_e) { /* drop-silent — logging never fails the search */ }
12478
+ var nowMs = Date.now();
12479
+ if (nowMs - _lastSearchQueryCleanupAt >= SEARCH_QUERY_CLEANUP_EVERY_MS) {
12480
+ _lastSearchQueryCleanupAt = nowMs;
12481
+ try {
12482
+ deps.searchSuggestions.cleanupOldQueries(nowMs - SEARCH_QUERY_RETENTION_MS)
12483
+ .catch(function () { /* drop-silent — retention is best-effort */ });
12484
+ } catch (_e2) { /* drop-silent — retention is best-effort */ }
12485
+ }
12486
+ }
12487
+ // Search-ranking impression — one event per rendered result list,
12488
+ // keyed to the active weight set (the slug the admin search-metrics
12489
+ // view aggregates CTR / conversion against). recordSearchEvent rejects
12490
+ // a missing weight set, so this is a no-op until the operator activates
12491
+ // one. Observability sink — drop-silent by design: a metrics hiccup
12492
+ // must never fail the search the shopper just ran.
12493
+ if (deps.searchRanking && searchTerm.length > 0) {
12494
+ _recordSearchImpression(req, searchTerm);
12495
+ }
12496
+ // Facet-narrowing usage — one event per applied facet value, so the
12497
+ // admin can see which facets shoppers actually use. Drop-silent.
12498
+ if (deps.searchFacets && searchTerm.length > 0 && filters && Object.keys(filters).length > 0) {
12499
+ _recordFacetUses(req, filters);
12350
12500
  }
12351
12501
  });
12352
12502
 
12503
+ // Autocomplete data for the header search box — the island (search-suggest.js)
12504
+ // fetches this as the shopper types. Mounted only when the searchSuggestions
12505
+ // dep is wired; absent it, the island's fetch 404s and the box stays a plain
12506
+ // form (no-JS and dep-off both degrade to a normal /search submit). The
12507
+ // response is public + 30s-cacheable: it carries no per-session data (product
12508
+ // matches, recent popular terms, operator-curated rows — all shared chrome).
12509
+ if (deps.searchSuggestions) {
12510
+ router.get("/search/suggestions", async function (req, res) {
12511
+ function _emit(status, obj) {
12512
+ res.status(status);
12513
+ res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
12514
+ // 30s shared cache — the data is identical for every visitor; per-
12515
+ // session chrome (cart count) rides its own no-store endpoint.
12516
+ res.setHeader && res.setHeader("cache-control", "public, max-age=30");
12517
+ var payload = JSON.stringify(obj);
12518
+ return res.end ? res.end(payload) : res.send(payload);
12519
+ }
12520
+ var EMPTY = { products: [], queries: [], featured: [] };
12521
+
12522
+ // Defensive request-shape reader: a missing / non-string / blank /
12523
+ // oversized q is a 200 with the empty shape, never a 4xx the island
12524
+ // has to special-case. Trim + cap at the primitive's max before it
12525
+ // ever reaches the engine.
12526
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
12527
+ var qRaw = url && url.searchParams.get("q");
12528
+ var q = typeof qRaw === "string" ? qRaw.trim() : "";
12529
+ if (q.length > 200) q = q.slice(0, 200);
12530
+ if (!q.length) return _emit(200, EMPTY);
12531
+
12532
+ // Hot path — ANY engine failure returns the empty shape, never a 500.
12533
+ // Drop-silent by design: a suggestions hiccup must degrade to "no
12534
+ // dropdown", never break the page the shopper is typing on.
12535
+ try {
12536
+ var out = await deps.searchSuggestions.suggest({ q: q });
12537
+ return _emit(200, {
12538
+ products: Array.isArray(out && out.products) ? out.products : [],
12539
+ queries: Array.isArray(out && out.queries) ? out.queries : [],
12540
+ featured: Array.isArray(out && out.featured) ? out.featured : [],
12541
+ });
12542
+ } catch (_e) {
12543
+ return _emit(200, EMPTY);
12544
+ }
12545
+ });
12546
+ }
12547
+
12353
12548
  router.get("/products/:slug", async function (req, res) {
12354
12549
  var slug = req.params && req.params.slug;
12355
12550
  if (!slug) return _send(res, 400, renderNotFound({ shop_name: shopName, theme: theme }));
@@ -12521,6 +12716,18 @@ function mount(router, deps) {
12521
12716
  // most-viewed-products aggregate. Container-served only (anonymous PDPs
12522
12717
  // are edge-cached and never reach here); fire-and-forget + drop-silent.
12523
12718
  _recordAnalyticsEvent(req, { event_type: "pdp_view", product_id: product.id });
12719
+ // Search-ranking click — a PDP reached via a ranked result list carries
12720
+ // `?from=search&sq=<query>`. Defensive reader: a missing / unrelated
12721
+ // marker is the common case (direct nav, related-rail) and logs nothing;
12722
+ // a blank / over-long `sq` is dropped by the primitive's own validation
12723
+ // (caught drop-silent in _recordSearchClick). The click is attributed to
12724
+ // the active weight set, the same set new impressions log against, so the
12725
+ // admin search-metrics CTR stays coherent.
12726
+ if (deps.searchRanking && pdpUrl && pdpUrl.searchParams.get("from") === "search") {
12727
+ var clickQ = pdpUrl.searchParams.get("sq");
12728
+ if (typeof clickQ === "string" && clickQ.length > 200) clickQ = clickQ.slice(0, 200);
12729
+ _recordSearchClick(req, clickQ, product.id);
12730
+ }
12524
12731
  _send(res, 200, html);
12525
12732
  });
12526
12733
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
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": {