@blamejs/blamejs-shop 0.1.22 → 0.1.23
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 +2 -2
- package/lib/admin.js +113 -159
- 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.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.23 (2026-05-25) — **The admin console renders styled again, and both surfaces now self-host their fonts.** The admin console shipped its design system as an inline <style> block, inline style="" attributes, and a cross-origin Google Fonts link — all three of which the strict `style-src 'self'` / `font-src 'self'` CSP that governs container-served routes refuses, so the console rendered as an unstyled page in production. Its styling now lives in an external stylesheet served from /assets (like the storefront's), and the typeface is self-hosted, so the console renders as designed under the same CSP that protects it. Both the storefront and the console now serve Inter from /assets rather than naming it and hoping the visitor has it installed. **Changed:** *Inter is self-hosted on both the storefront and the console* — The default theme named Inter / Inter Tight first in its font stack but shipped no font files, so the intended typography only rendered for visitors who happened to have Inter installed locally — everyone else fell back to a system sans-serif. The latin subset of Inter (400/500/600) and Inter Tight (600/700) now ships under `themes/default/assets/fonts` and is referenced via `@font-face`, so the typeface renders for every visitor and loads same-origin under `font-src 'self'` — no cross-origin font CDN on any page. The fonts are SIL OFL 1.1 (license alongside the files); `font-display:swap` paints text immediately in the system fallback and swaps when the file arrives. · *Admin console visual refresh* — With the stylesheet external it also got a pass: a proper centered sign-in card (replacing the bare form that was the first thing an operator saw), softer surfaces and shadows on cards and panels, a sticky console nav, and consistent spacing. The brand palette (ink + the storefront's accent) and every screen's structure are unchanged — this is presentation only. **Fixed:** *Admin console renders unstyled in production* — Every `/admin` screen built its design system three ways the strict CSP refuses: an inline `<style>` block, inline `style=""` attributes, and a `<link>` to fonts.googleapis.com. Container-served routes run under `style-src 'self'` / `font-src 'self'`, which drops all three — so the console arrived as unstyled HTML (a plain page) once deployed, even though it looked correct in isolation. The styling now ships as `/assets/themes/default/css/admin.css`, an external file `'self'` allows, linked with a `sha384` Subresource Integrity digest so the browser rejects a tampered or stale object; the inline attributes are gone, folded into that stylesheet. A render-shell test asserts every admin screen links the external stylesheet, carries no inline styles, and pulls no third-party font host, so the regression can't return. Operators redeploy to pick this up — `npm run deploy` uploads the new stylesheet (and the fonts below) to R2 as part of the asset sync.
|
|
12
|
+
|
|
11
13
|
- v0.1.22 (2026-05-25) — **Fix the container boot crash-loop, plus gift cards, subscription self-management, and a customers roster.** The deployable container could crash-loop on startup, taking every write route (add-to-cart, checkout, account, admin) down while edge-rendered reads kept working — this release fixes that boot path. It also adds gift cards (issue / balance / redeem-at-checkout with an operator ledger), customer subscription self-management at `/account/subscriptions`, and a read-only customers roster in the admin console. **Added:** *Gift cards — redeem at checkout, balance check, operator ledger* — A gift-card code can be applied at checkout as a credit against the order total: the amount due drops by the available balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order behind an atomic `balance >= amount` guard so concurrent spends can't overdraw — a card that fully covers the order is marked paid with no card charge. Customers check a balance at `GET /gift-cards` (which is not a code-existence oracle — unknown / malformed / expired all return the same generic result). Operators work `/admin/gift-cards`: list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a card (the bearer code shown once, right after creation), and open a card to read its full credit / debit / expire ledger. Codes are stored as a `namespaceHash` digest plus a 4-char hint — never in the clear. · *Customer subscription self-management* — Signed-in customers view and cancel their own subscriptions at `/account/subscriptions` — each row shows the plan summary, status, and renew / end date, with a Cancel control on cancelable subscriptions. Every action is ownership-checked: a subscription that doesn't belong to the signed-in customer is a 404, never another account's row. Cancellation runs through the payment provider, so the cancel control mounts only when the payment handle is wired; without it the list renders read-only. Customer-facing subscription creation — a subscription-checkout flow — remains a separate follow-up; this surface is for managing existing subscriptions. · *Customers admin roster* — `/admin/customers` renders a read-only account roster (newest first): display name, a short id, the join date, the sign-in method (passkey count plus any linked OAuth providers), and the order count, with cursor pagination in the browser and a JSON list (with `next_cursor`) for a bearer-token client. Account creation and changes stay with the storefront's passkey / OIDC ceremonies. Order counts and sign-in methods are resolved with bounded aggregate queries over each page's ids — no per-row database trips. Raw email is never stored, so it is never shown. **Changed:** *Console nav gains Customers and Gift cards* — The signed-in admin nav now includes Customers and Gift cards alongside the existing screens, each shown when its primitive is wired. All new endpoints content-negotiate like the rest of the console: a bearer-token client gets the JSON API, a signed-in browser gets HTML; a request without the bearer token returns the sign-in form on a GET and redirects on a write. The customers roster, the subscription cancel, and the gift-card balance page all degrade gracefully when an optional dependency (payment, or the primitive itself) isn't configured. · *Deploy now syncs theme assets to R2* — `npm run deploy` chains `wrangler deploy` with a new `scripts/sync-r2-assets.js` that uploads the repo's theme stylesheets to the R2 bucket the Worker serves `/assets/*` from. `wrangler deploy` alone ships the Worker + container but never touches R2, so a CSS change would silently drift from the deployed HTML — new markup against a stale stylesheet. Run `npm run sync-assets` to push the stylesheets without a full redeploy. · *Theme stylesheet and island scripts served with Subresource Integrity* — The default theme's stylesheet `<link>` and the externalized passkey island `<script>` tags now carry a `sha384` `integrity` attribute computed from the on-disk asset, so a browser refuses any `/assets/*` response whose bytes don't match — closing the window where a tampered or stale CDN object could execute against the page. The digests are computed once at boot and cached; a missing or unreadable asset degrades to no integrity attribute rather than blocking the render. **Fixed:** *Passkey sign-in / registration no longer blocked by CSP* — The WebAuthn passkey ceremonies on `/account/login` and `/account/register` were inline `<script>` blocks, which the storefront's strict `script-src 'self'` CSP refuses to execute — so on the deploy the sign-in and registration buttons did nothing (accounts are passkey-only, so this blocked all customer auth). The ceremonies now ship as external assets (`/assets/themes/default/js/passkey-login.js` + `passkey-register.js`) that `'self'` allows, served from R2 and uploaded by the deploy's asset sync. Same effect, CSP-clean — and a clearer split between the server-rendered form and its opt-in enhancement. · *Container no longer crash-loops on startup* — The container boot had three ways to fail before it could serve a request, each of which left edge-rendered reads working while every container-served route (add-to-cart, cart update / remove, checkout, `/account`, `/admin`) was unreachable. (1) The secure-default boot unlocks a wrapped vault **and** a wrapped audit-signing keypair, which the framework reads from `BLAMEJS_VAULT_PASSPHRASE` / `BLAMEJS_AUDIT_SIGNING_PASSPHRASE`, but the deploy contract documents a single `VAULT_PASSPHRASE` the framework never read — so the container had no passphrase source, no TTY to prompt, and exited. The entry point now bridges the documented secret onto the vault passphrase and derives a domain-separated audit-signing passphrase from it. (2) Encrypted-at-rest requires a tmpfs; the image now points it at `/dev/shm` rather than failing to find one. (3) The Docker liveness probe was `wget`, which bot-guard blocks as automation (403), so a healthy container was marked unhealthy and stopped — the probe is now a browser-shaped Node request (`scripts/healthcheck.js`) that passes bot-guard like real traffic, without exempting the health path from the security middleware. A boot gate (`test/layer-2-integration/server-boot.test.js`) spawns the real entry point and asserts it listens, answers the probe, and still blocks header-less clients. Operators must redeploy the container to pick this up.
|
|
12
14
|
|
|
13
15
|
- v0.1.21 (2026-05-25) — **Admin console — a collections screen.** Collections join the admin console. `/admin/collections` lists manual and smart product collections, creates either kind, edits title / description / sort strategy, archives one, manages a manual collection's members (add / remove / reorder), and builds a smart collection's rules with a live preview of the products they match — all from a signed-in browser. As with the other console screens, the same paths serve the existing JSON API to a bearer-token client unchanged. **Added:** *Collections management screen* — `/admin/collections` lists the shop's collections (title, slug, type, status, member or match count) with an all / active / archived filter; `/admin/collections/:slug` opens one. For a manual collection it manages members — add a product by id, remove, and reorder. For a smart collection it edits the rule set (field / operator / value rows over the supported fields and operators) and renders a live preview of the products the rules currently match. A form creates either kind; editing updates the title, description, and sort strategy (and rules for smart collections); and a collection can be archived. A bad-shape submit re-renders with the validator's message rather than a 500, and an unknown slug or product is a no-op notice. **Changed:** *Console nav gains Collections* — The signed-in admin nav now includes Collections, shown when the collections primitive is wired. The list, detail, create, edit, archive, and member endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token returns the sign-in form on a GET and redirects on a write.
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
61
61
|
| **`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`. |
|
|
62
62
|
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` 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. |
|
|
63
63
|
| **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
|
|
64
|
-
| **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant
|
|
64
|
+
| **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
|
|
65
65
|
| **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront. |
|
|
66
66
|
| **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
|
|
67
67
|
| **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
|
|
@@ -75,7 +75,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
75
75
|
| **`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). |
|
|
76
76
|
| **`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. |
|
|
77
77
|
| **`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. |
|
|
78
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Customers, Returns, Reviews, Subscriptions, Collections, Gift cards, and Webhooks links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. |
|
|
78
|
+
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Customers, Returns, Reviews, Subscriptions, Collections, Gift cards, and Webhooks links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
79
79
|
| **`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. |
|
|
80
80
|
| **`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. |
|
|
81
81
|
|
package/lib/admin.js
CHANGED
|
@@ -37,6 +37,36 @@ var b = require("./vendor/blamejs");
|
|
|
37
37
|
|
|
38
38
|
var AUDIT_NAMESPACE = "shop_admin";
|
|
39
39
|
|
|
40
|
+
// The console stylesheet ships as an external /assets file — an inline
|
|
41
|
+
// <style> block (or inline style="" attributes) is refused by the strict
|
|
42
|
+
// `style-src 'self'` CSP that governs container-served routes, which is
|
|
43
|
+
// why the console renders unstyled when those are inlined. `'self'`
|
|
44
|
+
// allows this file; the deploy's R2 asset sync uploads it. The `?v=` is
|
|
45
|
+
// the per-release cache-buster; the sha384 Subresource Integrity digest
|
|
46
|
+
// (b.crypto.sri, W3C SRI 1.0) makes the browser reject a tampered or
|
|
47
|
+
// stale object. Same-origin, so no `crossorigin` is needed. Kept local
|
|
48
|
+
// to this module per the admin renderer's no-cross-module convention; a
|
|
49
|
+
// missing asset (e.g. a unit-test cwd) omits the attribute, never throws.
|
|
50
|
+
var _assetFs = require("node:fs"); // allow:non-shop-require — one sync read of the bundled in-repo stylesheet to compute its SRI hash via b.crypto.sri (the primitive needs the bytes); no remote/write
|
|
51
|
+
var _ASSET_VERSION = require("../package.json").version;
|
|
52
|
+
var _SRI_CACHE = {};
|
|
53
|
+
function _assetSri(relUnderThemeAssets) {
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(_SRI_CACHE, relUnderThemeAssets)) {
|
|
55
|
+
return _SRI_CACHE[relUnderThemeAssets];
|
|
56
|
+
}
|
|
57
|
+
var sri = null;
|
|
58
|
+
try {
|
|
59
|
+
sri = b.crypto.sri(_assetFs.readFileSync(__dirname + "/../themes/default/assets/" + relUnderThemeAssets), { algorithm: "sha384" });
|
|
60
|
+
} catch (_e) { sri = null; }
|
|
61
|
+
_SRI_CACHE[relUnderThemeAssets] = sri;
|
|
62
|
+
return sri;
|
|
63
|
+
}
|
|
64
|
+
function _adminStylesheetLink() {
|
|
65
|
+
var sri = _assetSri("css/admin.css");
|
|
66
|
+
return "<link rel=\"stylesheet\" href=\"/assets/themes/default/css/admin.css?v=" + _ASSET_VERSION + "\"" +
|
|
67
|
+
(sri ? " integrity=\"" + sri + "\"" : "") + ">";
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
// Conservative content-type → file-extension map for the upload route.
|
|
41
71
|
// Unknown types fall back to no extension; the R2 object metadata still
|
|
42
72
|
// carries the full content-type so the asset serves correctly either
|
|
@@ -2249,97 +2279,13 @@ var DASHBOARD_LAYOUT =
|
|
|
2249
2279
|
" <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
|
|
2250
2280
|
" <meta name=\"robots\" content=\"noindex,nofollow\">\n" +
|
|
2251
2281
|
" <title>Admin dashboard — {{shop_name}}</title>\n" +
|
|
2252
|
-
"
|
|
2253
|
-
" <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n" +
|
|
2254
|
-
" <link href=\"https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600;700&family=Inter:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n" +
|
|
2255
|
-
" <style>\n" +
|
|
2256
|
-
" :root { --ink:#191919; --ink-2:#414141; --mute:#727272; --hair:#d9d9d9; --paper:#ffffff; --bg:#fafafa; --accent:#fa4f09; --accent-d:#d8410a; }\n" +
|
|
2257
|
-
" * { box-sizing: border-box; }\n" +
|
|
2258
|
-
" html, body { margin:0; padding:0; background:var(--bg); }\n" +
|
|
2259
|
-
" body { font-family:'Inter',ui-sans-serif,system-ui,sans-serif; color:var(--ink); font-size:15px; line-height:1.55; }\n" +
|
|
2260
|
-
" h1, h2, h3 { font-family:'Montserrat',sans-serif; font-weight:700; letter-spacing:-0.01em; margin:0 0 .65rem; }\n" +
|
|
2261
|
-
" .admin-header { background:var(--ink); color:var(--paper); border-bottom:3px solid var(--accent); }\n" +
|
|
2262
|
-
" .admin-header__inner { max-width:80rem; margin:0 auto; padding:1.2rem 1.5rem; display:flex; align-items:center; justify-content:space-between; }\n" +
|
|
2263
|
-
" .admin-header h1 { color:var(--paper); font-size:1.1rem; margin:0; font-weight:600; letter-spacing:.02em; text-transform:uppercase; }\n" +
|
|
2264
|
-
" .admin-header .brand-accent { color:var(--accent); }\n" +
|
|
2265
|
-
" main { max-width:80rem; margin:0 auto; padding:2.5rem 1.5rem 5rem; }\n" +
|
|
2266
|
-
" section { margin-bottom:2.5rem; }\n" +
|
|
2267
|
-
" section h2 { font-size:1.1rem; text-transform:uppercase; letter-spacing:.05em; color:var(--mute); font-weight:600; margin-bottom:1rem; }\n" +
|
|
2268
|
-
" .stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(12rem, 1fr)); gap:1rem; }\n" +
|
|
2269
|
-
" .stat-card { background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.25rem 1.4rem; }\n" +
|
|
2270
|
-
" .stat-card .label { font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; color:var(--mute); font-weight:600; }\n" +
|
|
2271
|
-
" .stat-card .value { font-family:'Montserrat',sans-serif; font-weight:700; font-size:1.8rem; color:var(--ink); margin-top:.35rem; line-height:1.1; }\n" +
|
|
2272
|
-
" .stat-card .value.accent { color:var(--accent); }\n" +
|
|
2273
|
-
" .panel { background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.5rem; }\n" +
|
|
2274
|
-
" .two-col { display:grid; grid-template-columns: 2fr 1fr; gap:1.5rem; align-items:start; }\n" +
|
|
2275
|
-
" @media (max-width: 56rem) { .two-col { grid-template-columns: 1fr; } }\n" +
|
|
2276
|
-
" table { width:100%; border-collapse:collapse; font-size:.9rem; }\n" +
|
|
2277
|
-
" thead th { text-align:left; padding:.65rem .75rem; border-bottom:2px solid var(--ink); font-family:'Montserrat',sans-serif; font-weight:600; font-size:.72rem; letter-spacing:.05em; text-transform:uppercase; color:var(--mute); }\n" +
|
|
2278
|
-
" tbody td { padding:.65rem .75rem; border-bottom:1px solid var(--hair); }\n" +
|
|
2279
|
-
" tbody tr:last-child td { border-bottom:none; }\n" +
|
|
2280
|
-
" td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }\n" +
|
|
2281
|
-
" .status-pill { display:inline-block; padding:.15rem .55rem; border-radius:999px; font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.04em; background:var(--bg); color:var(--ink-2); border:1px solid var(--hair); }\n" +
|
|
2282
|
-
" .status-pill.paid, .status-pill.fulfilling, .status-pill.shipped, .status-pill.delivered { background:#e9f5ec; color:#1f6b3a; border-color:#bfe1c9; }\n" +
|
|
2283
|
-
" .status-pill.refunded { background:#fff1eb; color:var(--accent-d); border-color:#f6c5af; }\n" +
|
|
2284
|
-
" .status-pill.cancelled { background:#f4f4f4; color:var(--mute); }\n" +
|
|
2285
|
-
" .status-pill.pending { background:#fff8e1; color:#7a5d0f; border-color:#f1e1a8; }\n" +
|
|
2286
|
-
" .spark { width:100%; height:8rem; background:var(--bg); border:1px solid var(--hair); border-radius:6px; padding:.5rem; }\n" +
|
|
2287
|
-
" .spark svg { display:block; width:100%; height:100%; }\n" +
|
|
2288
|
-
" .empty { color:var(--mute); font-style:italic; padding:1rem 0; text-align:center; }\n" +
|
|
2289
|
-
" .meta { color:var(--mute); font-size:.85rem; margin-bottom:1rem; }\n" +
|
|
2290
|
-
" .order-id { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.78rem; color:var(--ink-2); }\n" +
|
|
2291
|
-
" .form-field { display:block; margin-bottom:1.1rem; }\n" +
|
|
2292
|
-
" .form-field span { display:block; font-size:.78rem; text-transform:uppercase; letter-spacing:.05em; color:var(--mute); font-weight:600; margin-bottom:.35rem; }\n" +
|
|
2293
|
-
" .form-field input { width:100%; max-width:28rem; padding:.6rem .75rem; border:1px solid var(--hair); border-radius:6px; font:inherit; }\n" +
|
|
2294
|
-
" .form-field input:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent); }\n" +
|
|
2295
|
-
" .form-field small { display:block; color:var(--mute); font-size:.78rem; margin-top:.3rem; }\n" +
|
|
2296
|
-
" .btn { display:inline-flex; align-items:center; gap:.4rem; background:var(--accent); color:var(--paper); border:1px solid var(--accent); padding:.6rem 1.1rem; border-radius:6px; font-family:'Montserrat',sans-serif; font-weight:700; font-size:.82rem; letter-spacing:.04em; text-transform:uppercase; text-decoration:none; cursor:pointer; }\n" +
|
|
2297
|
-
" .btn:hover { background:var(--accent-d); border-color:var(--accent-d); }\n" +
|
|
2298
|
-
" .btn--ghost { background:transparent; color:var(--ink); border-color:var(--ink); }\n" +
|
|
2299
|
-
" .btn--ghost:hover { background:var(--ink); color:var(--paper); }\n" +
|
|
2300
|
-
" .btn--danger { background:transparent; color:var(--accent-d); border-color:var(--accent-d); }\n" +
|
|
2301
|
-
" .btn--danger:hover { background:var(--accent-d); color:var(--paper); }\n" +
|
|
2302
|
-
" .order-filters { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:1.25rem; }\n" +
|
|
2303
|
-
" .chip { display:inline-block; padding:.3rem .8rem; border-radius:999px; border:1px solid var(--hair); color:var(--ink-2); text-decoration:none; font-size:.78rem; text-transform:capitalize; }\n" +
|
|
2304
|
-
" .chip:hover { border-color:var(--accent); }\n" +
|
|
2305
|
-
" .chip--on { background:var(--ink); color:var(--paper); border-color:var(--ink); }\n" +
|
|
2306
|
-
" .order-totals { width:100%; }\n" +
|
|
2307
|
-
" .order-totals td { padding:.3rem 0; }\n" +
|
|
2308
|
-
" .order-actions { display:flex; flex-wrap:wrap; gap:.6rem; }\n" +
|
|
2309
|
-
" .return-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1.25rem; }\n" +
|
|
2310
|
-
" .return-action { border:1px solid var(--hair); border-radius:8px; padding:1rem; }\n" +
|
|
2311
|
-
" .return-action h4 { margin:0 0 .6rem; font-size:.9rem; }\n" +
|
|
2312
|
-
" .review-card { margin-bottom:1rem; }\n" +
|
|
2313
|
-
" .review-card__head { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:.5rem; }\n" +
|
|
2314
|
-
" .review-card__body { margin:.25rem 0 .75rem; white-space:pre-wrap; }\n" +
|
|
2315
|
-
" .review-stars { color:#c9821f; letter-spacing:.1em; }\n" +
|
|
2316
|
-
" .review-reject { display:inline-flex; gap:.4rem; align-items:center; }\n" +
|
|
2317
|
-
" .review-reject input { padding:.45rem .6rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
|
|
2318
|
-
" .inv-row-form { display:flex; gap:.4rem; align-items:center; }\n" +
|
|
2319
|
-
" .inv-row-form input { padding:.4rem .5rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
|
|
2320
|
-
" tr.row--low td { background:#fff8e1; }\n" +
|
|
2321
|
-
" .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
|
|
2322
|
-
" .nav-card { display:block; background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.4rem; text-decoration:none; color:var(--ink); }\n" +
|
|
2323
|
-
" .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
|
|
2324
|
-
" .nav-card h3 { margin:0 0 .35rem; font-size:1.05rem; }\n" +
|
|
2325
|
-
" .nav-card p { margin:0; color:var(--mute); font-size:.88rem; }\n" +
|
|
2326
|
-
" .banner { padding:.9rem 1.1rem; border-radius:8px; margin-bottom:1.5rem; font-size:.92rem; }\n" +
|
|
2327
|
-
" .banner--warn { background:#fff8e1; border:1px solid #f1e1a8; color:#7a5d0f; }\n" +
|
|
2328
|
-
" .banner--ok { background:#e9f5ec; border:1px solid #bfe1c9; color:#1f6b3a; }\n" +
|
|
2329
|
-
" .banner--err { background:#fff1eb; border:1px solid #f6c5af; color:var(--accent-d); }\n" +
|
|
2330
|
-
" .actions-row { display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; margin-top:1.5rem; }\n" +
|
|
2331
|
-
" .admin-nav { background:var(--paper); border-bottom:1px solid var(--hair); }\n" +
|
|
2332
|
-
" .admin-nav__inner { max-width:80rem; margin:0 auto; padding:0 1.5rem; display:flex; gap:.1rem; flex-wrap:wrap; }\n" +
|
|
2333
|
-
" .admin-nav a { display:inline-block; padding:.85rem .9rem; color:var(--ink-2); text-decoration:none; font-size:.84rem; font-weight:600; border-bottom:2px solid transparent; }\n" +
|
|
2334
|
-
" .admin-nav a:hover { color:var(--ink); }\n" +
|
|
2335
|
-
" .admin-nav a.active { color:var(--accent); border-bottom-color:var(--accent); }\n" +
|
|
2336
|
-
" </style>\n" +
|
|
2282
|
+
" RAW_ADMIN_CSS\n" +
|
|
2337
2283
|
"</head>\n" +
|
|
2338
2284
|
"<body>\n" +
|
|
2339
2285
|
" <header class=\"admin-header\">\n" +
|
|
2340
2286
|
" <div class=\"admin-header__inner\">\n" +
|
|
2341
2287
|
" <h1>{{shop_name}} <span class=\"brand-accent\">/ admin</span></h1>\n" +
|
|
2342
|
-
" <span
|
|
2288
|
+
" <span class=\"window-label\">{{window_label}}</span>\n" +
|
|
2343
2289
|
" </div>\n" +
|
|
2344
2290
|
" </header>\n" +
|
|
2345
2291
|
" {{nav}}\n" +
|
|
@@ -2460,11 +2406,11 @@ function renderDashboard(opts) {
|
|
|
2460
2406
|
var twoCol =
|
|
2461
2407
|
"<section><h2>Catalog + activity</h2><div class=\"two-col\">" +
|
|
2462
2408
|
" <div class=\"panel\">" +
|
|
2463
|
-
" <h3
|
|
2409
|
+
" <h3 class=\"subhead\">Top SKUs by units sold</h3>" +
|
|
2464
2410
|
" <table><thead><tr><th>SKU</th><th class=\"num\">Units</th><th class=\"num\">Revenue</th></tr></thead><tbody>" + topRows + "</tbody></table>" +
|
|
2465
2411
|
" </div>" +
|
|
2466
2412
|
" <div class=\"panel\">" +
|
|
2467
|
-
" <h3
|
|
2413
|
+
" <h3 class=\"subhead\">Recent orders</h3>" +
|
|
2468
2414
|
" <table><thead><tr><th>Order</th><th>Status</th><th class=\"num\">Total</th></tr></thead><tbody>" + recentRows + "</tbody></table>" +
|
|
2469
2415
|
" </div>" +
|
|
2470
2416
|
"</div></section>";
|
|
@@ -2530,7 +2476,9 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
|
|
|
2530
2476
|
window_label: subtitle || "",
|
|
2531
2477
|
nav: "RAW_NAV",
|
|
2532
2478
|
body: "RAW_BODY",
|
|
2533
|
-
}).replace("
|
|
2479
|
+
}).replace("RAW_ADMIN_CSS", _adminStylesheetLink())
|
|
2480
|
+
.replace("RAW_NAV", _adminNav(active, available))
|
|
2481
|
+
.replace("RAW_BODY", bodyHtml);
|
|
2534
2482
|
}
|
|
2535
2483
|
|
|
2536
2484
|
function renderAdminLogin(opts) {
|
|
@@ -2538,18 +2486,24 @@ function renderAdminLogin(opts) {
|
|
|
2538
2486
|
var err = opts.error
|
|
2539
2487
|
? "<div class=\"banner banner--err\">That key didn't match. Check the ADMIN_API_KEY this deployment was started with.</div>"
|
|
2540
2488
|
: "";
|
|
2489
|
+
var shopName = opts.shop_name || "blamejs.shop";
|
|
2541
2490
|
var body =
|
|
2542
|
-
"<
|
|
2543
|
-
"<
|
|
2544
|
-
|
|
2545
|
-
"<
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
"
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2491
|
+
"<div class=\"signin-wrap\">" +
|
|
2492
|
+
"<div class=\"signin-card\">" +
|
|
2493
|
+
"<div class=\"signin-mark\"><span class=\"dot\"></span>" + _htmlEscape(shopName) + " admin</div>" +
|
|
2494
|
+
"<h2>Sign in</h2>" +
|
|
2495
|
+
"<p class=\"signin-lede\">This console manages your shop's catalog, orders, and settings.</p>" +
|
|
2496
|
+
err +
|
|
2497
|
+
"<form method=\"post\" action=\"/admin/login\">" +
|
|
2498
|
+
"<label class=\"form-field\"><span>Admin API key</span>" +
|
|
2499
|
+
"<input type=\"password\" name=\"token\" autocomplete=\"off\" autofocus required>" +
|
|
2500
|
+
"<small>The ADMIN_API_KEY this deployment was started with.</small>" +
|
|
2501
|
+
"</label>" +
|
|
2502
|
+
"<button type=\"submit\" class=\"btn\">Sign in</button>" +
|
|
2503
|
+
"</form>" +
|
|
2504
|
+
"</div>" +
|
|
2505
|
+
"</div>";
|
|
2506
|
+
return _renderAdminShell(shopName, "Sign in", body, null);
|
|
2553
2507
|
}
|
|
2554
2508
|
|
|
2555
2509
|
function renderAdminLanding(opts) {
|
|
@@ -2583,14 +2537,14 @@ function renderAdminSetup(opts) {
|
|
|
2583
2537
|
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved. Your shop details are live.</div>" : "";
|
|
2584
2538
|
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
2585
2539
|
var body =
|
|
2586
|
-
"<section
|
|
2540
|
+
"<section class=\"mw-34\">" +
|
|
2587
2541
|
"<h2>Shop setup</h2>" +
|
|
2588
2542
|
"<p class=\"meta\">Set the basics customers see across the storefront. You can change these any time.</p>" +
|
|
2589
2543
|
saved + notice +
|
|
2590
2544
|
"<form method=\"post\" action=\"/admin/setup\">" +
|
|
2591
2545
|
_setupField("Shop name", "shop_name", v.shop_name, "text", "Shown in the header, page titles, and emails.", " maxlength=\"80\" required") +
|
|
2592
2546
|
_setupField("Contact email", "contact_email", v.contact_email, "email", "Where customer replies and operational mail land.", " maxlength=\"160\"") +
|
|
2593
|
-
_setupField("Default currency", "currency", v.currency, "text", "3-letter ISO 4217 code (e.g. USD, EUR, GBP).", " maxlength=\"3\"
|
|
2547
|
+
_setupField("Default currency", "currency", v.currency, "text", "3-letter ISO 4217 code (e.g. USD, EUR, GBP).", " maxlength=\"3\" class=\"input-code\"") +
|
|
2594
2548
|
_setupField("Support URL", "support_url", v.support_url, "url", "Linked from the storefront footer (help centre, contact page).", " maxlength=\"300\"") +
|
|
2595
2549
|
"<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save shop details</button>" +
|
|
2596
2550
|
"<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
|
|
@@ -2649,7 +2603,7 @@ function renderAdminIntegrations(opts) {
|
|
|
2649
2603
|
"<thead><tr><th>Integration</th><th>Status</th><th>To enable</th></tr></thead>" +
|
|
2650
2604
|
"<tbody>" + rows + "</tbody>" +
|
|
2651
2605
|
"</table></div>" +
|
|
2652
|
-
"<p class=\"meta
|
|
2606
|
+
"<p class=\"meta mt-125\">Sign in with Apple and PayPal are planned. “Sign in with Shop” / Shop Pay isn't available to a self-hosted store. See the README “Optional integrations” section for full setup steps.</p>" +
|
|
2653
2607
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
|
|
2654
2608
|
"</section>";
|
|
2655
2609
|
return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations", opts.nav_available);
|
|
@@ -2675,8 +2629,8 @@ function renderAdminProducts(opts) {
|
|
|
2675
2629
|
: "<p class=\"empty\">No products yet — create your first one below.</p>";
|
|
2676
2630
|
var body =
|
|
2677
2631
|
"<section><h2>Products</h2>" + created + notice + table +
|
|
2678
|
-
"<div class=\"panel
|
|
2679
|
-
"<h3
|
|
2632
|
+
"<div class=\"panel mt mw-34\">" +
|
|
2633
|
+
"<h3 class=\"subhead\">New product</h3>" +
|
|
2680
2634
|
"<form method=\"post\" action=\"/admin/products\">" +
|
|
2681
2635
|
_setupField("Title", "title", "", "text", "", " maxlength=\"200\" required") +
|
|
2682
2636
|
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — the storefront URL.", " maxlength=\"200\" required") +
|
|
@@ -2780,12 +2734,12 @@ function renderAdminOrder(opts) {
|
|
|
2780
2734
|
var actionForms = transitions.map(function (t) {
|
|
2781
2735
|
if (t.on === "refund") {
|
|
2782
2736
|
if (!opts.can_refund) return ""; // no payment intent — nothing to refund here
|
|
2783
|
-
return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\"
|
|
2737
|
+
return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" class=\"form-inline\">" +
|
|
2784
2738
|
"<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
|
|
2785
2739
|
"</form>";
|
|
2786
2740
|
}
|
|
2787
2741
|
var danger = (t.on === "cancel");
|
|
2788
|
-
return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\"
|
|
2742
|
+
return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\" class=\"form-inline\">" +
|
|
2789
2743
|
"<input type=\"hidden\" name=\"event\" value=\"" + _htmlEscape(t.on) + "\">" +
|
|
2790
2744
|
"<button class=\"btn" + (danger ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
|
|
2791
2745
|
"</form>";
|
|
@@ -2793,7 +2747,7 @@ function renderAdminOrder(opts) {
|
|
|
2793
2747
|
var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
|
|
2794
2748
|
|
|
2795
2749
|
var body =
|
|
2796
|
-
"<section
|
|
2750
|
+
"<section class=\"mw-48\">" +
|
|
2797
2751
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">← Orders</a></div>" +
|
|
2798
2752
|
"<h2>Order <code class=\"order-id\">" + _htmlEscape(o.id.slice(0, 8)) + "</code> " +
|
|
2799
2753
|
"<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
|
|
@@ -2801,13 +2755,13 @@ function renderAdminOrder(opts) {
|
|
|
2801
2755
|
(o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
|
|
2802
2756
|
moved + notice +
|
|
2803
2757
|
"<div class=\"two-col\">" +
|
|
2804
|
-
"<div class=\"panel\"><h3
|
|
2805
|
-
"<div class=\"panel\"><h3
|
|
2758
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
|
|
2759
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Ship to</h3>" +
|
|
2806
2760
|
(shipLines || "<span class=\"meta\">No shipping address.</span>") +
|
|
2807
|
-
"<h3
|
|
2761
|
+
"<h3 class=\"subhead subhead--sp\">Totals</h3>" + totals +
|
|
2808
2762
|
"</div>" +
|
|
2809
2763
|
"</div>" +
|
|
2810
|
-
"<div class=\"panel\"
|
|
2764
|
+
"<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
|
|
2811
2765
|
"<div class=\"order-actions\">" + actions + "</div>" +
|
|
2812
2766
|
"</div>" +
|
|
2813
2767
|
"</section>";
|
|
@@ -2946,7 +2900,7 @@ function renderAdminReturn(opts) {
|
|
|
2946
2900
|
"<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/approve\" class=\"return-action\">" +
|
|
2947
2901
|
"<h4>Approve</h4>" +
|
|
2948
2902
|
_setupField("Refund amount (minor units)", "refund_amount_minor", "", "number", "e.g. 4999 for $49.99.", " min=\"0\" required") +
|
|
2949
|
-
_setupField("Refund currency", "refund_currency", r.refund_currency || "USD", "text", "3-letter ISO 4217.", " maxlength=\"3\"
|
|
2903
|
+
_setupField("Refund currency", "refund_currency", r.refund_currency || "USD", "text", "3-letter ISO 4217.", " maxlength=\"3\" class=\"input-code\"") +
|
|
2950
2904
|
_setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
|
|
2951
2905
|
"<button class=\"btn\" type=\"submit\">Approve return</button>" +
|
|
2952
2906
|
"</form>");
|
|
@@ -2981,7 +2935,7 @@ function renderAdminReturn(opts) {
|
|
|
2981
2935
|
: "<span class=\"meta\">This return is in a final state — no further changes.</span>";
|
|
2982
2936
|
|
|
2983
2937
|
var body =
|
|
2984
|
-
"<section
|
|
2938
|
+
"<section class=\"mw-48\">" +
|
|
2985
2939
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/returns\">← Returns</a></div>" +
|
|
2986
2940
|
"<h2>Return <code class=\"order-id\">" + _htmlEscape(r.rma_code || r.id.slice(0, 8)) + "</code> " +
|
|
2987
2941
|
"<span class=\"status-pill " + _returnPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></h2>" +
|
|
@@ -2989,8 +2943,8 @@ function renderAdminReturn(opts) {
|
|
|
2989
2943
|
" · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(r.order_id) + "\">" + _htmlEscape(String(r.order_id).slice(0, 8)) + "</a></p>" +
|
|
2990
2944
|
moved + notice +
|
|
2991
2945
|
"<div class=\"two-col\">" +
|
|
2992
|
-
"<div class=\"panel\"><h3
|
|
2993
|
-
"<div class=\"panel\"><h3
|
|
2946
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
|
|
2947
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
|
|
2994
2948
|
_field("Reason", r.reason) +
|
|
2995
2949
|
_field("Customer detail", r.reason_detail) +
|
|
2996
2950
|
_field("Customer notes", r.customer_notes) +
|
|
@@ -2999,7 +2953,7 @@ function renderAdminReturn(opts) {
|
|
|
2999
2953
|
(r.rejected_reason ? _field("Rejection reason", r.rejected_reason) : "") +
|
|
3000
2954
|
"</div>" +
|
|
3001
2955
|
"</div>" +
|
|
3002
|
-
"<div class=\"panel\"
|
|
2956
|
+
"<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
|
|
3003
2957
|
actions +
|
|
3004
2958
|
"</div>" +
|
|
3005
2959
|
"</section>";
|
|
@@ -3032,7 +2986,7 @@ function renderAdminReviews(opts) {
|
|
|
3032
2986
|
// sense from its current status (a rejected review can be published, a
|
|
3033
2987
|
// published one taken down, a pending one either way).
|
|
3034
2988
|
var cards = list.map(function (rv) {
|
|
3035
|
-
var pub = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/publish\"
|
|
2989
|
+
var pub = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/publish\" class=\"form-inline\">" +
|
|
3036
2990
|
"<button class=\"btn\" type=\"submit\">Publish</button></form>";
|
|
3037
2991
|
var rej = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/reject\" class=\"review-reject\">" +
|
|
3038
2992
|
"<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
|
|
@@ -3083,8 +3037,8 @@ function renderAdminInventory(opts) {
|
|
|
3083
3037
|
"<td class=\"num\"><strong>" + _htmlEscape(String(available)) + "</strong></td>" +
|
|
3084
3038
|
"<td>" +
|
|
3085
3039
|
"<form method=\"post\" action=\"/admin/inventory/" + _htmlEscape(r.sku) + "/restock\" class=\"inv-row-form\">" +
|
|
3086
|
-
"<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\"
|
|
3087
|
-
"<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\"
|
|
3040
|
+
"<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\" class=\"input-narrow\">" +
|
|
3041
|
+
"<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\" class=\"input-narrow\">" +
|
|
3088
3042
|
"<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
|
|
3089
3043
|
"</form>" +
|
|
3090
3044
|
"</td></tr>";
|
|
@@ -3095,8 +3049,8 @@ function renderAdminInventory(opts) {
|
|
|
3095
3049
|
: "<p class=\"empty\">No inventory rows" + (opts.low ? " below threshold" : " yet") + ".</p>";
|
|
3096
3050
|
|
|
3097
3051
|
var createForm =
|
|
3098
|
-
"<div class=\"panel
|
|
3099
|
-
"<h3
|
|
3052
|
+
"<div class=\"panel mt mw-34\">" +
|
|
3053
|
+
"<h3 class=\"subhead\">Track a new SKU</h3>" +
|
|
3100
3054
|
"<form method=\"post\" action=\"/admin/inventory\">" +
|
|
3101
3055
|
_setupField("SKU", "sku", "", "text", "Must match a variant SKU.", " maxlength=\"128\" required") +
|
|
3102
3056
|
_setupField("Starting stock on hand", "stock_on_hand", "0", "number", "", " min=\"0\"") +
|
|
@@ -3133,7 +3087,7 @@ function renderAdminSubscriptionPlans(opts) {
|
|
|
3133
3087
|
var price = pricing.format(p.amount_minor, String(p.currency || "").toUpperCase()) + " / " + every;
|
|
3134
3088
|
var isActive = p.active === 1 || p.active === true;
|
|
3135
3089
|
var archiveCell = isActive
|
|
3136
|
-
? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive\"
|
|
3090
|
+
? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive\" class=\"form-inline\">" +
|
|
3137
3091
|
"<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>"
|
|
3138
3092
|
: "<span class=\"meta\">—</span>";
|
|
3139
3093
|
return "<tr>" +
|
|
@@ -3155,8 +3109,8 @@ function renderAdminSubscriptionPlans(opts) {
|
|
|
3155
3109
|
}).join("");
|
|
3156
3110
|
|
|
3157
3111
|
var createForm =
|
|
3158
|
-
"<div class=\"panel
|
|
3159
|
-
"<h3
|
|
3112
|
+
"<div class=\"panel mt mw-34\">" +
|
|
3113
|
+
"<h3 class=\"subhead\">Create a plan</h3>" +
|
|
3160
3114
|
"<p class=\"meta\">Pre-create the recurring Price in Stripe, then mirror it here so the storefront can render the plan without a network hop.</p>" +
|
|
3161
3115
|
"<form method=\"post\" action=\"/admin/subscription-plans\">" +
|
|
3162
3116
|
_setupField("Stripe price id", "stripe_price_id", "", "text", "The recurring Price id from your Stripe dashboard (e.g. price_…).", " maxlength=\"255\" required") +
|
|
@@ -3222,8 +3176,8 @@ function renderAdminGiftCards(opts) {
|
|
|
3222
3176
|
// plaintext code is shown once on the card's detail page after a
|
|
3223
3177
|
// successful issue, never again.
|
|
3224
3178
|
var issueForm =
|
|
3225
|
-
"<div class=\"panel
|
|
3226
|
-
"<h3
|
|
3179
|
+
"<div class=\"panel mt mw-34\">" +
|
|
3180
|
+
"<h3 class=\"subhead\">Issue a card</h3>" +
|
|
3227
3181
|
"<p class=\"meta\">Generates a bearer code. You'll see the code once, on the next screen — deliver it to the recipient; it can't be shown again.</p>" +
|
|
3228
3182
|
"<form method=\"post\" action=\"/admin/gift-cards\">" +
|
|
3229
3183
|
_setupField("Amount (minor units)", "amount_minor", "", "number", "In the currency's smallest unit — e.g. 5000 = $50.00.", " min=\"1\" required") +
|
|
@@ -3284,7 +3238,7 @@ function renderAdminGiftCard(opts) {
|
|
|
3284
3238
|
: "<p class=\"empty\">No ledger transactions recorded for this card yet.</p>";
|
|
3285
3239
|
|
|
3286
3240
|
var body = "<section><h2>Gift card</h2>" + issuedBanner + summary +
|
|
3287
|
-
"<h3
|
|
3241
|
+
"<h3 class=\"subhead subhead--sp-lg\">Ledger</h3>" + ledgerTable +
|
|
3288
3242
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a></div></section>";
|
|
3289
3243
|
return _renderAdminShell(opts.shop_name, "Gift card", body, "giftcards", opts.nav_available);
|
|
3290
3244
|
}
|
|
@@ -3309,8 +3263,8 @@ function renderAdminWebhooks(opts) {
|
|
|
3309
3263
|
"<td class=\"num\">" + _htmlEscape(String(e.rate_limit_per_minute)) + "/min</td>" +
|
|
3310
3264
|
"<td>" +
|
|
3311
3265
|
"<a class=\"btn btn--ghost\" href=\"/admin/webhooks/" + _htmlEscape(e.id) + "/deliveries\">Deliveries</a> " +
|
|
3312
|
-
"<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\"
|
|
3313
|
-
"<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\"
|
|
3266
|
+
"<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">" + (isActive ? "Disable" : "Enable") + "</button></form> " +
|
|
3267
|
+
"<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\" class=\"form-inline\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
|
|
3314
3268
|
"</td></tr>";
|
|
3315
3269
|
}).join("");
|
|
3316
3270
|
|
|
@@ -3319,20 +3273,20 @@ function renderAdminWebhooks(opts) {
|
|
|
3319
3273
|
: "<p class=\"empty\">No webhook endpoints yet.</p>";
|
|
3320
3274
|
|
|
3321
3275
|
var eventChecks = known.map(function (ev) {
|
|
3322
|
-
return "<label
|
|
3276
|
+
return "<label class=\"kv\"><input type=\"checkbox\" name=\"evt_" + _htmlEscape(ev) + "\"> <code>" + _htmlEscape(ev) + "</code></label>";
|
|
3323
3277
|
}).join("");
|
|
3324
3278
|
|
|
3325
3279
|
var createForm =
|
|
3326
|
-
"<div class=\"panel
|
|
3327
|
-
"<h3
|
|
3280
|
+
"<div class=\"panel mt mw-40\">" +
|
|
3281
|
+
"<h3 class=\"subhead\">Add an endpoint</h3>" +
|
|
3328
3282
|
"<p class=\"meta\">Deliveries are signed (HMAC-SHA3-512); the signing secret is shown once, right after you create the endpoint. Only https:// URLs are accepted.</p>" +
|
|
3329
3283
|
"<form method=\"post\" action=\"/admin/webhooks\">" +
|
|
3330
3284
|
_setupField("Endpoint URL", "url", "", "url", "Where deliveries are POSTed (https:// only).", " maxlength=\"2048\" required") +
|
|
3331
|
-
"<fieldset
|
|
3332
|
-
"<legend
|
|
3333
|
-
"<label
|
|
3285
|
+
"<fieldset class=\"box\">" +
|
|
3286
|
+
"<legend class=\"legend-sm\">Events</legend>" +
|
|
3287
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"events_all\"> <strong>All events (*)</strong></label>" +
|
|
3334
3288
|
eventChecks +
|
|
3335
|
-
"<small
|
|
3289
|
+
"<small class=\"u-mute\">Pick specific events, or check “All events” to subscribe to everything.</small>" +
|
|
3336
3290
|
"</fieldset>" +
|
|
3337
3291
|
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create endpoint</button></div>" +
|
|
3338
3292
|
"</form>" +
|
|
@@ -3346,13 +3300,13 @@ function renderAdminWebhookSecret(opts) {
|
|
|
3346
3300
|
opts = opts || {};
|
|
3347
3301
|
var e = opts.endpoint || {};
|
|
3348
3302
|
var body =
|
|
3349
|
-
"<section
|
|
3303
|
+
"<section class=\"mw-42\">" +
|
|
3350
3304
|
"<h2>Endpoint created</h2>" +
|
|
3351
3305
|
"<div class=\"banner banner--ok\">Copy the signing secret now — it is shown once and cannot be retrieved again.</div>" +
|
|
3352
3306
|
"<div class=\"panel\">" +
|
|
3353
3307
|
"<p class=\"meta\">Endpoint</p><p><code class=\"order-id\">" + _htmlEscape(e.url || "") + "</code></p>" +
|
|
3354
3308
|
"<p class=\"meta\">Signing secret (HMAC-SHA3-512, key id <code>v1</code>)</p>" +
|
|
3355
|
-
"<pre
|
|
3309
|
+
"<pre class=\"code-block\"><code>" + _htmlEscape(e.secret || "") + "</code></pre>" +
|
|
3356
3310
|
"<p class=\"meta\">Verify each delivery's signature with this secret using your framework's webhook verifier.</p>" +
|
|
3357
3311
|
"</div>" +
|
|
3358
3312
|
"<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/webhooks\">Done</a></div>" +
|
|
@@ -3379,7 +3333,7 @@ function renderAdminWebhookDeliveries(opts) {
|
|
|
3379
3333
|
: "<span class=\"status-pill " + (d.last_error ? "refunded" : "pending") + "\">" + (d.last_error ? "failed" : "pending") + "</span>";
|
|
3380
3334
|
var code = d.last_status != null ? _htmlEscape(String(d.last_status)) : "—";
|
|
3381
3335
|
var retry = ok ? "<span class=\"meta\">—</span>"
|
|
3382
|
-
: "<form method=\"post\" action=\"/admin/webhooks/deliveries/" + _htmlEscape(d.id) + "/retry\"
|
|
3336
|
+
: "<form method=\"post\" action=\"/admin/webhooks/deliveries/" + _htmlEscape(d.id) + "/retry\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">Retry</button></form>";
|
|
3383
3337
|
return "<tr>" +
|
|
3384
3338
|
"<td>" + _htmlEscape(d.event_type) + "</td>" +
|
|
3385
3339
|
"<td>" + statusCell + "</td>" +
|
|
@@ -3429,7 +3383,7 @@ function renderAdminCollections(opts) {
|
|
|
3429
3383
|
"<td><div class=\"actions-row\">" +
|
|
3430
3384
|
"<a class=\"btn btn--ghost\" href=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "\">Manage</a>" +
|
|
3431
3385
|
(isArchived ? "" :
|
|
3432
|
-
"<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "/archive\"
|
|
3386
|
+
"<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "/archive\" class=\"form-inline\">" +
|
|
3433
3387
|
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
|
|
3434
3388
|
"</div></td>" +
|
|
3435
3389
|
"</tr>";
|
|
@@ -3451,8 +3405,8 @@ function renderAdminCollections(opts) {
|
|
|
3451
3405
|
}).join("");
|
|
3452
3406
|
|
|
3453
3407
|
var createForm =
|
|
3454
|
-
"<div class=\"panel
|
|
3455
|
-
"<h3
|
|
3408
|
+
"<div class=\"panel mt mw-40\">" +
|
|
3409
|
+
"<h3 class=\"subhead\">Create a collection</h3>" +
|
|
3456
3410
|
"<p class=\"meta\">Manual collections are handpicked; smart collections match products by a rule set. Slug is the storefront URL (/collections/<slug>).</p>" +
|
|
3457
3411
|
"<form method=\"post\" action=\"/admin/collections\">" +
|
|
3458
3412
|
"<label class=\"form-field\"><span>Type</span><select name=\"type\">" +
|
|
@@ -3462,14 +3416,14 @@ function renderAdminCollections(opts) {
|
|
|
3462
3416
|
_setupField("Title", "title", "", "text", "", " maxlength=\"500\" required") +
|
|
3463
3417
|
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated.", " maxlength=\"200\" required") +
|
|
3464
3418
|
_setupField("Description (optional)", "description", "", "text", "", " maxlength=\"2000\"") +
|
|
3465
|
-
"<fieldset
|
|
3466
|
-
"<legend
|
|
3419
|
+
"<fieldset class=\"box\">" +
|
|
3420
|
+
"<legend class=\"legend-sm\">Smart rule (used only for smart collections)</legend>" +
|
|
3467
3421
|
"<div class=\"actions-row\">" +
|
|
3468
3422
|
"<select name=\"rule_field\"><option value=\"\">field…</option>" + fieldOpts + "</select>" +
|
|
3469
3423
|
"<select name=\"rule_op\"><option value=\"\">op…</option>" + opOpts + "</select>" +
|
|
3470
3424
|
"<input type=\"text\" name=\"rule_value\" placeholder=\"value (lists + between: comma-separated)\" maxlength=\"500\">" +
|
|
3471
3425
|
"</div>" +
|
|
3472
|
-
"<small
|
|
3426
|
+
"<small class=\"u-mute\">Numeric fields (price_minor, inventory_count, created_at) compare as integers. Add more rules after creating.</small>" +
|
|
3473
3427
|
"</fieldset>" +
|
|
3474
3428
|
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create collection</button></div>" +
|
|
3475
3429
|
"</form>" +
|
|
@@ -3501,8 +3455,8 @@ function renderAdminCollection(opts) {
|
|
|
3501
3455
|
}).join("");
|
|
3502
3456
|
|
|
3503
3457
|
var editForm =
|
|
3504
|
-
"<div class=\"panel
|
|
3505
|
-
"<h3
|
|
3458
|
+
"<div class=\"panel mw-40\">" +
|
|
3459
|
+
"<h3 class=\"subhead\">Details</h3>" +
|
|
3506
3460
|
"<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/edit\">" +
|
|
3507
3461
|
_setupField("Title", "title", col.title, "text", "", " maxlength=\"500\" required") +
|
|
3508
3462
|
_setupField("Description", "description", col.description || "", "text", "", " maxlength=\"2000\"") +
|
|
@@ -3524,17 +3478,17 @@ function renderAdminCollection(opts) {
|
|
|
3524
3478
|
var opOpts = "<option value=\"\">op…</option>" + ops.map(function (o) {
|
|
3525
3479
|
return "<option value=\"" + _htmlEscape(o) + "\"" + (o === rule.op ? " selected" : "") + ">" + _htmlEscape(o) + "</option>";
|
|
3526
3480
|
}).join("");
|
|
3527
|
-
return "<div class=\"actions-row\"
|
|
3481
|
+
return "<div class=\"actions-row m-04\">" +
|
|
3528
3482
|
"<select name=\"rule_field\">" + fieldOpts + "</select>" +
|
|
3529
3483
|
"<select name=\"rule_op\">" + opOpts + "</select>" +
|
|
3530
3484
|
"<input type=\"text\" name=\"rule_value\" value=\"" + _htmlEscape(String(rv)) + "\" placeholder=\"value\" maxlength=\"500\">" +
|
|
3531
3485
|
"</div>";
|
|
3532
3486
|
}).join("");
|
|
3533
3487
|
editForm +=
|
|
3534
|
-
"<fieldset
|
|
3535
|
-
"<legend
|
|
3488
|
+
"<fieldset class=\"box\">" +
|
|
3489
|
+
"<legend class=\"legend-sm\">Rules (all must match)</legend>" +
|
|
3536
3490
|
ruleRows +
|
|
3537
|
-
"<small
|
|
3491
|
+
"<small class=\"u-mute\">Leave a row blank to drop it. Lists (in / not_in) + between use comma-separated values.</small>" +
|
|
3538
3492
|
"</fieldset>";
|
|
3539
3493
|
}
|
|
3540
3494
|
editForm += "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save</button></div></form></div>";
|
|
@@ -3546,7 +3500,7 @@ function renderAdminCollection(opts) {
|
|
|
3546
3500
|
return "<tr>" +
|
|
3547
3501
|
"<td class=\"num\">" + _htmlEscape(String(m.position)) + "</td>" +
|
|
3548
3502
|
"<td><code class=\"order-id\">" + _htmlEscape(m.product_id) + "</code></td>" +
|
|
3549
|
-
"<td><form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/remove\"
|
|
3503
|
+
"<td><form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/remove\" class=\"form-inline\">" +
|
|
3550
3504
|
"<input type=\"hidden\" name=\"product_id\" value=\"" + _htmlEscape(m.product_id) + "\">" +
|
|
3551
3505
|
"<button class=\"btn btn--danger\" type=\"submit\">Remove</button></form></td>" +
|
|
3552
3506
|
"</tr>";
|
|
@@ -3560,8 +3514,8 @@ function renderAdminCollection(opts) {
|
|
|
3560
3514
|
// client JS — the primitive normalises positions to 0..N-1.
|
|
3561
3515
|
var currentIds = members.map(function (m) { return m.product_id; }).join(",");
|
|
3562
3516
|
var reorderForm = members.length > 1
|
|
3563
|
-
? "<div class=\"panel
|
|
3564
|
-
"<h3
|
|
3517
|
+
? "<div class=\"panel mt-1 mw-40\">" +
|
|
3518
|
+
"<h3 class=\"subhead\">Reorder members</h3>" +
|
|
3565
3519
|
"<p class=\"meta\">Rewrite the comma-separated id list into the order you want. Must list every current member.</p>" +
|
|
3566
3520
|
"<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/reorder\">" +
|
|
3567
3521
|
"<label class=\"form-field\"><span>Ordered product ids</span>" +
|
|
@@ -3572,15 +3526,15 @@ function renderAdminCollection(opts) {
|
|
|
3572
3526
|
: "";
|
|
3573
3527
|
|
|
3574
3528
|
var addForm =
|
|
3575
|
-
"<div class=\"panel
|
|
3576
|
-
"<h3
|
|
3529
|
+
"<div class=\"panel mt-1 mw-40\">" +
|
|
3530
|
+
"<h3 class=\"subhead\">Add a member</h3>" +
|
|
3577
3531
|
"<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/add\">" +
|
|
3578
3532
|
_setupField("Product id", "product_id", "", "text", "The catalog product's UUID.", " maxlength=\"64\" required") +
|
|
3579
3533
|
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add product</button></div>" +
|
|
3580
3534
|
"</form>" +
|
|
3581
3535
|
"</div>";
|
|
3582
3536
|
|
|
3583
|
-
detailBody = "<section
|
|
3537
|
+
detailBody = "<section class=\"mt\"><h3 class=\"fs-105\">Members</h3>" +
|
|
3584
3538
|
memberTable + reorderForm + addForm + "</section>";
|
|
3585
3539
|
} else {
|
|
3586
3540
|
var preview = opts.preview || [];
|
|
@@ -3594,7 +3548,7 @@ function renderAdminCollection(opts) {
|
|
|
3594
3548
|
var previewTable = preview.length
|
|
3595
3549
|
? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Status</th></tr></thead><tbody>" + previewCards + "</tbody></table></div>"
|
|
3596
3550
|
: "<p class=\"empty\">No products match these rules yet.</p>";
|
|
3597
|
-
detailBody = "<section
|
|
3551
|
+
detailBody = "<section class=\"mt\"><h3 class=\"fs-105\">Matched products (live preview)</h3>" +
|
|
3598
3552
|
"<p class=\"meta\">The first " + _htmlEscape(String(preview.length)) + " products the rules match right now.</p>" +
|
|
3599
3553
|
previewTable + "</section>";
|
|
3600
3554
|
}
|
package/package.json
CHANGED