@redeemid/emdash 0.0.21 → 0.0.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/README.md CHANGED
@@ -7,7 +7,7 @@ Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) site
7
7
 
8
8
  Readers sign in with the store's own customer account. Their Shopify customer tags decide what they can read. Shopify keeps the store, checkout, orders and subscriptions, and EmDash keeps the content. Nothing on the site grants access by itself, so there is one source of truth for who paid for what.
9
9
 
10
- **Status: 0.0.21, early.** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover the features below. Expect changes before 1.0.
10
+ **Status: 0.0.23, early.** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover the features below. Expect changes before 1.0.
11
11
 
12
12
  ## Contents
13
13
 
@@ -26,7 +26,7 @@ Readers sign in with the store's own customer account. Their Shopify customer ta
26
26
 
27
27
  1. **Sign in.** A reader clicks "Sign in", signs in on the store's customer account page, and comes back signed in as an EmDash Subscriber. That role only means "signed in". It never opens content.
28
28
  2. **Mirror.** RedeemID keeps a copy of the customer: email, name, tags and whether the account is enabled. Signed Shopify webhooks keep it current.
29
- 3. **Levels.** You map tags to levels and name them whatever you like, such as `gold-member` to Gold. Tags come from Shopify Flow, a subscription app or staff, never from the site.
29
+ 3. **Levels.** You map tags to levels and name them whatever you like, such as `gold-member` to Gold, or Gold for `gold` or for `silver` with `annual`. A level can include others, so Gold members read everything for Silver. Tags come from Shopify Flow, a subscription app or staff, never from the site.
30
30
  4. **Rules.** You say which content types or terms need which levels, in the admin (Plugins → RedeemID → Access) or in code. Each entry can have its own rule in the editor's Access panel, and editors can drop a Paywall break or a Members only block into any body.
31
31
  5. **Render.** Templates render bodies with `<Content>`. Readers who pass see everything. Everyone else gets a teaser and the wall, and the locked part never reaches the page.
32
32
 
@@ -181,7 +181,7 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
181
181
  <Gate levels={["pro"]}><a href="/reports/archive.zip">Download the archive</a></Gate>
182
182
  ```
183
183
 
184
- **`<AccessBadge>`** shows what an entry needs, on cards and on the entry. It shows "Free" for open entries (`free=""` hides it), adds `is-locked` and a lock for readers it keeps out, and puts the level slugs in `data-redeemid-level` for colours:
184
+ **`<AccessBadge>`** shows what an entry needs, on cards and on the entry. It shows "Free" for open entries (`free=""` hides it), adds `is-locked` and a lock for readers it keeps out, and puts the level slugs in `data-redeemid-level` for colours (and the first level's `badge` colour in `--redeemid-level-color`):
185
185
 
186
186
  ```astro
187
187
  <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
@@ -191,7 +191,7 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
191
191
 
