@blamejs/blamejs-shop 0.4.15 → 0.4.16
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 +2 -0
- package/README.md +3 -2
- package/lib/admin.js +351 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/email.js +51 -0
- package/lib/quotes.js +306 -82
- package/lib/storefront.js +416 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.16 (2026-06-06) — **Quotes: customers request a quote from the cart, operators respond with custom pricing, and an accepted quote becomes an order.** A full request-for-quote flow for bulk and negotiated purchases. A signed-in customer requests a quote from their cart with an optional message; the operator answers from a new Quotes console screen with per-line pricing and a validity window; the customer reviews the offer from their account or through a single-use link and accepts or declines. Accepting converts the quote into a normal pending order — stock is held the same way a checkout hold works — and an expired offer can no longer be accepted. **Added:** *Request a quote from the cart* — A signed-in customer with items in the cart can request a quote — line quantities plus an optional message — from a new cart panel. The request appears under `/account/quotes` with its status, and the optional message is HTML-escaped wherever it renders. · *Quotes console for responding with custom pricing* — `/admin/quotes` lists open requests newest-first with status filters. Opening a request shows its lines and the customer's message; the operator responds with per-line unit pricing and a validity window — the total is computed in minor units — or withdraws a response. The screen appears in the admin nav when the quotes primitive is wired. · *Review and accept through the account or a single-use link* — A responded quote can be reviewed and accepted or declined from the customer's account, or through a capability link (`/quote/{token}`) suited to sharing by whatever channel the store uses to reach the customer. The token is stored only as a namespaced hash and compared in constant time; an unknown token answers 404. Account access is owner-checked — another customer's quote id answers 404. · *Accepted quotes convert to real orders with stock holds* — Acceptance places inventory holds first and then creates a pending order through the same path checkout uses — if order creation fails the holds are rolled back, and the order annotates its held quantities so fulfillment sees them. A quote whose validity window has elapsed is refused at acceptance, so a stale price is never honored. The whole lifecycle (requested → responded → accepted, declined, expired, withdrawn, converted) is enforced by a state machine — out-of-order transitions are refused.
|
|
12
|
+
|
|
11
13
|
- v0.4.15 (2026-06-06) — **Multi-location inventory: stock locations, receiving, transfers, and write-offs in the admin console.** The admin console gains a location-aware inventory back office. Define warehouse, retail, and virtual stock locations and see per-location levels; record reason-coded inbound stock against a location; move stock between locations through a dispatch-and-receive flow; and record reason-coded write-offs with a full audit trail. Single-location stores are untouched — no configuration is needed and existing stock behavior is unchanged. **Added:** *Stock locations with per-location levels* — `/admin/inventory/locations` defines stock locations (warehouse, retail, or virtual, with a priority order) and shows each SKU's quantity at each location. A store that never defines a location keeps working exactly as before — the default location stays implicit and nothing about existing stock semantics changes. · *Receive stock against a location* — `/admin/inventory/receive` records inbound stock — SKU, quantity, location, reason — crediting both the location's level and the sellable aggregate in one step, with a batched receipt history on the same screen. If the receipt record cannot be written the stock credit is reversed, so the ledger and the levels never diverge. · *Location-to-location transfers with a dispatch/receive lifecycle* — `/admin/inventory/transfers` moves stock between locations through a real state machine: a draft transfer dispatches (debiting the source) and is later received (crediting the destination) or cancelled (restoring the source). Stock movements use the same atomic guards as checkout holds — a transfer dispatch racing a checkout for the last unit has exactly one winner, and a level can never go negative. · *Reason-coded write-offs with an audit trail* — `/admin/inventory/writeoffs` records stock losses — damage, shrinkage, expiry — against a location, with every write-off in the tamper-evident audit chain. A write-off that would eat into stock already promised to paid orders is refused with a clear message rather than silently unbalancing the ledgers, and low-stock alerts fire when a write-off takes a SKU under its threshold.
|
|
12
14
|
|
|
13
15
|
- v0.4.14 (2026-06-06) — **Gift wrap charges exactly the fee it displays, the per-order wrap cap is enforced, low-stock events reach subscribed webhooks, and the admin API accepts curl as documented.** Three storefront fixes and two admin-surface fixes. Gift wrap now charges the configured wrap fee rather than the wrap variant's catalog price, and a wrap's per-order cap is enforced at the cart with a clear message. The low-stock inventory event is now a registered webhook event, so endpoints subscribed to specific events receive it. The account profile explains plainly why an email address cannot be changed. And the admin JSON API now answers automation clients such as curl the way the documentation has always shown, with the bearer key as the deciding check. **Changed:** *The account profile explains why the email address cannot be changed* — The store keeps only a one-way hash of each account's email address, so an address can never be read back or rewritten — an email change is not possible by design. The profile screen now says this plainly where an email-change control would normally sit, and points to the alternatives: create a new account under the new address, or contact the store to reconcile order history. **Fixed:** *Gift wrap charges the configured fee, not the wrap variant's catalog price* — The gift-wrap selector displayed the wrap fee configured in the admin console, but checkout added the wrap to the order at the underlying catalog variant's price — when the two diverged, the customer was silently charged a different amount than the page showed. The cart now snapshots the configured fee onto the wrap line, so the displayed fee and the charged fee always agree. A regression test keeps the two amounts pinned together with deliberately different fee and catalog values. · *A gift wrap's per-order cap is enforced at the cart* — A wrap option's maximum-per-order value was validated, stored, and shown in the admin console but never enforced — any quantity could be applied. Applying a wrap beyond its cap is now rejected with a 422 and the cart page re-renders with a readable message; an application at the cap continues to succeed and the cart is left unchanged on rejection. · *The low-stock event is a registered webhook event* — Inventory low-stock alerts emitted `inventory.low_stock`, but the event was missing from the webhook event registry — endpoints subscribed to specific events never received it, and the admin endpoint form never offered it. The event is now registered, appears in the endpoint form's event checklist, and is delivered to endpoints that subscribe to it. Wildcard subscriptions, which already received it, are unchanged. · *Documented curl commands against the admin API work as written* — Every admin endpoint is bearer-key-gated JSON, and the documentation drives it with plain curl — but the bot guard's user-agent deny list refused automation clients on `/admin` before the key was ever checked, so the documented commands answered 403. The admin surface now runs the bot guard in tag mode: automation is recorded in the audit trail (`system.botguard.tag`) instead of refused, and the timing-safe bearer key remains the deciding check. A curl call with a valid key reaches the endpoint; one without a key is refused by the key check with a 401.
|
package/README.md
CHANGED
|
@@ -72,12 +72,13 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
72
72
|
| **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
|
|
73
73
|
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` validates the ship-to address (real ISO 3166-1 country; US/CA state + ZIP/postal formats; lenient elsewhere) and the customer email shape, then creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
|
|
74
74
|
| **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation, wishlist price-drop, abandoned-cart, review request, back-in-stock, **wishlist digest** (the periodic saved-items rollup, rendered per-line from the structured digest so every title / price is independently escaped), and **email magic-link sign-in**. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
|
|
75
|
-
| **`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. |
|
|
75
|
+
| **`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, quote review / accept, 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. |
|
|
76
76
|
| **`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; signed-in customers manage their own passkeys (`/account/passkeys` — list, add another, confirm-gated revoke scoped to the account with a last-sign-in-method guard) and edit their profile (`/account/profile`). |
|
|
77
77
|
| **`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). |
|
|
78
78
|
| **`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. |
|
|
79
79
|
| **`lib/wishlist.js`** + **`lib/wishlist-alerts.js`** + **`lib/wishlist-digest.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) and carries a per-customer opt-in panel for **sale + restock alerts** (price-drop / back-in-stock, event-driven) and the **periodic digest** (the saved-items rollup on a weekly / monthly schedule). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. Both notification paths are off by default and require a configured mailer plus an email-address resolver to actually send (the customer store keeps only an email hash) — see *Optional integrations*. UUID-shape-validated ids, `b.pagination` HMAC cursors; prices rendered through `pricing.format` (locale + zero-decimal-currency correct). |
|
|
80
80
|
| **`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`. |
|
|
81
|
+
| **`lib/quotes.js`** | Request-for-quote negotiation. A signed-in customer requests a quote from the cart (line quantities + an optional message); the operator responds from the console with per-line pricing and a validity window; the customer reviews and accepts or declines from `/account/quotes` or through a single-use capability link (`/quote/:token` — the token is stored only as a namespaced hash and compared timing-safe). Accepting converts through the normal order path — inventory holds are placed first and rolled back if order creation fails — and every status change replays a `b.fsm` lifecycle (requested → responded → accepted / declined / expired / withdrawn / converted), with expiry enforced at accept time so a stale price is never honored. |
|
|
81
82
|
| **`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. |
|
|
82
83
|
| **`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. |
|
|
83
84
|
| **`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. |
|
|
@@ -99,7 +100,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
99
100
|
| **`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). |
|
|
100
101
|
| **`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. |
|
|
101
102
|
| **`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. |
|
|
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; **Stock locations** (`/admin/inventory/locations`) defines warehouse / retail / virtual locations with per-location stock levels (a single-location store needs no configuration — the default location stays implicit), **Receive stock** (`/admin/inventory/receive`) records reason-coded inbound stock against a location with a batched receipt history, **Transfers** (`/admin/inventory/transfers`) moves stock between locations through a dispatch → receive state machine — the source is debited at dispatch, the destination credited on receive, and a dispatch racing a checkout hold for the last unit has exactly one winner — and **Write-offs** (`/admin/inventory/writeoffs`) records reason-coded stock losses with an audit trail, refusing a write-off that would eat into stock already held for paid orders; **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, Errors, Stock locations, Receive stock, Transfers,
|
|
103
|
+
| **`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; **Stock locations** (`/admin/inventory/locations`) defines warehouse / retail / virtual locations with per-location stock levels (a single-location store needs no configuration — the default location stays implicit), **Receive stock** (`/admin/inventory/receive`) records reason-coded inbound stock against a location with a batched receipt history, **Transfers** (`/admin/inventory/transfers`) moves stock between locations through a dispatch → receive state machine — the source is debited at dispatch, the destination credited on receive, and a dispatch racing a checkout hold for the last unit has exactly one winner — and **Write-offs** (`/admin/inventory/writeoffs`) records reason-coded stock losses with an audit trail, refusing a write-off that would eat into stock already held for paid orders; **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. **Quotes** (`/admin/quotes`) is the RFQ response queue — open a request's lines and customer message, respond with per-line pricing and a validity window, or withdraw a responded quote; an accepted quote converts to an order through the storefront's normal checkout path, holds included. **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, Errors, Stock locations, Receive stock, Transfers, Write-offs, and Quotes 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. |
|
|
103
104
|
| **`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. |
|
|
104
105
|
| **`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. |
|
|
105
106
|
|
package/lib/admin.js
CHANGED
|
@@ -237,6 +237,30 @@ function _strictMinorInt(value, prefix, label) {
|
|
|
237
237
|
return n;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// Convert an operator-entered major-unit amount (e.g. "19.99") into integer
|
|
241
|
+
// minor units via the money primitive — so the cents math is the
|
|
242
|
+
// framework's, not a hand-rolled `* 100` that loses precision under IEEE
|
|
243
|
+
// 754, and the conversion honours the target currency's exponent (JPY=0,
|
|
244
|
+
// KWD=3, USD=2). Refuses a missing / non-decimal-shaped / negative value
|
|
245
|
+
// with a TypeError the browser path surfaces as a 400 notice. `b.money.of`
|
|
246
|
+
// rejects Number inputs at the boundary, so the value is normalized to a
|
|
247
|
+
// trimmed decimal string first; the resulting BigInt minor units is range-
|
|
248
|
+
// checked back to a safe integer (quote money fits comfortably).
|
|
249
|
+
function _dollarsToMinor(value, label, currency) {
|
|
250
|
+
var cur = typeof currency === "string" && /^[A-Z]{3}$/.test(currency) ? currency : "USD";
|
|
251
|
+
var s = typeof value === "number" ? String(value) : (typeof value === "string" ? value.trim() : "");
|
|
252
|
+
if (!/^\d+(?:\.\d+)?$/.test(s)) {
|
|
253
|
+
throw new TypeError("admin: " + label + " must be a non-negative amount (e.g. 19.99)");
|
|
254
|
+
}
|
|
255
|
+
var minor;
|
|
256
|
+
try { minor = b.money.of(s, cur).toMinorUnits(); }
|
|
257
|
+
catch (_e) { throw new TypeError("admin: " + label + " has more decimal places than " + cur + " allows"); }
|
|
258
|
+
if (minor > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
259
|
+
throw new TypeError("admin: " + label + " is out of range");
|
|
260
|
+
}
|
|
261
|
+
return Number(minor);
|
|
262
|
+
}
|
|
263
|
+
|
|
240
264
|
// Strict non-negative integer for a form field (money minor units,
|
|
241
265
|
// dimensions). Refuses "", floats, and parseInt's loose-prefix "12abc"
|
|
242
266
|
// → 12 — the /^\d+$/ test is anchored so the whole string must be
|
|
@@ -579,6 +603,7 @@ function mount(router, deps) {
|
|
|
579
603
|
var inventoryReceive = deps.inventoryReceive || null; // inbound-stock receive console disabled when absent
|
|
580
604
|
var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
|
|
581
605
|
var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
|
|
606
|
+
var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/withdraw/convert) disabled when absent
|
|
582
607
|
// Read-only activity log at /admin/audit. Defaults ON — the framework
|
|
583
608
|
// audit chain is always booted by createApp, so the screen always has a
|
|
584
609
|
// data source (unlike the optional primitives above, which default off).
|
|
@@ -598,7 +623,7 @@ function mount(router, deps) {
|
|
|
598
623
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
599
624
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
600
625
|
// notice when the salesReports primitive isn't wired.
|
|
601
|
-
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, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs };
|
|
626
|
+
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, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes };
|
|
602
627
|
|
|
603
628
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
604
629
|
|
|
@@ -6862,6 +6887,187 @@ function mount(router, deps) {
|
|
|
6862
6887
|
));
|
|
6863
6888
|
}
|
|
6864
6889
|
|
|
6890
|
+
// ---- quotes (B2B request-for-quote negotiation) ---------------------
|
|
6891
|
+
// The operator side of the RFQ lifecycle. The list is the response queue
|
|
6892
|
+
// (oldest-waiting requests first) plus a recent-activity view; the detail
|
|
6893
|
+
// screen shows the requested lines + the customer message, and — for a
|
|
6894
|
+
// still-requested quote — a per-line pricing form that responds with a
|
|
6895
|
+
// priced quote + validity window. Responded/accepted quotes show the
|
|
6896
|
+
// quoted totals; an operator can withdraw a quote that hasn't been
|
|
6897
|
+
// accepted, or convert an accepted one into a pending order. Content-
|
|
6898
|
+
// negotiated like the other consoles (bearer → JSON, browser → HTML).
|
|
6899
|
+
if (quotes) {
|
|
6900
|
+
// Build the respondToQuote line_prices array from the per-line
|
|
6901
|
+
// `price_<sku>` dollar fields the detail form posts, converting each to
|
|
6902
|
+
// minor units. Every quote line must be priced; a missing / non-numeric
|
|
6903
|
+
// field throws a TypeError the route maps to a 400 re-render. The dollar
|
|
6904
|
+
// string is parsed to integer cents WITHOUT floating-point (split on the
|
|
6905
|
+
// decimal point) so 19.99 never lands as 1998 via float drift.
|
|
6906
|
+
function _quoteLinePricesFromForm(body, lines, currency) {
|
|
6907
|
+
var out = [];
|
|
6908
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
6909
|
+
var sku = lines[i].sku;
|
|
6910
|
+
var raw = body["price_" + sku];
|
|
6911
|
+
if (raw == null || (typeof raw === "string" && !raw.trim().length)) {
|
|
6912
|
+
throw new TypeError("quotes: a unit price is required for every line (missing " + sku + ")");
|
|
6913
|
+
}
|
|
6914
|
+
out.push({ sku: sku, unit_price_minor: _dollarsToMinor(raw, "unit price for " + sku, currency) });
|
|
6915
|
+
}
|
|
6916
|
+
return out;
|
|
6917
|
+
}
|
|
6918
|
+
|
|
6919
|
+
router.get("/admin/quotes", _pageOrApi(true,
|
|
6920
|
+
R(async function (req, res) {
|
|
6921
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6922
|
+
var cid = url && url.searchParams.get("customer_id");
|
|
6923
|
+
var rows = cid
|
|
6924
|
+
? await quotes.quotesForCustomer(cid, { limit: 200 })
|
|
6925
|
+
: await quotes.pendingResponse({ limit: 200 });
|
|
6926
|
+
_json(res, 200, { rows: rows });
|
|
6927
|
+
}),
|
|
6928
|
+
async function (req, res) {
|
|
6929
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6930
|
+
var cid = url && url.searchParams.get("customer_id");
|
|
6931
|
+
var rows = [];
|
|
6932
|
+
try {
|
|
6933
|
+
rows = cid
|
|
6934
|
+
? await quotes.quotesForCustomer(cid, { limit: 200 })
|
|
6935
|
+
: await quotes.pendingResponse({ limit: 200 });
|
|
6936
|
+
} catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
6937
|
+
_sendHtml(res, 200, renderAdminQuotes({
|
|
6938
|
+
shop_name: deps.shop_name, nav_available: navAvailable, quotes: rows,
|
|
6939
|
+
customer_filter: cid,
|
|
6940
|
+
responded: url && url.searchParams.get("responded"),
|
|
6941
|
+
withdrawn: url && url.searchParams.get("withdrawn"),
|
|
6942
|
+
converted: url && url.searchParams.get("converted"),
|
|
6943
|
+
notice: (url && url.searchParams.get("err"))
|
|
6944
|
+
? "That action couldn't be completed for the quote." : null,
|
|
6945
|
+
}));
|
|
6946
|
+
},
|
|
6947
|
+
));
|
|
6948
|
+
|
|
6949
|
+
// Detail: the quote + its lines, plus the per-line respond form (when
|
|
6950
|
+
// still requested) and the withdraw / convert actions.
|
|
6951
|
+
router.get("/admin/quotes/:id", _pageOrApi(true,
|
|
6952
|
+
R(async function (req, res) {
|
|
6953
|
+
var row = null;
|
|
6954
|
+
try { row = await quotes.getQuote(req.params.id); }
|
|
6955
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 404, "quote-not-found"); throw e; }
|
|
6956
|
+
if (!row) return _problem(res, 404, "quote-not-found");
|
|
6957
|
+
_json(res, 200, row);
|
|
6958
|
+
}),
|
|
6959
|
+
async function (req, res) {
|
|
6960
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6961
|
+
var row = null;
|
|
6962
|
+
try { row = await quotes.getQuote(req.params.id); }
|
|
6963
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
6964
|
+
if (!row) return _sendHtml(res, 404, renderAdminQuoteDetail({
|
|
6965
|
+
shop_name: deps.shop_name, nav_available: navAvailable, quote: null,
|
|
6966
|
+
}));
|
|
6967
|
+
_sendHtml(res, 200, renderAdminQuoteDetail({
|
|
6968
|
+
shop_name: deps.shop_name, nav_available: navAvailable, quote: row,
|
|
6969
|
+
notice: (url && url.searchParams.get("err"))
|
|
6970
|
+
? "That action couldn't be completed for the quote." : null,
|
|
6971
|
+
}));
|
|
6972
|
+
},
|
|
6973
|
+
));
|
|
6974
|
+
|
|
6975
|
+
// Respond: price every line + set shipping / tax / validity. The browser
|
|
6976
|
+
// form posts dollar amounts (converted to minor units here) + a
|
|
6977
|
+
// validity-in-days; the bearer JSON contract takes the primitive's native
|
|
6978
|
+
// shape (minor units + an absolute valid_until). A bad shape is a clean
|
|
6979
|
+
// 400 (bearer) / err re-render (browser), never a 500.
|
|
6980
|
+
router.post("/admin/quotes/:id/respond", _pageOrApi(false,
|
|
6981
|
+
W("quote.respond", async function (req, res) {
|
|
6982
|
+
var row;
|
|
6983
|
+
try { row = await quotes.respondToQuote(Object.assign({}, req.body || {}, { quote_id: req.params.id })); }
|
|
6984
|
+
catch (e) {
|
|
6985
|
+
if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
|
|
6986
|
+
if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
|
|
6987
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
6988
|
+
throw e;
|
|
6989
|
+
}
|
|
6990
|
+
_json(res, 200, row);
|
|
6991
|
+
return { id: row.id };
|
|
6992
|
+
}),
|
|
6993
|
+
async function (req, res) {
|
|
6994
|
+
var id = req.params.id;
|
|
6995
|
+
var enc = encodeURIComponent(id);
|
|
6996
|
+
var body = req.body || {};
|
|
6997
|
+
var current = null;
|
|
6998
|
+
try { current = await quotes.getQuote(id); } catch (_e) { current = null; }
|
|
6999
|
+
if (!current) return _redirect(res, "/admin/quotes?err=1");
|
|
7000
|
+
try {
|
|
7001
|
+
var validDays = _strictNonNegIntField(body.valid_days, "valid_days");
|
|
7002
|
+
if (validDays <= 0) throw new TypeError("admin: valid_days must be at least 1");
|
|
7003
|
+
var quoteCurrency = typeof body.currency === "string" && body.currency
|
|
7004
|
+
? body.currency.toUpperCase() : (current.currency || "USD");
|
|
7005
|
+
await quotes.respondToQuote({
|
|
7006
|
+
quote_id: id,
|
|
7007
|
+
line_prices: _quoteLinePricesFromForm(body, current.lines, quoteCurrency),
|
|
7008
|
+
shipping_minor: body.shipping == null || body.shipping === "" ? 0 : _dollarsToMinor(body.shipping, "shipping", quoteCurrency),
|
|
7009
|
+
tax_minor: body.tax == null || body.tax === "" ? 0 : _dollarsToMinor(body.tax, "tax", quoteCurrency),
|
|
7010
|
+
valid_until: Date.now() + b.constants.TIME.days(validDays),
|
|
7011
|
+
currency: quoteCurrency,
|
|
7012
|
+
operator_notes: body.operator_notes || null,
|
|
7013
|
+
});
|
|
7014
|
+
} catch (e) {
|
|
7015
|
+
if (!(e instanceof TypeError) && !(e && (e.code === "QUOTE_TRANSITION_REFUSED" || e.code === "QUOTE_NOT_FOUND"))) throw e;
|
|
7016
|
+
var msg = _safeNotice(e, "quote.respond");
|
|
7017
|
+
var fresh = await quotes.getQuote(id);
|
|
7018
|
+
return _sendHtml(res, msg.status, renderAdminQuoteDetail({
|
|
7019
|
+
shop_name: deps.shop_name, nav_available: navAvailable, quote: fresh,
|
|
7020
|
+
notice: msg.message.replace(/^(quotes|admin)[.:]\s*/, ""),
|
|
7021
|
+
}));
|
|
7022
|
+
}
|
|
7023
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.respond", outcome: "success", metadata: { quote_id: id } });
|
|
7024
|
+
// Fire the quote-responded email (drop-silent, fire-and-forget) when a
|
|
7025
|
+
// notifier is wired — the customer learns their quote is priced.
|
|
7026
|
+
if (typeof deps.notifyQuoteResponded === "function") {
|
|
7027
|
+
try { await deps.notifyQuoteResponded(id); }
|
|
7028
|
+
catch (_e) { /* drop-silent — the response already persisted */ }
|
|
7029
|
+
}
|
|
7030
|
+
_redirect(res, "/admin/quotes/" + enc + "?responded=1");
|
|
7031
|
+
},
|
|
7032
|
+
));
|
|
7033
|
+
|
|
7034
|
+
// Withdraw: cancel a quote that hasn't been accepted yet (requested or
|
|
7035
|
+
// responded). Accepted / terminal quotes refuse — the FSM gate is the
|
|
7036
|
+
// single source of truth, surfaced as a 409.
|
|
7037
|
+
router.post("/admin/quotes/:id/withdraw", _pageOrApi(false,
|
|
7038
|
+
W("quote.withdraw", async function (req, res) {
|
|
7039
|
+
var row;
|
|
7040
|
+
try {
|
|
7041
|
+
row = await quotes.cancelQuote({
|
|
7042
|
+
quote_id: req.params.id,
|
|
7043
|
+
cancel_reason: (req.body && req.body.cancel_reason) || "Withdrawn by operator",
|
|
7044
|
+
});
|
|
7045
|
+
} catch (e) {
|
|
7046
|
+
if (e && e.code === "QUOTE_NOT_FOUND") return _problem(res, 404, "quote-not-found");
|
|
7047
|
+
if (e && e.code === "QUOTE_TRANSITION_REFUSED") return _problem(res, 409, "quote-transition-refused", e.message);
|
|
7048
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
7049
|
+
throw e;
|
|
7050
|
+
}
|
|
7051
|
+
_json(res, 200, row);
|
|
7052
|
+
return { id: row.id };
|
|
7053
|
+
}),
|
|
7054
|
+
async function (req, res) {
|
|
7055
|
+
var id = req.params.id;
|
|
7056
|
+
try {
|
|
7057
|
+
await quotes.cancelQuote({
|
|
7058
|
+
quote_id: id,
|
|
7059
|
+
cancel_reason: (req.body && req.body.cancel_reason) || "Withdrawn by operator",
|
|
7060
|
+
});
|
|
7061
|
+
} catch (e) {
|
|
7062
|
+
if (!(e instanceof TypeError) && !(e && (e.code === "QUOTE_TRANSITION_REFUSED" || e.code === "QUOTE_NOT_FOUND"))) throw e;
|
|
7063
|
+
return _redirect(res, "/admin/quotes/" + encodeURIComponent(id) + "?err=1");
|
|
7064
|
+
}
|
|
7065
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quote.withdraw", outcome: "success", metadata: { quote_id: id } });
|
|
7066
|
+
_redirect(res, "/admin/quotes?withdrawn=1");
|
|
7067
|
+
},
|
|
7068
|
+
));
|
|
7069
|
+
}
|
|
7070
|
+
|
|
6865
7071
|
// ---- search ranking -------------------------------------------------
|
|
6866
7072
|
// Operator-tunable storefront search ranking: named weight sets (one
|
|
6867
7073
|
// active at a time), per-query manual pins, and a per-set metrics rollup.
|
|
@@ -12356,6 +12562,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
12356
12562
|
{ key: "inventory-transfers", href: "/admin/inventory/transfers", label: "Transfers", requires: "stockTransfers" },
|
|
12357
12563
|
{ key: "inventory-writeoffs", href: "/admin/inventory/writeoffs", label: "Write-offs", requires: "inventoryWriteoffs" },
|
|
12358
12564
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
12565
|
+
{ key: "quotes", href: "/admin/quotes", label: "Quotes", requires: "quotes" },
|
|
12359
12566
|
{ key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
|
|
12360
12567
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
12361
12568
|
{ key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
|
|
@@ -18963,6 +19170,149 @@ function _standardSurveyQuestions(kind) {
|
|
|
18963
19170
|
];
|
|
18964
19171
|
}
|
|
18965
19172
|
|
|
19173
|
+
// Map a quote status to a status-pill modifier class (reusing the order
|
|
19174
|
+
// pills: paid=green for the live/positive states, cancelled=grey for the
|
|
19175
|
+
// terminal-without-sale ones). Purely cosmetic.
|
|
19176
|
+
function _quotePillClass(status) {
|
|
19177
|
+
if (status === "responded") return "paid";
|
|
19178
|
+
if (status === "accepted" || status === "converted") return "paid";
|
|
19179
|
+
if (status === "requested") return "pending";
|
|
19180
|
+
return "cancelled"; // rejected / expired / cancelled
|
|
19181
|
+
}
|
|
19182
|
+
|
|
19183
|
+
function renderAdminQuotes(opts) {
|
|
19184
|
+
opts = opts || {};
|
|
19185
|
+
var rows = opts.quotes || [];
|
|
19186
|
+
var responded = opts.responded ? "<div class=\"banner banner--ok\">Quote sent to the customer.</div>" : "";
|
|
19187
|
+
var withdrawn = opts.withdrawn ? "<div class=\"banner banner--ok\">Quote withdrawn.</div>" : "";
|
|
19188
|
+
var converted = opts.converted ? "<div class=\"banner banner--ok\">Quote converted to an order.</div>" : "";
|
|
19189
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
19190
|
+
|
|
19191
|
+
var cf = opts.customer_filter;
|
|
19192
|
+
var heading = cf ? "Quotes for this customer" : "Quotes awaiting a response";
|
|
19193
|
+
var chips = "<div class=\"order-filters\">" +
|
|
19194
|
+
"<a class=\"chip" + (cf == null ? " chip--on" : "") + "\" href=\"/admin/quotes\">Response queue</a>" +
|
|
19195
|
+
(cf ? "<a class=\"chip chip--on\" href=\"/admin/quotes?customer_id=" + _htmlEscape(encodeURIComponent(cf)) + "\">This customer</a>" : "") +
|
|
19196
|
+
"</div>";
|
|
19197
|
+
|
|
19198
|
+
var bodyRows = rows.map(function (q) {
|
|
19199
|
+
var enc = _htmlEscape(encodeURIComponent(q.id));
|
|
19200
|
+
var total = q.total_minor == null
|
|
19201
|
+
? "<span class=\"u-mute\">—</span>"
|
|
19202
|
+
: _htmlEscape(pricing.format(q.total_minor, q.currency || "USD"));
|
|
19203
|
+
var lineCount = (q.lines || []).length;
|
|
19204
|
+
return "<tr>" +
|
|
19205
|
+
"<td><a href=\"/admin/quotes/" + enc + "\"><code class=\"order-id\">" + _htmlEscape(String(q.id).slice(0, 8)) + "</code></a></td>" +
|
|
19206
|
+
"<td><a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(q.customer_id)) + "\">" + _htmlEscape(String(q.customer_id).slice(0, 8)) + "</a></td>" +
|
|
19207
|
+
"<td><span class=\"status-pill " + _quotePillClass(q.status) + "\">" + _htmlEscape(q.status) + "</span></td>" +
|
|
19208
|
+
"<td class=\"num\">" + lineCount + "</td>" +
|
|
19209
|
+
"<td class=\"num\">" + total + "</td>" +
|
|
19210
|
+
"<td><a class=\"btn btn--ghost\" href=\"/admin/quotes/" + enc + "\">Open</a></td>" +
|
|
19211
|
+
"</tr>";
|
|
19212
|
+
}).join("");
|
|
19213
|
+
|
|
19214
|
+
var table = rows.length
|
|
19215
|
+
? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Quote</th><th scope=\"col\">Customer</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Lines</th><th scope=\"col\" class=\"num\">Total</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
|
|
19216
|
+
: "<p class=\"empty\">" + (cf ? "No quotes for this customer." : "No quotes are waiting for a response.") + "</p>";
|
|
19217
|
+
|
|
19218
|
+
var bodyHtml = "<section><h2>Quotes</h2>" + responded + withdrawn + converted + notice +
|
|
19219
|
+
"<p class=\"meta\">Request-for-quote negotiations. The response queue lists the requests waiting on you, oldest first — open one to price its lines and send the customer a quote.</p>" +
|
|
19220
|
+
chips + "<h3 class=\"subhead\">" + _htmlEscape(heading) + "</h3>" + table + "</section>";
|
|
19221
|
+
return _renderAdminShell(opts.shop_name, "Quotes", bodyHtml, "quotes", opts.nav_available);
|
|
19222
|
+
}
|
|
19223
|
+
|
|
19224
|
+
function renderAdminQuoteDetail(opts) {
|
|
19225
|
+
opts = opts || {};
|
|
19226
|
+
var q = opts.quote;
|
|
19227
|
+
if (!q) {
|
|
19228
|
+
var nf = "<section><h2>Quote</h2><p class=\"empty\">Quote not found.</p>" +
|
|
19229
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/quotes\">Back to quotes</a></div></section>";
|
|
19230
|
+
return _renderAdminShell(opts.shop_name, "Quote", nf, "quotes", opts.nav_available);
|
|
19231
|
+
}
|
|
19232
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
19233
|
+
var enc = _htmlEscape(encodeURIComponent(q.id));
|
|
19234
|
+
var currency = q.currency || "USD";
|
|
19235
|
+
|
|
19236
|
+
// Header summary.
|
|
19237
|
+
var summary =
|
|
19238
|
+
"<div class=\"panel\">" +
|
|
19239
|
+
"<p class=\"meta\">Status: <span class=\"status-pill " + _quotePillClass(q.status) + "\">" + _htmlEscape(q.status) + "</span></p>" +
|
|
19240
|
+
"<p class=\"meta\">Customer: <a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(q.customer_id)) + "\">" + _htmlEscape(q.customer_id) + "</a></p>" +
|
|
19241
|
+
(q.delivery_terms ? "<p class=\"meta\">Delivery terms: " + _htmlEscape(q.delivery_terms) + "</p>" : "") +
|
|
19242
|
+
(q.payment_terms ? "<p class=\"meta\">Payment terms: " + _htmlEscape(q.payment_terms) + "</p>" : "") +
|
|
19243
|
+
(q.message ? "<p class=\"meta\">Customer message: <q>" + _htmlEscape(q.message) + "</q></p>" : "") +
|
|
19244
|
+
(q.valid_until ? "<p class=\"meta\">Valid until: " + _htmlEscape(new Date(Number(q.valid_until)).toISOString()) + "</p>" : "") +
|
|
19245
|
+
(q.total_minor != null ? "<p class=\"meta\">Quoted total: <strong>" + _htmlEscape(pricing.format(q.total_minor, currency)) + "</strong></p>" : "") +
|
|
19246
|
+
(q.converted_order_id ? "<p class=\"meta\">Converted to order: <a href=\"/admin/orders/" + _htmlEscape(encodeURIComponent(q.converted_order_id)) + "\"><code class=\"order-id\">" + _htmlEscape(String(q.converted_order_id).slice(0, 8)) + "</code></a></p>" : "") +
|
|
19247
|
+
"</div>";
|
|
19248
|
+
|
|
19249
|
+
// Lines table — shows the requested qty + (once responded) the priced
|
|
19250
|
+
// unit + line total.
|
|
19251
|
+
var lineRows = (q.lines || []).map(function (l) {
|
|
19252
|
+
var unit = l.unit_price_minor == null
|
|
19253
|
+
? "<span class=\"u-mute\">—</span>"
|
|
19254
|
+
: _htmlEscape(pricing.format(l.unit_price_minor, l.currency || currency));
|
|
19255
|
+
var lineTotal = l.unit_price_minor == null
|
|
19256
|
+
? "<span class=\"u-mute\">—</span>"
|
|
19257
|
+
: _htmlEscape(pricing.format(l.unit_price_minor * l.quantity, l.currency || currency));
|
|
19258
|
+
return "<tr>" +
|
|
19259
|
+
"<td><code class=\"order-id\">" + _htmlEscape(l.sku) + "</code></td>" +
|
|
19260
|
+
"<td class=\"num\">" + _htmlEscape(String(l.quantity)) + "</td>" +
|
|
19261
|
+
"<td class=\"num\">" + unit + "</td>" +
|
|
19262
|
+
"<td class=\"num\">" + lineTotal + "</td>" +
|
|
19263
|
+
(l.notes ? "<td>" + _htmlEscape(l.notes) + "</td>" : "<td></td>") +
|
|
19264
|
+
"</tr>";
|
|
19265
|
+
}).join("");
|
|
19266
|
+
var linesPanel = "<div class=\"panel\">" +
|
|
19267
|
+
_tableWrap("<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Qty</th><th scope=\"col\" class=\"num\">Unit price</th><th scope=\"col\" class=\"num\">Line total</th><th scope=\"col\">Customer note</th></tr></thead><tbody>" + lineRows + "</tbody></table>") +
|
|
19268
|
+
"</div>";
|
|
19269
|
+
|
|
19270
|
+
// Respond form — only for a still-requested quote. One unit-price field per
|
|
19271
|
+
// line plus shipping / tax / validity. Major-unit dollar inputs (converted
|
|
19272
|
+
// to minor units server-side).
|
|
19273
|
+
var respondForm = "";
|
|
19274
|
+
if (q.status === "requested") {
|
|
19275
|
+
var priceFields = (q.lines || []).map(function (l) {
|
|
19276
|
+
return _setupField(l.sku + " — unit price (" + currency + ")", "price_" + l.sku, "", "text",
|
|
19277
|
+
"Quantity " + l.quantity + ".", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\" required");
|
|
19278
|
+
}).join("");
|
|
19279
|
+
respondForm =
|
|
19280
|
+
"<div class=\"panel mt mw-40\">" +
|
|
19281
|
+
"<h3 class=\"subhead\">Respond with a priced quote</h3>" +
|
|
19282
|
+
"<p class=\"meta\">Set a unit price for every line, plus optional shipping + tax and how long the quote stays valid. The customer is notified and can accept or decline.</p>" +
|
|
19283
|
+
"<form method=\"post\" action=\"/admin/quotes/" + enc + "/respond\">" +
|
|
19284
|
+
priceFields +
|
|
19285
|
+
_setupField("Shipping (" + currency + ")", "shipping", "", "text", "Optional. Leave blank for free shipping.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
|
|
19286
|
+
_setupField("Tax (" + currency + ")", "tax", "", "text", "Optional.", " inputmode=\"decimal\" pattern=\"\\d+(\\.\\d+)?\"") +
|
|
19287
|
+
_setupField("Valid for (days)", "valid_days", "14", "number", "How many days the customer has to accept.", " min=\"1\" max=\"365\" required") +
|
|
19288
|
+
_setupField("Currency", "currency", currency, "text", "ISO-4217, e.g. USD.", " maxlength=\"3\" pattern=\"[A-Za-z]{3}\"") +
|
|
19289
|
+
"<label class=\"form-field\"><span>Note to the customer</span><textarea name=\"operator_notes\" maxlength=\"4000\" rows=\"3\"></textarea></label>" +
|
|
19290
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Send quote</button></div>" +
|
|
19291
|
+
"</form>" +
|
|
19292
|
+
"</div>";
|
|
19293
|
+
}
|
|
19294
|
+
|
|
19295
|
+
// Withdraw — available while the quote hasn't been accepted (requested or
|
|
19296
|
+
// responded). The FSM refuses it for accepted/terminal quotes; we only
|
|
19297
|
+
// render the button when it would succeed.
|
|
19298
|
+
var withdrawForm = "";
|
|
19299
|
+
if (q.status === "requested" || q.status === "responded") {
|
|
19300
|
+
withdrawForm =
|
|
19301
|
+
"<form method=\"post\" action=\"/admin/quotes/" + enc + "/withdraw\" class=\"form-inline\">" +
|
|
19302
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Withdraw quote</button>" +
|
|
19303
|
+
"</form>";
|
|
19304
|
+
}
|
|
19305
|
+
|
|
19306
|
+
var actions = "<div class=\"actions-row\">" +
|
|
19307
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/quotes\">Back to quotes</a>" +
|
|
19308
|
+
withdrawForm +
|
|
19309
|
+
"</div>";
|
|
19310
|
+
|
|
19311
|
+
var bodyHtml = "<section><h2>Quote " + _htmlEscape(String(q.id).slice(0, 8)) + "</h2>" +
|
|
19312
|
+
notice + summary + linesPanel + respondForm + actions + "</section>";
|
|
19313
|
+
return _renderAdminShell(opts.shop_name, "Quote " + String(q.id).slice(0, 8), bodyHtml, "quotes", opts.nav_available);
|
|
19314
|
+
}
|
|
19315
|
+
|
|
18966
19316
|
function renderAdminSurveys(opts) {
|
|
18967
19317
|
opts = opts || {};
|
|
18968
19318
|
var rows = opts.surveys || [];
|
package/lib/asset-manifest.json
CHANGED
package/lib/email.js
CHANGED
|
@@ -143,6 +143,32 @@ var REFUND_TEXT =
|
|
|
143
143
|
"We've issued a refund of {{amount_formatted}} against order {{order_id}}.\n" +
|
|
144
144
|
"The funds will appear on your statement within 5-10 business days.\n";
|
|
145
145
|
|
|
146
|
+
// Quote responded — the operator priced a request-for-quote and the
|
|
147
|
+
// customer can now review + accept it. The view link carries the quote's
|
|
148
|
+
// capability token (built by the caller from the plaintext the quotes
|
|
149
|
+
// primitive minted), so the customer opens the quote page without signing
|
|
150
|
+
// in. The total is already formatted by the caller (pricing.format off the
|
|
151
|
+
// quoted total_minor) so the email never re-derives money math.
|
|
152
|
+
var QUOTE_RESPONDED_HTML =
|
|
153
|
+
"<!DOCTYPE html>\n" +
|
|
154
|
+
"<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Your quote is ready</title></head>" +
|
|
155
|
+
"<body style=\"margin:0;background:#ffffff;color:#0d0d0d;font-family:system-ui,sans-serif;\">\n" +
|
|
156
|
+
"<div style=\"max-width:560px;margin:0 auto;padding:24px;\">\n" +
|
|
157
|
+
" <h1 style=\"color:#0d0d0d;margin:0 0 12px;\">Your quote is ready</h1>\n" +
|
|
158
|
+
" <p style=\"margin:0 0 16px;\">Hi {{customer_name}}, we've priced your request. Your quoted total is <strong style=\"color:#fa4f09;\">{{total_formatted}}</strong>.</p>\n" +
|
|
159
|
+
" <p style=\"margin:0 0 16px;\">This quote is valid until {{valid_until}}. Review the full breakdown and accept or decline on the quote page.</p>\n" +
|
|
160
|
+
" <p style=\"margin:24px 0;\"><a href=\"{{quote_url}}\" style=\"background:#fa4f09;color:#ffffff;padding:12px 20px;text-decoration:none;display:inline-block;font-weight:bold;\">Review your quote</a></p>\n" +
|
|
161
|
+
" <p style=\"margin:0;color:#0d0d0d;font-size:13px;\">If the button doesn't work, paste this link into your browser: {{quote_url}}</p>\n" +
|
|
162
|
+
"</div>\n" +
|
|
163
|
+
"</body></html>\n";
|
|
164
|
+
|
|
165
|
+
var QUOTE_RESPONDED_TEXT =
|
|
166
|
+
"Your quote is ready\n\n" +
|
|
167
|
+
"Hi {{customer_name}}, we've priced your request.\n" +
|
|
168
|
+
"Quoted total: {{total_formatted}}\n" +
|
|
169
|
+
"Valid until: {{valid_until}}\n\n" +
|
|
170
|
+
"Review and accept or decline your quote: {{quote_url}}\n";
|
|
171
|
+
|
|
146
172
|
// Wishlist discount — a watched product just dropped in price.
|
|
147
173
|
// Brand tokens: #0d0d0d ink, #fa4f09 accent, #ffffff paper.
|
|
148
174
|
|
|
@@ -488,6 +514,31 @@ function create(opts) {
|
|
|
488
514
|
return await _send(input.customer.email, "Refund issued — " + input.order.id, html, text, input.replyTo);
|
|
489
515
|
},
|
|
490
516
|
|
|
517
|
+
// Quote responded — the operator priced an RFQ; tell the customer it's
|
|
518
|
+
// ready to review. The caller passes the customer's deliverable email,
|
|
519
|
+
// an already-formatted `total_formatted` (pricing.format off the quoted
|
|
520
|
+
// total), a human `valid_until` string, and the absolute `quote_url`
|
|
521
|
+
// (built from the quote's capability token). Every value rides through
|
|
522
|
+
// the strict {{var}} renderer (HTML-escaped); no raw HTML flows in.
|
|
523
|
+
quoteResponded: async function (input) {
|
|
524
|
+
if (!input) throw new TypeError("email.quoteResponded: input object required");
|
|
525
|
+
if (typeof input.customer_email !== "string" || !input.customer_email) {
|
|
526
|
+
throw new TypeError("email.quoteResponded: customer_email required");
|
|
527
|
+
}
|
|
528
|
+
if (typeof input.quote_url !== "string" || !input.quote_url) {
|
|
529
|
+
throw new TypeError("email.quoteResponded: quote_url required");
|
|
530
|
+
}
|
|
531
|
+
var vars = {
|
|
532
|
+
customer_name: input.customer_name || "there",
|
|
533
|
+
total_formatted: input.total_formatted == null ? "—" : String(input.total_formatted),
|
|
534
|
+
valid_until: input.valid_until == null ? "—" : String(input.valid_until),
|
|
535
|
+
quote_url: input.quote_url,
|
|
536
|
+
};
|
|
537
|
+
var html = _render(QUOTE_RESPONDED_HTML, vars);
|
|
538
|
+
var text = _render(QUOTE_RESPONDED_TEXT, vars);
|
|
539
|
+
return await _send(input.customer_email, "Your quote is ready", html, text, input.replyTo);
|
|
540
|
+
},
|
|
541
|
+
|
|
491
542
|
// Wishlist discount — a watched product just dropped in price.
|
|
492
543
|
// Operator passes already-formatted `old_price` / `new_price`
|
|
493
544
|
// strings; pricing rules vary (locale, multi-currency, promo
|