@redeemid/emdash 0.0.3 → 0.0.5
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 +43 -5
- package/package.json +1 -1
- package/src/access.ts +176 -18
- package/src/blocks/PaywallBreak.astro +3 -2
- package/src/blocks/index.ts +4 -2
- package/src/blocks-names.ts +61 -0
- package/src/components/AccessBadge.astro +45 -0
- package/src/components/Content.astro +58 -28
- package/src/components/Gate.astro +4 -12
- package/src/components/LockedPanel.astro +48 -0
- package/src/components/Notices.astro +36 -0
- package/src/config.ts +35 -4
- package/src/plugin.ts +43 -12
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -2,17 +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.
|
|
5
|
+
**Status: 0.0.5, 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
|
|
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.
|
|
13
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.
|
|
14
15
|
- **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
|
|
15
|
-
- **Paywall break block.** Editors write one body and drop a "Paywall break" block where members-only content starts, picking which levels may read on. `<Content>` renders the body and leaves the rest off the page for everyone else.
|
|
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
|
+
- **Members only block.** For a members part in the middle of a body: "Members only" (pick the levels) and "Members only: end". Everyone else sees the wall in its place and reads on after it.
|
|
18
|
+
- **The wall.** Readers kept out see why and what to do next: sign in, or join, with each level's own wall text. `<Notices>` shows the messages sign-in, Buy, Cart and Account send readers back with.
|
|
16
19
|
- **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
|
|
17
20
|
- **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.
|
|
18
21
|
- **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
|
|
@@ -33,7 +36,8 @@ const config = {
|
|
|
33
36
|
shop: "my-store.myshopify.com",
|
|
34
37
|
levels: [
|
|
35
38
|
{ slug: "member", name: "Member", tags: ["member"] },
|
|
36
|
-
|
|
39
|
+
// wall: what the wall says to readers this level would let in; joinUrl overrides join.href.
|
|
40
|
+
{ slug: "pro", name: "Pro", tags: ["pro"], wall: "Pro members read every report." },
|
|
37
41
|
// match: "all" needs every tag, e.g. both memberships:
|
|
38
42
|
{ slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" },
|
|
39
43
|
],
|
|
@@ -41,6 +45,10 @@ const config = {
|
|
|
41
45
|
{ collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
|
|
42
46
|
{ collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
|
|
43
47
|
],
|
|
48
|
+
// Optional: where the wall's join link goes.
|
|
49
|
+
join: { href: "/join/", label: "Become a member" },
|
|
50
|
+
// Optional: locked posts with no paywall break show the paragraphs within their first 40 words.
|
|
51
|
+
teaser: { posts: 40 },
|
|
44
52
|
};
|
|
45
53
|
|
|
46
54
|
export default defineConfig({
|
|
@@ -76,6 +84,19 @@ In Shopify, give the Customer Account API client these settings:
|
|
|
76
84
|
|
|
77
85
|
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`.
|
|
78
86
|
|
|
87
|
+
### Rules
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
| Rule | Covers |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `{ collection: "reports", levels: ["gold"] }` | Every entry in the collection |
|
|
94
|
+
| `{ 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. |
|
|
95
|
+
| `{ collection: "posts", taxonomy: "tag", term: "free-to-read", levels: [], open: true }` | Opens entries with that term to everyone, whatever else covers them |
|
|
96
|
+
| `{ collection: "*", levels: [] }` | The site default, for collections with no rule of their own |
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
79
100
|
## Use it in templates
|
|
80
101
|
|
|
81
102
|
EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
|
|
@@ -89,10 +110,27 @@ import Content from "@redeemid/emdash/components/Content.astro";
|
|
|
89
110
|
</Content>
|
|
90
111
|
```
|
|
91
112
|
|
|
92
|
-
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, or when none are picked the entry's rule,
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
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 there, and the body goes on after the end block.
|
|
116
|
+
|
|
117
|
+
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 are missing. `<Content>` and `<Gate>` show it; its default slot (and `<Content>`'s "locked" slot) goes on top.
|
|
93
118
|
|
|
94
119
|
To lock a part of a template that isn't the body, wrap it in `<Gate>` (`<Gate levels={["pro"]}>…</Gate>`).
|
|
95
120
|
|
|
121
|
+
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:
|
|
122
|
+
|
|
123
|
+
```astro
|
|
124
|
+
---
|
|
125
|
+
import AccessBadge from "@redeemid/emdash/components/AccessBadge.astro";
|
|
126
|
+
---
|
|
127
|
+
<AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
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.
|
|
131
|
+
|
|
132
|
+
Put `<Notices />` near the top of your layout. It shows the message for a `?redeemid_notice=` code (see [docs/shopify-setup.md](docs/shopify-setup.md)) and nothing otherwise; pass `messages={{ cart_empty: "…" }}` to reword one.
|
|
133
|
+
|
|
96
134
|
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:
|
|
97
135
|
|
|
98
136
|
```astro
|
package/package.json
CHANGED
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
60
|
-
*
|
|
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
|
|
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
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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 =
|
|
266
|
+
const ok = passes(v.levels, required);
|
|
109
267
|
return { allowed: ok, reason: ok ? "allowed" : "upgrade", required, viewer: v };
|
|
110
268
|
}
|
|
111
269
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* RedeemID's marker blocks (Paywall break, Members only and its end) render
|
|
4
|
+
* nothing themselves. <Content> splits the body at them and leaves out what
|
|
5
|
+
* readers can't read.
|
|
5
6
|
*/
|
|
6
7
|
---
|
package/src/blocks/index.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* Site-side renderers for RedeemID's Portable Text blocks, picked up by
|
|
3
3
|
* EmDash from the plugin's `componentsEntry`.
|
|
4
4
|
*/
|
|
5
|
-
import
|
|
5
|
+
import Marker from "./PaywallBreak.astro";
|
|
6
6
|
import PlanBlock from "./PlanBlock.astro";
|
|
7
7
|
|
|
8
8
|
export const blockComponents = {
|
|
9
|
-
"redeemid-paywall":
|
|
9
|
+
"redeemid-paywall": Marker,
|
|
10
|
+
"redeemid-members": Marker,
|
|
11
|
+
"redeemid-members-end": Marker,
|
|
10
12
|
"redeemid-plan": PlanBlock,
|
|
11
13
|
};
|
package/src/blocks-names.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Portable Text block types RedeemID adds to the editor. */
|
|
2
2
|
export const PAYWALL_BLOCK = "redeemid-paywall";
|
|
3
3
|
export const PLAN_BLOCK = "redeemid-plan";
|
|
4
|
+
/** Starts a members-only part of the body; it runs to the next end block. */
|
|
5
|
+
export const MEMBERS_BLOCK = "redeemid-members";
|
|
6
|
+
export const MEMBERS_END_BLOCK = "redeemid-members-end";
|
|
4
7
|
|
|
5
8
|
type Node = { _type?: unknown; levels?: unknown };
|
|
6
9
|
|
|
@@ -22,3 +25,61 @@ export function splitAtPaywall<T>(value: T[] | null | undefined): {
|
|
|
22
25
|
const levels = Array.isArray(raw) ? raw.filter((l): l is string => typeof l === "string" && l !== "") : [];
|
|
23
26
|
return { open: blocks.slice(0, at), locked: blocks.slice(at + 1), levels, paywall: true };
|
|
24
27
|
}
|
|
28
|
+
|
|
29
|
+
type TextNode = { _type?: unknown; children?: Array<{ text?: unknown }> };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The text blocks within the first `words` words of a body, as on WordPress:
|
|
33
|
+
* a block is never cut in half, so a block that would pass the count ends the
|
|
34
|
+
* teaser. Images and custom blocks are left out. 0 gives nothing.
|
|
35
|
+
*/
|
|
36
|
+
export function teaser<T>(value: T[] | null | undefined, words: number): T[] {
|
|
37
|
+
if (!Array.isArray(value) || !(words > 0)) return [];
|
|
38
|
+
const out: T[] = [];
|
|
39
|
+
let count = 0;
|
|
40
|
+
for (const b of value) {
|
|
41
|
+
const node = b as TextNode;
|
|
42
|
+
if (node?._type !== "block") continue;
|
|
43
|
+
const text = (node.children ?? []).map((c) => (typeof c.text === "string" ? c.text : "")).join("");
|
|
44
|
+
count += text.split(/\s+/).filter(Boolean).length;
|
|
45
|
+
if (count > words) break;
|
|
46
|
+
out.push(b);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Section<T> {
|
|
52
|
+
/** True for a part between a "Members only" block and its end. */
|
|
53
|
+
members: boolean;
|
|
54
|
+
/** Levels picked on the "Members only" block (empty = none picked). */
|
|
55
|
+
levels: string[];
|
|
56
|
+
blocks: T[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const levelsOf = (raw: unknown) =>
|
|
60
|
+
Array.isArray(raw) ? raw.filter((l): l is string => typeof l === "string" && l !== "") : [];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Split blocks into open parts and members-only parts. A "Members only" block
|
|
64
|
+
* starts a part that runs to the next "Members only: end" block (or the end of
|
|
65
|
+
* the blocks); the marker blocks themselves are dropped. A stray end block is
|
|
66
|
+
* dropped too.
|
|
67
|
+
*/
|
|
68
|
+
export function sections<T>(value: T[] | null | undefined): Section<T>[] {
|
|
69
|
+
const out: Section<T>[] = [];
|
|
70
|
+
let current: Section<T> = { members: false, levels: [], blocks: [] };
|
|
71
|
+
for (const b of Array.isArray(value) ? value : []) {
|
|
72
|
+
const type = (b as Node)?._type;
|
|
73
|
+
if (type === MEMBERS_BLOCK || type === MEMBERS_END_BLOCK) {
|
|
74
|
+
if (current.blocks.length > 0 || current.members) out.push(current);
|
|
75
|
+
current =
|
|
76
|
+
type === MEMBERS_BLOCK
|
|
77
|
+
? { members: true, levels: levelsOf((b as Node).levels), blocks: [] }
|
|
78
|
+
: { members: false, levels: [], blocks: [] };
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
current.blocks.push(b);
|
|
82
|
+
}
|
|
83
|
+
if (current.blocks.length > 0 || current.members) out.push(current);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -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">🔒︎ </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,20 +1,27 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* An entry's body, with RedeemID's
|
|
3
|
+
* An entry's body, with RedeemID's blocks applied.
|
|
4
4
|
*
|
|
5
5
|
* <Content value={post.data.content} collection="posts" terms={post.data.terms} />
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* picked on the break, or when none are picked the entry's rule
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* - "Paywall break": everything before the first one is shown to everyone.
|
|
8
|
+
* What follows reaches the page only for readers who may read it: the
|
|
9
|
+
* levels picked on the break, or when none are picked the entry's rule
|
|
10
|
+
* (with no rule, the break changes nothing, as on WordPress).
|
|
11
|
+
* - "Members only" … "Members only: end": the part between is for the levels
|
|
12
|
+
* picked on the start block, or the entry's rule, or any signed-in member.
|
|
13
|
+
* Readers who can't read it see the wall in its place, and the body goes on.
|
|
14
|
+
* - No break: the entry's rule covers the whole body; readers it keeps out see
|
|
15
|
+
* the collection's teaser (config `teaser`, in words), if any.
|
|
16
|
+
*
|
|
17
|
+
* Readers kept out get the wall (LockedPanel), with the "locked" slot on top.
|
|
12
18
|
*/
|
|
13
19
|
import { PortableText } from "emdash/ui";
|
|
14
20
|
|
|
15
|
-
import { canRead,
|
|
16
|
-
import { splitAtPaywall } from "../blocks-names.js";
|
|
21
|
+
import { canRead, entryRequirement, type Terms, type Verdict } from "../access.js";
|
|
22
|
+
import { sections, splitAtPaywall, teaser } from "../blocks-names.js";
|
|
17
23
|
import { siteConfig } from "../site-config.js";
|
|
24
|
+
import LockedPanel from "./LockedPanel.astro";
|
|
18
25
|
|
|
19
26
|
interface Props {
|
|
20
27
|
value?: unknown[] | null;
|
|
@@ -25,29 +32,52 @@ interface Props {
|
|
|
25
32
|
const { value, collection = "", terms } = Astro.props;
|
|
26
33
|
const split = splitAtPaywall(value);
|
|
27
34
|
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const entryRule = await entryRequirement(config, collection, terms);
|
|
36
|
+
// A break with levels picked locks the rest to them; with none picked it
|
|
37
|
+
// follows the entry's rule, and does nothing when no rule covers the entry.
|
|
38
|
+
const required = split.paywall ? (split.levels.length ? [split.levels] : entryRule) : undefined;
|
|
39
|
+
const verdict: Verdict | { allowed: true; reason: "open" } =
|
|
40
|
+
split.paywall && required === null
|
|
41
|
+
? { allowed: true, reason: "open" }
|
|
42
|
+
: await canRead(Astro, { collection, terms }, required ? { required } : undefined);
|
|
43
|
+
const words = config?.teaser?.[collection] ?? 0;
|
|
44
|
+
const shown = split.paywall ? split.open : verdict.allowed ? split.open : teaser(split.open, words);
|
|
45
|
+
const rest = verdict.allowed ? split.locked : [];
|
|
46
|
+
|
|
47
|
+
// Each "Members only" part gets its own verdict.
|
|
48
|
+
async function parts(blocks: unknown[]) {
|
|
49
|
+
const out: Array<{ blocks: unknown[]; verdict?: Verdict }> = [];
|
|
50
|
+
for (const section of sections(blocks)) {
|
|
51
|
+
if (!section.members) {
|
|
52
|
+
out.push({ blocks: section.blocks });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const needs = section.levels.length ? [section.levels] : (entryRule ?? [[]]);
|
|
56
|
+
out.push({ blocks: section.blocks, verdict: await canRead(Astro, { collection, terms }, { required: needs }) });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
const before = await parts(shown);
|
|
61
|
+
const after = await parts(rest);
|
|
36
62
|
---
|
|
37
63
|
|
|
38
|
-
{
|
|
64
|
+
{before.map((p) =>
|
|
65
|
+
!p.verdict || p.verdict.allowed ? (
|
|
66
|
+
p.blocks.length > 0 && <PortableText value={p.blocks} />
|
|
67
|
+
) : (
|
|
68
|
+
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} />
|
|
69
|
+
),
|
|
70
|
+
)}
|
|
39
71
|
{verdict.allowed ? (
|
|
40
|
-
|
|
72
|
+
after.map((p) =>
|
|
73
|
+
!p.verdict || p.verdict.allowed ? (
|
|
74
|
+
p.blocks.length > 0 && <PortableText value={p.blocks} />
|
|
75
|
+
) : (
|
|
76
|
+
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} />
|
|
77
|
+
),
|
|
78
|
+
)
|
|
41
79
|
) : (
|
|
42
|
-
<
|
|
80
|
+
<LockedPanel reason={verdict.reason} required={verdict.required} held={verdict.viewer.levels}>
|
|
43
81
|
<slot name="locked" />
|
|
44
|
-
|
|
45
|
-
<p>
|
|
46
|
-
This is for members. <a href={signInUrl(here)}>Sign in</a> to read on.
|
|
47
|
-
</p>
|
|
48
|
-
)}
|
|
49
|
-
{verdict.reason === "upgrade" && <p>Your membership doesn't include the rest of this yet.</p>}
|
|
50
|
-
{verdict.reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
|
|
51
|
-
{verdict.reason === "unconfigured" && <p>This is for members.</p>}
|
|
52
|
-
</div>
|
|
82
|
+
</LockedPanel>
|
|
53
83
|
)}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Pass `levels` to lock a part of a page to specific levels instead.
|
|
12
12
|
*/
|
|
13
|
-
import { canRead,
|
|
13
|
+
import { canRead, type Terms } from "../access.js";
|
|
14
|
+
import LockedPanel from "./LockedPanel.astro";
|
|
14
15
|
|
|
15
16
|
interface Props {
|
|
16
17
|
collection?: string;
|
|
@@ -20,21 +21,12 @@ interface Props {
|
|
|
20
21
|
|
|
21
22
|
const { collection = "", terms, levels } = Astro.props;
|
|
22
23
|
const verdict = await canRead(Astro, { collection, terms }, levels ? { levels } : undefined);
|
|
23
|
-
const here = Astro.url.pathname + Astro.url.search;
|
|
24
24
|
---
|
|
25
25
|
|
|
26
26
|
{verdict.allowed ? (
|
|
27
27
|
<slot />
|
|
28
28
|
) : (
|
|
29
|
-
<
|
|
29
|
+
<LockedPanel reason={verdict.reason} required={verdict.required} held={verdict.viewer.levels}>
|
|
30
30
|
<slot name="locked" />
|
|
31
|
-
|
|
32
|
-
<p>
|
|
33
|
-
This is for members. <a href={signInUrl(here)}>Sign in</a> to read it.
|
|
34
|
-
</p>
|
|
35
|
-
)}
|
|
36
|
-
{verdict.reason === "upgrade" && <p>Your membership doesn't include this yet.</p>}
|
|
37
|
-
{verdict.reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
|
|
38
|
-
{verdict.reason === "unconfigured" && <p>This is for members.</p>}
|
|
39
|
-
</div>
|
|
31
|
+
</LockedPanel>
|
|
40
32
|
)}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The wall readers see in place of what they can't read: why, and what to do
|
|
4
|
+
* next (sign in, or join). <Content> and <Gate> show it; use it directly for
|
|
5
|
+
* your own locked parts.
|
|
6
|
+
*
|
|
7
|
+
* The level it offers is the first one the reader is missing. Its `wall` text
|
|
8
|
+
* and `joinUrl` come from the level's config, the join link from `join`.
|
|
9
|
+
* Anything in the default slot is shown above the built-in text.
|
|
10
|
+
*/
|
|
11
|
+
import { passes, signInUrl, type Reason } from "../access.js";
|
|
12
|
+
import { siteConfig } from "../site-config.js";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
reason: Reason;
|
|
16
|
+
/** What the locked part needs (Verdict.required). */
|
|
17
|
+
required?: string[][];
|
|
18
|
+
/** Levels the reader holds (Verdict.viewer.levels). */
|
|
19
|
+
held?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { reason, required = [], held = [] } = Astro.props;
|
|
23
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
24
|
+
const missing = required.find((clause) => clause.length > 0 && !passes(held, [clause]));
|
|
25
|
+
const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : undefined;
|
|
26
|
+
const joinHref = level?.joinUrl ?? config?.join?.href;
|
|
27
|
+
const joinLabel = config?.join?.label ?? "Become a member";
|
|
28
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<div class="redeemid-locked" data-redeemid-reason={reason} data-redeemid-level={level?.slug}>
|
|
32
|
+
<slot />
|
|
33
|
+
{level?.wall && (reason === "sign_in" || reason === "upgrade") && <p class="redeemid-locked__wall">{level.wall}</p>}
|
|
34
|
+
{reason === "sign_in" && (
|
|
35
|
+
<p>
|
|
36
|
+
This is for members. <a href={signInUrl(here)}>Sign in</a> to read on.
|
|
37
|
+
{joinHref && <> Not a member yet? <a href={joinHref}>{joinLabel}</a>.</>}
|
|
38
|
+
</p>
|
|
39
|
+
)}
|
|
40
|
+
{reason === "upgrade" && (
|
|
41
|
+
<p>
|
|
42
|
+
{level ? `This is for ${level.name} members.` : "Your membership doesn't include this yet."}
|
|
43
|
+
{joinHref && <> <a href={joinHref}>{joinLabel}</a></>}
|
|
44
|
+
</p>
|
|
45
|
+
)}
|
|
46
|
+
{reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
|
|
47
|
+
{reason === "unconfigured" && <p>This is for members.</p>}
|
|
48
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The message for a `?redeemid_notice=` code that sign-in, Buy, Cart and
|
|
4
|
+
* Account send readers back with. Renders nothing without one. Pass
|
|
5
|
+
* `messages` to reword any of them.
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
messages?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULTS: Record<string, string> = {
|
|
12
|
+
not_configured: "Sign-in isn't set up on this site yet.",
|
|
13
|
+
sign_in_unavailable: "Sign-in isn't available right now. Try again in a few minutes.",
|
|
14
|
+
sign_in_expired: "That sign-in took too long. Try again.",
|
|
15
|
+
staff_account: "This account can't sign in here. Use a different email.",
|
|
16
|
+
site_not_ready: "This site isn't ready for sign-in yet.",
|
|
17
|
+
email_required: "Your account needs an email address to sign in.",
|
|
18
|
+
token_failed: "Sign-in didn't finish. Try again.",
|
|
19
|
+
bad_id_token: "Sign-in didn't finish. Try again.",
|
|
20
|
+
identity_failed: "Sign-in didn't finish. Try again.",
|
|
21
|
+
plan_unavailable: "That plan isn't available right now.",
|
|
22
|
+
checkout_failed: "Checkout didn't open. Try again.",
|
|
23
|
+
cart_empty: "Your cart is empty.",
|
|
24
|
+
account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const { messages = {} } = Astro.props;
|
|
28
|
+
const code = Astro.url.searchParams.get("redeemid_notice") ?? "";
|
|
29
|
+
const text = code ? (messages[code] ?? DEFAULTS[code]) : undefined;
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
{text && (
|
|
33
|
+
<div class="redeemid-notice" role="status" data-redeemid-notice={code}>
|
|
34
|
+
<p>{text}</p>
|
|
35
|
+
</div>
|
|
36
|
+
)}
|
package/src/config.ts
CHANGED
|
@@ -15,21 +15,39 @@ export interface LevelConfig {
|
|
|
15
15
|
tags: string[];
|
|
16
16
|
/** "any" (default): one of the tags is enough. "all": every tag is needed. */
|
|
17
17
|
match?: "any" | "all";
|
|
18
|
+
/** What the wall says to readers this level would let in, e.g. "Gold members read every investigation." */
|
|
19
|
+
wall?: string;
|
|
20
|
+
/** Where the wall's join link goes for this level. Falls back to `join.href`. */
|
|
21
|
+
joinUrl?: string;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
|
-
* Who may read entries
|
|
22
|
-
*
|
|
23
|
-
*
|
|
25
|
+
* Who may read entries, like RedeemID's content rules on WordPress:
|
|
26
|
+
*
|
|
27
|
+
* - A rule with `taxonomy` and `term` covers entries that carry that term, or
|
|
28
|
+
* a child of it in a hierarchical taxonomy (a child's own rule wins over its
|
|
29
|
+
* parent's).
|
|
30
|
+
* - A rule with neither covers the whole collection.
|
|
31
|
+
* - `collection: "*"` is the site default, for collections with no rule.
|
|
32
|
+
*
|
|
33
|
+
* Term rules beat the collection rule, which beats the site default. When
|
|
34
|
+
* several term rules cover one entry, a reader must pass every one of them
|
|
35
|
+
* (most restrictive), unless `combine` is "any".
|
|
24
36
|
*/
|
|
25
37
|
export interface RuleConfig {
|
|
38
|
+
/** Collection slug, or "*" for the site default. */
|
|
26
39
|
collection: string;
|
|
27
40
|
/** Taxonomy slug, e.g. "category". Requires `term`. */
|
|
28
41
|
taxonomy?: string;
|
|
29
42
|
/** Term slug, e.g. "premium". */
|
|
30
43
|
term?: string;
|
|
31
|
-
/** Level slugs
|
|
44
|
+
/** Level slugs, any of which may read. An empty list means any signed-in member. */
|
|
32
45
|
levels: string[];
|
|
46
|
+
/**
|
|
47
|
+
* A term rule that opens its entries to everyone, whatever else covers them
|
|
48
|
+
* ("Opens" on WordPress). `levels` is ignored.
|
|
49
|
+
*/
|
|
50
|
+
open?: boolean;
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
export interface RedeemIDConfig {
|
|
@@ -43,6 +61,19 @@ export interface RedeemIDConfig {
|
|
|
43
61
|
label?: string;
|
|
44
62
|
levels?: LevelConfig[];
|
|
45
63
|
rules?: RuleConfig[];
|
|
64
|
+
/** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
|
|
65
|
+
join?: { href: string; label?: string };
|
|
66
|
+
/**
|
|
67
|
+
* How several term rules on one entry combine: "all" (default, the most
|
|
68
|
+
* restrictive: pass every rule) or "any" (pass one of them).
|
|
69
|
+
*/
|
|
70
|
+
combine?: "all" | "any";
|
|
71
|
+
/**
|
|
72
|
+
* Teaser length in words, per collection, for locked entries with no paywall
|
|
73
|
+
* break: readers who can't read on see this many words of the body. Without
|
|
74
|
+
* one they see none of it.
|
|
75
|
+
*/
|
|
76
|
+
teaser?: Record<string, number>;
|
|
46
77
|
/**
|
|
47
78
|
* Extra /_emdash path prefixes signed-in members may use. Everything else
|
|
48
79
|
* under /_emdash is refused to Subscribers (see guard.ts).
|
package/src/plugin.ts
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
* plugins: [redeemidPlugin(config)],
|
|
7
7
|
* })
|
|
8
8
|
*
|
|
9
|
-
* It adds
|
|
9
|
+
* It adds four Portable Text blocks to the editor, a Members page, a Status
|
|
10
10
|
* page and a dashboard widget. Sign-in, webhooks and checkout stay on the
|
|
11
11
|
* auth provider's routes.
|
|
12
12
|
*
|
|
13
13
|
* - "Paywall break" (`redeemid-paywall`): everything after it in the body is
|
|
14
|
-
* for members. Pick the levels that may read on
|
|
15
|
-
*
|
|
14
|
+
* for members. Pick the levels that may read on ("Signed in" for any
|
|
15
|
+
* member); none picked means the entry's rule, and nothing is locked when
|
|
16
|
+
* no rule covers the entry, as on WordPress.
|
|
17
|
+
* - "Members only" (`redeemid-members`) and "Members only: end"
|
|
18
|
+
* (`redeemid-members-end`): the part between is for the levels picked on
|
|
19
|
+
* the start block; everyone else sees the wall there and reads on after.
|
|
16
20
|
* - "Plan" (`redeemid-plan`): a Shopify product's title and price with a
|
|
17
21
|
* button that adds it to the cart and opens checkout.
|
|
18
22
|
*
|
|
@@ -23,19 +27,22 @@
|
|
|
23
27
|
import type { ResolvedPlugin, RouteContext } from "emdash";
|
|
24
28
|
import { definePlugin } from "emdash";
|
|
25
29
|
|
|
26
|
-
import { levelsFor } from "./access.js";
|
|
30
|
+
import { describe, levelsFor, SIGNED_IN } from "./access.js";
|
|
27
31
|
import { env, ENV, storeDomain, type RedeemIDConfig } from "./config.js";
|
|
28
32
|
import { discover } from "./shopify.js";
|
|
29
33
|
import { stores, type MemberRecord } from "./storage.js";
|
|
30
34
|
import { PAGES, WIDGETS } from "./descriptor.js";
|
|
31
35
|
import { VERSION } from "./version.js";
|
|
32
36
|
|
|
33
|
-
import { PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
|
|
37
|
+
import { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
|
|
34
38
|
|
|
35
|
-
export { PAYWALL_BLOCK, PLAN_BLOCK };
|
|
39
|
+
export { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK };
|
|
36
40
|
|
|
37
41
|
function blocks(config: RedeemIDConfig) {
|
|
38
|
-
const levelOptions =
|
|
42
|
+
const levelOptions = [
|
|
43
|
+
{ label: "Signed in (any member)", value: SIGNED_IN },
|
|
44
|
+
...(config.levels ?? []).map((l) => ({ label: l.name, value: l.slug })),
|
|
45
|
+
];
|
|
39
46
|
return [
|
|
40
47
|
{
|
|
41
48
|
type: PAYWALL_BLOCK,
|
|
@@ -46,11 +53,32 @@ function blocks(config: RedeemIDConfig) {
|
|
|
46
53
|
{
|
|
47
54
|
type: "checkbox" as const,
|
|
48
55
|
action_id: "levels",
|
|
49
|
-
label: "Who can read on (none =
|
|
56
|
+
label: "Who can read on (none = the entry's rule)",
|
|
57
|
+
options: levelOptions,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: MEMBERS_BLOCK,
|
|
63
|
+
label: "Members only",
|
|
64
|
+
icon: "users",
|
|
65
|
+
description: "What follows, up to \"Members only: end\", is for members",
|
|
66
|
+
fields: [
|
|
67
|
+
{
|
|
68
|
+
type: "checkbox" as const,
|
|
69
|
+
action_id: "levels",
|
|
70
|
+
label: "Who can read it (none = the entry's rule, or any signed-in member)",
|
|
50
71
|
options: levelOptions,
|
|
51
72
|
},
|
|
52
73
|
],
|
|
53
74
|
},
|
|
75
|
+
{
|
|
76
|
+
type: MEMBERS_END_BLOCK,
|
|
77
|
+
label: "Members only: end",
|
|
78
|
+
icon: "users",
|
|
79
|
+
description: "Ends the members-only part; everyone reads on from here",
|
|
80
|
+
fields: [],
|
|
81
|
+
},
|
|
54
82
|
{
|
|
55
83
|
type: PLAN_BLOCK,
|
|
56
84
|
label: "Plan",
|
|
@@ -255,10 +283,13 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
255
283
|
{ key: "levels", label: "Who can read" },
|
|
256
284
|
],
|
|
257
285
|
rows: rules.map((r) => ({
|
|
258
|
-
where:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
286
|
+
where:
|
|
287
|
+
r.taxonomy && r.term
|
|
288
|
+
? `${r.collection === "*" ? "Every collection" : r.collection}, ${r.taxonomy}: ${r.term} (and its child terms)`
|
|
289
|
+
: r.collection === "*"
|
|
290
|
+
? "Site default (collections with no rule)"
|
|
291
|
+
: `All ${r.collection}`,
|
|
292
|
+
levels: r.open ? "Everyone (opens the entry)" : r.levels.length ? describe([r.levels], levels) : "Any signed-in member",
|
|
262
293
|
})),
|
|
263
294
|
}
|
|
264
295
|
: { type: "context", text: "No rules. Entries are open unless their body has a paywall break." },
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.5";
|