@redeemid/emdash 0.0.31 → 0.0.32

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/README.md CHANGED
@@ -27,7 +27,7 @@ Selling a membership is ordinary Shopify: a product, a checkout, and a Flow work
27
27
  - **For shoppers:** product pages with options, subscriptions and Product structured data, a product sitemap, collections with Shopify's filters and sorting, product search, recommendations, a cart page and cart drawer, discount codes, local prices by market, and a checkout that opens signed in for members. It all works without JavaScript.
28
28
  - **For members:** sign in, Account and Sign out without ever seeing the word "Shopify"; a member card with levels, renewal and end dates; preferences saved on their customer record; download or delete what the site keeps about them.
29
29
  - **For editors:** Paywall break, Members only, Sold on its own, Plan, Product, Collection, Product search, Contribution and Preferences blocks; an Access panel on every entry; teasers, a monthly or weekly meter, drip, badges and protected file downloads.
30
- - **For staff:** an Overview, then Members, Access (with Map and Test tabs), Reports, Privacy and Connection (Status, Setup, Webhooks, Health and App events tabs) in the EmDash admin, and a dashboard card. New content types and taxonomies appear on their own, so access never needs a code change.
30
+ - **For staff:** an Overview, then Members, Access (with Map and Test tabs), Reports, Events (Webhook, App and Health tabs), Privacy and Connection (Status, then a tab per setup step) in the EmDash admin, and a dashboard card. New content types and taxonomies appear on their own, so access never needs a code change.
31
31
  - **For developers:** Astro components for every piece, `canRead()` and friends for your own code, a storefront from the Storefront API with a cart and a signed-in checkout, a browser client with analytics events, signed outbound events, MCP tools for AI agents and a `redeemid-emdash` command.
32
32
 
33
33
  ## Requirements
@@ -75,14 +75,14 @@ import Content from "@redeemid/emdash/components/Content.astro";
75
75
  <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title} />
76
76
  ```
77
77
 
78
- Set the secrets, open **Plugins → RedeemID → Connection → Setup** in the admin, and work down the steps. [Getting started](docs/getting-started.md) walks through all of it.
78
+ Set the secrets, open **Plugins → RedeemID → Connection** in the admin, and work through its tabs. [Getting started](docs/getting-started.md) walks through all of it.
79
79
 
80
80
  ## Documentation
81
81
 
82
82
  | Guide | What's in it |
83
83
  | --- | --- |
84
84
  | [Getting started](docs/getting-started.md) | Requirements, install, config, secrets, templates and a first check |
85
- | [Connect a store](docs/shopify-setup.md) | The Shopify side: sign-in client, Storefront tokens and the webhook app |
85
+ | [Connect a store](docs/shopify-setup.md) | The Shopify side: sign-in client, Storefront tokens and the Admin API app |
86
86
  | [Shopify Flow recipes](docs/shopify-flow.md) | Tag on a paid order, remove on refund or cancellation, passes that end |
87
87
  | [Content and templates](docs/content.md) | `<Content>`, the Paywall break, Members only, the wall, `<Gate>`, badges, plan cards, site search and replacement text |
88
88
  | [Access](docs/access.md) | Rules, levels, the Access page and the entry panel, meter and drip, selling one entry, features and protected files |
@@ -56,7 +56,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
56
56
  - `api/state` (GET): the visitor's own state.
57
57
 
58
58
  ```json