192
192
  **`<MemberCard />`** is for an account page: levels with start dates, the renewal or end date and any gift membership that Shopify keeps (see [membership dates](#membership-dates)), past levels, the meter, and links to the store account, Refresh membership and Sign out. Refresh membership sends a member to the store with no sign-in form; one still signed in there comes straight back with their tags read again. Add `<Preferences />` and `<YourData />` beside it.
193
193
 
194
- **`<Plan>`** sells a membership: a Shopify product's title and price with a button that starts checkout (members check out with their email). With `levels`, members who already hold one see "You're on this plan." in place of the button, and a member who held one before (Shopify has since removed its tag) gets **Renew** (`renewLabel`) instead of `label`. The wall does the same: a lapsed member reads "Your Pro membership has ended." with **Renew Pro** and **Already renewed? Refresh**, not the newcomer's join link.
194
+ **`<Plan>`** sells a membership: a Shopify product's title and price with a button that starts checkout (members check out with their email). Its levels are `levels`, or else the levels whose `products` list the handle. Then the card fits the member: one who holds a level through its own tags sees "You're on this plan." in place of the button; one whose bigger level includes it sees "Included in your Gold membership."; one the plan would move up (its level includes theirs) gets **Upgrade** (`upgradeLabel`); and one who held it before (Shopify has since removed its tag) gets **Renew** (`renewLabel`) instead of `label`. `data-redeemid-state` is `held`, `included`, `upgrade`, `renew` or `join`. The wall does the same: a lapsed member reads "Your Pro membership has ended." with **Renew Pro** and **Already renewed? Refresh**, not the newcomer's join link.
195
195
 
196
196
  ```astro
197
197
  <Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
@@ -214,6 +214,18 @@ const needs = await entryRequirement(config, "posts", post.data.terms, post.id);
214
214
  // e.g. [["gold", "pro"]]: readers pass every clause, each with any one of its levels; null when open
215
215
  ```
216
216
 
217
+ **Site search.** EmDash's search API (`/_emdash/api/search`, which its live search box uses) is public and answers with a snippet of the matching text. `redeemidGuard()` checks each result for visitors and members: an entry the reader can't read in full keeps its title but loses its snippet, and stays in the results only when its title holds every searched word, so a search can't show locked text or tell which locked entry contains a word. Staff get every result. A search page that calls EmDash's `search()` in its template filters the same way with `readableResults()`:
218
+
219
+ ```astro
220
+ ---
221
+ import { search } from "emdash";
222
+ import { readableResults } from "@redeemid/emdash/search";
223
+
224
+ const q = Astro.url.searchParams.get("q") ?? "";
225
+ const results = q ? await readableResults(Astro, q, (await search(q)).items) : [];
226
+ ---
227
+ ```
228
+
217
229
  `siteConfig(Astro.locals)` is the config for this request: your code's config with the Access page's saved settings laid over it. Pass it to the functions that take a `config`, such as `entryRequirement()`, `readPreferences()` and the storefront and catalog calls.
218
230
 
219
231
  ## Guides
@@ -235,13 +247,35 @@ Each rule names a collection and the levels, any of which may read. An empty `le
235
247
 
236
248
  Term rules beat the collection's rule, which beats the site default. When several term rules cover one entry (a story in Investigations, which needs Gold, and Culture, which needs Silver), readers must pass every one of them, so only members with both read it. Set `combine: "any"` to let one be enough.
237
249
 
238
- Levels can need several tags: `{ slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" }`. A level's `wall` is what the wall says to readers it would let in, and its `joinUrl` overrides `join.href`.
250
+ ### Levels
251
+
252
+ A level is a name for customers who hold some Shopify tags. `tags` is one group: any one of them grants the level, or every one with `match: "all"`. `groups` adds more groups, and any one group is enough:
253
+
254
+ ```js
255
+ levels: [
256
+ { slug: "silver", name: "Silver", tags: ["silver"] },
257
+ // "gold", or "silver" with "annual".
258
+ { slug: "gold", name: "Gold", tags: ["gold"], groups: [{ tags: ["silver", "annual"], match: "all" }], includes: ["silver"] },
259
+ ],
260
+ ```
261
+
262
+ `includes` lists levels this one also opens: Gold members read everything for Silver, and inclusion carries on (Gold includes Silver includes Members). So a rule names only the lowest level that may read, and an entry that needs Silver and Gold needs Gold once. Members hold the levels their tags grant and every level those include; the account box and member card show only the top one ("Gold", not "Gold, Silver"), and `member.levels_changed` lists them all.
263
+
264
+ The rest of a level is how it looks and where it leads:
265
+
266
+ | Key | What it does |
267
+ | --- | --- |
268
+ | `wall` | What the wall says to readers this level would let in |
269
+ | `joinUrl` | Where the wall's join link goes for this level (else `join.href`) |
270
+ | `products` | Handles of the Shopify products whose purchase leads to the level. Display only: plan cards use it (see `<Plan>`). It grants nothing. |
271
+ | `home` | Members' home: the same-site path a member lands on after signing in from the home page or a plain sign-in link (their top level's, in level order). Signing in from an entry returns them to it. |
272
+ | `badge` | A hex colour, set as `--redeemid-level-color` on badges, the account box and the member card. Badges always show the name too. |
239
273
 
240
274
  ### Access settings in the admin
241
275
 
242
276
  Everything about who reads what can be set in the EmDash admin, so a new content type, taxonomy or level never needs a code change. Under **Plugins → RedeemID → Access**:
243
277
 
244
- - **Levels.** Add, rename and remove levels and the Shopify tags that grant them. Name them anything; the slug is made from the name. A level that rules, features or folders still use can't be removed.
278
+ - **Levels.** Add, rename and remove levels and the Shopify tags that grant them: one group per line, commas between tags, and `all:` before a line that needs every tag on it. Tick the levels each one includes, and set its plan products, members' home and badge colour. Name them anything; the slug is made from the name. A level that rules, features or folders still use can't be removed; removing one drops it from other levels' includes.
245
279
  - **Site rules.** The site default, how term rules combine, the meter and the wall's join link.
246
280
  - **Content types.** Who can read each collection, with drip, the meter and the teaser. Every collection on the site is listed, including ones made after RedeemID was set up.
247
281
  - **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
@@ -271,7 +305,7 @@ teaser: { posts: 40 },
271
305
 
272
306
  The meter lets a signed-in reader open a locked entry anyway, `limit` entries per `period` (`"month"`, the default, or `"week"`, in UTC). Each entry counts once per period, when its own page renders through `<Content>` with an `id`. Lists, feeds, search and the content API never count. Visitors get the wall and a nudge to sign in. While it runs, `<Content>` says how many free reads are left, and when it runs out the wall says so. Rules with `meter: false` are never metered, and the meter never opens an entry a member is waiting on by drip.
273
307
 
274
- Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had its tag (for a `match: "all"` level, the last of its tags). With several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives and forgets it when Shopify removes it. The wall shows the date the entry opens.
308
+ Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had a tag of its group (for a `match: "all"` group, the last of its tags); a level held through another's `includes` starts with that one. With several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives and forgets it when Shopify removes it. The wall shows the date the entry opens.
275
309
 
276
310
  ### Selling one entry on its own
277
311
 
@@ -398,7 +432,7 @@ What RedeemID keeps about a member: the synced record (Shopify customer ID, emai
398
432
  | Staff, on a member's page | **Export data**, **Erase site data**, and **Also request erasure in Shopify** (orders included, after Shopify's wait; **Cancel the Shopify erasure** withdraws it). The Shopify request needs the `write_customer_data_erasure` scope. |
399
433
  | Shopify `customers/data_request` | An open request on the **Privacy** page. Press **Export and mark done**, then send the export to the store owner. |
400
434
  | Shopify `customers/redact` | The member's record and meter are erased. |
401
- | Shopify `shop/redact` | Every member record and meter is erased. |
435
+ | Shopify `shop/redact` | Every member record and meter is erased, a hundred at a time, so even a large shop fits in one request on the Workers Free plan. |
402
436
 
403
437
  The Privacy page shows each request's source and Shopify customer ID, never an email or a name. The daily check deletes finished requests after a year; open ones stay until staff mark them done. An erase sends the `member.erased` event so a CRM can delete its copy too. The site's own user account stays; delete it under Users if the person asks. The JSON route is `/_emdash/api/auth/redeemid/privacy`: `GET` downloads, `POST` with `confirm=yes` erases, only for the signed-in member and only from the site's own origin.
404
438
 
@@ -407,6 +441,7 @@ The Privacy page shows each request's source and Shopify customer ID, never an e
407
441
  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**:
408
442
 
409
443
  - **Member detail.** Levels, tags and features; resync from Shopify; add and remove tags. Tag edits are made in Shopify and read back. Nothing is granted on the site.
444
+ - **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.
410
445
  - **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.
411
446
  - **Webhook subscriptions.** Webhooks → Setup 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.
412
447
  - **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
@@ -445,7 +480,7 @@ Staff questions as data, for AI agents and scripts. Every one needs an administr
445
480
  | MCP tool | Command | What it answers |
446
481
  | --- | --- | --- |
447
482
  | `explain_access` | `access <collection>` | Can this reader read this entry, and why. Give the entry by `id` or `terms`, and the reader by `tags`, `customerId` or `signedIn: false`. |
448
- | `list_levels` | `levels` | Every level, its tags, wall text and join link |
483
+ | `list_levels` | `levels` | Every level, its tag groups, the levels it includes, wall text and join link |
449
484
  | `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and file rules |
450
485
  | `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
451
486
  | `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
@@ -524,9 +559,9 @@ Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels
524
559
 
525
560
  | Option | Type | What it does |
526
561
  | --- | --- | --- |
527
- | `shop` | `string` | The store's `*.myshopify.com` domain (a custom customer-account domain works too). Leave it out to read `REDEEMID_SHOPIFY_STORE_DOMAIN`. |
562
+ | `shop` | `string` | The store's `*.myshopify.com` domain. A custom domain works for sign-in, but the Admin API (staff tools, webhook registration, tag reads) answers only on myshopify.com, and the Status page warns when the app is set up with a custom domain. Leave it out to read `REDEEMID_SHOPIFY_STORE_DOMAIN`. |
528
563
  | `label` | `string` | The sign-in button's label. Default "Sign in". |
529
- | `levels` | `{ slug, name, tags, match?, wall?, joinUrl? }[]` | Levels from customer tags. `match: "all"` needs every tag. |
564
+ | `levels` | `{ slug, name, tags?, match?, groups?, includes?, wall?, joinUrl?, products?, home?, badge? }[]` | Levels from customer tags. See [levels](#levels). |
530
565
  | `rules` | `{ collection, taxonomy?, term?, levels, open?, drip?, meter? }[]` | What needs which levels. See [rules](#rules). |
531
566
  | `combine` | `"all"` \| `"any"` | How several term rules on one entry combine. Default `"all"`. |
532
567
  | `join` | `{ href, label? }` | The wall's join link. |
@@ -578,7 +613,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
578
613
  | `YourData` | `title`, `heading` | Download or delete my data |
579
614
  | `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
580
615
  | `Notices` | `messages` | The message for `?redeemid_notice=` |
581
- | `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel` | A product with a buy button |
616
+ | `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel`, `upgradeLabel` | A product with a buy button that fits the member |
582
617
  | `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
583
618
  | `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
584
619
  | `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
@@ -619,6 +654,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
619
654
  | `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()` |
620
655
  | `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
621
656
  | `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
657
+ | `@redeemid/emdash/search` | `readableResults()`, for a search page that calls EmDash's `search()` |
622
658
  | `@redeemid/emdash/contract` | The stable API as a list, and `API_VERSION` |
623
659
 
624
660
  The full developer API (stable exports, JSON routes, the browser client and its events, MCP tools) is in [docs/developer-api.md](docs/developer-api.md).
@@ -649,10 +685,11 @@ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text
649
685
  - Shopify is the only source of entitlements. Nothing on the site grants access.
650
686
  - Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
651
687
  - Enabled or disabled status comes only from the `customers/enable` and `customers/disable` webhooks.
652
- - Locked content never reaches the content API, search, feeds or the sitemap for readers kept out, nor a page that renders bodies with `<Content>` (the Status page flags templates that use `<PortableText>` for a gated collection). The end-to-end test checks each of these. Members are refused EmDash's own APIs except what they need.
688
+ - Locked content never reaches the content API, site search (snippets, or which entry matched), feeds or the sitemap for readers kept out, nor a page that renders bodies with `<Content>` (the Status page flags templates that use `<PortableText>` for a gated collection). The end-to-end test checks each of these. Members are refused EmDash's own APIs except what they need.
653
689
  - Staff accounts (any EmDash role above Subscriber) can't sign in through the store, even with a matching email. When that happens, the store session ends too, so the next sign-in attempt starts fresh.
654
690
  - Customers can't create the first (admin) account on a new site.
655
691
  - Webhooks are checked against the app's signature before anything changes.
692
+ - `customers/merge` moves the sign-in link to the customer Shopify kept. With the Admin API app set up, the kept customer's tags are read straight away; without it they arrive with its next webhook or sign-in.
656
693
  - Access fails closed: when the saved access settings can't be read, a server keeps the ones it last read, and with none it locks every entry.
657
694
 
658
695
  ## Troubleshooting
@@ -1,6 +1,6 @@
1
1
  # Developer API
2
2
 
3
- What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.1.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
3
+ What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.3.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
4
4
 
5
5
  Everything on this page is stable: it changes only with a changelog entry and an API version bump (minor when something is added, major when something changes or goes). Anything not listed is internal, even when a module exports it. `src/contract.ts` is the same list as code, and a test fails when the code, that list and this page disagree.
6
6
 
@@ -17,13 +17,16 @@ Import these in Astro pages, components and endpoints.
17
17
  | Import | Stable exports |
18
18
  | --- | --- |
19
19
  | `@redeemid/emdash` | `redeemid`, `redeemidGuard`, `redeemidPlugin` |
20
- | `@redeemid/emdash/access` | `siteConfig`, `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
20
+ | `@redeemid/emdash/access` | `siteConfig`, `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `topLevels`, `closure`, `simplify`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
21
21
  | `@redeemid/emdash/features` | `hasFeature`, `viewerFeatures`, `featuresFor` |
22
22
  | `@redeemid/emdash/preferences` | `readPreferences`, `savePreferences`, `preferenceFields` |
23
23
  | `@redeemid/emdash/storefront` | `plan`, `product`, `collection`, `money`, `realOptions`, `variantFor`, `storeContext`, `cartDetails`, `addToCart`, `updateLines`, `removeLines`, `setDiscountCodes` |
24
24
  | `@redeemid/emdash/catalog` | `searchProducts`, `collectionPage`, `suggest`, `recommendations`, `browseFromUrl`, `browseHref`, `parseFilters`, `priceFilter`, `SORTS` |
25
25
  | `@redeemid/emdash/events` | `signBody` |
26
26
  | `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
27
+ | `@redeemid/emdash/search` | `readableResults` |
28
+
29
+ Levels: `levelsFor(tags, levels)` answers every level the tags hold, including the ones a held level `includes` (so `levels` in `api/state`, `Viewer.levels` and `member.levels_changed` list Silver for a Gold member whose Gold includes Silver). `topLevels(held, levels)` keeps the ones no other held level includes, for showing ("Gold"). `closure(slug, levels)` is every level holding `slug` opens, itself first. `simplify(required, levels)` drops what inclusion already covers from a requirement (`[["silver"], ["gold"]]` becomes `[["gold"]]`); `entryRequirement` already does it. `membership()` marks a level held only through another with `via` (that level's slug).
27
30
 
28
31
  Functions that take a `config` want the site's config for the request: `await siteConfig(Astro.locals)`, which is the code's config with the admin's saved settings laid over it. Passing the object from `astro.config.mjs` instead skips what staff saved on the Access page. A few examples:
29
32
 
@@ -50,7 +53,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
50
53
  - `api/state` (GET): the visitor's own state.
51
54
 
52
55
  ```json
53
- { "apiVersion": "1.1.0", "version": "0.0.21", "signedIn": true, "linked": true,
56
+ { "apiVersion": "1.3.0", "version": "0.0.23", "signedIn": true, "linked": true,
54
57
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
55
58
  "meter": { "used": 1, "limit": 3, "period": "month" } }
56
59
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  "exports": {
14
14
  ".": "./src/index.ts",
15
15
  "./access": "./src/access.ts",
16
+ "./search": "./src/search-guard.ts",
16
17
  "./guard.ts": "./src/guard.ts",
17
18
  "./rate-limit-cloudflare.ts": "./src/rate-limit-cloudflare.ts",
18
19
  "./templates.ts": "./src/templates.ts",
@@ -15,8 +15,8 @@
15
15
 
16
16
  import type { StorageCollection } from "emdash";
17
17
 
18
- import { applyEntryRule, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
19
- import type { FeatureConfig, FileRule, LevelConfig, MeterConfig, RedeemIDConfig, RuleConfig } from "./config.js";
18
+ import { applyEntryRule, badgeColor, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
19
+ import { groupsOf, type FeatureConfig, type FileRule, type LevelConfig, type MeterConfig, type RedeemIDConfig, type RuleConfig, type TagGroup } from "./config.js";
20
20
  import {
21
21
  days,
22
22
  deleteAccess,
@@ -153,6 +153,40 @@ function ruleText(rule: RuleConfig | undefined, levels: LevelConfig[]): string {
153
153
  return extra ? `${who} (${extra})` : who;
154
154
  }
155
155
 
156
+ /** A level's tag groups as the form shows them: one per line, "all:" before a line that needs every tag. */
157
+ function groupsText(level: LevelConfig | undefined): string {
158
+ if (!level) return "";
159
+ return groupsOf(level)
160
+ .map((g) => `${g.match === "all" && g.tags.length > 1 ? "all: " : ""}${g.tags.join(", ")}`)
161
+ .join("\n");
162
+ }
163
+
164
+ /** The level's tags in the table: "gold or silver + annual". */
165
+ function groupsWords(level: LevelConfig): string {
166
+ return groupsOf(level)
167
+ .map((g) => g.tags.join(g.match === "all" ? " + " : " or "))
168
+ .join(" or ");
169
+ }
170
+
171
+ /**
172
+ * Tag groups from the form: one per line, tags separated by commas; a line
173
+ * starting "all:" needs every tag. `firstMatch` ("all") applies to a first
174
+ * line with no prefix, for callers that still send the old Customer needs field.
175
+ */
176
+ export function parseGroups(raw: unknown, firstMatch?: unknown): TagGroup[] {
177
+ const lines = Array.isArray(raw) ? [raw.join(",")] : String(raw ?? "").split(/\r?\n/);
178
+ const out: TagGroup[] = [];
179
+ lines.forEach((line, i) => {
180
+ const all = /^\s*all\s*:/i.test(line);
181
+ const tags = list(line.replace(/^\s*(all|any)\s*:/i, "").split(","));
182
+ if (tags.length === 0) return;
183
+ const match = all || (i === 0 && firstMatch === "all") ? "all" : "any";
184
+ if (out.some((g) => (g.match ?? "any") === match && g.tags.join("|").toLowerCase() === tags.join("|").toLowerCase())) return;
185
+ out.push({ tags, ...(match === "all" && tags.length > 1 ? { match: "all" as const } : {}) });
186
+ });
187
+ return out;
188
+ }
189
+
156
190
  // ---------------------------------------------------------------- tabs
157
191
 
158
192
  function levelsTab(env: AccessEnv, editing?: string): unknown[] {
@@ -161,8 +195,9 @@ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
161
195
  const rules = config.rules ?? [];
162
196
  const used = (slug: string) => rules.filter((r) => r.levels.includes(slug)).length;
163
197
  const current = editing ? levels.find((l) => l.slug === editing) : undefined;
198
+ const others = levels.filter((l) => l.slug !== current?.slug);
164
199
  return [
165
- { type: "context", text: "A level is a name for readers who hold some Shopify customer tags. Name them anything. Tags come from Shopify (Flow, a subscription app or staff), never from this site." },
200
+ { type: "context", text: "A level is a name for readers who hold some Shopify customer tags. Name them anything. Tags come from Shopify (Flow, a subscription app or staff), never from this site. A level that includes another reads everything for it too, so a rule names only the lowest level that may read." },
166
201
  ...sourceLine(env, "levels", "Levels"),
167
202
  levels.length
168
203
  ? {
@@ -173,31 +208,44 @@ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
173
208
  { key: "name", label: "Level" },
174
209
  { key: "slug", label: "Slug", format: "code" },
175
210
  { key: "tags", label: "Shopify tags" },
176
- { key: "match", label: "Needs" },
211
+ { key: "includes", label: "Includes" },
177
212
  { key: "rules", label: "Rules", format: "number" },
178
213
  { key: "act", label: "", format: "element" },
179
214
  ],
180
215
  rows: levels.map((l) => ({
181
216
  name: l.name,
182
217
  slug: l.slug,
183
- tags: l.tags.join(", "),
184
- match: l.tags.length < 2 ? "The tag" : l.match === "all" ? "Every tag" : "Any tag",
218
+ tags: groupsWords(l),
219
+ includes: (l.includes ?? []).map((s) => levels.find((x) => x.slug === s)?.name ?? s).join(", "),
185
220
  rules: used(l.slug),
186
221
  act: { type: "menu", action_id: "level_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${l.slug}` }, { label: "Remove", value: `remove:${l.slug}` }] },
