@redeemid/emdash 0.0.2 → 0.0.4

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
@@ -2,16 +2,20 @@
2
2
 
3
3
  Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
4
4
 
5
- **Status: 0.0.2, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the page gate and plan checkout are covered by an end-to-end test against a fake store.
5
+ **Status: 0.0.4, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, teasers, badges, the page gate, the Shopify cart and checkout are covered by an end-to-end test against a fake store.
6
6
 
7
7
  ## What it does
8
8
 
9
9
  - **Sign in with the store's customer account.** Members click "Sign in" on your site, sign in on the store's account page and come back signed in. They become EmDash Subscribers. That role only means "signed in"; it never grants content.
10
10
  - **Mirrors the customer.** Email, name, tags and enabled/disabled status, kept current by signed Shopify webhooks.
11
11
  - **Levels from tags.** You map Shopify customer tags to levels. Set tags in Shopify Flow or any app; RedeemID only reads them.
12
- - **Rules per collection or term.** Lock a collection, or only entries in a category or tag.
13
- - **Plans and checkout.** `<Plan>` shows a Shopify product's price and starts checkout through the Storefront API.
14
- - **A page gate.** `<Gate>` shows the entry to readers who may read it and a teaser plus "Sign in" to everyone else.
12
+ - **Rules like RedeemID's on WordPress.** Lock a collection, entries with a term (and its child terms), or set a site default; a term can also open its entries to everyone. When several term rules cover an entry, readers must pass all of them.
13
+ - **Teasers and badges.** Locked entries can show their first paragraphs, up to a word count. `<AccessBadge>` names the level an entry needs and shows a lock to readers it keeps out.
14
+ - **Plans, cart and checkout.** `<Plan>` shows a Shopify product's price and adds it to the visitor's Shopify cart; `<Cart>` shows the count and opens Shopify checkout.
15
+ - **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
16
+ - **Paywall break block.** Editors write one body and drop a "Paywall break" block where members-only content starts, picking which levels may read on, or leaving it to the entry's rule. `<Content>` renders the body and leaves the rest off the page for everyone else.
17
+ - **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
18
+ - **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels), a Status page (secrets, store discovery, the URLs to give Shopify, levels and rules) and a dashboard widget.
15
19
  - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
16
20
 
17
21
  ## Install
@@ -23,28 +27,31 @@ pnpm add @redeemid/emdash
23
27
  ```
24
28
 
25
29
  ```js title="astro.config.mjs"
26
- import { redeemid, redeemidGuard } from "@redeemid/emdash";
30
+ import { redeemid, redeemidGuard, redeemidPlugin } from "@redeemid/emdash";
27
31
  import emdash from "emdash/astro";
28
32
 