59
- { "apiVersion": "1.5.0", "version": "0.0.31", "signedIn": true, "linked": true,
59
+ { "apiVersion": "1.5.0", "version": "0.0.32", "signedIn": true, "linked": true,
60
60
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
61
61
  "meter": { "used": 1, "limit": 3, "period": "month" } }
62
62
  ```
@@ -125,7 +125,7 @@ Signed outbound webhooks for a CRM or email tool. Verify `X-RedeemID-Signature`
125
125
  events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
126
126
  ```
127
127
 
128
- With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL with the headers `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body>`. Check the signature before you trust a body. Events go out after the response (with `waitUntil` on Workers), so sign-in, webhooks and pages never wait for your receiver. Each try waits at most 3 seconds; a send that fails is tried again after 1 second and after 5 seconds. One that fails all three tries is kept under **App events → Failed events**, where staff can **Send again** (same `id`, so drop duplicates by it) or **Clear** it; failed events are deleted after 30 days.
128
+ With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL with the headers `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body>`. Check the signature before you trust a body. Events go out after the response (with `waitUntil` on Workers), so sign-in, webhooks and pages never wait for your receiver. Each try waits at most 3 seconds; a send that fails is tried again after 1 second and after 5 seconds. One that fails all three tries is kept under **Events → App → Failed events**, where staff can **Send again** (same `id`, so drop duplicates by it) or **Clear** it; failed events are deleted after 30 days.
129
129
 
130
130
  | Event | `data` |
131
131
  | --- | --- |
@@ -12,7 +12,7 @@ From an empty EmDash site to a locked post that opens for a tagged customer.
12
12
 
13
13
  ## 1. Set up Shopify
14
14
 
15
- Follow [Connect a store](shopify-setup.md). It gives you a Customer Account API client, Storefront API tokens and a webhook app. Once the package is installed, **Plugins → RedeemID → Connection → Setup** walks through the same steps for your site, with its own callback URI filled in, and says which are done.
15
+ Follow [Connect a store](shopify-setup.md). It gives you a Customer Account API client, Storefront API tokens and an Admin API app (staff tools, resyncs and webhooks). Once the package is installed, **Plugins → RedeemID → Connection** walks through the same steps for your site, one tab each, with its own callback URI filled in, and marks the ones that are done.
16
16
 
17
17
  ## 2. Install
18
18
 
@@ -68,7 +68,7 @@ REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET=… # verifies webhooks
68
68
  REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # only to sell on the site: plans, shop and checkout
69
69
  ```
70
70
 
71
- The [secrets reference](reference.md#secrets) lists the rest. Connection → Setup prints the command for each secret its steps need.
71
+ The [secrets reference](reference.md#secrets) lists the rest. Connection prints the command for each secret its steps need.
72
72
 
73
73
  ## 5. Render content through RedeemID
74
74
 
@@ -89,6 +89,6 @@ Use `<Content>` in place of `<PortableText>` for every body that may be locked,
89
89
  ## 6. Check it
90
90
 
91
91
  1. Finish EmDash setup as staff first. Store customers can't create the first account.
92
- 2. Open **Plugins → RedeemID → Connection → Setup**. Work down the steps until it says RedeemID is set up (it registers the webhooks for you). **Status** lists every setting as Set or Missing.
92
+ 2. Open **Plugins → RedeemID → Connection**. Work through the step tabs until each has a check mark (the Webhooks tab registers the webhooks for you). **Status** lists every setting as Set or Missing.
93
93
  3. Tag a test customer `member` in Shopify, open a locked post, and sign in as that customer. The post opens.
94
94
  4. Remove the tag in Shopify. The post locks again once the webhook arrives.
@@ -48,5 +48,5 @@ Each action counts separately per visitor (by `CF-Connecting-IP`), and Cloudflar
48
48
  - **A setting shows Missing on Connection → Status.** Check the name: Connection → Status lists any `REDEEMID_` variable it doesn't read. Set it as a runtime secret, not a build variable. On Workers, a deploy can remove plain-text dashboard variables, so use encrypted secrets.
49
49
  - **Sign-in comes back with a notice.** The code says why; see [notices](reference.md#notices). `sign_in_unavailable` means store discovery failed, usually a wrong `shop`. `not_configured` means the client ID or `shop` is missing.
50
50
  - **Register webhooks names a refused topic.** The app lacks a scope, such as `read_customer_merge` for `customers/merge`. Add it to the app, have the store accept the new version, and press Register again.
51
- - **Tags changed in Shopify but the site didn't follow.** Check Connection → Webhooks and Health, and App events with Problems only. Resync the member from their page, or ask them to press Refresh membership.
51
+ - **Tags changed in Shopify but the site didn't follow.** Check Events → Webhook and Health, and Events → App with Problems only. Resync the member from their page, or ask them to press Refresh membership.
52
52
  - **A member sees the visitor's page.** A cache is serving it. See [caching](#caching).
package/docs/reference.md CHANGED
@@ -110,7 +110,7 @@ The full developer API (stable exports, JSON routes, the browser client and its
110
110
 
111
111
  Under `/_emdash/api/auth/redeemid/`: `login`, `callback`, `logout`, `webhook`, `buy`, `cart`, `cart-edit`, `account`, `preferences` and `privacy`, and the JSON routes `api/state`, `api/cart` and `api/search`. Staff-only routes for the admin, agents and the command line are under `/_emdash/api/plugins/redeemid/`.
112
112
 
113
- In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added` and `customer.tags_removed` (Connection → Setup registers these for you). The compliance topics `customers/data_request`, `customers/redact` and `shop/redact` go to the same URL, set in the app's own configuration.
113
+ In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added` and `customer.tags_removed` (Connection → Webhooks registers these for you). The compliance topics `customers/data_request`, `customers/redact` and `shop/redact` go to the same URL, set in the app's own configuration.
114
114
 
115
115
  ## Notices
116
116
 
@@ -63,7 +63,7 @@ After a purchase, **Refresh membership** on the member card reads their tags aga
63
63
 
64
64
  ## Check a recipe
65
65
 
66
- 1. Under **Plugins → RedeemID → Connection → Status → Webhook subscriptions**, make sure `customer.tags_added` and `customer.tags_removed` show as Registered. The Setup tab registers them for you.
66
+ 1. Under **Plugins → RedeemID → Connection → Webhooks**, make sure `customer.tags_added` and `customer.tags_removed` show as Registered. The tab registers them for you.
67
67
  2. Buy the product with a test customer, using Shopify's Bogus Gateway or a 100% discount code.
68
68
  3. In Flow, open the workflow's **Recent runs**. The run should show the tag added.
69
69
  4. Open **Plugins → RedeemID → Members**, then the customer. The level should be on top and the tag under **Tags**.
@@ -2,7 +2,7 @@
2
2
 
3
3
  What to set up in Shopify, and on the site, to run RedeemID on an EmDash site. One store can serve several sites: each site gets its own Headless storefront for sign-in, and each receives webhooks.
4
4
 
5
- Once the package is installed, **Plugins → RedeemID → Connection → Setup** in the EmDash admin walks through these same steps with your site's own addresses filled in, checks each one, and registers the webhooks for you.
5
+ Once the package is installed, **Plugins → RedeemID → Connection** in the EmDash admin walks through these same steps, one tab each, with your site's own addresses filled in, checks each one, and registers the webhooks for you.
6
6
 
7
7
  ## 1. Sign-in: a Headless storefront
8
8
 
@@ -27,7 +27,7 @@ The store must use the new customer accounts (Settings → Customer accounts). C
27
27
 
28
28
  RedeemID keeps tags and status current from webhooks sent by a Shopify app on the store (for example a Dev Dashboard app). Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
29
29
 
30
- Give the app the `read_customers` and `write_customers` Admin API scopes (`write_customers` alone is enough, since a write scope includes its read scope), plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then Connection → Setup (or the Webhook subscriptions on Connection → Status) registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
30
+ Give the app the `read_customers` and `write_customers` Admin API scopes (`write_customers` alone is enough, since a write scope includes its read scope), plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then Connection → Webhooks registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
31
31
 
32
32
  `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`
33
33
 
@@ -47,8 +47,8 @@ Set the secrets. On Cloudflare Workers:
47
47
  npx wrangler secret put REDEEMID_SHOPIFY_STORE_DOMAIN # e.g. my-store.myshopify.com, unless `shop` is in the config
48
48
  npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID # Customer Account API client ID
49
49
  npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET # confidential clients only
50
- npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the webhook app's client secret
51
- npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID # optional; the webhook app's client ID, for the staff tools
50
+ npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the Admin API app's client secret (signs webhooks)
51
+ npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID # optional; the Admin API app's client ID, for the staff tools
52
52
  npx wrangler secret put REDEEMID_EVENTS_SECRET # optional; signs outbound events (events.url)
53
53
  npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN # optional; the Storefront API private token, to sell on the site (plans, shop, checkout)
54
54
  npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN # optional; used only without the private token
package/docs/staff.md CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  ## Staff tools
4
4
 
5
- With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API token for the webhook app with the client credentials grant. The app must be in the same Shopify organization as the store, as Dev Dashboard apps are, and needs `read_customers` and `write_customers`. Then, under **Plugins → RedeemID**:
5
+ With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API token for the Admin API app with the client credentials grant. The app must be in the same Shopify organization as the store, as Dev Dashboard apps are, and needs `read_customers` and `write_customers`. Then, under **Plugins → RedeemID**:
6
6
 
7
7
  - **Member detail.** Account, levels and tag count on top, then tabs: Membership (levels, features, membership dates and level history), Tags (add in a dialog, remove), Account (email, consent, last sync and the site user) and Privacy. Resync from Shopify, and on a myshopify.com store, open the customer in Shopify. Tag edits are made in Shopify and read back. Nothing is granted on the site.
8
8
  - **Site user.** Signing in links a customer to the site user with the same verified email. When the emails differ, staff link the customer to an existing Subscriber by that user's email on the member's page (never a staff account, never a user linked to another customer), or unlink them. This part needs no Admin API app.
9
9
  - **Bulk resync.** Reads the 25 members synced longest ago, few enough for the Workers free limit of 50 database queries a request. Press it again for the next 25. It never runs on its own.
10
- - **Webhook subscriptions.** Connection → Status → Webhook subscriptions lists the store's subscriptions to this site and registers any that are missing. A topic Shopify refuses, such as `customers/merge` without `read_customer_merge`, is named with the reason and doesn't stop the others. `customers/redact` stays in the app's own settings.
10
+ - **Webhook subscriptions.** Connection → Webhooks lists the store's subscriptions to this site and registers any that are missing. A topic Shopify refuses, such as `customers/merge` without `read_customer_merge`, is named with the reason and doesn't stop the others. `customers/redact` stays in the app's own settings.
11
11
  - **Access → Test.** A collection, an entry, terms and a reader's tags in; the verdict out.
12
12
 
13
13
  Resync updates email, name, tags, email marketing consent and the membership dates. Enabled or disabled status comes only from the enable and disable webhooks. With the app set up, two more things read the customer from the Admin API as it is now: every sign-in (and Refresh membership), and every `customer.tags_added` or `customer.tags_removed` webhook. Those topics carry only the change and can arrive out of order, so reading the current tags keeps the mirror right. Without the app, or when the read fails, the webhook's change is applied as sent.
@@ -27,11 +27,13 @@ RedeemID reads them with the Admin API (resync, sign-in, tag webhooks), so they
27
27
 
28
28
  ## Overview, dashboard card and Connection
29
29
 
30
- **Plugins → RedeemID → Overview** is the first page: anything that needs doing (setup steps left, webhook problems, open privacy requests), cards for sign-in, members, webhooks, access, privacy and setup with links to each, members by level, recent app events and links to the docs, the store's customers and Flow. The **RedeemID** card on the EmDash dashboard shows the same alerts and counts with links to Overview, Members, Access and Connection's Webhooks tab (or its Setup tab while something is missing).
30
+ **Plugins → RedeemID → Overview** is the first page: anything that needs doing (setup steps left, webhook problems, open privacy requests), cards for sign-in, members, webhooks, access, privacy and setup with links to each, members by level, recent app events and links to the docs, the store's customers and Flow. The **RedeemID** card on the EmDash dashboard shows the same alerts and counts with links to Overview, Members, Access and Events (or Connection while a setup step is left).
31
31
 
32
- **Connection** holds everything about the store link in five tabs: Status, Setup, Webhooks, Health and App events. It opens on Setup until every step is done, then on Status. Each tab has its own address, such as `/_emdash/admin/plugins/redeemid/connection/webhooks`, which the dashboard card and Overview link to.
32
+ **Connection** holds the store link: **Status** (the values to copy into Shopify, and every setting as Set or Missing), then one tab per setup step: Store, Sign-in, Admin app, Webhooks, First level, Try it and Storefront. A finished step's tab shows a check mark. It opens on the first step not done, then on Status once they all are.
33
33
 
34
- **Status** shows the addresses to paste into Shopify (callback URI, JavaScript origin, logout URI, webhook URL) and the store domain as read-only fields with copy buttons, every setting as Set or Missing with the environment variable it comes from, the template checks and the webhook subscriptions.
34
+ **Events** shows what happened, in three tabs: Webhook, App and Health. Each tab of both pages has its own address, such as `/_emdash/admin/plugins/redeemid/connection/sign-in` or `/_emdash/admin/plugins/redeemid/events/app`, which the dashboard card and Overview link to.
35
+
36
+ **Status** shows the addresses to paste into Shopify (callback URI, JavaScript origin, logout URI, webhook URL) and the store domain as read-only fields with copy buttons, every setting as Set or Missing with the environment variable it comes from, and the template checks.
35
37
 
36
38
  The admin pages are built as Block Kit on the server. The package's admin entry (`@redeemid/emdash/admin`, loaded by EmDash from the plugin descriptor) renders them in the browser and adds what Block Kit has no block for: copy fields, dialogs and linked cards. It needs `react`, `@cloudflare/kumo` and `@emdash-cms/blocks`, which every EmDash site already has through `emdash`.
37
39
 
@@ -39,10 +41,10 @@ The admin pages are built as Block Kit on the server. The package's admin entry
39
41
 
40
42
  Members gain and lose access only when Shopify sends tag changes, so RedeemID shows what arrived and warns when deliveries stop.
41
43
 
42
- - **Connection → Webhooks.** Each delivery's topic, what became of it (processed, skipped, older than what's stored, not used) and the customer ID, with counts of deliveries turned away (bad signature) and failed (storage errors; Shopify sends those again).
43
- - **Connection → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget. If you enter an address on the Health tab and the site has an EmDash email provider, they also go by email: once when problems start or change, then once a week while they last.
44
- - **Webhook subscriptions** (on Connection → Status). The endpoint, whether signatures can be checked, and the subscriptions.
45
- - **Connection → App events.** What RedeemID did and what came of it: webhooks received, turned away and failed, sign-ins and refusals (by reason), Admin API calls (by operation, with timings), resyncs, tag edits, webhook registrations, settings saves, outbound events that failed, metered reads and the health checks. Filter by event or show problems only. Entries hold ids, labels and outcomes, never bodies, tokens or emails. Failed outbound events also keep the event body (ids and levels, never an email or a name) so they can be sent again.
44
+ - **Events → Webhook.** Each delivery's topic, what became of it (processed, skipped, older than what's stored, not used) and the customer ID, with counts of deliveries turned away (bad signature) and failed (storage errors; Shopify sends those again).
45
+ - **Events → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget. If you enter an address on the Health tab and the site has an EmDash email provider, they also go by email: once when problems start or change, then once a week while they last.
46
+ - **Subscriptions** (on Connection → Webhooks). The endpoint, whether signatures can be checked, and the subscriptions.
47
+ - **Events → App.** What RedeemID did and what came of it: webhooks received, turned away and failed, sign-ins and refusals (by reason), Admin API calls (by operation, with timings), resyncs, tag edits, webhook registrations, settings saves, outbound events that failed, metered reads and the health checks. Filter by event or show problems only. Entries hold ids, labels and outcomes, never bodies, tokens or emails. Failed outbound events also keep the event body (ids and levels, never an email or a name) so they can be sent again.
46
48
  - **Reports.** Members on each level, who joined and left in the last 30 days and by week for 12 weeks (from each member's tag dates), sign-ins and metered reads.
47
49
 
48
50
  App events and delivery records are kept 30 days. The daily check runs from EmDash's scheduler: on Node it runs by itself; on Cloudflare Workers, add a Cron Trigger to the site's Worker as EmDash's deployment guide describes. The plugin asks for `email:send` only to send these warnings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.31",
3
+ "version": "0.0.32",
4
4
  "description": "Shopify commerce for EmDash sites: storefront, cart, checkout, customer accounts, memberships and paywalls. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/descriptor.ts CHANGED
@@ -12,8 +12,8 @@ import { VERSION } from "./version.js";
12
12
  * Every page shares one sidebar folder, so RedeemID shows as a single
13
13
  * RedeemID entry under Plugins (opening to these pages) instead of loose
14
14
  * items mixed in with other plugins' pages. Overview comes first; then the
15
- * day-to-day pages (people and access), then Connection, whose tabs hold
16
- * Status, Setup, Webhooks, Health and App events.
15
+ * day-to-day pages (people, access and what happened), then Connection,
16
+ * with Status and one tab per setup step.
17
17
  */
18
18
  const GROUP = "RedeemID";
19
19
  export const PAGES = [
@@ -21,12 +21,15 @@ export const PAGES = [
21
21
  { path: "/members", label: "Members", icon: "users", group: GROUP },
22
22
  { path: "/access", label: "Access", icon: "lock", group: GROUP },
23
23
  { path: "/reports", label: "Reports", icon: "chart", group: GROUP },
24
+ { path: "/events", label: "Events", icon: "pulse", group: GROUP },
24
25
  { path: "/privacy", label: "Privacy", icon: "shield-check", group: GROUP },
25
26
  { path: "/connection", label: "Connection", icon: "plugs-connected", group: GROUP },
26
27
  ];
27
- /** Connection's tabs by address (/connection/webhooks), so a link can open one. */
28
- export const CONNECTION_TABS = ["status", "setup", "webhooks", "health", "events"] as const;
29
- export const TAB_PATHS = CONNECTION_TABS.map((t) => `/connection/${t}`);
28
+ /** Connection's tabs by address (/connection/sign-in), so a link can open one: Status, then a tab per setup step. */
29
+ export const CONNECTION_TABS = ["status", "store", "sign-in", "app", "webhooks", "level", "try", "storefront"] as const;
30
+ /** Events' tabs by address (/events/app). */
31
+ export const EVENTS_TABS = ["webhook", "app", "health"] as const;
32
+ export const TAB_PATHS = [...CONNECTION_TABS.map((t) => `/connection/${t}`), ...EVENTS_TABS.map((t) => `/events/${t}`)];
30
33
  /** The Access panel on every saved entry, in every collection (new ones too). */
31
34
  export const EDITOR_PANELS = [{ id: "access", title: "Access", route: "access-panel", order: 10 }];
32
35
  export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
package/src/monitor.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Watching what RedeemID does, in the admin (Block Kit):
3
3
  *
4
- * - Webhooks: what Shopify sent and what became of it (Received), whether
5
- * deliveries are flowing (Health: the daily check, rejected and failed
6
- * deliveries, and when to warn staff by email), and where Shopify sends them
7
- * (Setup: endpoint, signatures, subscriptions).
8
- * - App events: the log in log.ts, newest first, by event or problems only.
4
+ * - Webhooks: what Shopify sent and what became of it (Events → Webhook),
5
+ * whether deliveries are flowing (Events → Health: the daily check, rejected
6
+ * and failed deliveries, and when to warn staff by email), and where Shopify
7
+ * sends them (Connection → Webhooks: endpoint, signatures, subscriptions).
8
+ * - App events (Events → App): the log in log.ts, newest first, by event or
9
+ * problems only.
9
10
  * - Reports: members by level, who joined and left, sign-ins and metered reads.
10
11
  *
11
12
  * The daily check re-creates subscriptions Shopify dropped and never changes
@@ -129,7 +130,7 @@ export async function healthCheck(env_: MonitorEnv): Promise<HealthResult> {
129
130
 
130
131
  const last = await lastDelivery(s);
131
132
  if (settings.quietDays > 0) {
132
- if (!last) problems.push({ level: "warn", text: "No webhooks received yet. Check the webhook subscriptions under Connection → Status." });
133
+ if (!last) problems.push({ level: "warn", text: "No webhooks received yet. Check the webhook subscriptions under Connection → Webhooks." });
133
134
  else if (Date.parse(last.receivedAt) < now - settings.quietDays * DAY) {
134
135
  const days = Math.floor((now - Date.parse(last.receivedAt)) / DAY);
135
136
  problems.push({ level: "warn", text: `No webhooks for ${days} days. Quiet stores are normal; if customers changed in Shopify since, check Setup.` });
@@ -140,7 +141,7 @@ export async function healthCheck(env_: MonitorEnv): Promise<HealthResult> {
140
141
  const rejected = await count(s, "webhook.rejected", day);
141
142
  if (rejected) problems.push({ level: "warn", text: `${rejected} ${rejected === 1 ? "delivery was" : "deliveries were"} turned away in the last day (bad signature): check the webhook signing secret.` });
142
143
  const failed = await count(s, "webhook.failed", day);
143
- if (failed) problems.push({ level: "error", text: `${failed} ${failed === 1 ? "delivery" : "deliveries"} failed to save in the last day. Shopify sends them again; see App events.` });
144
+ if (failed) problems.push({ level: "error", text: `${failed} ${failed === 1 ? "delivery" : "deliveries"} failed to save in the last day. Shopify sends them again; see Events → App.` });
144
145
 
145
146
  const result: HealthResult = { at: new Date(now).toISOString(), problems, registered, subscriptions };
146
147
  result.emailed = await alert(env_, settings, result, now);
@@ -195,10 +196,7 @@ export async function tidy(s: Stores): Promise<void> {
195
196
 
196
197
  export { topicName };
197
198
 
198
- // ---------------------------------------------------------------- Webhooks page
199
-
200
- export const WEBHOOK_TABS = ["received", "health", "setup"] as const;
201
- export type WebhookTab = (typeof WEBHOOK_TABS)[number];
199
+ // ---------------------------------------------------------------- Webhook panels
202
200
 
203
201
  const OUTCOMES: Record<string, string> = {
204
202
  processed: "Processed",
@@ -210,7 +208,11 @@ const OUTCOMES: Record<string, string> = {
210
208
 
211
209
  const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
212
210
 
213
- export async function webhooksPage(env_: MonitorEnv, tab: WebhookTab = "received", toast?: Toast, cursor?: string): Promise<Blocks> {
211
+ /**
212
+ * The webhook parts of the admin: deliveries and their health (Events →
213
+ * Webhook and Health) and the app's subscriptions (Connection → Webhooks).
214
+ */
215
+ export async function webhookPanels(env_: MonitorEnv, cursor?: string): Promise<{ received: unknown[]; health: unknown[]; subscriptions: unknown[]; attention: boolean }> {
214
216
  const { s, kv } = env_;
215
217
  const now = env_.now?.() ?? Date.now();
216
218
  const month = since(now, LOG_DAYS);
@@ -236,7 +238,7 @@ export async function webhooksPage(env_: MonitorEnv, tab: WebhookTab = "received
236
238
  { label: "Failed", value: String(failed), ...(failed ? { trend: "down" } : {}) },
237
239
  ],
238
240
  },
239
- ...(cursor ? [{ type: "actions", elements: [{ type: "button", action_id: "webhooks_tab", value: "received", label: "Back to the newest", style: "secondary" }] }] : []),
241
+ ...(cursor ? [{ type: "actions", elements: [{ type: "button", action_id: "deliveries_page", value: "", label: "Back to the newest", style: "secondary" }] }] : []),
240
242
  {
241
243
  type: "table",
242
244
  page_action_id: "deliveries_page",
@@ -303,7 +305,7 @@ export async function webhooksPage(env_: MonitorEnv, tab: WebhookTab = "received
303
305
  },
304
306
  ];
305
307
 
306
- const setupTab = [
308
+ const subscriptions = [
307
309
  { type: "context", text: "Where Shopify sends deliveries, how they're checked, and the app's webhook subscriptions." },
308
310
  { type: "section", text: `Endpoint: ${env_.uri}` },
309
311
  {
@@ -316,21 +318,7 @@ export async function webhooksPage(env_: MonitorEnv, tab: WebhookTab = "received
316
318
  { type: "context", text: "customers/redact, customers/data_request and shop/redact are set in the app's compliance webhooks, not here." },
317
319
  ];
318
320
 
319
- return {
320
- blocks: [
321
- { type: "header", text: "Webhooks" },
322
- {
323
- type: "tab",
324
- default_tab: WEBHOOK_TABS.indexOf(tab),
325
- panels: [
326
- { label: "Received", blocks: receivedTab },
327
- { label: attention ? "Health (!)" : "Health", blocks: healthTab },
328
- { label: "Setup", blocks: setupTab },
329
- ],
330
- },
331
- ],
332
- ...(toast ? { toast } : {}),
333
- };
321
+ return { received: receivedTab, health: healthTab, subscriptions, attention };
334
322
  }
335
323
 
336
324
  const SUBS: Record<HealthResult["subscriptions"], string> = {
@@ -385,7 +373,6 @@ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: s
385
373
  const failed = cursor ? null : await s.failedEvents.query({ orderBy: { at: "desc" }, limit: 50 }).catch(() => null);
386
374
  return {
387
375
  blocks: [
388
- { type: "header", text: "App events" },
389
376
  {
390
377
  type: "context",
391
378
  text: `What RedeemID did and what came of it: webhooks, sign-ins, calls to Shopify, staff actions, settings saves and outbound events. Kept ${LOG_DAYS} days. Never bodies, tokens or emails.`,
@@ -467,7 +454,7 @@ function failedBlocks(list: FailedEvent[]): unknown[] {
467
454
  action_id: "failed_clear_all",
468
455
  label: "Clear all",
469
456
  style: "danger",
470
- confirm: { title: "Clear every failed event?", text: "They won't be sent. App events keeps the record that they failed.", confirm: "Clear all", deny: "Cancel", style: "danger" },
457
+ confirm: { title: "Clear every failed event?", text: "They won't be sent. Events → App keeps the record that they failed.", confirm: "Clear all", deny: "Cancel", style: "danger" },
471
458
  },
472
459
  ],
473
460
  },
@@ -559,7 +546,7 @@ export async function reportsPage(config: RedeemIDConfig, s: Stores, now = Date.
559
546
  { type: "header", text: "Reports" },
560
547
  {
561
548
  type: "context",
562
- text: "A level is joined or left when Shopify adds or removes its tag. Counts come from each member's tag dates; sign-ins and metered reads from App events (kept 30 days).",
549
+ text: "A level is joined or left when Shopify adds or removes its tag. Counts come from each member's tag dates; sign-ins and metered reads from Events → App (kept 30 days).",
563
550
  },
564
551
  {
565
552
  type: "stats",
package/src/plugin.ts CHANGED
@@ -34,17 +34,17 @@ import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./setti
34
34
  import { env, ENV, shopOrigin, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
35
35
  import { discover } from "./shopify.js";
36
36
  import { stores, type MemberRecord, type Stores } from "./storage.js";
37
- import { CONNECTION_TABS, EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
37
+ import { CONNECTION_TABS, EDITOR_PANELS, EVENTS_TABS, PAGES, WIDGETS } from "./descriptor.js";
38
38
  import { VERSION } from "./version.js";
39
39
  import { AdminError, adminConfigured } from "./admin-api.js";
40
40
  import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, recentRequests, requestShopifyErasure } from "./privacy.js";
41
41
  import { bulkResync, BULK_BATCH, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
42
42
  import { textProblems } from "./text.js";
43
43
  import { cacheFindings, gatedFindings, templateFindings } from "./template-scan.js";
44
- import { setupFacts, setupPage, setupSteps, WEBHOOK } from "./setup.js";
44
+ import { nextStep, requiredSteps, setupFacts, setupPanels, setupSteps, STEP_TABS, WEBHOOK } from "./setup.js";
45
45
  import { record } from "./log.js";
46
46
  import { resend } from "./events.js";
47
- import { eventRow, eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
47
+ import { eventRow, eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, webhookPanels, type MonitorEnv } from "./monitor.js";
48
48
  import { z } from "astro/zod";
49
49
  import { AgentError, explainAccess, findProducts, getCollection, getMember, getProduct, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
50
50
 
@@ -289,11 +289,14 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
289
289
  if (i.page === "/members") return await membersPage(config);
290
290
  if (i.page === "/access") return await accessPage(await accessEnv(config));
291
291
  if (i.page === "/reports") return await reportsPage(config, await memberStores());
292
- // Connection, or one of its tabs by address (/connection/webhooks).
292
+ // Connection and Events, or one of their tabs by address (/connection/sign-in, /events/app).
293
293
  if (i.page === "/connection" || i.page?.startsWith("/connection/")) {
294
294
  await scheduleHealth(ctx);
295
- const tab = i.page.split("/")[2] as ConnectionTab | undefined;
296
- return await connectionPage(config, ctx, tab && CONNECTION_TABS.includes(tab) ? tab : undefined);
295
+ return await connectionPage(config, ctx, tabOf(CONNECTION_TABS, i.page.split("/")[2]));
296
+ }
297
+ if (i.page === "/events" || i.page?.startsWith("/events/")) {
298
+ await scheduleHealth(ctx);
299
+ return await eventsAdminPage(config, ctx, tabOf(EVENTS_TABS, i.page.split("/")[2]));
297
300
  }
298
301
  if (i.page === "/privacy") return await privacyPage(await memberStores());
299
302
  if (i.page === "widget:status") return await statusWidget(config, ctx);
@@ -484,14 +487,11 @@ async function setupAction(
484
487
  config: RedeemIDConfig,
485
488
  live: () => Promise<RedeemIDConfig>,
486
489
  ctx: RouteContext,
487
- i: { action_id?: string; values?: Record<string, unknown> },
490
+ i: { action_id?: string; value?: unknown; values?: Record<string, unknown> },
488
491
  ) {
489
- const s = await memberStores();
490
492
  const origin = siteOrigin(ctx);
491
- const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => {
492
- const now = await live();
493
- return connectionPage(now, ctx, "setup", { setup: setupPage(await setupFacts(now, s, origin), toast) });
494
- };
493
+ const tab: ConnectionTab = i.action_id === "setup_register" ? "webhooks" : i.action_id === "setup_level" ? "level" : (tabOf(CONNECTION_TABS, i.value) ?? "status");
494
+ const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => connectionPage(await live(), ctx, tab, toast);
495
495
  if (i.action_id === "setup_register") {
496
496
  try {
497
497
  const { added, failed } = await registerWebhooks(config, origin + WEBHOOK);
@@ -588,41 +588,37 @@ async function action(
588
588
  const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
589
589
  const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
590
590
  await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
591
- return connectionPage(config, ctx, "status", { hooks: await webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") }) });
591
+ return connectionPage(config, ctx, "webhooks", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
592
592
  } catch (e) {
593
- return connectionPage(config, ctx, "status", { hooks: await webhooksPage(monitor(), "setup", fail(e)) });
593
+ return connectionPage(config, ctx, "webhooks", fail(e));
594
594
  }
595
595
  }
596
- case "webhooks_tab": {
597
- const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
598
- return connectionPage(config, ctx, tab === "setup" ? "status" : tab === "health" ? "health" : "webhooks");
599
- }
600
596
  case "deliveries_page":
601
- return connectionPage(config, ctx, "webhooks", { hooks: await webhooksPage(monitor(), "received", undefined, cursorOf(i.value)) });
597
+ return eventsAdminPage(config, ctx, "webhook", { cursor: cursorOf(i.value) });
602
598
  case "health_check": {
603
599
  const r = await healthCheck({ ...monitor(), by: "staff" });
604
600
  const message = r.problems.length ? `Checked: ${r.problems.length === 1 ? "1 problem" : `${r.problems.length} problems`}.` : "Checked: subscriptions are in place and deliveries look healthy.";
605
- return connectionPage(config, ctx, "health", { hooks: await webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message }) });
601
+ return eventsAdminPage(config, ctx, "health", {}, { type: r.problems.length ? "info" : "success", message });
606
602
  }
607
603
  case "monitor_save":
608
- return connectionPage(config, ctx, "health", { hooks: await webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values)) });
604
+ return eventsAdminPage(config, ctx, "health", {}, await saveMonitor(ctx.kv, i.values));
609
605
  case "events_filter":
610
- return connectionPage(config, ctx, "events", { events: await eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true }) });
606
+ return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true }) });
611
607
  case "events_show":
612
- return connectionPage(config, ctx, "events", { events: await eventsPage(s, { event: String(i.value ?? "") || undefined }) });
608
+ return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, { event: String(i.value ?? "") || undefined }) });
613
609
  case "events_page":
614
- return connectionPage(config, ctx, "events", { events: await eventsPage(s, parseFilter(i.block_id), cursorOf(i.value)) });
610
+ return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, parseFilter(i.block_id), cursorOf(i.value)) });
615
611
  case "failed_resend": {
616
612
  const sent = await resend(config, s, String(i.value ?? "")).catch(() => false);
617
- return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." }) });
613
+ return eventsAdminPage(config, ctx, "app", {}, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." });
618
614
  }
619
615
  case "failed_clear":
620
616
  await s.failedEvents.delete(String(i.value ?? ""));
621
- return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, { type: "success", message: "Cleared." }) });
617
+ return eventsAdminPage(config, ctx, "app", {}, { type: "success", message: "Cleared." });
622
618
  case "failed_clear_all": {
623
619
  const { items } = await s.failedEvents.query({ limit: 100 });
624
620
  await s.failedEvents.deleteMany(items.map((r) => r.id));
625
- return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, { type: "success", message: "Failed events cleared." }) });
621
+ return eventsAdminPage(config, ctx, "app", {}, { type: "success", message: "Failed events cleared." });
626
622
  }
627
623
  case "member_export": {
628
624
  const id = String(i.value ?? "");
@@ -790,45 +786,70 @@ function textSummary(config: RedeemIDConfig): string {
790
786
  }
791
787
 
792
788
  type ConnectionTab = (typeof CONNECTION_TABS)[number];
789
+ type EventsTab = (typeof EVENTS_TABS)[number];
790
+ type Toast = { type: "success" | "error" | "info"; message: string };
793
791
 
794
- type Built = { blocks: unknown[]; toast?: unknown };
795
- const withoutHeader = (b: Built) => b.blocks.filter((x, n) => !(n === 0 && (x as { type?: string }).type === "header"));
796
- const panelsOf = (b: Built) => ((b.blocks.find((x) => (x as { type?: string }).type === "tab") as { panels?: Array<{ blocks: unknown[] }> } | undefined)?.panels ?? []).map((p) => p.blocks);
792
+ /** A tab name from an address or a button value, if it's one of these. */
793
+ function tabOf<T extends string>(tabs: readonly T[], v: unknown): T | undefined {
794
+ return tabs.includes(v as T) ? (v as T) : undefined;
795
+ }
796
+ const withoutHeader = (b: { blocks: unknown[] }) => b.blocks.filter((x, n) => !(n === 0 && (x as { type?: string }).type === "header"));
797
797
 
798
798
  /**
799
- * Everything about the store connection on one page: the values Shopify
800
- * needs (Status), the setup steps, webhook deliveries and their health, and
801
- * the app's event log. An action passes the part it rebuilt (with its
802
- * toast); the other tabs load as usual. Opens on Setup until it's done.
799
+ * How this site talks to Shopify: Status (the values to copy into Shopify
800
+ * and what the site reads from its environment), then one tab per setup
801
+ * step, each with a check mark once done. Webhooks also holds the app's
802
+ * subscriptions. Opens on the first step not done, or on Status.
803
803
  */
804
- async function connectionPage(config: RedeemIDConfig, ctx: RouteContext, tab?: ConnectionTab, given: { hooks?: Built; events?: Built; setup?: Built } = {}) {
804
+ async function connectionPage(config: RedeemIDConfig, ctx: RouteContext, tab?: ConnectionTab, toast?: Toast) {
805
805
  const s = await memberStores();
806
- const [hooks, events, facts, status] = await Promise.all([
807
- given.hooks ?? webhooksPage(monitorEnv(config, s, ctx)),
808
- given.events ?? eventsPage(s),
809
- setupFacts(config, s, siteOrigin(ctx)),
810
- statusPage(config, ctx.request),
811
- ]);
812
- const setup = given.setup ?? setupPage(facts);
813
- const steps = setupSteps(facts).filter((x) => x.state !== "optional" && x.key !== "storefront");
814
- const ready = steps.every((x) => x.state === "done");
815
- const [received = [], health = [], subscriptions = []] = panelsOf(hooks);
816
- const attention = JSON.stringify(hooks.blocks).includes('"label":"Health (!)"');
817
- const open = tab ?? (ready ? "status" : "setup");
818
- const toast = (given.hooks ?? given.events ?? given.setup)?.toast;
806
+ const [facts, status, hooks] = await Promise.all([setupFacts(config, s, siteOrigin(ctx)), statusPage(config, ctx.request), webhookPanels(monitorEnv(config, s, ctx))]);
807
+ const panels = setupPanels(facts);
808
+ const next = nextStep(facts);
809
+ const steps = requiredSteps(setupSteps(facts));
810
+ const open = tab ?? (next ? (STEP_TABS[next.key] as ConnectionTab) : "status");
811
+ const progress = next
812
+ ? [{ type: "banner", variant: "alert", title: `Setup: ${steps.filter((x) => x.state === "done").length} of ${steps.length} steps done`, description: `Work through the tabs. Each one checks the site as it is, so set a secret, redeploy or restart, and press Check again. Next: ${next.title}.` }]
813
+ : [];
814
+ // The Webhooks step has its own Register button while it's to do.
815
+ const ownButton = panels.some((p) => p.tab === "webhooks" && JSON.stringify(p.blocks).includes('"setup_register"'));
816
+ const subscriptions = ownButton ? hooks.subscriptions.filter((b) => !JSON.stringify(b).includes('"webhooks_register"')) : hooks.subscriptions;
819
817
  return {
820
818
  blocks: [
821
819
  { type: "header", text: "Connection" },
822
- { type: "context", text: "How this site talks to Shopify: the values to copy into Shopify, the setup steps, webhook deliveries and what RedeemID has done." },
820
+ { type: "context", text: "How this site talks to Shopify: the values to copy into the store, and each setup step." },
823
821
  {
824
822
  type: "tab",
825
823
  default_tab: CONNECTION_TABS.indexOf(open),
826
824
  panels: [
827
- { label: "Status", blocks: [...withoutHeader(status), { type: "divider" }, { type: "header", text: "Webhook subscriptions" }, ...subscriptions] },
828
- { label: ready ? "Setup" : `Setup (${steps.filter((x) => x.state === "done").length}/${steps.length})`, blocks: withoutHeader(setup) },
829
- { label: "Webhooks", blocks: received },
830
- { label: attention ? "Health (!)" : "Health", blocks: health },
831
- { label: "App events", blocks: withoutHeader(events) },
825
+ { label: "Status", blocks: [...progress, ...withoutHeader(status)] },
826
+ ...panels.map((p) => (p.tab === "webhooks" ? { label: p.label, blocks: [...p.blocks, { type: "divider" }, { type: "header", text: "Subscriptions" }, ...subscriptions] } : { label: p.label, blocks: p.blocks })),
827
+ ],
828
+ },
829
+ ],
830
+ ...(toast ? { toast } : {}),
831
+ };
832
+ }
833
+
834
+ /**
835
+ * What happened: webhook deliveries from Shopify, what RedeemID did (App),
836
+ * and whether deliveries are healthy. An action passes the App panel it
837
+ * rebuilt (a filter or a page) or a deliveries cursor.
838
+ */
839
+ async function eventsAdminPage(config: RedeemIDConfig, ctx: RouteContext, tab: EventsTab = "webhook", given: { events?: { blocks: unknown[] }; cursor?: string } = {}, toast?: Toast) {
840
+ const s = await memberStores();
841
+ const [hooks, events] = await Promise.all([webhookPanels(monitorEnv(config, s, ctx), given.cursor), given.events ?? eventsPage(s)]);
842
+ return {
843
+ blocks: [
844
+ { type: "header", text: "Events" },
845
+ { type: "context", text: "What Shopify sent, what RedeemID did with it, and whether deliveries are healthy." },
846
+ {
847
+ type: "tab",
848
+ default_tab: EVENTS_TABS.indexOf(tab),
849
+ panels: [
850
+ { label: "Webhook", blocks: hooks.received },
851
+ { label: "App", blocks: events.blocks },
852
+ { label: hooks.attention ? "Health (!)" : "Health", blocks: hooks.health },
832
853
  ],
833
854
  },
834
855
  ],
@@ -848,7 +869,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
848
869
  { type: "header", text: "Status" },
849
870
  ready
850
871
  ? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their store customer account." }
851
- : { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload. Setup walks through each one." },
872
+ : { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload. The tabs after Status walk through each one." },
852
873
  ...customDomainBlocks(config),
853
874
  { type: "header", text: "Shopify settings" },
854
875
  { type: "context", text: "Copy these into the store: the first three into the Customer Account API settings of the Headless storefront, the webhook URL into the app's webhook subscriptions." },
@@ -1045,7 +1066,7 @@ async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
1045
1066
  { type: "link", label: "Overview", target: page("/"), appearance: "primary" },
1046
1067
  { type: "link", label: "Members", target: page("/members"), appearance: "secondary" },
1047
1068
  { type: "link", label: "Access", target: page("/access"), appearance: "secondary" },
1048
- { type: "link", label: s.signIn && !s.check?.problems.length ? "Webhooks" : "Setup", target: page(s.signIn && !s.check?.problems.length ? "/connection/webhooks" : "/connection/setup"), appearance: "secondary" },
1069
+ { type: "link", label: s.signIn && !s.check?.problems.length ? "Events" : "Connection", target: page(s.signIn && !s.check?.problems.length ? "/events/webhook" : "/connection"), appearance: "secondary" },
1049
1070
  ],
1050
1071
  },
1051
1072
  ],
@@ -1060,8 +1081,7 @@ async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
1060
1081
  setupFacts(config, st, siteOrigin(ctx)).catch(() => null),
1061
1082
  st.log.query({ orderBy: { at: "desc" }, limit: 8 }).catch(() => ({ items: [] as Array<{ data: Parameters<typeof eventRow>[0] }> })),
1062
1083
  ]);
1063
- const steps = facts ? setupSteps(facts) : [];
1064
- const required = steps.filter((x) => x.state !== "optional");
1084
+ const required = facts ? requiredSteps(setupSteps(facts)) : [];
1065
1085
  const done = required.filter((x) => x.state === "done").length;
1066
1086
  const hooks = webhookWord(s);
1067
1087
  const rules = config.rules ?? [];
@@ -1090,10 +1110,10 @@ async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
1090
1110
  items: [
1091
1111
  { title: "Sign-in", value: s.signIn ? "Ready" : "Not ready", tone: s.signIn ? "ok" : "error", text: domain ? `Store: ${domain.replace(/^https?:\/\//, "")}` : "No store set", link: page("/connection/status"), label: "Status" },
1092
1112
  { title: "Members", value: String(s.members.length), text: `${s.active} with a level, ${s.signedIn} signed in here`, link: page("/members"), label: "Members" },
1093
- { title: "Webhooks", value: hooks.value, tone: hooks.tone, text: s.delivery ? `Last delivery ${ago(s.delivery.receivedAt)} (${s.delivery.topic})` : "None received yet", link: page("/connection/webhooks"), label: "Webhooks" },
1113
+ { title: "Webhooks", value: hooks.value, tone: hooks.tone, text: s.delivery ? `Last delivery ${ago(s.delivery.receivedAt)} (${s.delivery.topic})` : "None received yet", link: page("/events/webhook"), label: "Webhooks" },
1094
1114
  { title: "Access", value: count(s.levels.length, "level"), text: [count(rules.length, "rule"), count((config.features ?? []).length, "feature"), count((config.files ?? []).length, "protected folder")].join(", "), link: page("/access"), label: "Access" },
1095
1115
  { title: "Privacy", value: s.openRequests ? `${s.openRequests} open` : "Nothing open", tone: s.openRequests ? "warn" : undefined, text: "Exports and erasures from members, staff and Shopify", link: page("/privacy"), label: "Privacy" },
1096
- { title: "Setup", value: required.length ? `${done} of ${required.length}` : "Unknown", tone: required.length && done === required.length ? "ok" : "warn", text: done === required.length ? "Every step is done" : `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}`, link: page("/connection/setup"), label: "Setup" },
1116
+ { title: "Setup", value: required.length ? `${done} of ${required.length}` : "Unknown", tone: required.length && done === required.length ? "ok" : "warn", text: done === required.length ? "Every step is done" : `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}`, link: page("/connection"), label: "Setup" },
1097
1117
  ],
1098
1118
  },
1099
1119
  { type: "header", text: "Levels" },
@@ -1129,7 +1149,7 @@ async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
1129
1149
  return { event, about, result, at };
1130
1150
  }),