187
222
  })),
188
223
  }
189
224
  : { type: "empty", title: "No levels yet", description: "Add one below, such as Members for the tag member.", size: "sm" },
190
225
  { type: "header", text: current ? `Edit ${current.name}` : "Add a level" },
226
+ { type: "context", text: "Shopify tags: a line passes when the customer has any one of its tags; start a line with all: to need every tag on it. Any one line grants the level, so gold on one line and all: silver, annual on the next means Gold, or Silver with Annual." },
191
227
  {
192
228
  type: "form",
193
229
  block_id: current ? `level:${current.slug}` : "level:new",
194
230
  fields: [
195
231
  { type: "text_input", action_id: "name", label: "Name", placeholder: "Members", initial_value: current?.name ?? "" },
196
232
  ...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "members" }]),
197
- { type: "text_input", action_id: "tags", label: "Shopify tags, comma separated", placeholder: "member", initial_value: current?.tags.join(", ") ?? "" },
198
- { type: "select", action_id: "match", label: "Customer needs", options: [{ label: "Any one tag", value: "any" }, { label: "Every tag", value: "all" }], initial_value: current?.match ?? "any" },
233
+ {
234
+ type: "text_input",
235
+ action_id: "tags",
236
+ label: "Shopify tags: commas between tags, one group per line",
237
+ placeholder: "gold\nall: silver, annual",
238
+ initial_value: groupsText(current),
239
+ multiline: true,
240
+ },
241
+ ...(others.length
242
+ ? [{ type: "checkbox", action_id: "includes", label: "Also opens what these levels read", options: others.map((l) => ({ label: l.name, value: l.slug })), initial_value: current?.includes ?? [] }]
243
+ : []),
199
244
  { type: "text_input", action_id: "wall", label: "Wall text (optional)", initial_value: current?.wall ?? "", multiline: true },
