@redeemid/emdash 0.0.24 → 0.0.26
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 +47 -638
- package/docs/access.md +131 -0
- package/docs/content.md +84 -0
- package/docs/developer-api.md +25 -5
- package/docs/getting-started.md +94 -0
- package/docs/production.md +52 -0
- package/docs/reference.md +128 -0
- package/docs/shop.md +94 -0
- package/docs/shopify-flow.md +1 -1
- package/docs/shopify-setup.md +4 -4
- package/docs/staff.md +81 -0
- package/package.json +11 -2
- package/src/api-version.ts +1 -1
- package/src/catalog.ts +78 -0
- package/src/components/Content.astro +7 -1
- package/src/components/Gate.astro +3 -0
- package/src/components/MemberCard.astro +3 -0
- package/src/components/Plan.astro +3 -0
- package/src/components/Preferences.astro +3 -0
- package/src/components/YourData.astro +3 -0
- package/src/contract.ts +2 -1
- package/src/guard.ts +23 -2
- package/src/plugin.ts +19 -2
- package/src/route-cache.ts +134 -0
- package/src/settings.ts +9 -0
- package/src/template-scan.ts +33 -6
- package/src/version.ts +1 -1
package/docs/access.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Access: levels, rules and the meter
|
|
2
|
+
|
|
3
|
+
Who reads what. Shopify decides who holds which tags; everything here only says what each tag opens.
|
|
4
|
+
|
|
5
|
+
## Rules
|
|
6
|
+
|
|
7
|
+
Each rule names a collection and the levels, any of which may read. An empty `levels` list, or the built-in `signed-in` level, means any signed-in member.
|
|
8
|
+
|
|
9
|
+
| Rule | Covers |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `{ collection: "reports", levels: ["gold"] }` | Every entry in the collection |
|
|
12
|
+
| `{ collection: "posts", taxonomy: "category", term: "investigations", levels: ["gold"] }` | Entries with that term, or a child of it in a hierarchical taxonomy. A child's own rule wins over its parent's. |
|
|
13
|
+
| `{ collection: "posts", taxonomy: "tag", term: "free-to-read", levels: [], open: true }` | Opens entries with that term to everyone, whatever else covers them |
|
|
14
|
+
| `{ collection: "pages", levels: [], open: true }` | Opens a whole collection to everyone, unless a term rule locks an entry |
|
|
15
|
+
| `{ collection: "*", taxonomy: "category", term: "members", levels: ["member"] }` | That term in every collection, new ones included. A rule for the same term and a named collection wins there. |
|
|
16
|
+
| `{ collection: "*", levels: [] }` | The site default, for collections with no rule of their own |
|
|
17
|
+
| `{ collection: "posts", taxonomy: "category", term: "archive", levels: ["member"], drip: 30 }` | Drip: members read these entries 30 days after their level started |
|
|
18
|
+
| `{ collection: "posts", taxonomy: "category", term: "exclusive", levels: ["pro"], meter: false }` | The meter never opens these entries |
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
## Levels
|
|
23
|
+
|
|
24
|
+
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:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
levels: [
|
|
28
|
+
{ slug: "silver", name: "Silver", tags: ["silver"] },
|
|
29
|
+
// "gold", or "silver" with "annual".
|
|
30
|
+
{ slug: "gold", name: "Gold", tags: ["gold"], groups: [{ tags: ["silver", "annual"], match: "all" }], includes: ["silver"] },
|
|
31
|
+
],
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`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.
|
|
35
|
+
|
|
36
|
+
The rest of a level is how it looks and where it leads:
|
|
37
|
+
|
|
38
|
+
| Key | What it does |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `wall` | What the wall says to readers this level would let in |
|
|
41
|
+
| `joinUrl` | Where the wall's join link goes for this level (else `join.href`) |
|
|
42
|
+
| `products` | Handles of the Shopify products whose purchase leads to the level. Display only: plan cards use it (see [`<Plan>`](content.md)). It grants nothing. |
|
|
43
|
+
| `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. |
|
|
44
|
+
| `badge` | A hex colour, set as `--redeemid-level-color` on badges, the account box and the member card. Badges always show the name too. |
|
|
45
|
+
|
|
46
|
+
## Access settings in the admin
|
|
47
|
+
|
|
48
|
+
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**:
|
|
49
|
+
|
|
50
|
+
- **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.
|
|
51
|
+
- **Site rules.** The site default, how term rules combine, the meter and the wall's join link.
|
|
52
|
+
- **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.
|
|
53
|
+
- **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
|
|
54
|
+
- **Features** and **Protected files.**
|
|
55
|
+
|
|
56
|
+
The first save of a setting copies its value from your code config, so nothing is lost, and from then on the admin's value is used. **Use the code's setting** removes the admin's copy. Saved settings are kept in the site's database. Each server keeps the last ones it read and checks one small record per request to see whether anyone saved since, so the whole set is read again only after a save. If the database can't be read, the settings last read stand; a server that has read none yet locks every entry (and refuses files in any media folder) until it can, rather than falling back to the code's settings and opening what the admin locked.
|
|
57
|
+
|
|
58
|
+
Each saved entry also gets an **Access** panel in the editor. It shows who can read the entry right now and which rule says so, and sets the entry's own rule:
|
|
59
|
+
|
|
60
|
+
| Choice | Readers need |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| Follow the site rules | What the rules say (the default) |
|
|
63
|
+
| Free to everyone | Nothing |
|
|
64
|
+
| Only some levels | One of the levels ticked (none ticked: any signed-in member) |
|
|
65
|
+
| Site rules plus a level | What the rules say and one of the levels ticked |
|
|
66
|
+
|
|
67
|
+
The panel can also hold the entry back for some days of membership, keep the meter off it, lock it until a date and then open it, or keep it free until a date and then lock it. Pass the entry's `id` (or slug) to `<Content>`, `<AccessBadge>` and `<Gate>` so its own rule applies there.
|
|
68
|
+
|
|
69
|
+
Moving an entry to the trash keeps its rule for a restore but frees its slug, so a new entry that takes the slug doesn't inherit the rule.
|
|
70
|
+
|
|
71
|
+
## Meter and drip
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
meter: { limit: 3, period: "month", collections: ["posts"] },
|
|
75
|
+
teaser: { posts: 40 },
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
## Selling one entry on its own
|
|
83
|
+
|
|
84
|
+
Add the **Sold on its own** block anywhere in an entry's body. Give it the Shopify customer tag buyers get, such as `item-big-story`, and the product that sells it (its handle, or a link to it). Then:
|
|
85
|
+
|
|
86
|
+
- A reader holding that tag reads the whole entry. Its rule, Paywall break, Members only parts and drip don't apply, and the meter isn't used.
|
|
87
|
+
- Locked readers see **Buy just this** on the wall with the product's price, beside the level's offer.
|
|
88
|
+
- `<AccessBadge>` drops the lock for buyers, and protected files in an `entries: true` folder that the entry uses reach them too.
|
|
89
|
+
|
|
90
|
+
Shopify adds the tag when the order is paid, usually with a Flow workflow (Order paid → the line item is that product → add the customer tag). A refund workflow that removes the tag takes the entry away again.
|
|
91
|
+
|
|
92
|
+
## Features
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
features: [
|
|
96
|
+
{ slug: "ad-free", name: "Ad-free", levels: ["gold", "premium"] },
|
|
97
|
+
{ slug: "supporter", name: "Supporter", tags: ["supporter"] },
|
|
98
|
+
],
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A member holds a feature through any of its levels or tags. `<MemberCard>` lists them under the member's levels.
|
|
102
|
+
|
|
103
|
+
```astro
|
|
104
|
+
---
|
|
105
|
+
import Feature from "@redeemid/emdash/components/Feature.astro";
|
|
106
|
+
import { hasFeature } from "@redeemid/emdash/features";
|
|
107
|
+
---
|
|
108
|
+
<Feature slug="supporter"><p>Thanks for your support.</p><p slot="else">Become a supporter.</p></Feature>
|
|
109
|
+
{!(await hasFeature(Astro, "ad-free")) && <aside class="ad">…</aside>}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Protected files
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
files: [
|
|
116
|
+
{ folder: "Gold downloads", levels: ["gold"] },
|
|
117
|
+
{ folder: "Member downloads", levels: [] }, // any signed-in member
|
|
118
|
+
{ folder: "Supporter extras", features: ["supporter"] },
|
|
119
|
+
{ folder: "Story files", entries: true }, // whoever can read an entry that uses it
|
|
120
|
+
],
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Upload files into a folder in the EmDash media library (names match without regard to case). Every request for one at `/_emdash/api/media/file/…` is checked before EmDash serves it:
|
|
124
|
+
|
|
125
|
+
- **Allowed:** the file, with `Cache-Control: private, no-store`.
|
|
126
|
+
- **Signed out:** a 401 page with a sign-in link that comes back to the file.
|
|
127
|
+
- **Signed in without access:** a 403 page.
|
|
128
|
+
|
|
129
|
+
A rule lets in members who hold any of its `levels` or `features`. With `entries: true` it also lets in whoever can read a published entry that uses the file. Staff can always download, disabled members can't, and a file that can't be checked isn't served.
|
|
130
|
+
|
|
131
|
+
Limits: only EmDash's file route is checked, so don't give the storage bucket a public URL. Image services that fetch without the reader's cookies, such as a CDN resizer, are refused. Moving a file out of the folder makes it public again.
|
package/docs/content.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Content and templates
|
|
2
|
+
|
|
3
|
+
EmDash has no content filter hook, so templates decide what to render.
|
|
4
|
+
|
|
5
|
+
**`<Content>`** renders an entry body. Levels come from the entry's rule (its content type, its terms, or its own Access panel); a Paywall break only says where that rule starts. Everything before the first Paywall break block is shown to everyone, and what follows reaches the page only for readers the rule lets in. With no rule covering the entry, the break changes nothing. A body with no break follows the entry's rule as a whole, and readers it keeps out see the collection's `teaser` if one is set (whole paragraphs, never half of one).
|
|
6
|
+
|
|
7
|
+
```astro
|
|
8
|
+
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title}>
|
|
9
|
+
<a slot="locked" href="/join/">Become a member</a>
|
|
10
|
+
</Content>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Pass `id` on the entry's own page. It lets the meter count the read, and a locked entry gets Google's paywalled-content markup: JSON-LD with `isAccessibleForFree: false` and the wall (`.redeemid-locked`) as the part that isn't free, so search engines can tell a paywall from cloaking. `title` is its headline. Set `schema: false` if an SEO plugin already adds this markup.
|
|
14
|
+
|
|
15
|
+
**Members only.** A "Members only" block starts a part for the levels picked on it (none picked: the entry's rule, or any signed-in member) that runs to the next "Members only: end" block. Readers who can't read it see the wall in its place and read on after it.
|
|
16
|
+
|
|
17
|
+
**The wall** is `<LockedPanel>`. It says why the reader is kept out and offers sign-in or the join link, with the wall text of the first level they're missing. `<Content>` and `<Gate>` show it, and their `locked` slot goes on top.
|
|
18
|
+
|
|
19
|
+
**`<Gate>`** locks any part of a template that isn't the body:
|
|
20
|
+
|
|
21
|
+
```astro
|
|
22
|
+
<Gate levels={["pro"]}><a href="/reports/archive.zip">Download the archive</a></Gate>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**`<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`):
|
|
26
|
+
|
|
27
|
+
```astro
|
|
28
|
+
<AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**`<Notices />`** goes near the top of the layout. It shows the message for a `?redeemid_notice=` code (see [notices](reference.md#notices)) and nothing otherwise.
|
|
32
|
+
|
|
33
|
+
**`<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](staff.md#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.
|
|
34
|
+
|
|
35
|
+
**`<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.
|
|
36
|
+
|
|
37
|
+
```astro
|
|
38
|
+
<Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
|
|
39
|
+
<p>Shown if the product can't be loaded.</p>
|
|
40
|
+
</Plan>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**In your own code** (`@redeemid/emdash/access`):
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { canRead, entryRequirement, readEntry, siteConfig } from "@redeemid/emdash/access";
|
|
47
|
+
|
|
48
|
+
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
49
|
+
// verdict.allowed; verdict.reason: open | allowed | sign_in | upgrade | disabled | unconfigured
|
|
50
|
+
const read = await readEntry(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
51
|
+
// adds drip (read.opensAt) and the meter (read.meterLeft), and counts a metered read
|
|
52
|
+
|
|
53
|
+
const config = await siteConfig(Astro.locals);
|
|
54
|
+
const needs = await entryRequirement(config, "posts", post.data.terms, post.id);
|
|
55
|
+
// e.g. [["gold", "pro"]]: readers pass every clause, each with any one of its levels; null when open
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**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()`:
|
|
59
|
+
|
|
60
|
+
```astro
|
|
61
|
+
---
|
|
62
|
+
import { search } from "emdash";
|
|
63
|
+
import { readableResults } from "@redeemid/emdash/search";
|
|
64
|
+
|
|
65
|
+
const q = Astro.url.searchParams.get("q") ?? "";
|
|
66
|
+
const results = q ? await readableResults(Astro, q, (await search(q)).items) : [];
|
|
67
|
+
---
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`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.
|
|
71
|
+
|
|
72
|
+
## Replacement text
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
text: {
|
|
76
|
+
"Sign in": "Log in",
|
|
77
|
+
"This is for members. {signIn} to read on.": "Members only. {signIn} to keep reading.",
|
|
78
|
+
"This is for {level} members.": "{level} members only.",
|
|
79
|
+
},
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each key is a string's original English and its value is the new text. Only the strings you name change: there is no find-and-replace, so replacing "Sign in" leaves "Sign in to download this file." alone. `STRINGS` in `@redeemid/emdash/text` lists every string, grouped by where it shows.
|
|
83
|
+
|
|
84
|
+
A replacement may use its original's placeholders (such as `{level}`, or `{signIn}` for a link) in any order, or not at all, but no others. Text is plain, so HTML shows as typed. A replacement with an unknown placeholder, or for a string RedeemID doesn't show, is ignored and listed on the Status page with the reason. Props you pass (`label`, `title`, Notices `messages`) win over `text`. Staff screens in the admin aren't covered.
|
package/docs/developer-api.md
CHANGED
|
@@ -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.
|
|
3
|
+
What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.5.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
|
|
|
@@ -21,10 +21,11 @@ Import these in Astro pages, components and endpoints.
|
|
|
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
|
-
| `@redeemid/emdash/catalog` | `searchProducts`, `collectionPage`, `suggest`, `recommendations`, `browseFromUrl`, `browseHref`, `parseFilters`, `priceFilter`, `SORTS` |
|
|
24
|
+
| `@redeemid/emdash/catalog` | `searchProducts`, `collectionPage`, `suggest`, `recommendations`, `browseFromUrl`, `browseHref`, `parseFilters`, `priceFilter`, `SORTS`, `productSitemap` |
|
|
25
25
|
| `@redeemid/emdash/events` | `signBody` |
|
|
26
26
|
| `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
|
|
27
27
|
| `@redeemid/emdash/search` | `readableResults` |
|
|
28
|
+
| `@redeemid/emdash/cache` | `cacheEntry`, `cachePage`, `noRouteCache`, `CACHE_TAG` |
|
|
28
29
|
|
|
29
30
|
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).
|
|
30
31
|
|
|
@@ -44,6 +45,8 @@ const totes = config && (await searchProducts(config, "tote", {}, { context: sto
|
|
|
44
45
|
---
|
|
45
46
|
```
|
|
46
47
|
|
|
48
|
+
Route cache: call `await cacheEntry(Astro, collection, entry, cacheHint)` on an entry's page and `cachePage(Astro, cacheHint)` on lists in place of `Astro.cache.set()`. See [caching](production.md#caching).
|
|
49
|
+
|
|
47
50
|
Catalog reads (`product`, `collection`, `searchProducts`, `collectionPage`, `suggest`, `recommendations`) are cached for five minutes per Worker isolate. Pass `storeContext(config, Astro.request)` as `context` for local prices when config `market` is set.
|
|
48
51
|
|
|
49
52
|
## JSON routes
|
|
@@ -53,7 +56,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
53
56
|
- `api/state` (GET): the visitor's own state.
|
|
54
57
|
|
|
55
58
|
```json
|
|
56
|
-
{ "apiVersion": "1.
|
|
59
|
+
{ "apiVersion": "1.5.0", "version": "0.0.26", "signedIn": true, "linked": true,
|
|
57
60
|
"levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
|
|
58
61
|
"meter": { "used": 1, "limit": 3, "period": "month" } }
|
|
59
62
|
```
|
|
@@ -70,7 +73,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
70
73
|
- `?collection=shop`: the collection's page, `{ handle, title, description, products, filters, pageInfo }`.
|
|
71
74
|
|
|
72
75
|
Both take `first` (1 to 50), `sort`, `after` or `before` (cursors from `pageInfo`), `filter` (repeatable: a filter value's `input` exactly as Shopify gave it), `price_min` and `price_max`. Sorts are `relevance`, `best-selling`, `newest`, `price-asc`, `price-desc`, `title-asc`, `title-desc` (search: relevance and price only). Over the rate limit a search answers 429, as `api/cart` does.
|
|
73
|
-
- `preferences` (GET, POST) and `privacy` (GET export, POST erase): see
|
|
76
|
+
- `preferences` (GET, POST) and `privacy` (GET export, POST erase): see [preferences](shop.md#preferences) and [privacy requests](staff.md#privacy-requests).
|
|
74
77
|
|
|
75
78
|
Form routes, which redirect back with `?redeemid_notice=<code>` when something needs saying: `login`, `callback`, `logout`, `buy`, `cart`, `cart-edit`, `account`. `webhook` is for Shopify only and answers with a status code.
|
|
76
79
|
|
|
@@ -116,7 +119,24 @@ The client also enhances RedeemID's markup: with `<CartDrawer />` on the page, A
|
|
|
116
119
|
|
|
117
120
|
## Server events
|
|
118
121
|
|
|
119
|
-
Signed outbound webhooks
|
|
122
|
+
Signed outbound webhooks for a CRM or email tool. Verify `X-RedeemID-Signature` with `signBody(rawBody, secret)` from `@redeemid/emdash/events`.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
|
|
126
|
+
```
|
|
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.
|
|
129
|
+
|
|
130
|
+
| Event | `data` |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| `member.signed_in` | `customerId`, `levels`, `first` (first sign-in), `refresh` |
|
|
133
|
+
| `member.levels_changed` | `customerId`, `levels`, `added`, `removed`, `source` (`sign-in`, the webhook topic, or a staff resync or tag edit) |
|
|
134
|
+
| `meter.used` | `userId` (EmDash), `entry` (`collection:id`), `used`, `limit`, `period` (`2026-11`, or `2026-W45` by week) |
|
|
135
|
+
| `meter.exhausted` | The same, sent with the last free read |
|
|
136
|
+
| `preferences.saved` | `customerId`, `fields` (the names changed, never the answers) |
|
|
137
|
+
| `member.erased` | `customerId`, `source` (`member`, `staff` or `shopify`) |
|
|
138
|
+
|
|
139
|
+
No event carries an email address or a name; match people by `customerId`. Leave `only` out to send every event.
|
|
120
140
|
|
|
121
141
|
## MCP and the command line
|
|
122
142
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
From an empty EmDash site to a locked post that opens for a tagged customer.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- **EmDash 1.1 or later** with Astro 6 or later, on Node or Cloudflare Workers (the Workers free plan is enough). The MCP tools need an EmDash version with plugin MCP tools.
|
|
8
|
+
- **A Shopify store with the new customer accounts** (Settings → Customer accounts). Classic accounts have no Customer Account API.
|
|
9
|
+
- **The Headless sales channel**, for the sign-in client and Storefront API tokens.
|
|
10
|
+
- **A Shopify app on the store**, such as a Dev Dashboard app, to send webhooks. With its client ID too, it runs the staff tools and preferences.
|
|
11
|
+
- **HTTPS.** Shopify only accepts `https` callback URIs, so a local site needs a tunnel to sign in for real.
|
|
12
|
+
|
|
13
|
+
## 1. Set up Shopify
|
|
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 → Setup** walks through the same steps for your site, with its own callback URI filled in, and says which are done.
|
|
16
|
+
|
|
17
|
+
## 2. Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm add @redeemid/emdash
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
RedeemID is a native EmDash plugin: an npm dependency, not a registry plugin, because sign-in and page gating run in the site process.
|
|
24
|
+
|
|
25
|
+
## 3. Configure
|
|
26
|
+
|
|
27
|
+
```js title="astro.config.mjs"
|
|
28
|
+
import { redeemid, redeemidGuard, redeemidPlugin } from "@redeemid/emdash";
|
|
29
|
+
import { defineConfig } from "astro/config";
|
|
30
|
+
import emdash from "emdash/astro";
|
|
31
|
+
|
|
32
|
+
const config = {
|
|
33
|
+
shop: "my-store.myshopify.com",
|
|
34
|
+
levels: [
|
|
35
|
+
{ slug: "member", name: "Member", tags: ["member"] },
|
|
36
|
+
{ slug: "pro", name: "Pro", tags: ["pro"], wall: "Pro members read every report." },
|
|
37
|
+
],
|
|
38
|
+
rules: [
|
|
39
|
+
{ collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
|
|
40
|
+
{ collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
|
|
41
|
+
],
|
|
42
|
+
join: { href: "/join/", label: "Become a member" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default defineConfig({
|
|
46
|
+
integrations: [
|
|
47
|
+
emdash({
|
|
48
|
+
// database, storage, …
|
|
49
|
+
authProviders: [redeemid(config)], // sign-in, webhooks, cart and checkout routes
|
|
50
|
+
plugins: [redeemidPlugin(config)], // admin pages and editor blocks
|
|
51
|
+
}),
|
|
52
|
+
redeemidGuard(), // gates pages and keeps members out of EmDash's own APIs
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Pass the same `config` to `redeemid()` and `redeemidPlugin()`. Every option is in the [config reference](reference.md#config).
|
|
58
|
+
|
|
59
|
+
Levels, rules, the meter, features, protected folders, teasers and the join link in this config are the site's defaults. You can leave them all out and set them under **Plugins → RedeemID → Access** instead; see [access settings in the admin](access.md#access-settings-in-the-admin).
|
|
60
|
+
|
|
61
|
+
## 4. Set the secrets
|
|
62
|
+
|
|
63
|
+
Set them as runtime secrets of the site (`.env` locally, `wrangler secret put` on Workers), not as build-time variables:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=… # sign-in
|
|
67
|
+
REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET=… # verifies webhooks
|
|
68
|
+
REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # only to sell on the site: plans, shop and checkout
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The [secrets reference](reference.md#secrets) lists the rest. The Setup page prints the command for each secret its steps need.
|
|
72
|
+
|
|
73
|
+
## 5. Render content through RedeemID
|
|
74
|
+
|
|
75
|
+
```astro
|
|
76
|
+
---
|
|
77
|
+
import Account from "@redeemid/emdash/components/Account.astro";
|
|
78
|
+
import Cart from "@redeemid/emdash/components/Cart.astro";
|
|
79
|
+
import Content from "@redeemid/emdash/components/Content.astro";
|
|
80
|
+
import Notices from "@redeemid/emdash/components/Notices.astro";
|
|
81
|
+
---
|
|
82
|
+
<header><Account /> <Cart /></header>
|
|
83
|
+
<Notices />
|
|
84
|
+
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title} />
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Use `<Content>` in place of `<PortableText>` for every body that may be locked, because plain `<PortableText>` shows everything. You don't have to find them by hand: `redeemidGuard()` scans the site's `.astro` files at build time, and the Status page names each template, with its line, that renders a gated collection's body with `<PortableText>`. While any rule is on, it also lists templates whose collection it can't tell (such as a component given the body as a prop), so you can check them.
|
|
88
|
+
|
|
89
|
+
## 6. Check it
|
|
90
|
+
|
|
91
|
+
1. Finish EmDash setup as staff first. Store customers can't create the first account.
|
|
92
|
+
2. Open **Plugins → RedeemID → 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.
|
|
93
|
+
3. Tag a test customer `member` in Shopify, open a locked post, and sign in as that customer. The post opens.
|
|
94
|
+
4. Remove the tag in Shopify. The post locks again once the webhook arrives.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Running in production
|
|
2
|
+
|
|
3
|
+
## Caching
|
|
4
|
+
|
|
5
|
+
`redeemidGuard()` keeps every page private for a signed-in visitor or a visitor with a cart: `Cache-Control: private, no-store`, and out of Astro's route cache (it calls `cache.set(false)` after the page sets its own hint). Other visitors all get the same page, so their pages stay cacheable.
|
|
6
|
+
|
|
7
|
+
Astro's route cache keys pages by URL and serves a stored page without running any code, so whoever asks next gets the same copy. Don't call `Astro.cache.set()` yourself on pages that show entries. Use RedeemID's helpers in the page's frontmatter instead:
|
|
8
|
+
|
|
9
|
+
```astro
|
|
10
|
+
---
|
|
11
|
+
import { getEmDashEntry, getEmDashCollection } from "emdash";
|
|
12
|
+
import { cacheEntry, cachePage } from "@redeemid/emdash/cache";
|
|
13
|
+
|
|
14
|
+
// An entry's page: cached only when it reads the same for everyone.
|
|
15
|
+
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
|
|
16
|
+
await cacheEntry(Astro, "posts", post, cacheHint);
|
|
17
|
+
|
|
18
|
+
// A list, or any page without a gated body.
|
|
19
|
+
const { entries, cacheHint: listHint } = await getEmDashCollection("posts");
|
|
20
|
+
cachePage(Astro, listHint);
|
|
21
|
+
---
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **`cacheEntry(Astro, collection, entry, cacheHint)`** caches the page only when no rule covers the entry (content type, terms or its Access panel), it isn't free just until a date, and its body has no Members only part, Sold on its own, Plan or Preferences block. Any other page stays out of the route cache, even if a layout sets a hint afterwards: `redeemidGuard()` takes it out again once the page has rendered. It returns whether the page was cached. `Cache-Control` is left as it was, since a visitor's copy is the same for every visitor.
|
|
25
|
+
- **`cachePage(Astro, cacheHint)`** caches a page as the hint says. Use it for lists and pages that show no gated body.
|
|
26
|
+
- **`noRouteCache(Astro)`** keeps any page out of the route cache.
|
|
27
|
+
|
|
28
|
+
RedeemID's own components also keep their page out when what they show depends on the reader, so a route rule (`routeRules` in `astro.config.mjs`) or an old `Astro.cache.set()` can't store one reader's copy: `<Content>` for a locked entry, a Members only part, a Sold on its own block or a free-until window; `<Gate>` with a rule; `<Plan>`; `<MemberCard>`, `<Preferences>` and `<YourData>`. `<AccessBadge>` and `<Account>` don't, so lists and other pages stay cacheable; on a cached page a member sees the visitor's badges and header. Still use the helpers in page frontmatter: they decide before anything renders, and they tag the page for purging.
|
|
29
|
+
|
|
30
|
+
Both caching helpers add the tag `redeemid` (`CACHE_TAG`). Saving on the Access page or an entry's Access panel purges that tag, so cached pages follow the new rules at once. EmDash purges an entry's own pages when the entry is saved. A page cached by a route rule alone has no `redeemid` tag; add it to the rule (`routeRules: { "/posts/[slug]": { maxAge: 300, tags: ["redeemid"] } }`). A deploy that changes the rules in `astro.config.mjs` purges nothing: purge the tag with your cache provider, or wait for `maxAge`.
|
|
31
|
+
|
|
32
|
+
A cached page is the same for everyone, signed-in members included, since nothing runs on a hit. Keep member-aware parts of cached pages, such as the header's Account link or a cart count, in the browser with [`window.redeemid`](developer-api.md), or leave those pages out of the cache.
|
|
33
|
+
|
|
34
|
+
The Status page lists templates that call `Astro.cache.set()` on pages of a gated collection.
|
|
35
|
+
|
|
36
|
+
## Rate limits
|
|
37
|
+
|
|
38
|
+
On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one visitor can start Sign in, Buy, a cart change or a catalog search (`api/search`), the routes that call Shopify. Add a [Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) named `REDEEMID_RATE_LIMIT`, which sets the limit and period:
|
|
39
|
+
|
|
40
|
+
```jsonc
|
|
41
|
+
"ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Each action counts separately per visitor (by `CF-Connecting-IP`), and Cloudflare's count is approximate. A visitor over the limit goes back to their page with the `too_many_requests` notice; the JSON routes answer `429` with `{ "error": "too_many_requests" }`. If the binding is missing or fails, nothing is limited. Off by default; leave it off on Node.
|
|
45
|
+
|
|
46
|
+
## Troubleshooting
|
|
47
|
+
|
|
48
|
+
- **A setting shows Missing on the Status page.** Check the name: the Status page 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
|
+
- **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
|
+
- **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 Webhooks → Received and Health, and App events with Problems only. Resync the member from their page, or ask them to press Refresh membership.
|
|
52
|
+
- **A member sees the visitor's page.** A cache is serving it. See [caching](#caching).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Reference
|
|
2
|
+
|
|
3
|
+
## Config
|
|
4
|
+
|
|
5
|
+
Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels`, `rules`, `meter`, `features`, `files`, `combine`, `teaser` and `join` are defaults that the Access page can replace.
|
|
6
|
+
|
|
7
|
+
| Option | Type | What it does |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `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`. |
|
|
10
|
+
| `label` | `string` | The sign-in button's label. Default "Sign in". |
|
|
11
|
+
| `levels` | `{ slug, name, tags?, match?, groups?, includes?, wall?, joinUrl?, products?, home?, badge? }[]` | Levels from customer tags. See [levels](access.md#levels). |
|
|
12
|
+
| `rules` | `{ collection, taxonomy?, term?, levels, open?, drip?, meter? }[]` | What needs which levels. See [rules](access.md#rules). |
|
|
13
|
+
| `combine` | `"all"` \| `"any"` | How several term rules on one entry combine. Default `"all"`. |
|
|
14
|
+
| `join` | `{ href, label? }` | The wall's join link. |
|
|
15
|
+
| `teaser` | `{ [collection]: words }` | Teaser length for locked entries with no paywall break. |
|
|
16
|
+
| `meter` | `{ limit, period?, collections? }` | Free reads for signed-in readers. |
|
|
17
|
+
| `features` | `{ slug, name, levels?, tags? }[]` | Perks held through levels or tags. |
|
|
18
|
+
| `preferences` | `{ key, access? }[]` | Customer metafields members see (`access: "view"`) or change. |
|
|
19
|
+
| `files` | `{ folder, levels?, features?, entries? }[]` | Protected media folders. |
|
|
20
|
+
| `text` | `{ [original]: replacement }` | Replacement text, one string at a time. |
|
|
21
|
+
| `events` | `{ url, only? }` | Signed outbound events. |
|
|
22
|
+
| `agents` | `{ actions?: boolean }` | Also offer resync and tag edits to agents and the command line. |
|
|
23
|
+
| `market` | `{ country?, language? }` | Local prices: a fixed two-letter `country`, or `"visitor"` for each visitor's own. |
|
|
24
|
+
| `checkout` | `{ signIn?: false }` | `signIn: false` stops passing a member's store sign-in to checkout. |
|
|
25
|
+
| `schema` | `false` | Turn off the paywalled-content JSON-LD. |
|
|
26
|
+
| `memberPaths` | `string[]` | Extra `/_emdash` path prefixes signed-in members may use. Everything else there is refused to them. |
|
|
27
|
+
| `entries` | `{ ["collection:id"]: EntryRule }` | Entries' own rules. Usually set in the editor's Access panel. |
|
|
28
|
+
| `allowInsecureShop` | `boolean` | Local tests only: lets `shop` be an `http://` fake store. Never set it on a live site. |
|
|
29
|
+
|
|
30
|
+
`redeemidGuard(options)` takes `{ rateLimit?: boolean }`.
|
|
31
|
+
|
|
32
|
+
## Secrets
|
|
33
|
+
|
|
34
|
+
| Variable | Needed for | What it is |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Sign-in | Client ID of the Headless storefront's Customer Account API client |
|
|
37
|
+
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Sign-in, confidential clients | Its client secret. Leave unset for a public client. |
|
|
38
|
+
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks | Client secret of the app that sends the webhooks. Shopify signs each webhook with it. |
|
|
39
|
+
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Webhook registration, staff tools, preferences, membership dates, Shopify erasure requests | Client ID of that same app. Optional, but most of the admin needs it. |
|
|
40
|
+
| `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Plans, shop, checkout | Storefront API private access token, used server-side. Optional if the site sells nothing. |
|
|
41
|
+
| `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Plans, shop, checkout | Public access token, only used without the private one |
|
|
42
|
+
| `REDEEMID_SHOPIFY_STORE_DOMAIN` | Discovery | The store's domain, only when `shop` isn't in the config |
|
|
43
|
+
| `REDEEMID_EVENTS_SECRET` | Events | Signs outbound events. Set it with `events.url`. |
|
|
44
|
+
|
|
45
|
+
The Status page shows each one as Set or Missing, and lists any `REDEEMID_` variable that is set but not read, by name only, so a misspelt name stands out. Authorization, token and logout endpoints need no setting: RedeemID reads them from the store's discovery documents.
|
|
46
|
+
|
|
47
|
+
## Components
|
|
48
|
+
|
|
49
|
+
Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
50
|
+
|
|
51
|
+
| Component | Props | Use |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `Content` | `value`, `collection`, `terms`, `id`, `title`; slot `locked` | An entry body with breaks, Members only parts, teaser, meter and wall |
|
|
54
|
+
| `Gate` | `collection`, `terms`, `id`, `levels`; slot `locked` | Locks any part of a template |
|
|
55
|
+
| `LockedPanel` | `reason`, `required`, … | The wall on its own |
|
|
56
|
+
| `AccessBadge` | `collection`, `terms`, `content`, `id`, `free`, `class` | What an entry needs, with a lock for readers kept out |
|
|
57
|
+
| `Account` | | Sign in, or the member's name, levels, Account and Sign out |
|
|
58
|
+
| `MemberCard` | `title` | Levels, renewal, end and gift dates, past levels, meter, features, Refresh membership |
|
|
59
|
+
| `Preferences` | `title`, `heading`, `label`; slot `signed-out` | The member's preference form |
|
|
60
|
+
| `YourData` | `title`, `heading` | Download or delete my data |
|
|
61
|
+
| `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
|
|
62
|
+
| `Notices` | `messages` | The message for `?redeemid_notice=` |
|
|
63
|
+
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel`, `upgradeLabel` | A product with a buy button that fits the member |
|
|
64
|
+
| `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
|
|
65
|
+
| `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
|
|
66
|
+
| `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
|
|
67
|
+
| `Recommendations` | `handle`, `intent`, `limit`, `productHref`, `title`, `quickAdd` | Shopify's recommendations |
|
|
68
|
+
| `ProductGrid` | `products`, `productHref`, `heading`, `quickAdd` | Product cards, for your own lists |
|
|
69
|
+
| `BrowseControls` | `filters`, `pageInfo`, `id`, `search`, `query` | Sort, filter and page controls, for your own lists from `collectionPage()` or `searchProducts()` |
|
|
70
|
+
| `Cart` | `label`, `href`, `drawer` | The cart count, linking to checkout or opening the drawer |
|
|
71
|
+
| `CartView` | `productHref`, `empty` | A cart page |
|
|
72
|
+
| `CartDrawer` | `title` | The cart in a slide-in dialog |
|
|
73
|
+
| `RedeemIDClient` | | The browser client, `window.redeemid` |
|
|
74
|
+
| `Contribution` | `handle`, `amounts`, `initial`, `label`, `title` | Pay what you want |
|
|
75
|
+
|
|
76
|
+
## Editor blocks
|
|
77
|
+
|
|
78
|
+
`redeemidPlugin()` adds these Portable Text blocks to the EmDash editor. `<Content>` renders them.
|
|
79
|
+
|
|
80
|
+
| Block | Type | Does |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| Paywall break | `redeemid-paywall` | Everyone reads up to here; the rest follows the entry's rule |
|
|
83
|
+
| Members only / Members only: end | `redeemid-members`, `redeemid-members-end` | A members-only part in the middle of a body |
|
|
84
|
+
| Sold on its own | `redeemid-item` | Sells the entry by itself to holders of a tag |
|
|
85
|
+
| Plan | `redeemid-plan` | A product with a buy button |
|
|
86
|
+
| Product | `redeemid-product` | A product with variants and Add to cart |
|
|
87
|
+
| Collection | `redeemid-collection` | A grid of products, optionally with filters, pages and Add to cart |
|
|
88
|
+
| Product search | `redeemid-search` | A search box with Shopify's results |
|
|
89
|
+
| Contribution | `redeemid-contribution` | Pay what you want |
|
|
90
|
+
| Preferences | `redeemid-preferences` | The member's preference form |
|
|
91
|
+
|
|
92
|
+
## Modules
|
|
93
|
+
|
|
94
|
+
| Import | Gives |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `@redeemid/emdash` | `redeemid()`, `redeemidPlugin()`, `redeemidGuard()`, config types |
|
|
97
|
+
| `@redeemid/emdash/access` | `siteConfig()`, `canRead()`, `readEntry()`, `entryRequirement()`, `viewer()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
|
|
98
|
+
| `@redeemid/emdash/features` | `hasFeature()`, `viewerFeatures()` |
|
|
99
|
+
| `@redeemid/emdash/preferences` | `readPreferences()`, `savePreferences()` |
|
|
100
|
+
| `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls |
|
|
101
|
+
| `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()`, `productSitemap()` |
|
|
102
|
+
| `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
|
|
103
|
+
| `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
|
|
104
|
+
| `@redeemid/emdash/search` | `readableResults()`, for a search page that calls EmDash's `search()` |
|
|
105
|
+
| `@redeemid/emdash/contract` | The stable API as a list, and `API_VERSION` |
|
|
106
|
+
|
|
107
|
+
The full developer API (stable exports, JSON routes, the browser client and its events, MCP tools) is in [docs/developer-api.md](developer-api.md).
|
|
108
|
+
|
|
109
|
+
## Routes
|
|
110
|
+
|
|
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
|
+
|
|
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` (the Setup page 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
|
+
|
|
115
|
+
## Notices
|
|
116
|
+
|
|
117
|
+
Routes send readers back with `?redeemid_notice=<code>`, and `<Notices />` shows the message.
|
|
118
|
+
|
|
119
|
+
| From | Codes |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| Sign in | `not_configured`, `sign_in_unavailable`, `sign_in_expired`, `staff_account`, `account_disabled`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed` |
|
|
122
|
+
| Buy and cart | `plan_unavailable`, `checkout_failed`, `cart_empty`, `cart_added`, `cart_updated`, `discount_applied`, `discount_not_applicable` |
|
|
123
|
+
| Account | `account_unavailable`, `refreshed` |
|
|
124
|
+
| Preferences | `preferences_saved`, `preferences_refused`, `preferences_unavailable` |
|
|
125
|
+
| Privacy | `data_erased`, `erase_unconfirmed` |
|
|
126
|
+
| Rate limits | `too_many_requests` |
|
|
127
|
+
|
|
128
|
+
Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text`.
|