@blamejs/blamejs-shop 0.1.29 → 0.1.31
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 +4 -1
- package/SECURITY.md +9 -0
- package/lib/admin.js +317 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/product-qa.js +88 -0
- package/lib/storefront.js +640 -1
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +10 -0
- package/lib/vendor/blamejs/README.md +4 -0
- package/lib/vendor/blamejs/api-snapshot.json +116 -2
- package/lib/vendor/blamejs/index.js +8 -0
- package/lib/vendor/blamejs/lib/acme.js +2 -2
- package/lib/vendor/blamejs/lib/auth/dpop.js +14 -44
- package/lib/vendor/blamejs/lib/base32.js +154 -0
- package/lib/vendor/blamejs/lib/dbsc.js +5 -18
- package/lib/vendor/blamejs/lib/json-schema.js +740 -0
- package/lib/vendor/blamejs/lib/jwk.js +127 -0
- package/lib/vendor/blamejs/lib/middleware/bot-guard.js +43 -6
- package/lib/vendor/blamejs/lib/totp.js +10 -31
- package/lib/vendor/blamejs/lib/uri-template.js +286 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.64.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.65.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.12.66.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.68.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.12.69.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/base32.test.js +79 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bot-guard.test.js +102 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +4 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/json-schema.test.js +134 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/jwk.test.js +72 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/uri-template.test.js +99 -0
- 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.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.31 (2026-05-26) — **Product compare — a side-by-side comparison table any shopper can build from the product pages.** Shoppers can now line products up against each other. Every product page gains an "Add to compare" control; a "Compare" link in the footer opens a side-by-side table of the chosen products — image, price, availability, and each product's attributes in aligned rows. No sign-in is required: the comparison basket rides the visitor's existing sealed session cookie, and a signed-in shopper's customer id is carried alongside. The basket holds up to four products (a fifth add is refused with a notice rather than quietly dropping one), the toggle is idempotent, and a product that goes archived or out of stock resolves out of the table with a remove control instead of breaking the page. The control and table render identically from the edge and the container, and the comparison page reuses the catalog grid styling, so no new stylesheet ships. **Added:** *Add to compare on the product page* — Each product page shows an "Add to compare" / "Remove from compare" control reflecting whether the product is already in the basket. `POST /compare/toggle` adds or removes it and redirects back to where the shopper was (the redirect target is validated, so a crafted `return_to` can't bounce the visitor off-site). Comparing needs no account — the basket is keyed on the visitor's existing sealed session cookie; a signed-in shopper's customer id is captured alongside. The basket is capped at four products: a fifth add is refused with a notice and the basket is left unchanged. · *The comparison table* — `GET /compare` renders the selected products side by side — image, title, price, availability, and the per-product attributes resolved through the catalog, in aligned rows. Each column has a remove control, and `POST /compare/clear` empties the whole basket. A product that has since been archived or sold out shows in a gone/out-of-stock state with a remove control rather than failing the render. An empty basket shows a friendly empty state and mints no cookie. A "Compare" link sits in the footer on every page.
|
|
12
|
+
|
|
13
|
+
- v0.1.30 (2026-05-26) — **Product Q&A — customer questions and seller answers on the product page.** Products gain a question-and-answer thread alongside reviews. A signed-in shopper asks a question from the product page; it lands pending and surfaces only after an operator approves it. Operators moderate at `/admin/questions`: a status-filtered queue of every question across the catalog, and a per-question detail page where they approve or reject the question, post the authoritative seller answer, approve / reject individual answers, and pin one answer as the definitive 'top answer'. Approved questions and their approved answers render on the product page in both the edge and container render paths, reusing the reviews section's styling so no new CSS ships. As with reviews, a missing Q&A table degrades the product page to an empty Q&A state rather than failing the render. **Added:** *Ask a question on the product page* — `GET /products/:slug/question` renders an auth-gated question form; `POST /products/:slug/question` submits it. Asking requires a signed-in customer — a logged-out shopper is redirected to sign in. The question is stored pending and is not shown on the product page until an operator approves it. A malformed submission re-renders the form with the reason and a 400; an unknown product is a 404. · *Published Q&A on the product page* — The product page shows approved questions with their approved answers below the reviews section. Each answer carries a source badge — 'Answered by the seller', 'Customer answer', or 'Automated answer' — and the operator-pinned answer is flagged 'Top answer'. The empty state invites the first question. Rendered identically by the edge worker and the container so the page is the same whichever serves it; the read degrades to the empty state when the Q&A tables aren't present. · *Q&A moderation console* — `/admin/questions` is the question queue across all products, with pending / approved / rejected status-filter chips. Each question opens a detail page showing its full answer thread (every status), an approve / reject control for the question, a form to post the seller's answer, and per-answer approve / reject / pin controls. Posting an answer leaves it pending; approving it publishes it on the product page. Pinning floats one operator answer to the top of the thread. Every path content-negotiates: a bearer-token client gets a JSON contract, a signed-in browser gets the HTML console. An unknown id or a refused transition redirects back with a notice, never a 500. · *productQA read APIs for the storefront and console* — `answersForQuestion` returns the approved answers under a question (pinned first) for the product-page thread; `listAnswersForQuestion` returns every answer regardless of status for the moderation detail; `listQuestionsByStatus` and `listAnswersByStatus` return the cross-product moderation queues. **Changed:** *Console nav gains Q&A; product page wires the Q&A primitive* — The signed-in admin nav includes Q&A, shown only when the Q&A primitive is wired so the link never points at an unmounted route. The storefront and admin share one Q&A instance, wired with the customer primitive so an authenticated questioner's id is verified before the row is stored.
|
|
14
|
+
|
|
11
15
|
- v0.1.29 (2026-05-26) — **Category navigation goes live — public browse pages for the storefront's category tree, with breadcrumbs and sub-category grids.** The category tree now has a customer-facing surface. A new /categories index lists the active top-level categories as a card grid, and a new /categories/<slug> page renders a single category: its title and optional description, a breadcrumb chain from the catalog root down to the current category, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived or unpublished categories drop out of every surface. An unknown, archived, or malformed slug returns a 404, and a category with no children renders a graceful empty state. The pages are linked from the site footer's Shop column. **Added:** *Category index at /categories* — A public page listing the active top-level categories as a card grid, each card carrying the category's title, optional description, and optional hero image, and linking into its browse page. Archived and unpublished categories are excluded. Linked from the site footer's Shop column. · *Category browse page at /categories/<slug>* — A public page for a single category showing its title and optional description, a breadcrumb chain from the catalog root down to the current category (each ancestor linked, the current category shown as plain text), an optional hero image, and a card grid of the category's direct child sub-categories. A category with no children renders a graceful empty state. An unknown, archived, or malformed slug returns a 404.
|
|
12
16
|
|
|
13
17
|
- v0.1.28 (2026-05-25) — **Refer-a-friend goes live — a shareable link, attributed signups, and conversions recorded when a referred friend's first order is paid.** The referrals ledger now has a customer-facing surface. A new /account/referrals page gives the signed-in customer a personal referral code, an absolute shareable link, the list of friends they've referred with each one's funnel stage, and an in-account top-referrer leaderboard. A new /r/<code> landing captures an inbound referral: it sets a short-lived sealed first-party cookie naming the code and sends the visitor home. When that visitor creates an account — by passkey, Google, or Apple — the new customer is attributed to the referrer, first-touch, with a self-referral guard so a code can never refer its own owner. When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's count (the leaderboard key) — automatically, off the request path, and recorded once per order so a re-delivered payment webhook can't double-count. Issuing the actual reward (a gift card, store credit, or loyalty points — operator's choice) stays an explicit operator action through the referrals reward API, so the payout instrument and any fraud review are the operator's to control. **Added:** *Customer referrals page at /account/referrals* — A login-gated page showing the customer's referral code and an absolute shareable link (derived from the configured shop origin, falling back to the request host), the count of friends invited / joined / converted, the list of referred friends with each one's funnel stage (invited → visited → joined → converted) and dates, and an in-account top-referrer leaderboard. The friend list carries no personal data — referred emails are stored hash-only — and the leaderboard shows rank plus initials only, never names, emails, or account ids, with the signed-in customer's own row marked "You". Linked from the account dashboard as "Refer a friend". A customer with no code yet sees a one-click create control; a customer with one already gets the same page (creating is idempotent). · *Referral attribution landing at /r/<code>* — A public landing that captures an inbound referral: it sets a short-lived (30-day) sealed, first-party cookie naming the code, records the visit, and redirects the visitor to the home page. Attribution is first-touch — the first referral link a visitor follows wins, and a later link doesn't overwrite it. An unknown, malformed, or disabled code is silently ignored: the visitor still lands on the home page with no error and no cookie, so the route is never a code-existence oracle. · *Signups attributed to the referrer* — When a visitor who arrived through a referral link creates an account — passkey, Sign in with Google, or Sign in with Apple — the new customer is attributed to the referrer named in the referral cookie. A self-referral (the code belongs to the new account) is refused, a customer is attributed only once, and an existing customer signing in through Google or Apple is never attributed (only a genuinely new account is). Attribution is best-effort and never blocks account creation; the referral cookie is cleared after one signup so a stale link can't attribute a later one. · *Referral conversion recorded when a referred friend's first order is paid* — When a referred customer's first order reaches paid, the order primitive marks that referral converted and bumps the referrer's referral count (the leaderboard key). The update rides every paid path (card, wallet, PayPal, gift-card-covered), runs fire-and-forget off the request path, and is recorded once per order so a re-delivered payment webhook collapses onto a single conversion. Only orders carrying a customer account qualify; a guest order records nothing, and a customer's second and later orders don't re-trigger it. Issuing the payout itself — a gift card, store credit, or loyalty points — is a separate operator action through the referrals reward API, so the operator controls the reward instrument and can review before paying out.
|
package/README.md
CHANGED
|
@@ -64,12 +64,14 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
64
64
|
| **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
|
|
65
65
|
| **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront. |
|
|
66
66
|
| **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
|
|
67
|
+
| **`lib/product-qa.js`** | Customer questions and operator/customer answers per product, operator-moderated, distinct from the rating-based reviews. A signed-in shopper asks at `/products/:slug/question`; questions land `pending` and surface only after approval. Author identity is the customer id (verified against the customers primitive) or a hash-only email — the raw address is never stored. The product page renders approved questions with their approved answers (seller / customer / system badge, pinned 'top answer' first) in both the edge and container paths. `/admin/questions` is the moderation console: the cross-product queue (`listQuestionsByStatus`), and a per-question detail to approve / reject the question, post the seller answer (`submitAnswer`), approve / reject / pin answers. |
|
|
67
68
|
| **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
|
|
68
69
|
| **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
|
|
69
70
|
| **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
|
|
70
71
|
| **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
|
|
71
72
|
| **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
|
|
72
73
|
| **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
|
|
74
|
+
| **`lib/product-compare.js`** | Side-by-side product comparison, no sign-in required. Each product page gets an "Add to compare" / "Remove from compare" control; `POST /compare/toggle` adds or removes the product and `GET /compare` renders the comparison table — image, price, availability, and the per-product attributes resolved through the catalog. The basket is keyed on the visitor's existing sealed session cookie (a signed-in shopper's customer id rides alongside), capped at four products (a fifth add is refused with a notice rather than silently dropping one), and is idempotent. Products that go archived or out of stock resolve out of the table with a remove control instead of breaking the render; an empty basket shows a friendly empty state. `POST /compare/clear` empties it. Linked from the footer on every page. |
|
|
73
75
|
| **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
|
|
74
76
|
| **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
|
|
75
77
|
| **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
|
|
@@ -78,7 +80,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
78
80
|
| **`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). |
|
|
79
81
|
| **`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. |
|
|
80
82
|
| **`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. |
|
|
81
|
-
| **`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; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **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; **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); **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; **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), and open a card to see its full credit / debit / expire ledger; **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. The Customers, Returns, Reviews, Subscriptions, Collections, Gift cards, and Webhooks 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. |
|
|
83
|
+
| **`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; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **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; **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); **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), and open a card to see its full credit / debit / expire ledger; **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. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, and Webhooks 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. |
|
|
82
84
|
| **`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. |
|
|
83
85
|
| **`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. |
|
|
84
86
|
|
|
@@ -111,6 +113,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
111
113
|
- `migrations-d1/0025_referrals.sql` — referral codes (one active per customer) + invitation funnel (hash-only referred email)
|
|
112
114
|
- `migrations-d1/0182_referral_leaderboard.sql` — referral tier thresholds (singleton config) + tiered-bonus payout log
|
|
113
115
|
- `migrations-d1/0201_category_navigation.sql` — hierarchical category tree (self-referential parent_slug, sibling position, soft-delete tombstone)
|
|
116
|
+
- `migrations-d1/0133_product_qa.sql` — product Q&A questions + answers + distinct-session vote-ups (moderation FSM, hash-only author identity, FK CASCADE)
|
|
114
117
|
|
|
115
118
|
### Demo seed
|
|
116
119
|
|
package/SECURITY.md
CHANGED
|
@@ -148,6 +148,15 @@ node -e "
|
|
|
148
148
|
publishes them through `/admin/reviews`. The author identity is
|
|
149
149
|
stored hash-only (`b.crypto.namespaceHash`) — the raw email is never
|
|
150
150
|
persisted.
|
|
151
|
+
- **Product Q&A is operator-moderated user-generated content.** A
|
|
152
|
+
customer question is stored `pending` and never reaches the product
|
|
153
|
+
page until an operator approves it through `/admin/questions`;
|
|
154
|
+
customer-submitted answers are not accepted from the storefront at
|
|
155
|
+
all — only the operator posts the authoritative answer, and it too
|
|
156
|
+
lands `pending` until approved. So neither an unmoderated question nor
|
|
157
|
+
an unmoderated reply can ever surface publicly. Author identity is the
|
|
158
|
+
customer id (verified against the customers primitive) or a hash-only
|
|
159
|
+
email — the raw address is never persisted.
|
|
151
160
|
- **Gift-card codes are bearer secrets, stored hash-only.** A gift
|
|
152
161
|
card's plaintext code is shown exactly once at issuance and never
|
|
153
162
|
persisted — only its `namespaceHash` digest plus a 4-character hint
|
package/lib/admin.js
CHANGED
|
@@ -244,13 +244,14 @@ function mount(router, deps) {
|
|
|
244
244
|
var assetPrefix = typeof deps.asset_prefix === "string" ? deps.asset_prefix : "/assets/";
|
|
245
245
|
var catalogImport = deps.catalogImport || null; // bulk-import route disabled when absent
|
|
246
246
|
var reviews = deps.reviews || null; // moderation endpoints disabled when absent
|
|
247
|
+
var productQa = deps.productQa || null; // Q&A moderation endpoints disabled when absent
|
|
247
248
|
var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
|
|
248
249
|
var customers = deps.customers || null; // read-only customers console disabled when absent
|
|
249
250
|
|
|
250
251
|
// Which optional console sections are wired — gates their nav links so a
|
|
251
252
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
252
253
|
// into every authed render call as `nav_available`.
|
|
253
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards };
|
|
254
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards };
|
|
254
255
|
|
|
255
256
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
256
257
|
|
|
@@ -986,6 +987,196 @@ function mount(router, deps) {
|
|
|
986
987
|
));
|
|
987
988
|
}
|
|
988
989
|
|
|
990
|
+
// ---- product Q&A (moderation) ---------------------------------------
|
|
991
|
+
|
|
992
|
+
// Operator-side Q&A moderation. The queue lists questions across all
|
|
993
|
+
// products in one status (defaults to `pending`); the per-question
|
|
994
|
+
// detail page shows the thread and lets the operator approve / reject
|
|
995
|
+
// the question, post the authoritative answer, approve / reject /
|
|
996
|
+
// pin individual answers. The storefront leaves new questions in
|
|
997
|
+
// `pending`; operator-posted answers land `pending` and are approved
|
|
998
|
+
// from the same page. Endpoints are omitted entirely when no productQa
|
|
999
|
+
// primitive is wired.
|
|
1000
|
+
if (productQa) {
|
|
1001
|
+
// A guardUuid / shape rejection is a TypeError; a not-found row and
|
|
1002
|
+
// a refused FSM transition carry a code. Map all to client errors so
|
|
1003
|
+
// a bad id / stale action is a notice, never a 500.
|
|
1004
|
+
function _qaClientError(e) {
|
|
1005
|
+
if (!e) return null;
|
|
1006
|
+
if (e instanceof TypeError) return { status: 400, slug: "bad-request" };
|
|
1007
|
+
if (e.code === "PRODUCT_QA_QUESTION_NOT_FOUND") return { status: 404, slug: "question-not-found" };
|
|
1008
|
+
if (e.code === "PRODUCT_QA_ANSWER_NOT_FOUND") return { status: 404, slug: "answer-not-found" };
|
|
1009
|
+
if (e.code === "PRODUCT_QA_TRANSITION_REFUSED") return { status: 409, slug: "transition-refused" };
|
|
1010
|
+
if (e.code === "PRODUCT_QA_PIN_REFUSED") return { status: 409, slug: "pin-refused" };
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
router.get("/admin/questions", _pageOrApi(true,
|
|
1015
|
+
R(async function (req, res) {
|
|
1016
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1017
|
+
var status = (url && url.searchParams.get("status")) || "pending";
|
|
1018
|
+
var limitS = url && url.searchParams.get("limit");
|
|
1019
|
+
var limit = limitS == null ? undefined : parseInt(limitS, 10);
|
|
1020
|
+
var rows = await productQa.listQuestionsByStatus(status, { limit: limit });
|
|
1021
|
+
_json(res, 200, { rows: rows });
|
|
1022
|
+
}),
|
|
1023
|
+
async function (req, res) {
|
|
1024
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1025
|
+
var status = (url && url.searchParams.get("status")) || "pending";
|
|
1026
|
+
var notice = null, rows = [];
|
|
1027
|
+
// A bad ?status= raises a TypeError — fall back to pending.
|
|
1028
|
+
try {
|
|
1029
|
+
rows = await productQa.listQuestionsByStatus(status, { limit: 100 });
|
|
1030
|
+
} catch (e) {
|
|
1031
|
+
if (!(e instanceof TypeError)) throw e;
|
|
1032
|
+
status = "pending"; notice = "Unknown status filter — showing pending questions.";
|
|
1033
|
+
rows = await productQa.listQuestionsByStatus("pending", { limit: 100 });
|
|
1034
|
+
}
|
|
1035
|
+
_sendHtml(res, 200, renderAdminQuestions({
|
|
1036
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
1037
|
+
questions: rows, status: status, notice: notice,
|
|
1038
|
+
moved: url && url.searchParams.get("moved"),
|
|
1039
|
+
}));
|
|
1040
|
+
},
|
|
1041
|
+
));
|
|
1042
|
+
|
|
1043
|
+
router.get("/admin/questions/:id", _pageOrApi(true,
|
|
1044
|
+
R(async function (req, res) {
|
|
1045
|
+
var q;
|
|
1046
|
+
try { q = await productQa.getQuestion(req.params.id); }
|
|
1047
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 404, "question-not-found"); throw e; }
|
|
1048
|
+
if (!q) return _problem(res, 404, "question-not-found");
|
|
1049
|
+
q.answers = await productQa.listAnswersForQuestion(q.id, { limit: 200 });
|
|
1050
|
+
_json(res, 200, q);
|
|
1051
|
+
}),
|
|
1052
|
+
async function (req, res) {
|
|
1053
|
+
var q;
|
|
1054
|
+
try { q = await productQa.getQuestion(req.params.id); }
|
|
1055
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; q = null; }
|
|
1056
|
+
if (!q) return _sendHtml(res, 404, renderAdminQuestions({
|
|
1057
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
1058
|
+
questions: [], status: "pending", notice: "Question not found.",
|
|
1059
|
+
}));
|
|
1060
|
+
// Show every answer regardless of status so the operator can
|
|
1061
|
+
// moderate pending/rejected answers, not just approved ones.
|
|
1062
|
+
var answers = await productQa.listAnswersForQuestion(q.id, { limit: 200 });
|
|
1063
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1064
|
+
_sendHtml(res, 200, renderAdminQuestion({
|
|
1065
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
1066
|
+
question: q, answers: answers,
|
|
1067
|
+
moved: url && url.searchParams.get("moved"),
|
|
1068
|
+
notice: url && url.searchParams.get("err") ? "That action couldn't be completed." : null,
|
|
1069
|
+
}));
|
|
1070
|
+
},
|
|
1071
|
+
));
|
|
1072
|
+
|
|
1073
|
+
// A Q&A action: run `opFn(id, body)`, then PRG. A bad id / shape /
|
|
1074
|
+
// FSM refusal becomes a notice on the queue (question-level) or the
|
|
1075
|
+
// detail (answer-level), never a 500; anything else propagates.
|
|
1076
|
+
function _qaQuestionAction(jsonHandler, auditEvent, opFn) {
|
|
1077
|
+
return _pageOrApi(false, jsonHandler, async function (req, res) {
|
|
1078
|
+
var id = req.params.id;
|
|
1079
|
+
try { await opFn(id, req.body || {}); }
|
|
1080
|
+
catch (e) {
|
|
1081
|
+
if (_qaClientError(e)) return _redirect(res, "/admin/questions?err=1");
|
|
1082
|
+
throw e;
|
|
1083
|
+
}
|
|
1084
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
|
|
1085
|
+
_redirect(res, "/admin/questions?moved=1");
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
function _qaAnswerAction(jsonHandler, auditEvent, opFn, backFor) {
|
|
1089
|
+
return _pageOrApi(false, jsonHandler, async function (req, res) {
|
|
1090
|
+
var id = req.params.id;
|
|
1091
|
+
var back = backFor(req);
|
|
1092
|
+
try { await opFn(id, req.body || {}); }
|
|
1093
|
+
catch (e) {
|
|
1094
|
+
if (_qaClientError(e)) return _redirect(res, "/admin/questions/" + encodeURIComponent(back) + "?err=1");
|
|
1095
|
+
throw e;
|
|
1096
|
+
}
|
|
1097
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
|
|
1098
|
+
_redirect(res, "/admin/questions/" + encodeURIComponent(back) + "?moved=1");
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
router.post("/admin/questions/:id/approve", _qaQuestionAction(
|
|
1103
|
+
W("question.approve", async function (req, res) {
|
|
1104
|
+
var q;
|
|
1105
|
+
try { q = await productQa.approveQuestion(req.params.id); }
|
|
1106
|
+
catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
|
|
1107
|
+
_json(res, 200, q); return q;
|
|
1108
|
+
}),
|
|
1109
|
+
"question.approve",
|
|
1110
|
+
function (id) { return productQa.approveQuestion(id); },
|
|
1111
|
+
));
|
|
1112
|
+
|
|
1113
|
+
router.post("/admin/questions/:id/reject", _qaQuestionAction(
|
|
1114
|
+
W("question.reject", async function (req, res) {
|
|
1115
|
+
var body = req.body || {};
|
|
1116
|
+
var q;
|
|
1117
|
+
try { q = await productQa.rejectQuestion(req.params.id, body.reason); }
|
|
1118
|
+
catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
|
|
1119
|
+
_json(res, 200, q); return q;
|
|
1120
|
+
}),
|
|
1121
|
+
"question.reject",
|
|
1122
|
+
function (id, body) { return productQa.rejectQuestion(id, body.reason || undefined); },
|
|
1123
|
+
));
|
|
1124
|
+
|
|
1125
|
+
// Post the operator's authoritative answer to a question. The answer
|
|
1126
|
+
// lands `pending`; the operator approves it from the same detail page
|
|
1127
|
+
// (so the post + publish stay explicit, mirroring the question FSM).
|
|
1128
|
+
router.post("/admin/questions/:id/answer", _qaAnswerAction(
|
|
1129
|
+
W("question.answer", async function (req, res) {
|
|
1130
|
+
var body = req.body || {};
|
|
1131
|
+
var a;
|
|
1132
|
+
try {
|
|
1133
|
+
a = await productQa.submitAnswer({ question_id: req.params.id, author: "operator", body: body.body });
|
|
1134
|
+
} catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
|
|
1135
|
+
_json(res, 201, a); return a;
|
|
1136
|
+
}),
|
|
1137
|
+
"question.answer",
|
|
1138
|
+
function (id, body) { return productQa.submitAnswer({ question_id: id, author: "operator", body: body.body }); },
|
|
1139
|
+
function (req) { return req.params.id; },
|
|
1140
|
+
));
|
|
1141
|
+
|
|
1142
|
+
router.post("/admin/answers/:id/approve", _qaAnswerAction(
|
|
1143
|
+
W("answer.approve", async function (req, res) {
|
|
1144
|
+
var a;
|
|
1145
|
+
try { a = await productQa.approveAnswer(req.params.id); }
|
|
1146
|
+
catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
|
|
1147
|
+
_json(res, 200, a); return a;
|
|
1148
|
+
}),
|
|
1149
|
+
"answer.approve",
|
|
1150
|
+
function (id) { return productQa.approveAnswer(id); },
|
|
1151
|
+
function (req) { return (req.body || {}).question_id || ""; },
|
|
1152
|
+
));
|
|
1153
|
+
|
|
1154
|
+
router.post("/admin/answers/:id/reject", _qaAnswerAction(
|
|
1155
|
+
W("answer.reject", async function (req, res) {
|
|
1156
|
+
var body = req.body || {};
|
|
1157
|
+
var a;
|
|
1158
|
+
try { a = await productQa.rejectAnswer(req.params.id, body.reason); }
|
|
1159
|
+
catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
|
|
1160
|
+
_json(res, 200, a); return a;
|
|
1161
|
+
}),
|
|
1162
|
+
"answer.reject",
|
|
1163
|
+
function (id, body) { return productQa.rejectAnswer(id, body.reason || undefined); },
|
|
1164
|
+
function (req) { return (req.body || {}).question_id || ""; },
|
|
1165
|
+
));
|
|
1166
|
+
|
|
1167
|
+
router.post("/admin/answers/:id/pin", _qaAnswerAction(
|
|
1168
|
+
W("answer.pin", async function (req, res) {
|
|
1169
|
+
var a;
|
|
1170
|
+
try { a = await productQa.pinAnswer(req.params.id); }
|
|
1171
|
+
catch (e) { var ce = _qaClientError(e); if (ce) return _problem(res, ce.status, ce.slug, e.message); throw e; }
|
|
1172
|
+
_json(res, 200, a); return a;
|
|
1173
|
+
}),
|
|
1174
|
+
"answer.pin",
|
|
1175
|
+
function (id) { return productQa.pinAnswer(id); },
|
|
1176
|
+
function (req) { return (req.body || {}).question_id || ""; },
|
|
1177
|
+
));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
989
1180
|
// ---- returns (moderation) -------------------------------------------
|
|
990
1181
|
|
|
991
1182
|
// Operator-side RMA moderation. The queue lists return
|
|
@@ -2442,6 +2633,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
2442
2633
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
2443
2634
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
2444
2635
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
2636
|
+
{ key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
|
|
2445
2637
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
2446
2638
|
{ key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
|
|
2447
2639
|
{ key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
|
|
@@ -3006,6 +3198,128 @@ function renderAdminReviews(opts) {
|
|
|
3006
3198
|
return _renderAdminShell(opts.shop_name, "Reviews", body, "reviews", opts.nav_available);
|
|
3007
3199
|
}
|
|
3008
3200
|
|
|
3201
|
+
// The Q&A moderation states an operator can filter the question queue by.
|
|
3202
|
+
var QA_STATUS_FILTERS = ["pending", "approved", "rejected"];
|
|
3203
|
+
|
|
3204
|
+
function _qaStatusPill(status) {
|
|
3205
|
+
var cls = status === "approved" ? "paid" : status === "rejected" ? "cancelled" : "pending";
|
|
3206
|
+
return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
// Cross-product question queue. Each card links to the per-question
|
|
3210
|
+
// detail where the operator posts + publishes the answer. Inline
|
|
3211
|
+
// approve / reject act on the question itself.
|
|
3212
|
+
function renderAdminQuestions(opts) {
|
|
3213
|
+
opts = opts || {};
|
|
3214
|
+
var list = opts.questions || [];
|
|
3215
|
+
var active = opts.status || "pending";
|
|
3216
|
+
var moved = opts.moved ? "<div class=\"banner banner--ok\">Question updated.</div>" : "";
|
|
3217
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
3218
|
+
|
|
3219
|
+
var chips = "<div class=\"order-filters\">" +
|
|
3220
|
+
QA_STATUS_FILTERS.map(function (s) {
|
|
3221
|
+
return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/questions?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
|
|
3222
|
+
}).join("") +
|
|
3223
|
+
"</div>";
|
|
3224
|
+
|
|
3225
|
+
var cards = list.map(function (q) {
|
|
3226
|
+
var detail = "/admin/questions/" + _htmlEscape(q.id);
|
|
3227
|
+
var pub = "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/approve\" class=\"form-inline\">" +
|
|
3228
|
+
"<button class=\"btn\" type=\"submit\">Approve</button></form>";
|
|
3229
|
+
var rej = "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/reject\" class=\"review-reject\">" +
|
|
3230
|
+
"<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
|
|
3231
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Reject</button></form>";
|
|
3232
|
+
// Rejected questions are terminal for the approve path (the lib
|
|
3233
|
+
// refuses approve-from-rejected), so only show reject from approved
|
|
3234
|
+
// and both from pending.
|
|
3235
|
+
var actions = q.status === "approved" ? rej
|
|
3236
|
+
: q.status === "rejected" ? ""
|
|
3237
|
+
: pub + " " + rej;
|
|
3238
|
+
return "<div class=\"panel review-card\">" +
|
|
3239
|
+
"<div class=\"review-card__head\">" +
|
|
3240
|
+
_qaStatusPill(q.status) + " " +
|
|
3241
|
+
"<a href=\"" + detail + "\"><strong>Open thread</strong></a>" +
|
|
3242
|
+
"</div>" +
|
|
3243
|
+
"<p class=\"review-card__body\">" + _htmlEscape(q.body || "") + "</p>" +
|
|
3244
|
+
"<p class=\"meta\">Product <code class=\"order-id\">" + _htmlEscape(String(q.product_id).slice(0, 8)) + "</code> · " + _htmlEscape(_fmtDate(q.occurred_at)) + "</p>" +
|
|
3245
|
+
(actions ? "<div class=\"order-actions\">" + actions + "</div>" : "") +
|
|
3246
|
+
"</div>";
|
|
3247
|
+
}).join("");
|
|
3248
|
+
|
|
3249
|
+
var queue = list.length ? cards : "<p class=\"empty\">No “" + _htmlEscape(active) + "” questions.</p>";
|
|
3250
|
+
var body = "<section><h2>Q&A</h2>" + moved + notice + chips + queue + "</section>";
|
|
3251
|
+
return _renderAdminShell(opts.shop_name, "Q&A", body, "questions", opts.nav_available);
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// Per-question detail: the question, its answer thread (every status),
|
|
3255
|
+
// the operator answer form, and per-answer approve / reject / pin.
|
|
3256
|
+
function renderAdminQuestion(opts) {
|
|
3257
|
+
opts = opts || {};
|
|
3258
|
+
var q = opts.question || {};
|
|
3259
|
+
var answers = opts.answers || [];
|
|
3260
|
+
var moved = opts.moved ? "<div class=\"banner banner--ok\">Updated.</div>" : "";
|
|
3261
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
3262
|
+
|
|
3263
|
+
var qApprove = q.status === "pending"
|
|
3264
|
+
? "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/approve\" class=\"form-inline\"><button class=\"btn\" type=\"submit\">Approve question</button></form>"
|
|
3265
|
+
: "";
|
|
3266
|
+
var qReject = q.status !== "rejected"
|
|
3267
|
+
? "<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/reject\" class=\"review-reject\">" +
|
|
3268
|
+
"<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
|
|
3269
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Reject question</button></form>"
|
|
3270
|
+
: "";
|
|
3271
|
+
|
|
3272
|
+
var qPanel = "<div class=\"panel review-card\">" +
|
|
3273
|
+
"<div class=\"review-card__head\">" + _qaStatusPill(q.status) + "</div>" +
|
|
3274
|
+
"<p class=\"review-card__body\">" + _htmlEscape(q.body || "") + "</p>" +
|
|
3275
|
+
"<p class=\"meta\">Product <code class=\"order-id\">" + _htmlEscape(String(q.product_id).slice(0, 8)) + "</code> · " + _htmlEscape(_fmtDate(q.occurred_at)) + "</p>" +
|
|
3276
|
+
((qApprove || qReject) ? "<div class=\"order-actions\">" + qApprove + " " + qReject + "</div>" : "") +
|
|
3277
|
+
"</div>";
|
|
3278
|
+
|
|
3279
|
+
var answerCards = answers.map(function (a) {
|
|
3280
|
+
var hid = "<input type=\"hidden\" name=\"question_id\" value=\"" + _htmlEscape(q.id) + "\">";
|
|
3281
|
+
var approve = a.status === "pending"
|
|
3282
|
+
? "<form method=\"post\" action=\"/admin/answers/" + _htmlEscape(a.id) + "/approve\" class=\"form-inline\">" + hid + "<button class=\"btn\" type=\"submit\">Approve</button></form>"
|
|
3283
|
+
: "";
|
|
3284
|
+
var reject = a.status !== "rejected"
|
|
3285
|
+
? "<form method=\"post\" action=\"/admin/answers/" + _htmlEscape(a.id) + "/reject\" class=\"review-reject\">" + hid +
|
|
3286
|
+
"<input type=\"text\" name=\"reason\" placeholder=\"Reason\" maxlength=\"300\" required>" +
|
|
3287
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Reject</button></form>"
|
|
3288
|
+
: "";
|
|
3289
|
+
// Pin is only valid for an approved operator answer; the lib refuses
|
|
3290
|
+
// otherwise, so only surface the control when it'll succeed.
|
|
3291
|
+
var pin = (Number(a.is_operator) === 1 && a.status === "approved" && Number(a.pinned) !== 1)
|
|
3292
|
+
? "<form method=\"post\" action=\"/admin/answers/" + _htmlEscape(a.id) + "/pin\" class=\"form-inline\">" + hid + "<button class=\"btn\" type=\"submit\">Pin as top</button></form>"
|
|
3293
|
+
: "";
|
|
3294
|
+
var who = Number(a.is_operator) === 1 ? "Seller" : (a.author === "system" ? "System" : "Customer");
|
|
3295
|
+
return "<div class=\"panel review-card\">" +
|
|
3296
|
+
"<div class=\"review-card__head\">" +
|
|
3297
|
+
_qaStatusPill(a.status) + " <strong>" + _htmlEscape(who) + "</strong> " +
|
|
3298
|
+
(Number(a.pinned) === 1 ? "<span class=\"status-pill paid\">Pinned</span>" : "") +
|
|
3299
|
+
"</div>" +
|
|
3300
|
+
"<p class=\"review-card__body\">" + _htmlEscape(a.body || "") + "</p>" +
|
|
3301
|
+
"<div class=\"order-actions\">" + approve + " " + reject + " " + pin + "</div>" +
|
|
3302
|
+
"</div>";
|
|
3303
|
+
}).join("");
|
|
3304
|
+
var thread = answers.length ? answerCards : "<p class=\"empty\">No answers yet.</p>";
|
|
3305
|
+
|
|
3306
|
+
var answerForm = "<div class=\"panel\"><h3>Post the seller answer</h3>" +
|
|
3307
|
+
"<form method=\"post\" action=\"/admin/questions/" + _htmlEscape(q.id) + "/answer\">" +
|
|
3308
|
+
"<textarea name=\"body\" maxlength=\"4000\" rows=\"4\" required></textarea>" +
|
|
3309
|
+
"<div class=\"order-actions\"><button class=\"btn\" type=\"submit\">Submit answer</button></div>" +
|
|
3310
|
+
"</form>" +
|
|
3311
|
+
"<p class=\"meta\">The answer lands pending — approve it above to publish it on the product page.</p>" +
|
|
3312
|
+
"</div>";
|
|
3313
|
+
|
|
3314
|
+
var body = "<section><h2>Question</h2>" +
|
|
3315
|
+
"<p class=\"meta\"><a href=\"/admin/questions\">← Back to queue</a></p>" +
|
|
3316
|
+
moved + notice + qPanel +
|
|
3317
|
+
"<h3 class=\"mt\">Answers</h3>" + thread + answerForm +
|
|
3318
|
+
"</section>";
|
|
3319
|
+
return _renderAdminShell(opts.shop_name, "Question", body, "questions", opts.nav_available);
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
|
|
3009
3323
|
function renderAdminInventory(opts) {
|
|
3010
3324
|
opts = opts || {};
|
|
3011
3325
|
var rows = opts.inventory || [];
|
|
@@ -3573,6 +3887,8 @@ module.exports = {
|
|
|
3573
3887
|
renderAdminReturns: renderAdminReturns,
|
|
3574
3888
|
renderAdminReturn: renderAdminReturn,
|
|
3575
3889
|
renderAdminReviews: renderAdminReviews,
|
|
3890
|
+
renderAdminQuestions: renderAdminQuestions,
|
|
3891
|
+
renderAdminQuestion: renderAdminQuestion,
|
|
3576
3892
|
renderAdminCollections: renderAdminCollections,
|
|
3577
3893
|
renderAdminCollection: renderAdminCollection,
|
|
3578
3894
|
renderAdminGiftCards: renderAdminGiftCards,
|
package/lib/asset-manifest.json
CHANGED
package/lib/product-qa.js
CHANGED
|
@@ -631,6 +631,90 @@ function create(opts) {
|
|
|
631
631
|
return r.rows.length ? _decodeAnswer(r.rows[0]) : null;
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
+
// Every approved answer under a question, pinned answer first, then
|
|
635
|
+
// by vote_count then oldest-first. Drives the PDP thread (the
|
|
636
|
+
// question + its full answer list) where topAnswerForQuestion only
|
|
637
|
+
// surfaces the single best one. Pending / rejected answers never
|
|
638
|
+
// surface here.
|
|
639
|
+
async function answersForQuestion(questionId, listOpts) {
|
|
640
|
+
_uuid(questionId, "question_id");
|
|
641
|
+
listOpts = listOpts || {};
|
|
642
|
+
var limit = _limit(listOpts.limit);
|
|
643
|
+
var r = await query(
|
|
644
|
+
"SELECT * FROM product_qa_answers " +
|
|
645
|
+
"WHERE question_id = ?1 AND status = 'approved' " +
|
|
646
|
+
"ORDER BY pinned DESC, vote_count DESC, occurred_at ASC, id ASC " +
|
|
647
|
+
"LIMIT ?2",
|
|
648
|
+
[questionId, limit],
|
|
649
|
+
);
|
|
650
|
+
var rows = [];
|
|
651
|
+
for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeAnswer(r.rows[i]));
|
|
652
|
+
return rows;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Every answer under a question regardless of status — the operator
|
|
656
|
+
// moderation detail needs the pending / rejected answers too, which
|
|
657
|
+
// answersForQuestion (approved-only, storefront-facing) hides. Pinned
|
|
658
|
+
// first, then newest-first so the queue reads top-down.
|
|
659
|
+
async function listAnswersForQuestion(questionId, listOpts) {
|
|
660
|
+
_uuid(questionId, "question_id");
|
|
661
|
+
listOpts = listOpts || {};
|
|
662
|
+
var limit = _limit(listOpts.limit);
|
|
663
|
+
var r = await query(
|
|
664
|
+
"SELECT * FROM product_qa_answers WHERE question_id = ?1 " +
|
|
665
|
+
"ORDER BY pinned DESC, occurred_at DESC, id DESC LIMIT ?2",
|
|
666
|
+
[questionId, limit],
|
|
667
|
+
);
|
|
668
|
+
var rows = [];
|
|
669
|
+
for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeAnswer(r.rows[i]));
|
|
670
|
+
return rows;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Cross-product moderation queue — every question in one status
|
|
674
|
+
// regardless of product. The operator console needs all `pending`
|
|
675
|
+
// questions globally, which questionsForProduct (per-product) can't
|
|
676
|
+
// serve. status is required (the queue is always status-scoped).
|
|
677
|
+
// Newest-first by the same (occurred_at DESC, id DESC) total order
|
|
678
|
+
// questionsForProduct uses.
|
|
679
|
+
async function listQuestionsByStatus(status, listOpts) {
|
|
680
|
+
var s = _statusFilter(status);
|
|
681
|
+
if (s === undefined) {
|
|
682
|
+
throw new TypeError("productQA.listQuestionsByStatus: status is required (one of " + STATUSES.join(", ") + ")");
|
|
683
|
+
}
|
|
684
|
+
listOpts = listOpts || {};
|
|
685
|
+
var limit = _limit(listOpts.limit);
|
|
686
|
+
var r = await query(
|
|
687
|
+
"SELECT * FROM product_qa_questions WHERE status = ?1 " +
|
|
688
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?2",
|
|
689
|
+
[s, limit],
|
|
690
|
+
);
|
|
691
|
+
var rows = [];
|
|
692
|
+
for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeQuestion(r.rows[i]));
|
|
693
|
+
return rows;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Cross-product answer moderation queue — every answer in one status
|
|
697
|
+
// regardless of question. The operator console moderates customer-
|
|
698
|
+
// submitted answers (operator-authored answers land approved by the
|
|
699
|
+
// post-answer path, so the pending queue is the customer backlog).
|
|
700
|
+
// Newest-first.
|
|
701
|
+
async function listAnswersByStatus(status, listOpts) {
|
|
702
|
+
var s = _statusFilter(status);
|
|
703
|
+
if (s === undefined) {
|
|
704
|
+
throw new TypeError("productQA.listAnswersByStatus: status is required (one of " + STATUSES.join(", ") + ")");
|
|
705
|
+
}
|
|
706
|
+
listOpts = listOpts || {};
|
|
707
|
+
var limit = _limit(listOpts.limit);
|
|
708
|
+
var r = await query(
|
|
709
|
+
"SELECT * FROM product_qa_answers WHERE status = ?1 " +
|
|
710
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?2",
|
|
711
|
+
[s, limit],
|
|
712
|
+
);
|
|
713
|
+
var rows = [];
|
|
714
|
+
for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeAnswer(r.rows[i]));
|
|
715
|
+
return rows;
|
|
716
|
+
}
|
|
717
|
+
|
|
634
718
|
// Per-product rollup used by the PDP header ("12 questions, 8
|
|
635
719
|
// answered, average 3 upvotes per answer"). Counts approved rows
|
|
636
720
|
// only — pending / rejected don't surface to storefront copy.
|
|
@@ -723,6 +807,10 @@ function create(opts) {
|
|
|
723
807
|
questionsForProduct: questionsForProduct,
|
|
724
808
|
getQuestion: getQuestion,
|
|
725
809
|
topAnswerForQuestion: topAnswerForQuestion,
|
|
810
|
+
answersForQuestion: answersForQuestion,
|
|
811
|
+
listAnswersForQuestion: listAnswersForQuestion,
|
|
812
|
+
listQuestionsByStatus: listQuestionsByStatus,
|
|
813
|
+
listAnswersByStatus: listAnswersByStatus,
|
|
726
814
|
metricsForProduct: metricsForProduct,
|
|
727
815
|
cleanupRejected: cleanupRejected,
|
|
728
816
|
};
|