200
245
  { type: "text_input", action_id: "join_url", label: "Join link (optional)", placeholder: "/join/", initial_value: current?.joinUrl ?? "" },
246
+ { type: "text_input", action_id: "products", label: "Shopify product handles that lead to it (optional, for plan cards)", placeholder: "gold-membership", initial_value: current?.products?.join(", ") ?? "" },
247
+ { type: "text_input", action_id: "home", label: "Members' home after sign-in (optional)", placeholder: "/members/gold/", initial_value: current?.home ?? "" },
248
+ { type: "text_input", action_id: "badge", label: "Badge colour (optional)", placeholder: "#c9a227", initial_value: current?.badge ?? "" },
201
249
  ],
202
250
  submit: { label: current ? "Save level" : "Add level", action_id: "level_save" },
203
251
  },
@@ -497,16 +545,31 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
497
545
  const now = effective(env);
498
546
  const used = (now.rules ?? []).filter((r) => r.levels.includes(slug!)).length + (now.files ?? []).filter((f) => f.levels?.includes(slug!)).length + (now.features ?? []).filter((f) => f.levels?.includes(slug!)).length;
499
547
  if (used) return bad("levels", `Rules, features or folders use this level (${used}). Change them first.`);
500
- await saveSite(env, (site, n) => ({ ...site, levels: start(site, n, "levels").filter((l) => l.slug !== slug) }));
548
+ await saveSite(env, (site, n) => ({
549
+ ...site,
550
+ levels: start(site, n, "levels")
551
+ .filter((l) => l.slug !== slug)
552
+ .map((l) => {
553
+ if (!l.includes?.includes(slug!)) return l;
554
+ const includes = l.includes.filter((s) => s !== slug);
555
+ const { includes: _gone, ...rest } = l;
556
+ return includes.length ? { ...rest, includes } : rest;
557
+ }),
558
+ }));
501
559
  return ok("levels", "Level removed.");
