@blamejs/blamejs-shop 0.4.16 → 0.4.17
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 +1 -1
- package/lib/admin.js +437 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/email-campaigns.js +799 -9
- package/lib/security-middleware.js +1 -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.17 (2026-06-06) — **Email campaigns: consent-gated broadcasts to the newsletter list, with one-click unsubscribe and a send ledger that never re-mails.** The admin console gains an Email campaigns screen: author a broadcast, target a mailing audience, preview it, test-send to yourself, and send. Consent is the design center — the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists; customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the moment their message sends, and every message carries one-click unsubscribe headers plus an in-body link. Sending drains in rate-bounded batches on the scheduled tick, one bad address never aborts a campaign, and a per-recipient ledger makes a resumed send never deliver twice. **Added:** *Campaign console: author, preview, test-send, send* — `/admin/campaigns` lists campaigns with status and delivered / failed / skipped counts, and a campaign editor takes a subject and a Markdown-or-plaintext body. The body renders escape-by-default with an https-only link gate — the same rendering discipline as the blog — so markup or script in a body lands as inert text in the inbox and in the console list. Preview and an operator-addressed test send are available before any real send. · *Consent resolved per recipient, at the send moment* — The reachable-recipient count shown before sending is computed live from the newsletter list, excluding anyone unsubscribed or on the marketing suppression list — and the same two checks run again for each recipient at the moment their message sends, so someone who unsubscribes mid-broadcast is skipped. Customer accounts keep only an email hash by design, so the newsletter list is the only deliverable-address source and the console says exactly how many recipients are reachable. · *One-click unsubscribe on every broadcast* — Every campaign message carries the RFC 8058 `List-Unsubscribe` / `List-Unsubscribe-Post` headers (so mail clients show their native unsubscribe control), an RFC 2919 `List-Id`, and an in-body unsubscribe link through the existing newsletter opt-out flow. · *Rate-bounded, resumable sending* — Sends drain in batches on the scheduled tick with a reserved hourly budget, so a large campaign spreads out rather than bursting. Each recipient's outcome lands in a send ledger keyed uniquely per campaign and recipient — a send interrupted by the rate budget or a restart resumes where it left off and never mails anyone twice. A failing address is counted and shown, never fatal to the rest of the campaign.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
100
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). |
|
|
101
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. |
|
|
102
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. |
|
|
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
|
|
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. **Email campaigns** (`/admin/campaigns`) is the consent-gated broadcast console — author a campaign (escape-by-default Markdown body), target a mailing audience, preview, test-send to an operator-supplied address, and send to the recipients who are actually reachable: the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists — customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the send moment, every message carries RFC 8058 one-click unsubscribe headers plus an in-body link, and a per-recipient send ledger makes a resumed broadcast never re-mail. Sending drains in rate-bounded batches on the scheduled tick; per-campaign delivered / failed / skipped counts show on the detail screen. **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, Quotes, and Email campaigns 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. |
|
|
104
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. |
|
|
105
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. |
|
|
106
106
|
|
package/lib/admin.js
CHANGED
|
@@ -604,6 +604,8 @@ function mount(router, deps) {
|
|
|
604
604
|
var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
|
|
605
605
|
var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
|
|
606
606
|
var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/withdraw/convert) disabled when absent
|
|
607
|
+
var emailCampaigns = deps.emailCampaigns || null; // consent-gated broadcast/campaign console disabled when absent
|
|
608
|
+
var mailingAudiences = deps.mailingAudiences || null; // audience picker for the campaign console (target-an-audience dropdown)
|
|
607
609
|
// Read-only activity log at /admin/audit. Defaults ON — the framework
|
|
608
610
|
// audit chain is always booted by createApp, so the screen always has a
|
|
609
611
|
// data source (unlike the optional primitives above, which default off).
|
|
@@ -623,7 +625,7 @@ function mount(router, deps) {
|
|
|
623
625
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
624
626
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
625
627
|
// notice when the salesReports primitive isn't wired.
|
|
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 };
|
|
628
|
+
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, emailCampaigns: !!emailCampaigns };
|
|
627
629
|
|
|
628
630
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
629
631
|
|
|
@@ -5703,6 +5705,250 @@ function mount(router, deps) {
|
|
|
5703
5705
|
));
|
|
5704
5706
|
}
|
|
5705
5707
|
|
|
5708
|
+
// ---- email campaigns (consent-gated broadcast) ----------------------
|
|
5709
|
+
//
|
|
5710
|
+
// The marketing-broadcast console. An operator authors a campaign
|
|
5711
|
+
// (subject + Markdown body), targets an existing mailing audience, sees
|
|
5712
|
+
// the RESOLVED REACHABLE count (who is actually marketing-consented +
|
|
5713
|
+
// deliverable right now — never the raw membership), test-sends to their
|
|
5714
|
+
// own inbox, then sends. Consent is resolved AT SEND TIME, per recipient:
|
|
5715
|
+
// only a marketing-consented (newsletter-subscribed, not suppressed)
|
|
5716
|
+
// recipient with a deliverable plaintext address gets the broadcast, and
|
|
5717
|
+
// someone who unsubscribes after the send starts is honored mid-send.
|
|
5718
|
+
// Every broadcast carries an RFC 8058 one-click List-Unsubscribe header
|
|
5719
|
+
// pair plus an in-body unsubscribe link. The operator-authored body is
|
|
5720
|
+
// treated as hostile: it renders escape-by-default (any `<` lands as
|
|
5721
|
+
// `<`; links pass the https-only safeUrl gate) so a compromised admin
|
|
5722
|
+
// key can't inject script into mail or stored XSS into this console.
|
|
5723
|
+
if (emailCampaigns) {
|
|
5724
|
+
var _campaignAudiences = function () {
|
|
5725
|
+
if (!mailingAudiences) return Promise.resolve([]);
|
|
5726
|
+
return mailingAudiences.listAudiences({ include_archived: false });
|
|
5727
|
+
};
|
|
5728
|
+
|
|
5729
|
+
// List — every campaign with status + per-campaign delivery counts.
|
|
5730
|
+
// Content-negotiated: bearer → the JSON array; browser → the table.
|
|
5731
|
+
router.get("/admin/campaigns", _pageOrApi(true,
|
|
5732
|
+
R(async function (req, res) {
|
|
5733
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
5734
|
+
var status = url && url.searchParams.get("status");
|
|
5735
|
+
var rows = await emailCampaigns.listCampaigns(status ? { status: status } : {});
|
|
5736
|
+
_json(res, 200, { campaigns: rows, can_broadcast: emailCampaigns.canBroadcast() });
|
|
5737
|
+
}),
|
|
5738
|
+
async function (req, res) {
|
|
5739
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
5740
|
+
var rows = await emailCampaigns.listCampaigns({});
|
|
5741
|
+
// Roll each campaign's per-recipient send ledger up for the count
|
|
5742
|
+
// column — bounded read per row (the list is operator-scale).
|
|
5743
|
+
var withCounts = [];
|
|
5744
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
5745
|
+
var counts = null;
|
|
5746
|
+
try { counts = await emailCampaigns.sendCounts(rows[i].slug); }
|
|
5747
|
+
catch (_e) { counts = null; }
|
|
5748
|
+
withCounts.push({ campaign: rows[i], counts: counts });
|
|
5749
|
+
}
|
|
5750
|
+
_sendHtml(res, 200, renderAdminCampaigns({
|
|
5751
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
5752
|
+
rows: withCounts, can_broadcast: emailCampaigns.canBroadcast(),
|
|
5753
|
+
sent: url && url.searchParams.get("sent"),
|
|
5754
|
+
saved: url && url.searchParams.get("saved"),
|
|
5755
|
+
tested: url && url.searchParams.get("tested"),
|
|
5756
|
+
notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
|
|
5757
|
+
}));
|
|
5758
|
+
},
|
|
5759
|
+
));
|
|
5760
|
+
|
|
5761
|
+
// New-campaign form — its own GET so a bad submit's err redirect
|
|
5762
|
+
// keeps the operator's context.
|
|
5763
|
+
router.get("/admin/campaigns/new", _pageOrApi(true,
|
|
5764
|
+
R(async function (_req, res) {
|
|
5765
|
+
_json(res, 200, { audiences: await _campaignAudiences() });
|
|
5766
|
+
}),
|
|
5767
|
+
async function (req, res) {
|
|
5768
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
5769
|
+
_sendHtml(res, 200, renderAdminCampaignNew({
|
|
5770
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
5771
|
+
audiences: await _campaignAudiences(),
|
|
5772
|
+
notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
|
|
5773
|
+
}));
|
|
5774
|
+
},
|
|
5775
|
+
));
|
|
5776
|
+
|
|
5777
|
+
function _campaignInput(body) {
|
|
5778
|
+
return {
|
|
5779
|
+
slug: body.slug,
|
|
5780
|
+
subject: body.subject,
|
|
5781
|
+
body_html: body.body_html,
|
|
5782
|
+
// Markdown source is the single authored body; the text alt is
|
|
5783
|
+
// derived from it at send time, so persist the same source in
|
|
5784
|
+
// both columns (the renderer produces the HTML + text views).
|
|
5785
|
+
body_text: body.body_text != null && body.body_text !== "" ? body.body_text : body.body_html,
|
|
5786
|
+
audience_slug: body.audience_slug,
|
|
5787
|
+
from_address: body.from_address,
|
|
5788
|
+
from_name: body.from_name,
|
|
5789
|
+
reply_to: body.reply_to != null && body.reply_to !== "" ? body.reply_to : undefined,
|
|
5790
|
+
};
|
|
5791
|
+
}
|
|
5792
|
+
|
|
5793
|
+
// Create — composes defineCampaign (validates slug / subject / body /
|
|
5794
|
+
// audience / sender identity, throws TypeError → 400 / err redirect).
|
|
5795
|
+
router.post("/admin/campaigns", _pageOrApi(false,
|
|
5796
|
+
W("email_campaign.create", async function (req, res) {
|
|
5797
|
+
var c;
|
|
5798
|
+
try { c = await emailCampaigns.defineCampaign(_campaignInput(req.body || {})); }
|
|
5799
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
5800
|
+
_json(res, 201, c);
|
|
5801
|
+
return { id: c.slug };
|
|
5802
|
+
}),
|
|
5803
|
+
async function (req, res) {
|
|
5804
|
+
try { await emailCampaigns.defineCampaign(_campaignInput(req.body || {})); }
|
|
5805
|
+
catch (e) {
|
|
5806
|
+
var n = _safeNotice(e, "email_campaign.create");
|
|
5807
|
+
if (n.status >= 500) throw e;
|
|
5808
|
+
return _redirect(res, "/admin/campaigns/new?err=bad");
|
|
5809
|
+
}
|
|
5810
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.create", outcome: "success" });
|
|
5811
|
+
_redirect(res, "/admin/campaigns?saved=1");
|
|
5812
|
+
},
|
|
5813
|
+
));
|
|
5814
|
+
|
|
5815
|
+
// Detail — the campaign + its rendered (escape-by-default) preview, the
|
|
5816
|
+
// resolved reachable count (computed live), the send-ledger counts, and
|
|
5817
|
+
// the test-send + send actions. Content-negotiated.
|
|
5818
|
+
router.get("/admin/campaigns/:slug", _pageOrApi(true,
|
|
5819
|
+
R(async function (req, res) {
|
|
5820
|
+
var c = await emailCampaigns.getCampaign(req.params.slug);
|
|
5821
|
+
if (!c) return _problem(res, 404, "email-campaign-not-found");
|
|
5822
|
+
var reach = null;
|
|
5823
|
+
try { reach = await emailCampaigns.reachability(req.params.slug); }
|
|
5824
|
+
catch (_e) { reach = null; }
|
|
5825
|
+
var counts = await emailCampaigns.sendCounts(req.params.slug);
|
|
5826
|
+
_json(res, 200, Object.assign({}, c, { reachability: reach, send_counts: counts, can_broadcast: emailCampaigns.canBroadcast() }));
|
|
5827
|
+
}),
|
|
5828
|
+
async function (req, res) {
|
|
5829
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
5830
|
+
var c = await emailCampaigns.getCampaign(req.params.slug);
|
|
5831
|
+
if (!c) return _sendHtml(res, 404, renderAdminCampaigns({
|
|
5832
|
+
shop_name: deps.shop_name, nav_available: navAvailable, rows: [],
|
|
5833
|
+
can_broadcast: emailCampaigns.canBroadcast(), notice: "Campaign not found.",
|
|
5834
|
+
}));
|
|
5835
|
+
var preview = null;
|
|
5836
|
+
try { preview = await emailCampaigns.previewCampaign(req.params.slug); }
|
|
5837
|
+
catch (_e) { preview = null; }
|
|
5838
|
+
var reach = null;
|
|
5839
|
+
try { reach = await emailCampaigns.reachability(req.params.slug); }
|
|
5840
|
+
catch (_e) { reach = null; }
|
|
5841
|
+
var counts = await emailCampaigns.sendCounts(req.params.slug);
|
|
5842
|
+
_sendHtml(res, 200, renderAdminCampaign({
|
|
5843
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
5844
|
+
campaign: c, preview: preview, reachability: reach, counts: counts,
|
|
5845
|
+
can_broadcast: emailCampaigns.canBroadcast(),
|
|
5846
|
+
sent: url && url.searchParams.get("sent"),
|
|
5847
|
+
tested: url && url.searchParams.get("tested"),
|
|
5848
|
+
notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
|
|
5849
|
+
}));
|
|
5850
|
+
},
|
|
5851
|
+
));
|
|
5852
|
+
|
|
5853
|
+
// Test-send — render + mail the campaign to ONE operator-supplied
|
|
5854
|
+
// address (bypasses the audience + consent gate; it's the operator's
|
|
5855
|
+
// own inbox). Rate-bound on the shared send window.
|
|
5856
|
+
router.post("/admin/campaigns/:slug/test", _pageOrApi(false,
|
|
5857
|
+
W("email_campaign.test", async function (req, res) {
|
|
5858
|
+
var slug = req.params.slug;
|
|
5859
|
+
var out;
|
|
5860
|
+
try { out = await emailCampaigns.testSend(slug, (req.body || {}).to); }
|
|
5861
|
+
catch (e) {
|
|
5862
|
+
if (e instanceof TypeError) {
|
|
5863
|
+
var code = e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? 429 : 400;
|
|
5864
|
+
return _problem(res, code, e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? "rate-limited" : "bad-request", e.message);
|
|
5865
|
+
}
|
|
5866
|
+
_safeNotice(e, "email_campaign.test");
|
|
5867
|
+
return _problem(res, 502, "send-failed", "The test message could not be sent.");
|
|
5868
|
+
}
|
|
5869
|
+
_json(res, 200, out);
|
|
5870
|
+
return { id: slug };
|
|
5871
|
+
}),
|
|
5872
|
+
async function (req, res) {
|
|
5873
|
+
var slug = req.params.slug;
|
|
5874
|
+
var enc = encodeURIComponent(slug);
|
|
5875
|
+
try { await emailCampaigns.testSend(slug, (req.body || {}).to); }
|
|
5876
|
+
catch (e) {
|
|
5877
|
+
if (e instanceof TypeError) {
|
|
5878
|
+
var reason = e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? "rate" : "test";
|
|
5879
|
+
return _redirect(res, "/admin/campaigns/" + enc + "?err=" + reason);
|
|
5880
|
+
}
|
|
5881
|
+
_safeNotice(e, "email_campaign.test");
|
|
5882
|
+
return _redirect(res, "/admin/campaigns/" + enc + "?err=send");
|
|
5883
|
+
}
|
|
5884
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.test", outcome: "success", metadata: { slug: slug } });
|
|
5885
|
+
_redirect(res, "/admin/campaigns/" + enc + "?tested=1");
|
|
5886
|
+
},
|
|
5887
|
+
));
|
|
5888
|
+
|
|
5889
|
+
// Send — the consent-gated broadcast. Resolves reachability at the
|
|
5890
|
+
// send moment, drains the audience, only marketing-consented +
|
|
5891
|
+
// deliverable recipients receive, every message carries the one-click
|
|
5892
|
+
// unsubscribe pair. One bad address is counted, never fatal.
|
|
5893
|
+
router.post("/admin/campaigns/:slug/send", _pageOrApi(false,
|
|
5894
|
+
W("email_campaign.send", async function (req, res) {
|
|
5895
|
+
var slug = req.params.slug;
|
|
5896
|
+
var c = await emailCampaigns.getCampaign(slug);
|
|
5897
|
+
if (!c) return _problem(res, 404, "email-campaign-not-found");
|
|
5898
|
+
var out;
|
|
5899
|
+
try { out = await emailCampaigns.broadcast(slug); }
|
|
5900
|
+
catch (e) {
|
|
5901
|
+
if (e instanceof TypeError) {
|
|
5902
|
+
var code = e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? 409 : 400;
|
|
5903
|
+
return _problem(res, code, e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? "broadcast-unavailable" : "bad-request", e.message);
|
|
5904
|
+
}
|
|
5905
|
+
throw e;
|
|
5906
|
+
}
|
|
5907
|
+
_json(res, 200, out);
|
|
5908
|
+
return { id: slug };
|
|
5909
|
+
}),
|
|
5910
|
+
async function (req, res) {
|
|
5911
|
+
var slug = req.params.slug;
|
|
5912
|
+
var enc = encodeURIComponent(slug);
|
|
5913
|
+
var c = await emailCampaigns.getCampaign(slug);
|
|
5914
|
+
if (!c) return _redirect(res, "/admin/campaigns?err=notfound");
|
|
5915
|
+
try { await emailCampaigns.broadcast(slug); }
|
|
5916
|
+
catch (e) {
|
|
5917
|
+
if (e instanceof TypeError) {
|
|
5918
|
+
var reason = e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? "unavailable" : "send";
|
|
5919
|
+
return _redirect(res, "/admin/campaigns/" + enc + "?err=" + reason);
|
|
5920
|
+
}
|
|
5921
|
+
_safeNotice(e, "email_campaign.send");
|
|
5922
|
+
return _redirect(res, "/admin/campaigns/" + enc + "?err=send");
|
|
5923
|
+
}
|
|
5924
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.send", outcome: "success", metadata: { slug: slug } });
|
|
5925
|
+
_redirect(res, "/admin/campaigns/" + enc + "?sent=1");
|
|
5926
|
+
},
|
|
5927
|
+
));
|
|
5928
|
+
|
|
5929
|
+
// Cancel — terminal off-ramp for a draft / scheduled campaign.
|
|
5930
|
+
router.post("/admin/campaigns/:slug/cancel", _pageOrApi(false,
|
|
5931
|
+
W("email_campaign.cancel", async function (req, res) {
|
|
5932
|
+
var slug = req.params.slug;
|
|
5933
|
+
var reason = (req.body || {}).reason || "Cancelled from the console.";
|
|
5934
|
+
var out;
|
|
5935
|
+
try { out = await emailCampaigns.cancelCampaign(slug, reason); }
|
|
5936
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
5937
|
+
_json(res, 200, out);
|
|
5938
|
+
return { id: slug };
|
|
5939
|
+
}),
|
|
5940
|
+
async function (req, res) {
|
|
5941
|
+
var slug = req.params.slug;
|
|
5942
|
+
var enc = encodeURIComponent(slug);
|
|
5943
|
+
var reason = (req.body || {}).reason || "Cancelled from the console.";
|
|
5944
|
+
try { await emailCampaigns.cancelCampaign(slug, reason); }
|
|
5945
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/campaigns/" + enc + "?err=cancel"); }
|
|
5946
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.cancel", outcome: "success", metadata: { slug: slug } });
|
|
5947
|
+
_redirect(res, "/admin/campaigns?saved=1");
|
|
5948
|
+
},
|
|
5949
|
+
));
|
|
5950
|
+
}
|
|
5951
|
+
|
|
5706
5952
|
// ---- gift wraps -----------------------------------------------------
|
|
5707
5953
|
//
|
|
5708
5954
|
// The operator-defined gift-wrap catalog: define / update / archive a wrap
|
|
@@ -12598,6 +12844,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
12598
12844
|
{ key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
|
|
12599
12845
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
12600
12846
|
{ key: "promo-banners", href: "/admin/promo-banners", label: "Promo banners", requires: "promoBanners" },
|
|
12847
|
+
{ key: "campaigns", href: "/admin/campaigns", label: "Email campaigns", requires: "emailCampaigns" },
|
|
12601
12848
|
{ key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
|
|
12602
12849
|
{ key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
|
|
12603
12850
|
{ key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
|
|
@@ -15421,6 +15668,195 @@ function renderAdminInvReceive(opts) {
|
|
|
15421
15668
|
return _renderAdminShell(opts.shop_name, "Receive stock", body, "inventory-receive", opts.nav_available);
|
|
15422
15669
|
}
|
|
15423
15670
|
|
|
15671
|
+
// ---- email campaign console renders ----------------------------------
|
|
15672
|
+
|
|
15673
|
+
// Map an ?err= code on a campaign console redirect to an operator-facing
|
|
15674
|
+
// notice. The codes are emitted by the campaign routes, never operator
|
|
15675
|
+
// free text, so no escaping concern here.
|
|
15676
|
+
function _campaignErrNotice(code) {
|
|
15677
|
+
if (code === "bad") return "Check the campaign — slug, subject, body, audience, and a valid sender address are all required.";
|
|
15678
|
+
if (code === "rate") return "Send rate limit reached. Try again in a moment.";
|
|
15679
|
+
if (code === "test") return "The test recipient address wasn't valid.";
|
|
15680
|
+
if (code === "send") return "The send couldn't be completed. Check the error log.";
|
|
15681
|
+
if (code === "unavailable") return "Broadcast isn't available — this deployment has no deliverable-address source (newsletter list) or no configured unsubscribe origin.";
|
|
15682
|
+
if (code === "cancel") return "That campaign can't be cancelled from its current state.";
|
|
15683
|
+
if (code === "notfound") return "Campaign not found.";
|
|
15684
|
+
return "That action couldn't be completed.";
|
|
15685
|
+
}
|
|
15686
|
+
|
|
15687
|
+
function _campaignStatusPill(status) {
|
|
15688
|
+
var cls = "status-pill";
|
|
15689
|
+
if (status === "sent") cls = "status-pill status-pill--ok";
|
|
15690
|
+
if (status === "cancelled") cls = "status-pill status-pill--muted";
|
|
15691
|
+
if (status === "sending") cls = "status-pill status-pill--warn";
|
|
15692
|
+
return "<span class=\"" + cls + "\">" + _htmlEscape(String(status)) + "</span>";
|
|
15693
|
+
}
|
|
15694
|
+
|
|
15695
|
+
// Per-recipient delivery counts cell — "12 sent · 3 skipped · 1 failed".
|
|
15696
|
+
// All four outcomes roll into a compact summary; a zero-everything
|
|
15697
|
+
// campaign shows an em-dash.
|
|
15698
|
+
function _campaignCountsSummary(counts) {
|
|
15699
|
+
if (!counts) return "<span class=\"meta\">—</span>";
|
|
15700
|
+
var sent = Number(counts.sent || 0);
|
|
15701
|
+
var skipped = Number(counts.skipped_unsubscribed || 0) + Number(counts.skipped_suppressed || 0);
|
|
15702
|
+
var failed = Number(counts.failed || 0);
|
|
15703
|
+
if (!sent && !skipped && !failed) return "<span class=\"meta\">—</span>";
|
|
15704
|
+
var parts = [];
|
|
15705
|
+
parts.push(_htmlEscape(String(sent)) + " sent");
|
|
15706
|
+
if (skipped) parts.push(_htmlEscape(String(skipped)) + " skipped");
|
|
15707
|
+
if (failed) parts.push(_htmlEscape(String(failed)) + " failed");
|
|
15708
|
+
return _htmlEscape(parts.join(" · "));
|
|
15709
|
+
}
|
|
15710
|
+
|
|
15711
|
+
function renderAdminCampaigns(opts) {
|
|
15712
|
+
opts = opts || {};
|
|
15713
|
+
var rows = opts.rows || [];
|
|
15714
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Campaign saved.</div>" : "";
|
|
15715
|
+
var sent = opts.sent ? "<div class=\"banner banner--ok\">Campaign sent.</div>" : "";
|
|
15716
|
+
var tested = opts.tested ? "<div class=\"banner banner--ok\">Test message sent.</div>" : "";
|
|
15717
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
15718
|
+
|
|
15719
|
+
// Honesty banner — when the broadcast path isn't wired (no deliverable
|
|
15720
|
+
// address source / no unsubscribe origin), say so plainly. Campaigns
|
|
15721
|
+
// can still be authored + previewed; the Send action will refuse.
|
|
15722
|
+
var reachBanner = opts.can_broadcast
|
|
15723
|
+
? ""
|
|
15724
|
+
: "<div class=\"banner banner--warn\">Sending is unavailable: this store has no deliverable-address source " +
|
|
15725
|
+
"(a newsletter subscriber list with plaintext addresses) or no configured unsubscribe origin. " +
|
|
15726
|
+
"Customer email is stored hash-only, so only newsletter subscribers are reachable. You can still draft and preview campaigns.</div>";
|
|
15727
|
+
|
|
15728
|
+
var tableRows = rows.map(function (rc) {
|
|
15729
|
+
var c = rc.campaign;
|
|
15730
|
+
var enc = encodeURIComponent(c.slug);
|
|
15731
|
+
return "<tr>" +
|
|
15732
|
+
"<td><a href=\"/admin/campaigns/" + enc + "\">" + _htmlEscape(c.subject) + "</a><div class=\"meta\"><code>" + _htmlEscape(c.slug) + "</code></div></td>" +
|
|
15733
|
+
"<td>" + _htmlEscape(c.audience_slug) + "</td>" +
|
|
15734
|
+
"<td>" + _campaignStatusPill(c.status) + "</td>" +
|
|
15735
|
+
"<td>" + _campaignCountsSummary(rc.counts) + "</td>" +
|
|
15736
|
+
"</tr>";
|
|
15737
|
+
}).join("");
|
|
15738
|
+
|
|
15739
|
+
var list = rows.length
|
|
15740
|
+
? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Subject</th><th scope=\"col\">Audience</th><th scope=\"col\">Status</th><th scope=\"col\">Delivery</th></tr></thead><tbody>" + tableRows + "</tbody></table>") + "</div>"
|
|
15741
|
+
: "<p class=\"empty\">No campaigns yet. Create one to broadcast to a mailing audience.</p>";
|
|
15742
|
+
|
|
15743
|
+
var body =
|
|
15744
|
+
"<section><h2>Email campaigns</h2>" +
|
|
15745
|
+
"<p class=\"meta\">Broadcast a message to a mailing audience. Only marketing-consented, reachable subscribers receive a campaign — consent is checked at send time, and every message carries a one-click unsubscribe.</p>" +
|
|
15746
|
+
saved + sent + tested + notice + reachBanner +
|
|
15747
|
+
"<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/campaigns/new\">New campaign</a></div>" +
|
|
15748
|
+
list +
|
|
15749
|
+
"</section>";
|
|
15750
|
+
return _renderAdminShell(opts.shop_name, "Email campaigns", body, "campaigns", opts.nav_available);
|
|
15751
|
+
}
|
|
15752
|
+
|
|
15753
|
+
function renderAdminCampaignNew(opts) {
|
|
15754
|
+
opts = opts || {};
|
|
15755
|
+
var audiences = opts.audiences || [];
|
|
15756
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
15757
|
+
|
|
15758
|
+
var audOptions = audiences.map(function (a) {
|
|
15759
|
+
return "<option value=\"" + _htmlEscape(a.slug) + "\">" + _htmlEscape(a.title) + " (" + _htmlEscape(a.slug) + ")</option>";
|
|
15760
|
+
}).join("");
|
|
15761
|
+
var audField = audiences.length
|
|
15762
|
+
? "<label class=\"form-field\"><span>Audience</span><select name=\"audience_slug\" required>" + audOptions + "</select>" +
|
|
15763
|
+
"<small>The mailing audience to broadcast to. Manage audiences from the mailing-audiences API.</small></label>"
|
|
15764
|
+
: "<label class=\"form-field\"><span>Audience slug</span><input type=\"text\" name=\"audience_slug\" maxlength=\"64\" required>" +
|
|
15765
|
+
"<small>No mailing audiences are defined yet — enter the slug of one you've created via the API.</small></label>";
|
|
15766
|
+
|
|
15767
|
+
var body =
|
|
15768
|
+
"<section class=\"mw-42\"><h2>New campaign</h2>" + notice +
|
|
15769
|
+
"<form method=\"post\" action=\"/admin/campaigns\">" +
|
|
15770
|
+
_setupField("Campaign slug", "slug", "", "text", "Lowercase id, e.g. spring-sale-2026.", " maxlength=\"64\" required") +
|
|
15771
|
+
_setupField("Subject", "subject", "", "text", "The email subject line.", " maxlength=\"200\" required") +
|
|
15772
|
+
"<label class=\"form-field\"><span>Body (Markdown)</span>" +
|
|
15773
|
+
"<textarea name=\"body_html\" rows=\"10\" maxlength=\"100000\" required></textarea>" +
|
|
15774
|
+
"<small>Markdown — headings, lists, links, bold/italic. Rendered escape-by-default: raw HTML is shown as text, links must be https.</small></label>" +
|
|
15775
|
+
audField +
|
|
15776
|
+
_setupField("From address", "from_address", "", "email", "The sender address (must be a domain you can send from).", " maxlength=\"254\" required") +
|
|
15777
|
+
_setupField("From name", "from_name", "", "text", "The friendly sender name.", " maxlength=\"100\" required") +
|
|
15778
|
+
_setupField("Reply-to", "reply_to", "", "email", "Optional — where replies land.", " maxlength=\"254\"") +
|
|
15779
|
+
"<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Create campaign</button>" +
|
|
15780
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/campaigns\">Cancel</a></div>" +
|
|
15781
|
+
"</form>" +
|
|
15782
|
+
"</section>";
|
|
15783
|
+
return _renderAdminShell(opts.shop_name, "New campaign", body, "campaigns", opts.nav_available);
|
|
15784
|
+
}
|
|
15785
|
+
|
|
15786
|
+
function renderAdminCampaign(opts) {
|
|
15787
|
+
opts = opts || {};
|
|
15788
|
+
var c = opts.campaign;
|
|
15789
|
+
var enc = encodeURIComponent(c.slug);
|
|
15790
|
+
var sent = opts.sent ? "<div class=\"banner banner--ok\">Campaign sent.</div>" : "";
|
|
15791
|
+
var tested = opts.tested ? "<div class=\"banner banner--ok\">Test message sent.</div>" : "";
|
|
15792
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
15793
|
+
|
|
15794
|
+
// Resolved reachable count — the true send size, computed live. The
|
|
15795
|
+
// operator sees this BEFORE confirming a send.
|
|
15796
|
+
var reach = opts.reachability;
|
|
15797
|
+
var reachPanel = reach
|
|
15798
|
+
? "<div class=\"panel\"><h3 class=\"subhead\">Reachable recipients</h3>" +
|
|
15799
|
+
"<p class=\"big-stat\">" + _htmlEscape(String(reach.reachable)) + "</p>" +
|
|
15800
|
+
"<p class=\"meta\">Resolved at send time from " + _htmlEscape(String(reach.resolved)) + " audience members: " +
|
|
15801
|
+
_htmlEscape(String(reach.reachable)) + " reachable, " +
|
|
15802
|
+
_htmlEscape(String(reach.suppressed)) + " suppressed, " +
|
|
15803
|
+
_htmlEscape(String(reach.unsubscribed)) + " unsubscribed, " +
|
|
15804
|
+
_htmlEscape(String(reach.no_address)) + " with no deliverable address.</p>" +
|
|
15805
|
+
"</div>"
|
|
15806
|
+
: "<div class=\"panel\"><p class=\"meta\">Reachability can't be resolved — no deliverable-address source is wired.</p></div>";
|
|
15807
|
+
|
|
15808
|
+
// Per-recipient delivery counts from the send ledger.
|
|
15809
|
+
var counts = opts.counts || {};
|
|
15810
|
+
var countsPanel = "<div class=\"panel\"><h3 class=\"subhead\">Delivery</h3>" +
|
|
15811
|
+
"<ul class=\"kv-list\">" +
|
|
15812
|
+
"<li><span>Sent</span><strong>" + _htmlEscape(String(counts.sent || 0)) + "</strong></li>" +
|
|
15813
|
+
"<li><span>Skipped (unsubscribed)</span><strong>" + _htmlEscape(String(counts.skipped_unsubscribed || 0)) + "</strong></li>" +
|
|
15814
|
+
"<li><span>Skipped (suppressed)</span><strong>" + _htmlEscape(String(counts.skipped_suppressed || 0)) + "</strong></li>" +
|
|
15815
|
+
"<li><span>Failed</span><strong>" + _htmlEscape(String(counts.failed || 0)) + "</strong></li>" +
|
|
15816
|
+
"</ul></div>";
|
|
15817
|
+
|
|
15818
|
+
// Rendered preview — the renderer already escaped the operator body, so
|
|
15819
|
+
// the preview HTML is splice-safe (no double-escape). Spliced literally
|
|
15820
|
+
// so a `$` in the body can't trip String.replace dollar substitution.
|
|
15821
|
+
var previewBody = opts.preview ? opts.preview.body_html : "<span class=\"meta\">Preview unavailable.</span>";
|
|
15822
|
+
var previewPanel = "<div class=\"panel\"><h3 class=\"subhead\">Preview</h3>" +
|
|
15823
|
+
"<div class=\"mail-preview\">RAW_PREVIEW_BODY</div></div>";
|
|
15824
|
+
|
|
15825
|
+
// Send / test actions only when the campaign isn't terminal.
|
|
15826
|
+
var isTerminal = c.status === "sent" || c.status === "cancelled";
|
|
15827
|
+
var actions = "";
|
|
15828
|
+
if (!isTerminal) {
|
|
15829
|
+
var sendBtn = opts.can_broadcast
|
|
15830
|
+
? "<form method=\"post\" action=\"/admin/campaigns/" + enc + "/send\" class=\"form-inline\">" +
|
|
15831
|
+
"<button class=\"btn\" type=\"submit\">Send to " + _htmlEscape(reach ? String(reach.reachable) : "0") + " recipient(s)</button></form>"
|
|
15832
|
+
: "<span class=\"meta\">Sending is unavailable on this deployment.</span>";
|
|
15833
|
+
actions =
|
|
15834
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Send a test</h3>" +
|
|
15835
|
+
"<form method=\"post\" action=\"/admin/campaigns/" + enc + "/test\" class=\"form-inline\">" +
|
|
15836
|
+
"<input type=\"email\" name=\"to\" placeholder=\"you@example.com\" maxlength=\"254\" required>" +
|
|
15837
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Send test</button></form></div>" +
|
|
15838
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Send campaign</h3>" + sendBtn +
|
|
15839
|
+
"<form method=\"post\" action=\"/admin/campaigns/" + enc + "/cancel\" class=\"form-inline mt\">" +
|
|
15840
|
+
"<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Cancel campaign</button></form></div>";
|
|
15841
|
+
}
|
|
15842
|
+
|
|
15843
|
+
var meta = "<p class=\"meta\">Audience <code>" + _htmlEscape(c.audience_slug) + "</code> · " +
|
|
15844
|
+
"from " + _htmlEscape(c.from_name) + " <" + _htmlEscape(c.from_address) + "> · " +
|
|
15845
|
+
_campaignStatusPill(c.status) + "</p>";
|
|
15846
|
+
|
|
15847
|
+
var body =
|
|
15848
|
+
"<section><h2>" + _htmlEscape(c.subject) + "</h2>" + meta +
|
|
15849
|
+
sent + tested + notice +
|
|
15850
|
+
reachPanel + countsPanel + previewPanel + actions +
|
|
15851
|
+
"<div class=\"actions-row mt\"><a class=\"btn btn--ghost\" href=\"/admin/campaigns\">← All campaigns</a></div>" +
|
|
15852
|
+
"</section>";
|
|
15853
|
+
var html = _renderAdminShell(opts.shop_name, c.subject, body, "campaigns", opts.nav_available);
|
|
15854
|
+
// Splice the already-escaped preview body literally (it contains only the
|
|
15855
|
+
// fixed tag set the escape-by-default renderer emits; the operator's
|
|
15856
|
+
// bytes were escaped before this point).
|
|
15857
|
+
return _spliceRaw(html, "RAW_PREVIEW_BODY", previewBody);
|
|
15858
|
+
}
|
|
15859
|
+
|
|
15424
15860
|
// Location→location transfer console: the open form + the open-transfer
|
|
15425
15861
|
// queue with the FSM action legal from each row's status. Reasons,
|
|
15426
15862
|
// carriers, and tracking numbers are operator free text — escaped.
|
package/lib/asset-manifest.json
CHANGED