33
+ const config = {
34
+ shop: "my-store.myshopify.com",
35
+ levels: [
36
+ { slug: "member", name: "Member", tags: ["member"] },
37
+ { slug: "pro", name: "Pro", tags: ["pro"] },
38
+ // match: "all" needs every tag, e.g. both memberships:
39
+ { slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" },
40
+ ],
41
+ rules: [
42
+ { collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
43
+ { collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
44
+ ],
45
+ // Optional: locked posts with no paywall break show the paragraphs within their first 40 words.
46
+ teaser: { posts: 40 },
47
+ };
48
+
29
49
  export default defineConfig({
30
50
  integrations: [
31
51
  emdash({
32
52
  // database, storage, …
33
- authProviders: [
34
- redeemid({
35
- shop: "my-store.myshopify.com",
36
- levels: [
37
- { slug: "member", name: "Member", tags: ["member"] },
38
- { slug: "pro", name: "Pro", tags: ["pro"] },
39
- // match: "all" needs every tag, e.g. both memberships:
40
- { slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" },
41
- ],
42
- rules: [
43
- { collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
44
- { collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
45
- ],
46
- }),
47
- ],
53
+ authProviders: [redeemid(config)], // sign-in, webhooks, cart and checkout routes
54
+ plugins: [redeemidPlugin(config)], // admin pages and the editor blocks
48
55
  }),
49
56
  redeemidGuard(),
50
57
  ],
@@ -72,30 +79,59 @@ In Shopify, give the Customer Account API client these settings:
72
79
 
73
80
  Point webhooks (`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`, `customers/redact`) at `https://your-site.example/_emdash/api/auth/redeemid/webhook`.
74
81
 
82
+ ### Rules
83
+
84
+ 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.
85
+
86
+ | Rule | Covers |
87
+ | --- | --- |
88
+ | `{ collection: "reports", levels: ["gold"] }` | Every entry in the collection |
89
+ | `{ collection: "posts", taxonomy: "category", term: "investigations", levels: ["gold"] }` | Entries with that term, or with a child of it in a hierarchical taxonomy. A child's own rule wins over its parent's. |
90
+ | `{ collection: "posts", taxonomy: "tag", term: "free-to-read", levels: [], open: true }` | Opens entries with that term to everyone, whatever else covers them |
91
+ | `{ collection: "*", levels: [] }` | The site default, for collections with no rule of their own |
92
+
93
+ Term rules beat the collection's rule, which beats the site default. When several term rules cover one entry (a story in Investigations, Gold, and Culture, Silver), readers must pass every one of them, so only Gold + Silver members read it. Set `combine: "any"` to let one of them be enough.
94
+
75
95
  ## Use it in templates
76
96
 
77
- EmDash has no content filter hook, so templates decide what to render. Wrap the entry body:
97
+ EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
98
+
99
+ ```astro
100
+ ---
101
+ import Content from "@redeemid/emdash/components/Content.astro";
102
+ ---
103
+ <Content value={post.data.content} collection="posts" terms={post.data.terms}>
104
+ <a slot="locked" href="/join/">Become a member</a>
105
+ </Content>
106
+ ```
107
+
108
+ Everything before the first Paywall break block is shown to everyone. What follows reaches the page only for readers the break allows: the levels picked on it ("Signed in" for any member), or when none are picked the entry's rule. With none picked and no rule covering the entry, the break changes nothing, as on WordPress. A body with no break follows the entry's rule as a whole; readers it keeps out see the collection's `teaser` (the paragraphs within that many words, never half a paragraph), if one is set. Plain `<PortableText>` ignores the break and shows everything, so use `<Content>` for any body that may hold one.
109
+
110
+ To lock a part of a template that isn't the body, wrap it in `<Gate>` (`<Gate levels={["pro"]}>…</Gate>`).
111
+
112
+ Show what an entry needs with `<AccessBadge>`, on cards and on the entry. It reads the entry's rules (and a paywall break's levels when you pass `content`), shows "Free" for open entries (`free=""` hides it), and adds `is-locked` and a lock for readers it keeps out. `data-redeemid-level` holds the level slugs, for colours:
78
113
 
79
114
  ```astro
80
115
  ---
81
- import Gate from "@redeemid/emdash/components/Gate.astro";
116
+ import AccessBadge from "@redeemid/emdash/components/AccessBadge.astro";
82
117
  ---
83
- <Gate collection="posts" terms={post.data.terms}>
84
- <PortableText value={post.data.content} />
85
- <p slot="locked">{post.data.excerpt}</p>
86
- </Gate>
118
+ <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
87
119
  ```
88
120
 
89
- Add the account box to your header:
121
+ In your own code (`@redeemid/emdash/access`), `entryRequirement(config, collection, terms)` gives what an entry needs (a list of clauses, each passed by any of its levels, or `null` when open), and `canRead(Astro, entry)` gives the verdict for the visitor.
122
+
123
+ Add the account box and cart to your header. Visitors see Sign in; members see their name, levels, Account and Sign out. The cart shows how many items are in the visitor's Shopify cart and goes to its checkout:
90
124
 
91
125
  ```astro
92
126
  ---
93
127
  import Account from "@redeemid/emdash/components/Account.astro";
128
+ import Cart from "@redeemid/emdash/components/Cart.astro";
94
129
  ---
95
130
  <Account />
131
+ <Cart />
96
132
  ```
97
133
 
98
- Sell a plan. `<Plan>` shows a Shopify product's title and price with a button that starts checkout (signed-in members check out with their email). Shopify Flow or an app tags the customer after the order, and access follows that tag:
134
+ Sell a plan. `<Plan>` shows a Shopify product's title and price with a button that adds it to the visitor's Shopify cart and opens checkout (signed-in members check out with their email). Shopify Flow or an app tags the customer after the order, and access follows that tag:
99
135
 
100
136
  ```astro
101
137
  ---
@@ -53,4 +53,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
53
53
  3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
54
54
  4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
55
55
 
56
- If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`.
56
+ If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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",
@@ -15,6 +15,8 @@
15
15
  "./access": "./src/access.ts",
16
16
  "./guard.ts": "./src/guard.ts",
17
17
  "./storefront": "./src/storefront.ts",
18
+ "./plugin": "./src/plugin.ts",
19
+ "./blocks": "./src/blocks/index.ts",
18
20
  "./components/*": "./src/components/*",
19
21
  "./routes/*": "./src/routes/*"
20
22
  },
package/src/access.ts CHANGED
@@ -34,15 +34,33 @@ export interface Viewer {
34
34
 
35
35
  export type Reason = "open" | "allowed" | "sign_in" | "upgrade" | "disabled" | "unconfigured";
36
36
 
37
+ /**
38
+ * What an entry needs, in conjunctive form: every clause must pass, and a
39
+ * clause passes when the reader holds any of its levels. An empty clause means
40
+ * any signed-in member. `null` means the entry is open to everyone.
41
+ */
42
+ export type Requirement = string[][] | null;
43
+
44
+ /**
45
+ * The built-in level every signed-in member holds, as on WordPress. A clause
46
+ * with it passes for any signed-in member, like an empty clause.
47
+ */
48
+ export const SIGNED_IN = "signed-in";
49
+
37
50
  export interface Verdict {
38
51
  allowed: boolean;
39
52
  reason: Reason;
40
- /** Levels any of which would unlock the entry. Empty = any signed-in member. */
41
- required: string[];
53
+ /** What the entry needs (see Requirement). `[]` when it is open. */
54
+ required: string[][];
42
55
  viewer: Viewer;
43
56
  }
44
57
 
45
- export type Terms = Record<string, Array<{ slug: string } | string> | undefined>;
58
+ /** A term as EmDash hydrates it onto an entry, or just its slug. */
59
+ export type TermRef = { slug: string; id?: string; parentId?: string | null } | string;
60
+ export type Terms = Record<string, TermRef[] | undefined>;
61
+
62
+ /** Parent slug by term slug, per taxonomy, for walking up a hierarchy. */
63
+ export type TermTree = Record<string, Record<string, string | null>>;
46
64
 
47
65
  export function levelsFor(tags: string[], levels: LevelConfig[] = []): string[] {
48
66
  const held = new Set(tags.map((t) => t.toLowerCase()));
@@ -55,25 +73,157 @@ export function levelsFor(tags: string[], levels: LevelConfig[] = []): string[]
55
73
  .map((l) => l.slug);
56
74
  }
57
75
 
76
+ const slugOf = (t: TermRef) => (typeof t === "string" ? t : t.slug);
77
+
78
+ /**
79
+ * The clauses, simplified: duplicates go, a clause that contains another
80
+ * clause goes (needing Pro already passes "Member or Pro"), and an empty
81
+ * clause goes when a level clause is there (any level implies signed in).
82
+ */
83
+ function reduce(clauses: string[][]): string[][] {
84
+ const seen = new Set<string>();
85
+ const out: string[][] = [];
86
+ for (const c of clauses) {
87
+ const sorted = c.includes(SIGNED_IN) ? [] : [...new Set(c)].sort();
88
+ const key = sorted.join("|");
89
+ if (seen.has(key)) continue;
90
+ seen.add(key);
91
+ out.push(sorted);
92
+ }
93
+ const levelled = out.filter((c) => c.length > 0);
94
+ if (levelled.length === 0) return out;
95
+ return levelled.filter((c) => !levelled.some((o) => o !== c && o.length < c.length && o.every((l) => c.includes(l))));
96
+ }
97
+
98
+ function combined(rules: RuleConfig[], mode: "all" | "any"): string[][] {
99
+ if (mode === "any") {
100
+ return rules.some((r) => r.levels.length === 0) ? [[]] : [[...new Set(rules.flatMap((r) => r.levels))]];
101
+ }
102
+ return reduce(rules.map((r) => r.levels));
103
+ }
104
+
58
105
  /**
59
- * Required levels for an entry, or null when no rule covers it.
60
- * Term rules beat collection rules; several matching term rules combine.
106
+ * What an entry needs (see Requirement), from the site's rules.
107
+ *
108
+ * Each term the entry carries is walked up its taxonomy (with `tree`) to the
109
+ * nearest term that has a rule, so a child term takes its parent's rule unless
110
+ * it has its own. Any matching `open` rule opens the entry.
61
111
  */
62
- export function requiredLevels(rules: RuleConfig[] = [], collection: string, terms: Terms = {}): string[] | null {
112
+ export function requirement(
113
+ rules: RuleConfig[] = [],
114
+ collection: string,
115
+ terms: Terms = {},
116
+ options: { combine?: "all" | "any"; tree?: TermTree } = {},
117
+ ): Requirement {
118
+ const mode = options.combine ?? "all";
63
119
  const forCollection = rules.filter((r) => r.collection === collection);
64
- const termRules = forCollection.filter((r) => {
65
- if (!r.taxonomy || !r.term) return false;
66
- const list = terms[r.taxonomy] ?? [];
67
- return list.some((t) => (typeof t === "string" ? t : t.slug) === r.term);
120
+ const matched: RuleConfig[] = [];
121
+ for (const [taxonomy, list] of Object.entries(terms)) {
122
+ const taxRules = forCollection.filter((r) => r.taxonomy === taxonomy && r.term);
123
+ if (taxRules.length === 0) continue;
124
+ const parents = options.tree?.[taxonomy] ?? {};
125
+ for (const ref of list ?? []) {
126
+ let slug: string | null = slugOf(ref);
127
+ const seen = new Set<string>();
128
+ while (slug && !seen.has(slug)) {
129
+ seen.add(slug);
130
+ const own = taxRules.filter((r) => r.term === slug);
131
+ if (own.length > 0) {
132
+ matched.push(...own);
133
+ break;
134
+ }
135
+ slug = parents[slug] ?? null;
136
+ }
137
+ }
138
+ }
139
+ if (matched.some((r) => r.open)) return null;
140
+ if (matched.length > 0) return combined(matched, mode);
141
+ const whole = forCollection.filter((r) => !r.taxonomy && !r.term && !r.open);
142
+ if (whole.length > 0) return combined(whole, mode);
143
+ const site = rules.filter((r) => r.collection === "*" && !r.taxonomy && !r.term && !r.open);
144
+ if (site.length > 0) return combined(site, mode);
145
+ return null;
146
+ }
147
+
148
+ /** Does someone holding `held` pass `required`? Signed-in membership is checked by the caller. */
149
+ export function passes(held: string[], required: string[][]): boolean {
150
+ return required.every((clause) => clause.length === 0 || clause.includes(SIGNED_IN) || clause.some((l) => held.includes(l)));
151
+ }
152
+
153
+ /**
154
+ * The requirement in words, for badges and admin pages: "Gold + Silver",
155
+ * "Silver or Gold", "Signed in". `null` gives `open`.
156
+ */
157
+ export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free"): string {
158
+ if (required === null) return open;
159
+ const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
160
+ const parts = reduce(required).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
161
+ return parts.length ? parts.join(" + ") : "Signed in";
162
+ }
163
+
164
+ /**
165
+ * Parent slugs for the taxonomies whose rules could cover an entry through a
166
+ * parent term. Reads EmDash's term lists (cached by EmDash) only when a term
167
+ * on the entry has no rule of its own.
168
+ */
169
+ export async function termTree(rules: RuleConfig[] = [], collection: string, terms: Terms = {}): Promise<TermTree> {
170
+ const tree: TermTree = {};
171
+ const forCollection = rules.filter((r) => r.collection === collection && r.taxonomy && r.term);
172
+ const wanted = Object.entries(terms).filter(([taxonomy, list]) => {
173
+ const own = new Set(forCollection.filter((r) => r.taxonomy === taxonomy).map((r) => r.term));
174
+ return own.size > 0 && (list ?? []).some((t) => !own.has(slugOf(t)));
68
175
  });
69
- const chosen = termRules.length > 0 ? termRules : forCollection.filter((r) => !r.taxonomy && !r.term);
70
- if (chosen.length === 0) return null;
71
- // Any rule that admits every member wins.
72
- if (chosen.some((r) => r.levels.length === 0)) return [];
73
- return [...new Set(chosen.flatMap((r) => r.levels))];
176
+ if (wanted.length === 0) return tree;
177
+ try {
178
+ const { getTaxonomyTerms } = await import("emdash");
179
+ for (const [taxonomy] of wanted) {
180
+ type Node = { id: string; slug: string; parentId?: string | null; children?: Node[] };
181
+ const flat: Node[] = [];
182
+ const walk = (list: Node[]) => list.forEach((n) => (flat.push(n), walk(n.children ?? [])));
183
+ walk((await getTaxonomyTerms(taxonomy)) as Node[]);
184
+ const byId = new Map(flat.map((n) => [n.id, n.slug]));
185
+ const byGroup = new Map(flat.map((n) => [(n as { translationGroup?: string | null }).translationGroup ?? n.id, n.slug]));
186
+ tree[taxonomy] = Object.fromEntries(
187
+ flat.map((n) => [n.slug, n.parentId ? (byId.get(n.parentId) ?? byGroup.get(n.parentId) ?? null) : null]),
188
+ );
189
+ }
190
+ } catch {
191
+ // No term lists (outside an EmDash request): only direct term rules apply.
192
+ }
193
+ return tree;
194
+ }
195
+
196
+ /** The entry's requirement with the site's config, walking term parents. */
197
+ export async function entryRequirement(
198
+ config: RedeemIDConfig | null,
199
+ collection: string,
200
+ terms: Terms = {},
201
+ ): Promise<Requirement> {
202
+ if (!config) return null;
203
+ const tree = await termTree(config.rules, collection, terms);
204
+ return requirement(config.rules, collection, terms, { combine: config.combine, tree });
74
205
  }
75
206
 
207
+ const viewers = new WeakMap<object, Promise<Viewer>>();
208
+
209
+ /** The visitor, read once per request (badges on a list page all ask). */
76
210
  export async function viewer(astro: AstroLike): Promise<Viewer> {
211
+ const key = astro.locals as object;
212
+ if (key && typeof key === "object") {
213
+ const known = viewers.get(key);
214
+ if (known) {
215
+ const v = await known;
216
+ if (v.signedIn) astro.response?.headers.set("Cache-Control", "private, no-store");
217
+ return v;
218
+ }
219
+ const fresh = readViewer(astro);
220
+ viewers.set(key, fresh);
221
+ return fresh;
222
+ }
223
+ return readViewer(astro);
224
+ }
225
+
226
+ async function readViewer(astro: AstroLike): Promise<Viewer> {
77
227
  const locals = astro.locals as Locals;
78
228
  const user = locals.user;
79
229
  if (!user?.id || user.disabled) return { signedIn: false, member: null, levels: [] };
@@ -90,14 +240,22 @@ export async function viewer(astro: AstroLike): Promise<Viewer> {
90
240
  return { signedIn: true, member, levels };
91
241
  }
92
242
 
243
+ /**
244
+ * May this visitor read the entry? `override` replaces the entry's rule, for a
245
+ * part of a page locked to its own levels (a paywall break with levels).
246
+ */
93
247
  export async function canRead(
94
248
  astro: AstroLike,
95
249
  entry: { collection: string; terms?: Terms },
96
- override?: { levels: string[] },
250
+ override?: { levels: string[] } | { required: string[][] },
97
251
  ): Promise<Verdict> {
98
252
  const locals = astro.locals as Locals;
99
253
  const config: RedeemIDConfig | null = siteConfig(locals.emdash?.config);
100
- const required = override ? override.levels : requiredLevels(config?.rules, entry.collection, entry.terms);
254
+ const required: Requirement = override
255
+ ? "required" in override
256
+ ? override.required
257
+ : [override.levels]
258
+ : await entryRequirement(config, entry.collection, entry.terms);
101
259
  const v = await viewer(astro);
102
260
 
103
261
  if (required === null) return { allowed: true, reason: "open", required: [], viewer: v };
@@ -105,7 +263,7 @@ export async function canRead(
105
263
  if (!v.signedIn) return { allowed: false, reason: "sign_in", required, viewer: v };
106
264
  if (v.member?.status === "disabled") return { allowed: false, reason: "disabled", required, viewer: v };
107
265
  if (!v.member) return { allowed: false, reason: "upgrade", required, viewer: v };
108
- const ok = required.length === 0 || required.some((l) => v.levels.includes(l));
266
+ const ok = passes(v.levels, required);
109
267
  return { allowed: ok, reason: ok ? "allowed" : "upgrade", required, viewer: v };
110
268
  }
111
269
 
@@ -119,6 +277,16 @@ export function signInUrl(returnTo: string): string {
119
277
  return `/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(safeReturn(returnTo))}`;
120
278
  }
121
279
 
280
+ /** Where the Cart link goes: the visitor's Shopify checkout. */
281
+ export function cartUrl(returnTo = "/"): string {
282
+ return `/_emdash/api/auth/redeemid/cart?return=${encodeURIComponent(safeReturn(returnTo))}`;
283
+ }
284
+
285
+ /** Where the Account link goes: the member's Shopify customer account. */
286
+ export function accountUrl(returnTo = "/"): string {
287
+ return `/_emdash/api/auth/redeemid/account?return=${encodeURIComponent(safeReturn(returnTo))}`;
288
+ }
289
+
122
290
  export function signOutUrl(returnTo = "/"): string {
123
291
  return `/_emdash/api/auth/redeemid/logout?return=${encodeURIComponent(safeReturn(returnTo))}`;
124
292
  }
@@ -0,0 +1,4 @@
1
+ declare module "*.astro" {
2
+ const Component: (props: Record<string, unknown>) => unknown;
3
+ export default Component;
4
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ /**
3
+ * The paywall break itself renders nothing. <Content> splits the body here
4
+ * and leaves out what follows for readers who can't read it.
5
+ */
6
+ ---
@@ -0,0 +1,13 @@
1
+ ---
2
+ /** The "Plan" block: a Shopify product with its price and a buy button. */
3
+ import Plan from "../components/Plan.astro";
4
+
5
+ interface Props {
6
+ node: { handle?: string; title?: string; label?: string };
7
+ }
8
+
9
+ const { node } = Astro.props;
10
+ const handle = (node.handle ?? "").trim();
11
+ ---
12
+
13
+ {handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} />}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Site-side renderers for RedeemID's Portable Text blocks, picked up by
3
+ * EmDash from the plugin's `componentsEntry`.
4
+ */
5
+ import PaywallBreak from "./PaywallBreak.astro";
6
+ import PlanBlock from "./PlanBlock.astro";
7
+
8
+ export const blockComponents = {
9
+ "redeemid-paywall": PaywallBreak,
10
+ "redeemid-plan": PlanBlock,
11
+ };
@@ -0,0 +1,46 @@
1
+ /** Portable Text block types RedeemID adds to the editor. */
2
+ export const PAYWALL_BLOCK = "redeemid-paywall";
3
+ export const PLAN_BLOCK = "redeemid-plan";
4
+
5
+ type Node = { _type?: unknown; levels?: unknown };
6
+
7
+ /**
8
+ * Split a Portable Text body at its first paywall break: the open part, the
9
+ * members part, and the levels picked on the break (empty = none picked).
10
+ * With no break, everything is in `open` and `paywall` is false.
11
+ */
12
+ export function splitAtPaywall<T>(value: T[] | null | undefined): {
13
+ open: T[];
14
+ locked: T[];
15
+ levels: string[];
16
+ paywall: boolean;
17
+ } {
18
+ const blocks = Array.isArray(value) ? value : [];
19
+ const at = blocks.findIndex((b) => (b as Node)?._type === PAYWALL_BLOCK);
20
+ if (at === -1) return { open: blocks, locked: [], levels: [], paywall: false };
21
+ const raw = (blocks[at] as Node).levels;
22
+ const levels = Array.isArray(raw) ? raw.filter((l): l is string => typeof l === "string" && l !== "") : [];
23
+ return { open: blocks.slice(0, at), locked: blocks.slice(at + 1), levels, paywall: true };
24
+ }
25
+
26
+ type TextNode = { _type?: unknown; children?: Array<{ text?: unknown }> };
27
+
28
+ /**
29
+ * The text blocks within the first `words` words of a body, as on WordPress:
30
+ * a block is never cut in half, so a block that would pass the count ends the
31
+ * teaser. Images and custom blocks are left out. 0 gives nothing.
32
+ */
33
+ export function teaser<T>(value: T[] | null | undefined, words: number): T[] {
34
+ if (!Array.isArray(value) || !(words > 0)) return [];
35
+ const out: T[] = [];
36
+ let count = 0;
37
+ for (const b of value) {
38
+ const node = b as TextNode;
39
+ if (node?._type !== "block") continue;
40
+ const text = (node.children ?? []).map((c) => (typeof c.text === "string" ? c.text : "")).join("");
41
+ count += text.split(/\s+/).filter(Boolean).length;
42
+ if (count > words) break;
43
+ out.push(b);
44
+ }
45
+ return out;
46
+ }
package/src/cart.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The visitor's Shopify cart, kept in the Astro session. Only the cart id
3
+ * and its item count live here; Shopify holds the lines and the checkout.
4
+ */
5
+
6
+ export const SESSION_CART = "redeemid:cart";
7
+
8
+ export interface CartRef {
9
+ id: string;
10
+ count: number;
11
+ }
12
+
13
+ type Session = { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
14
+
15
+ export async function readCart(session: Session): Promise<CartRef | null> {
16
+ const v = (await session?.get(SESSION_CART)) as CartRef | undefined;
17
+ return v && typeof v.id === "string" ? v : null;
18
+ }
19
+
20
+ export function saveCart(session: Session, cart: CartRef | null): void {
21
+ if (!session) return;
22
+ if (cart) session.set(SESSION_CART, cart);
23
+ else if (session.delete) session.delete(SESSION_CART);
24
+ else session.set(SESSION_CART, undefined);
25
+ }
@@ -0,0 +1,45 @@
1
+ ---
2
+ /**
3
+ * The level an entry needs, read from its rules (and from a paywall break
4
+ * with levels), like the Access Badge block on WordPress:
5
+ *
6
+ * <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
7
+ *
8
+ * Open entries show `free` ("Free"; pass free="" to show nothing). The badge
9
+ * carries `data-redeemid-level` (the level slugs) for styling, and
10
+ * `is-locked` with a lock for readers who can't read the members part.
11
+ */
12
+ import { canRead, describe, entryRequirement, type Terms } from "../access.js";
13
+ import { splitAtPaywall } from "../blocks-names.js";
14
+ import { siteConfig } from "../site-config.js";
15
+
16
+ interface Props {
17
+ collection: string;
18
+ terms?: Terms;
19
+ /** The entry's body, so a paywall break's levels count. */
20
+ content?: unknown[] | null;
21
+ free?: string;
22
+ class?: string;
23
+ }
24
+
25
+ const { collection, terms, content, free = "Free", class: className } = Astro.props;
26
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
27
+ const split = splitAtPaywall(content);
28
+ const required = split.paywall && split.levels.length
29
+ ? [split.levels]
30
+ : await entryRequirement(config, collection, terms);
31
+ const label = describe(required, config?.levels, free);
32
+ const locked = required !== null && !(await canRead(Astro, { collection, terms }, { required })).allowed;
33
+ const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
34
+ ---
35
+
36
+ {label && (
37
+ <span
38
+ class:list={["redeemid-badge", { "is-locked": locked }, className]}
39
+ data-redeemid-level={slugs}
40
+ >
41
+ {locked && <span class="redeemid-badge__lock" aria-hidden="true">🔒&#xFE0E; </span>}
42
+ {label}
43
+ {locked && <span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"> (locked)</span>}
44
+ </span>
45
+ )}
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  /**
3
- * Sign in link for visitors; name, levels and Sign out for members.
3
+ * Sign in link for visitors; name, levels, Account and Sign out for members.
4
4
  */
5
5
  import { siteConfig } from "../site-config.js";
6
- import { signInUrl, signOutUrl, viewer } from "../access.js";
6
+ import { accountUrl, signInUrl, signOutUrl, viewer } from "../access.js";
7
7
 
8
8
  const v = await viewer(Astro);
9
9
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
@@ -16,6 +16,7 @@ const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).ma
16
16
  <>
17
17
  <span class="redeemid-account__who">{v.member?.firstName ?? v.member?.email ?? "Signed in"}</span>
18
18
  {names.length > 0 && <span class="redeemid-account__levels">{names.join(", ")}</span>}
19
+ <a class="redeemid-account__link" href={accountUrl(here)}>Account</a>
19
20
  <form method="post" action={signOutUrl(here)}>
20
21
  <button type="submit">Sign out</button>
21
22
  </form>
@@ -0,0 +1,21 @@
1
+ ---
2
+ /**
3
+ * Cart link with the item count from the visitor's Shopify cart. It goes
4
+ * to that cart's Shopify checkout.
5
+ */
6
+ import { cartUrl } from "../access.js";
7
+ import { readCart } from "../cart.js";
8
+
9
+ interface Props {
10
+ label?: string;
11
+ }
12
+
13
+ const { label = "Cart" } = Astro.props;
14
+ const ref = await readCart(Astro.session);
15
+ const count = ref?.count ?? 0;
16
+ const here = Astro.url.pathname + Astro.url.search;
17
+ ---
18
+
19
+ <a class="redeemid-cart" href={cartUrl(here)} data-redeemid-cart-count={count}>
20
+ {label}{count > 0 && <span class="redeemid-cart__count"> ({count})</span>}
21
+ </a>