502
560
  }
503
561
 
504
562
  case "level_save": {
505
563
  const name = String(values.name ?? "").trim();
506
- const tags = list(values.tags);
564
+ const groups = parseGroups(values.tags, values.match);
507
565
  const editing = target !== "new" ? target : null;
508
566
  if (!name) return bad("levels", "Give the level a name.", { level: editing ?? undefined });
509
- if (tags.length === 0) return bad("levels", "Give it at least one Shopify tag.", { level: editing ?? undefined });
567
+ if (groups.length === 0) return bad("levels", "Give it at least one Shopify tag.", { level: editing ?? undefined });
568
+ const home = String(values.home ?? "").trim();
569
+ if (home && (!home.startsWith("/") || home.startsWith("//"))) return bad("levels", "The members' home must be a path on this site, such as /members/.", { level: editing ?? undefined });
570
+ const badge = String(values.badge ?? "").trim();
571
+ if (badge && !badgeColor({ slug: "x", name: "x", badge })) return bad("levels", "The badge colour must be a hex colour, such as #c9a227.", { level: editing ?? undefined });
572
+ const products = list(values.products).map((h) => h.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "")).filter(Boolean);
510
573
  const fail = await saveSite(env, (site, n) => {
511
574
  const levels = start(site, n, "levels");
512
575
  let slug = editing;
@@ -515,11 +578,19 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
515
578
  if (typeof made !== "string") return made.error;
516
579
  slug = made;
517
580
  }
518
- const level: LevelConfig = { slug, name, tags, ...(values.match === "all" ? { match: "all" as const } : {}) };
581
+ const [first, ...more] = groups;
582
+ const level: LevelConfig = { slug, name, tags: first!.tags, ...(first!.match === "all" ? { match: "all" as const } : {}) };
583
+ if (more.length) level.groups = more;
584
+ const known = new Set(levels.map((l) => l.slug));
585
+ const includes = list(values.includes).filter((s) => s !== slug && known.has(s));
586
+ if (includes.length) level.includes = includes;
519
587
  const wall = String(values.wall ?? "").trim();
520
588
  const joinUrl = String(values.join_url ?? "").trim();
521
589
  if (wall) level.wall = wall;
522
590
  if (joinUrl) level.joinUrl = joinUrl;
591
+ if (products.length) level.products = [...new Set(products)];
592
+ if (home) level.home = home;
593
+ if (badge) level.badge = badge;
523
594
  const at = levels.findIndex((l) => l.slug === slug);
524
595
  if (at >= 0) levels[at] = level;
525
596
  else levels.push(level);