@blamejs/blamejs-shop 0.4.9 → 0.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -1
- package/SECURITY.md +12 -0
- package/lib/admin.js +257 -1
- package/lib/asset-manifest.json +7 -3
- package/lib/search-suggestions.js +35 -0
- package/lib/storefront.js +82 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- v0.4.8 (2026-06-05) — **Concurrent order status changes are race-safe: a cancelled order can't be revived and a freed stock hold can't be settled twice.** An order's status change read the current state, decided the next one, then wrote it back. Under two writers racing the same order — a payment confirmation arriving as the stale-checkout sweeper cancels it, or an operator marking it paid while a provider webhook lands — both could act on the same starting state, so the later write silently overwrote the earlier one. The write now carries the state it was decided from, so it only lands while the order is still in that state; the writer that loses the race applies nothing and reports the order as the winner left it. This closes a window where the sweeper could free an order's stock hold and the payment could then still confirm — overselling the last unit — and where a settled hold could be released or debited a second time. The common card path was already shielded by a pre-cancel check against the payment provider; this protects the paths that aren't (express wallets and PayPal, a store running without the card provider configured, an operator marking an order paid by hand, and a shopper cancelling as a webhook arrives). **Fixed:** *Order status transitions are serialized by a conditional claim* — A status transition now writes with the condition that the order is still in the state the transition was computed from, so concurrent transitions on one order resolve to exactly one winner. The loser matches no row and becomes a no-op: it writes no status change, records no transition entry, and runs no inventory settlement — preventing a stock hold from being released or debited twice and preventing a terminal order from being moved back to an active state. An uncontended transition behaves exactly as before. This mirrors the existing claim used when attaching a payment intent to a pending order. · *Stock holds can no longer be double-settled across the cancel and capture paths* — When the abandoned-checkout sweeper cancels a pending order at the same moment its payment confirms, only one edge now settles the hold: the cancel frees it or the capture debits it, never both. Previously the two could interleave so a hold was released and the same order still reached paid, overselling the unit. The fix covers the paths the provider-side pre-cancel check does not — express-wallet and PayPal orders, deployments without the card provider configured, an operator marking an order paid from the console, and a shopper cancelling while a webhook is in flight.
|
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, 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 “free” surfaces “Free shipping over $50”. 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/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.11",
|
|
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-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
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();
|
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
|
|
@@ -12348,8 +12360,77 @@ function mount(router, deps) {
|
|
|
12348
12360
|
if (q.trim().length > 0) {
|
|
12349
12361
|
_recordAnalyticsEvent(req, { event_type: "search_query", search_q: q.trim() });
|
|
12350
12362
|
}
|
|
12363
|
+
// Log the query for the admin "Popular searches" view + the autocomplete
|
|
12364
|
+
// popular-terms group. Observability sink — drop-silent by design: a
|
|
12365
|
+
// logging hiccup must never fail the search the shopper just ran. The
|
|
12366
|
+
// session id is the well-shaped sid cookie or "anon" (the primitive
|
|
12367
|
+
// hashes it before any write, so neither value is recoverable). Only a
|
|
12368
|
+
// real (non-empty) query is logged. Alongside it, a self-gated retention
|
|
12369
|
+
// sweep runs at most once a day to prune rows past the 90-day window.
|
|
12370
|
+
if (deps.searchSuggestions && q.trim().length > 0) {
|
|
12371
|
+
try {
|
|
12372
|
+
deps.searchSuggestions.recordQuery({
|
|
12373
|
+
q: q.trim(),
|
|
12374
|
+
session_id: _readSidCookie(req) || "anon",
|
|
12375
|
+
result_count: totalCount,
|
|
12376
|
+
}).catch(function () { /* drop-silent — logging never fails the search */ });
|
|
12377
|
+
} catch (_e) { /* drop-silent — logging never fails the search */ }
|
|
12378
|
+
var nowMs = Date.now();
|
|
12379
|
+
if (nowMs - _lastSearchQueryCleanupAt >= SEARCH_QUERY_CLEANUP_EVERY_MS) {
|
|
12380
|
+
_lastSearchQueryCleanupAt = nowMs;
|
|
12381
|
+
try {
|
|
12382
|
+
deps.searchSuggestions.cleanupOldQueries(nowMs - SEARCH_QUERY_RETENTION_MS)
|
|
12383
|
+
.catch(function () { /* drop-silent — retention is best-effort */ });
|
|
12384
|
+
} catch (_e2) { /* drop-silent — retention is best-effort */ }
|
|
12385
|
+
}
|
|
12386
|
+
}
|
|
12351
12387
|
});
|
|
12352
12388
|
|
|
12389
|
+
// Autocomplete data for the header search box — the island (search-suggest.js)
|
|
12390
|
+
// fetches this as the shopper types. Mounted only when the searchSuggestions
|
|
12391
|
+
// dep is wired; absent it, the island's fetch 404s and the box stays a plain
|
|
12392
|
+
// form (no-JS and dep-off both degrade to a normal /search submit). The
|
|
12393
|
+
// response is public + 30s-cacheable: it carries no per-session data (product
|
|
12394
|
+
// matches, recent popular terms, operator-curated rows — all shared chrome).
|
|
12395
|
+
if (deps.searchSuggestions) {
|
|
12396
|
+
router.get("/search/suggestions", async function (req, res) {
|
|
12397
|
+
function _emit(status, obj) {
|
|
12398
|
+
res.status(status);
|
|
12399
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
12400
|
+
// 30s shared cache — the data is identical for every visitor; per-
|
|
12401
|
+
// session chrome (cart count) rides its own no-store endpoint.
|
|
12402
|
+
res.setHeader && res.setHeader("cache-control", "public, max-age=30");
|
|
12403
|
+
var payload = JSON.stringify(obj);
|
|
12404
|
+
return res.end ? res.end(payload) : res.send(payload);
|
|
12405
|
+
}
|
|
12406
|
+
var EMPTY = { products: [], queries: [], featured: [] };
|
|
12407
|
+
|
|
12408
|
+
// Defensive request-shape reader: a missing / non-string / blank /
|
|
12409
|
+
// oversized q is a 200 with the empty shape, never a 4xx the island
|
|
12410
|
+
// has to special-case. Trim + cap at the primitive's max before it
|
|
12411
|
+
// ever reaches the engine.
|
|
12412
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
12413
|
+
var qRaw = url && url.searchParams.get("q");
|
|
12414
|
+
var q = typeof qRaw === "string" ? qRaw.trim() : "";
|
|
12415
|
+
if (q.length > 200) q = q.slice(0, 200);
|
|
12416
|
+
if (!q.length) return _emit(200, EMPTY);
|
|
12417
|
+
|
|
12418
|
+
// Hot path — ANY engine failure returns the empty shape, never a 500.
|
|
12419
|
+
// Drop-silent by design: a suggestions hiccup must degrade to "no
|
|
12420
|
+
// dropdown", never break the page the shopper is typing on.
|
|
12421
|
+
try {
|
|
12422
|
+
var out = await deps.searchSuggestions.suggest({ q: q });
|
|
12423
|
+
return _emit(200, {
|
|
12424
|
+
products: Array.isArray(out && out.products) ? out.products : [],
|
|
12425
|
+
queries: Array.isArray(out && out.queries) ? out.queries : [],
|
|
12426
|
+
featured: Array.isArray(out && out.featured) ? out.featured : [],
|
|
12427
|
+
});
|
|
12428
|
+
} catch (_e) {
|
|
12429
|
+
return _emit(200, EMPTY);
|
|
12430
|
+
}
|
|
12431
|
+
});
|
|
12432
|
+
}
|
|
12433
|
+
|
|
12353
12434
|
router.get("/products/:slug", async function (req, res) {
|
|
12354
12435
|
var slug = req.params && req.params.slug;
|
|
12355
12436
|
if (!slug) return _send(res, 400, renderNotFound({ shop_name: shopName, theme: theme }));
|
package/package.json
CHANGED