1131
1151
  },
1132
- { type: "actions", elements: [{ type: "link", label: "All app events", target: page("/connection/events"), appearance: "secondary" }, { type: "link", label: "Reports", target: page("/reports"), appearance: "secondary" }] },
1152
+ { type: "actions", elements: [{ type: "link", label: "All app events", target: page("/events/app"), appearance: "secondary" }, { type: "link", label: "Reports", target: page("/reports"), appearance: "secondary" }] },
1133
1153
  { type: "header", text: "Resources" },
1134
1154
  {
1135
1155
  type: "redeemid_cards",
package/src/setup.ts CHANGED
@@ -1,19 +1,19 @@
1
1
  /**
2
- * Plugins → RedeemID → Setup: connecting a store, step by step, from the
3
- * admin alone. Each step says what to do in Shopify (with the exact values
2
+ * RedeemID → Connection: connecting a store, step by step, from the admin
3
+ * alone, one tab per step. Each step says what to do in Shopify (with the exact values
4
4
  * to paste), what to set on the site, and has a button for whatever
5
5
  * RedeemID can do itself: register the webhooks, add the first level.
6
6
  *
7
7
  * Nothing about progress is stored. Each step's state comes from the site
8
- * as it is (setupFacts), so opening Setup again picks up where things stand
9
- * and it always agrees with the Status page.
8
+ * as it is (setupFacts), so opening Connection again picks up where things stand
9
+ * and it always agrees with the Status tab.
10
10
  *
11
11
  * Secrets stay where they are today, in the site's environment: the wizard
12
12
  * checks them and gives the command to set each one, and never stores one.
13
13
  * It writes only what staff can already write (a level, through Access) and
14
14
  * never grants access: levels still come only from Shopify tags.
15
15
  *
16
- * setupSteps() and setupPage() are pure.
16
+ * setupSteps() and setupPanels() are pure.
17
17
  */
18
18
 
19
19
  import { adminConfigured, adminScopes, grantedScopes, topicName, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
@@ -24,7 +24,7 @@ import type { Stores } from "./storage.js";
24
24
  type Toast = { message: string; type: "success" | "error" | "info" };
25
25
  export type Blocks = { blocks: unknown[]; toast?: Toast };
26
26
 
27
- /** Scopes the webhook app needs for staff tools and webhook registration. */
27
+ /** Scopes the Admin API app needs for staff tools and webhook registration. */
28
28
  export const SETUP_SCOPES = ["read_customers", "write_customers"] as const;
29
29
 
30
30
  export const CALLBACK = "/_emdash/api/auth/redeemid/callback";
@@ -134,8 +134,8 @@ export function setupSteps(f: SetupFacts): Step[] {
134
134
  return [
135
135
  { key: "store", title: "Connect the store", state: store ? "done" : "todo" },
136
136
  { key: "sign_in", title: "Create the sign-in client", state: signIn ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
137
- { key: "app", title: "Connect the webhook app", state: app ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
138
- { key: "webhooks", title: "Register the webhooks", state: hooks ? "done" : app ? "todo" : "waiting", waitingFor: "Connect the webhook app first: webhooks are registered through it." },
137
+ { key: "app", title: "Connect the Admin API app", state: app ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
138
+ { key: "webhooks", title: "Register the webhooks", state: hooks ? "done" : app ? "todo" : "waiting", waitingFor: "Connect the Admin API app first: webhooks are registered through it." },
139
139
  { key: "level", title: "Create your first level", state: level ? "done" : "todo" },
140
140
  { key: "try", title: "Try a sign-in", state: f.signedIn > 0 ? "done" : ready ? "todo" : "waiting", waitingFor: "Finish the steps above first." },
141
141
  { key: "storefront", title: "Add Storefront tokens (for plans and the shop)", state: f.storefront ? "done" : "optional" },
@@ -182,7 +182,7 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
182
182
  text: "In Shopify admin, open Sales channels, then Headless (add the channel if it isn't there), and Create storefront. Name it after this site. Open its Customer Account API settings, pick a client type (Public needs no secret; Confidential gives one) and, under Application setup, add these:",
183
183
  },
184
184
  {
185
- type: "fields",
185
+ type: "redeemid_copy",
186
186
  fields: [
187
187
  { label: "Callback URI", value: `${f.origin}${CALLBACK}` },
188
188
  { label: "JavaScript origin (public clients)", value: f.origin },
@@ -200,7 +200,7 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
200
200
  ? `Shopify refused the app: ${f.admin} The app must be installed on this store and be in the same Shopify organization.`
201
201
  : f.admin === "ok"
202
202
  ? `The app is connected but lacks ${missingScopes(f).join(" and ")}. Add the scopes, release a new version and let the store accept it.`
203
- : "Shopify signs webhooks with an app's client secret, and RedeemID uses the same app to register webhooks and to resync members.";
203
+ : "RedeemID uses an Admin API app for the staff tools (resyncs, tag edits, member lookups) and to register webhooks, and Shopify signs webhooks with its client secret.";
204
204
  return [
205
205
  ...out,
206
206
  { type: "section", text: why },
@@ -266,34 +266,29 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
266
266
  return out;
267
267
  }
268
268
 
269
- /** The Setup page. Pure. */
270
- export function setupPage(f: SetupFacts, toast?: Toast): Blocks {
271
- const steps = setupSteps(f);
272
- const required = steps.filter((s) => s.state !== "optional" && s.key !== "storefront");
273
- const done = required.filter((s) => s.state === "done").length;
274
- return {
269
+ /** Connection's tab address for each step (/connection/sign-in). */
270
+ export const STEP_TABS: Record<Step["key"], string> = { store: "store", sign_in: "sign-in", app: "app", webhooks: "webhooks", level: "level", try: "try", storefront: "storefront" };
271
+ const SHORT: Record<Step["key"], string> = { store: "Store", sign_in: "Sign-in", app: "Admin app", webhooks: "Webhooks", level: "First level", try: "Try it", storefront: "Storefront" };
272
+
273
+ /** The steps that count toward "set up" (Storefront is optional). */
274
+ export const requiredSteps = (steps: Step[]) => steps.filter((s) => s.key !== "storefront");
275
+
276
+ /**
277
+ * One tab per step, for Connection: the step, with a Check again button
278
+ * until it's done. Labels carry a check mark once done. Pure.
279
+ */
280
+ export function setupPanels(f: SetupFacts): Array<{ tab: string; label: string; blocks: unknown[] }> {
281
+ return setupSteps(f).map((step) => ({
282
+ tab: STEP_TABS[step.key],
283
+ label: step.state === "done" ? `${SHORT[step.key]} ✓` : SHORT[step.key],
275
284
  blocks: [
276
- { type: "header", text: "Setup" },
277
- done === required.length
278
- ? { type: "banner", variant: "default", title: "RedeemID is set up", description: "Members can sign in with their Shopify customer account. Levels, rules and the meter are under Access." }
279
- : {
280
- type: "banner",
281
- variant: "alert",
282
- title: `${done} of ${required.length} steps done`,
283
- description: "Work down the steps. Each one checks the site as it is, so set a secret, redeploy or restart, and press Check again.",
284
- },
285
- { type: "actions", elements: [{ type: "button", action_id: "setup_check", label: "Check again", style: "secondary" }] },
286
- {
287
- type: "table",
288
- page_action_id: "setup_steps",
289
- columns: [
290
- { key: "step", label: "Step" },
291
- { key: "state", label: "State", format: "badge" },
292
- ],
293
- rows: steps.map((s) => ({ step: s.title, state: BADGE[s.state] })),
294
- },
295
- ...steps.flatMap((s) => [{ type: "divider" }, ...stepBlocks(s, f)]),
285
+ ...stepBlocks(step, f),
286
+ ...(step.state === "done" ? [] : [{ type: "actions", elements: [{ type: "button", action_id: "setup_check", value: STEP_TABS[step.key], label: "Check again", style: "secondary" }] }]),
296
287
  ],
297
- ...(toast ? { toast } : {}),
298
- };
288
+ }));
289
+ }
290
+
291
+ /** Where Connection opens: the first step not done, or null once every required one is. Pure. */
292
+ export function nextStep(f: SetupFacts): Step | null {
293
+ return requiredSteps(setupSteps(f)).find((s) => s.state !== "done") ?? null;
299
294
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.31";
1
+ export const VERSION = "0.0.32";