@redeemid/emdash 0.0.5 → 0.0.7
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 +23 -7
- package/docs/shopify-setup.md +1 -1
- package/package.json +1 -1
- package/src/access.ts +208 -23
- package/src/blocks/PlanBlock.astro +2 -2
- package/src/blocks-names.ts +1 -1
- package/src/components/AccessBadge.astro +1 -1
- package/src/components/Content.astro +31 -6
- package/src/components/LockedPanel.astro +17 -1
- package/src/components/MemberCard.astro +95 -0
- package/src/components/Notices.astro +1 -0
- package/src/components/Plan.astro +14 -4
- package/src/config.ts +24 -2
- package/src/plugin.ts +15 -1
- package/src/routes/callback.ts +25 -13
- package/src/routes/login.ts +6 -0
- package/src/routes/shared.ts +2 -0
- package/src/shopify.ts +11 -1
- package/src/storage.ts +41 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +3 -3
package/README.md
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
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.7, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, 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.** 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
|
+
- **Meter and drip.** Signed-in readers may open a few locked entries free each month or week. A rule can also drip: members read its entries once they've held their level for so many days.
|
|
13
14
|
- **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
15
|
- **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.
|
|
16
|
+
- **Member account card.** `<MemberCard>` shows members their levels with when each started, the levels they held before, the meter, and Refresh membership, which reads their tags from the store again.
|
|
15
17
|
- **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
|
|
16
18
|
- **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
19
|
- **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.
|
|
@@ -49,6 +51,8 @@ const config = {
|
|
|
49
51
|
join: { href: "/join/", label: "Become a member" },
|
|
50
52
|
// Optional: locked posts with no paywall break show the paragraphs within their first 40 words.
|
|
51
53
|
teaser: { posts: 40 },
|
|
54
|
+
// Optional: signed-in readers may open 3 locked posts a month.
|
|
55
|
+
meter: { limit: 3, period: "month", collections: ["posts"] },
|
|
52
56
|
};
|
|
53
57
|
|
|
54
58
|
export default defineConfig({
|
|
@@ -95,8 +99,17 @@ Each rule names a collection and the levels, any of which may read. An empty `le
|
|
|
95
99
|
| `{ collection: "posts", taxonomy: "tag", term: "free-to-read", levels: [], open: true }` | Opens entries with that term to everyone, whatever else covers them |
|
|
96
100
|
| `{ collection: "*", levels: [] }` | The site default, for collections with no rule of their own |
|
|
97
101
|
|
|
102
|
+
| `{ collection: "posts", taxonomy: "category", term: "archive", levels: ["member"], drip: 30 }` | Drip: members read these entries 30 days after their level started |
|
|
103
|
+
| `{ collection: "posts", taxonomy: "category", term: "exclusive", levels: ["pro"], meter: false }` | The meter never opens these entries |
|
|
104
|
+
|
|
98
105
|
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
106
|
|
|
107
|
+
### Meter and drip
|
|
108
|
+
|
|
109
|
+
The meter lets a signed-in reader who can't read an entry open it 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; 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.
|
|
110
|
+
|
|
111
|
+
Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had its tag (for a `match: "all"` level, the last of its tags); with several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives from sign-in and webhooks, and forgets a tag when Shopify removes it. The wall shows the date the entry opens.
|
|
112
|
+
|
|
100
113
|
## Use it in templates
|
|
101
114
|
|
|
102
115
|
EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
|
|
@@ -105,12 +118,12 @@ EmDash has no content filter hook, so templates decide what to render. Render th
|
|
|
105
118
|
---
|
|
106
119
|
import Content from "@redeemid/emdash/components/Content.astro";
|
|
107
120
|
---
|
|
108
|
-
<Content value={post.data.content} collection="posts" terms={post.data.terms}>
|
|
121
|
+
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id}>
|
|
109
122
|
<a slot="locked" href="/join/">Become a member</a>
|
|
110
123
|
</Content>
|
|
111
124
|
```
|
|
112
125
|
|
|
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
|
|
126
|
+
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. 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
127
|
|
|
115
128
|
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
129
|
|
|
@@ -127,7 +140,7 @@ import AccessBadge from "@redeemid/emdash/components/AccessBadge.astro";
|
|
|
127
140
|
<AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
|
|
128
141
|
```
|
|
129
142
|
|
|
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),
|
|
143
|
+
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), `canRead(Astro, entry)` gives the verdict for the visitor, and `readEntry(Astro, { collection, terms, id })` gives it with drip and the meter applied (and counts a metered read).
|
|
131
144
|
|
|
132
145
|
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
146
|
|
|
@@ -142,13 +155,15 @@ import Cart from "@redeemid/emdash/components/Cart.astro";
|
|
|
142
155
|
<Cart />
|
|
143
156
|
```
|
|
144
157
|
|
|
145
|
-
|
|
158
|
+
For an account page, `<MemberCard />` shows the member's levels (with when each started), levels they held before, how much of the meter they've used, and links to their store account, Refresh membership and Sign out. Visitors get a sign-in link. Refresh membership (`refreshUrl(returnTo)` in `@redeemid/emdash/access`) sends the member to the store with no sign-in form; a member still signed in there comes straight back with their tags read again and the `refreshed` notice, and anyone else goes through the usual sign-in. Use it after a purchase, when Shopify's tag webhook hasn't arrived yet.
|
|
159
|
+
|
|
160
|
+
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. With `levels`, members who already hold one of them see "You're on this plan." (`heldLabel`) in place of the button; the Plan block has the same choice:
|
|
146
161
|
|
|
147
162
|
```astro
|
|
148
163
|
---
|
|
149
164
|
import Plan from "@redeemid/emdash/components/Plan.astro";
|
|
150
165
|
---
|
|
151
|
-
<Plan handle="gold-membership" label="Join Gold">
|
|
166
|
+
<Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
|
|
152
167
|
<p>Shown if the product can't be loaded.</p>
|
|
153
168
|
</Plan>
|
|
154
169
|
```
|
|
@@ -160,6 +175,7 @@ import { canRead } from "@redeemid/emdash/access";
|
|
|
160
175
|
|
|
161
176
|
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
|
|
162
177
|
// verdict.allowed, verdict.reason: open | allowed | sign_in | upgrade | disabled
|
|
178
|
+
// readEntry() adds drip (verdict.opensAt) and metered (verdict.meterLeft)
|
|
163
179
|
```
|
|
164
180
|
|
|
165
181
|
Pages rendered for a signed-in member are sent with `Cache-Control: private, no-store`; pages for visitors stay cacheable.
|
package/docs/shopify-setup.md
CHANGED
|
@@ -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`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails.
|
|
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. Refresh membership returns `refreshed` once it has read the member's tags again.
|
package/package.json
CHANGED
package/src/access.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface Viewer {
|
|
|
32
32
|
levels: string[];
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export type Reason = "open" | "allowed" | "sign_in" | "upgrade" | "disabled" | "unconfigured";
|
|
35
|
+
export type Reason = "open" | "allowed" | "sign_in" | "upgrade" | "disabled" | "unconfigured" | "drip" | "metered";
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* What an entry needs, in conjunctive form: every clause must pass, and a
|
|
@@ -42,7 +42,7 @@ export type Reason = "open" | "allowed" | "sign_in" | "upgrade" | "disabled" | "
|
|
|
42
42
|
export type Requirement = string[][] | null;
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* The built-in level every signed-in member holds
|
|
45
|
+
* The built-in level every signed-in member holds. A clause
|
|
46
46
|
* with it passes for any signed-in member, like an empty clause.
|
|
47
47
|
*/
|
|
48
48
|
export const SIGNED_IN = "signed-in";
|
|
@@ -53,6 +53,12 @@ export interface Verdict {
|
|
|
53
53
|
/** What the entry needs (see Requirement). `[]` when it is open. */
|
|
54
54
|
required: string[][];
|
|
55
55
|
viewer: Viewer;
|
|
56
|
+
/** For "drip": when the entry opens for this member. */
|
|
57
|
+
opensAt?: Date;
|
|
58
|
+
/** For "metered", and for a locked "upgrade" when a meter runs: free entries left this period. */
|
|
59
|
+
meterLeft?: number;
|
|
60
|
+
/** The meter's size and period, when one ran. */
|
|
61
|
+
meter?: { limit: number; period: "month" | "week" };
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** A term as EmDash hydrates it onto an entry, or just its slug. */
|
|
@@ -102,26 +108,14 @@ function combined(rules: RuleConfig[], mode: "all" | "any"): string[][] {
|
|
|
102
108
|
return reduce(rules.map((r) => r.levels));
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
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.
|
|
111
|
-
*/
|
|
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";
|
|
111
|
+
/** The rules that decide an entry: matching term rules, else the collection's, else the site default. */
|
|
112
|
+
function deciding(rules: RuleConfig[], collection: string, terms: Terms, tree?: TermTree): RuleConfig[] | "open" {
|
|
119
113
|
const forCollection = rules.filter((r) => r.collection === collection);
|
|
120
114
|
const matched: RuleConfig[] = [];
|
|
121
115
|
for (const [taxonomy, list] of Object.entries(terms)) {
|
|
122
116
|
const taxRules = forCollection.filter((r) => r.taxonomy === taxonomy && r.term);
|
|
123
117
|
if (taxRules.length === 0) continue;
|
|
124
|
-
const parents =
|
|
118
|
+
const parents = tree?.[taxonomy] ?? {};
|
|
125
119
|
for (const ref of list ?? []) {
|
|
126
120
|
let slug: string | null = slugOf(ref);
|
|
127
121
|
const seen = new Set<string>();
|
|
@@ -136,13 +130,142 @@ export function requirement(
|
|
|
136
130
|
}
|
|
137
131
|
}
|
|
138
132
|
}
|
|
139
|
-
if (matched.some((r) => r.open)) return
|
|
140
|
-
if (matched.length > 0) return
|
|
133
|
+
if (matched.some((r) => r.open)) return "open";
|
|
134
|
+
if (matched.length > 0) return matched;
|
|
141
135
|
const whole = forCollection.filter((r) => !r.taxonomy && !r.term && !r.open);
|
|
142
|
-
if (whole.length > 0) return
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
136
|
+
if (whole.length > 0) return whole;
|
|
137
|
+
return rules.filter((r) => r.collection === "*" && !r.taxonomy && !r.term && !r.open);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What an entry needs (see Requirement), from the site's rules.
|
|
142
|
+
*
|
|
143
|
+
* Each term the entry carries is walked up its taxonomy (with `tree`) to the
|
|
144
|
+
* nearest term that has a rule, so a child term takes its parent's rule unless
|
|
145
|
+
* it has its own. Any matching `open` rule opens the entry.
|
|
146
|
+
*/
|
|
147
|
+
export function requirement(
|
|
148
|
+
rules: RuleConfig[] = [],
|
|
149
|
+
collection: string,
|
|
150
|
+
terms: Terms = {},
|
|
151
|
+
options: { combine?: "all" | "any"; tree?: TermTree } = {},
|
|
152
|
+
): Requirement {
|
|
153
|
+
const found = deciding(rules, collection, terms, options.tree);
|
|
154
|
+
if (found === "open" || found.length === 0) return null;
|
|
155
|
+
return combined(found, options.combine ?? "all");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Days of membership the entry's deciding rules ask for (the largest drip), or 0. */
|
|
159
|
+
export function dripDays(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): number {
|
|
160
|
+
const found = deciding(rules, collection, terms, tree);
|
|
161
|
+
if (found === "open") return 0;
|
|
162
|
+
return Math.max(0, ...found.map((r) => (typeof r.drip === "number" && r.drip > 0 ? r.drip : 0)));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** May the meter open the entry? Not when a deciding rule says `meter: false`. */
|
|
166
|
+
export function meterable(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): boolean {
|
|
167
|
+
const found = deciding(rules, collection, terms, tree);
|
|
168
|
+
return found === "open" || !found.some((r) => r.meter === false);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* When a member's membership started, for drip: the earliest start among the
|
|
173
|
+
* levels they hold now (a level starts when its last needed tag arrived),
|
|
174
|
+
* else when they first signed in. Null when neither is known.
|
|
175
|
+
*/
|
|
176
|
+
export function memberSince(member: MemberRecord, levels: LevelConfig[] = []): string | null {
|
|
177
|
+
const since = member.tagsSince ?? {};
|
|
178
|
+
const starts: string[] = [];
|
|
179
|
+
for (const slug of levelsFor(member.tags, levels)) {
|
|
180
|
+
const level = levels.find((l) => l.slug === slug);
|
|
181
|
+
const dates = (level?.tags ?? []).map((t) => since[t.toLowerCase()]).filter((d): d is string => Boolean(d)).sort();
|
|
182
|
+
if (dates.length === 0) continue;
|
|
183
|
+
const pick = level?.match === "all" ? dates[dates.length - 1] : dates[0];
|
|
184
|
+
if (pick) starts.push(pick);
|
|
185
|
+
}
|
|
186
|
+
starts.sort();
|
|
187
|
+
return starts[0] ?? member.linkedAt ?? null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface HeldLevel {
|
|
191
|
+
slug: string;
|
|
192
|
+
name: string;
|
|
193
|
+
/** When the level started (see memberSince), ISO, or null when unknown. */
|
|
194
|
+
since: string | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface PastLevel {
|
|
198
|
+
slug: string;
|
|
199
|
+
name: string;
|
|
200
|
+
from: string | null;
|
|
201
|
+
to: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The member's levels now (with when each started) and the levels they held
|
|
206
|
+
* before (from the tags Shopify has since removed), newest first.
|
|
207
|
+
*/
|
|
208
|
+
export function membership(member: MemberRecord, levels: LevelConfig[] = []): { current: HeldLevel[]; past: PastLevel[] } {
|
|
209
|
+
const held = levelsFor(member.tags, levels);
|
|
210
|
+
const current = held.map((slug) => {
|
|
211
|
+
const level = levels.find((l) => l.slug === slug)!;
|
|
212
|
+
return { slug, name: level.name, since: memberSince({ ...member, linkedAt: null }, [level]) };
|
|
213
|
+
});
|
|
214
|
+
const pastTags = member.pastTags ?? [];
|
|
215
|
+
const past: PastLevel[] = [];
|
|
216
|
+
for (const level of levels) {
|
|
217
|
+
if (held.includes(level.slug)) continue;
|
|
218
|
+
const wanted = new Set(level.tags.map((t) => t.toLowerCase()));
|
|
219
|
+
const spans = pastTags.filter((p) => wanted.has(p.tag));
|
|
220
|
+
if (spans.length === 0) continue;
|
|
221
|
+
const froms = spans.map((p) => p.from).filter((d): d is string => Boolean(d)).sort();
|
|
222
|
+
const to = spans.map((p) => p.to).sort().at(-1)!;
|
|
223
|
+
past.push({ slug: level.slug, name: level.name, from: froms[0] ?? null, to });
|
|
224
|
+
}
|
|
225
|
+
past.sort((a, b) => (a.to < b.to ? 1 : a.to > b.to ? -1 : 0));
|
|
226
|
+
return { current, past };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** How much of this period's meter the signed-in reader has used, or null when no meter runs. */
|
|
230
|
+
export async function meterUsage(astro: AstroLike): Promise<{ used: number; limit: number; period: "month" | "week" } | null> {
|
|
231
|
+
const locals = astro.locals as Locals;
|
|
232
|
+
const meter = siteConfig(locals.emdash?.config)?.meter;
|
|
233
|
+
const user = locals.user;
|
|
234
|
+
const db = locals.emdash?.db;
|
|
235
|
+
if (!meter || !(meter.limit > 0) || !user?.id || !db) return null;
|
|
236
|
+
const period = meter.period ?? "month";
|
|
237
|
+
try {
|
|
238
|
+
const record = await stores(db).meter.get(`${user.id}:${periodKey(new Date(), period)}`);
|
|
239
|
+
return { used: Math.min(record?.items.length ?? 0, meter.limit), limit: meter.limit, period };
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** When drip opens an entry for this member: `since` plus `days`. */
|
|
246
|
+
export function dripOpensAt(since: string | null, days: number): Date | null {
|
|
247
|
+
if (!since || days <= 0) return null;
|
|
248
|
+
const t = Date.parse(since);
|
|
249
|
+
return Number.isNaN(t) ? null : new Date(t + days * 86_400_000);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The meter period an instant falls in: "2026-11", or ISO week "2026-W45". UTC. */
|
|
253
|
+
export function periodKey(at: Date, period: "month" | "week" = "month"): string {
|
|
254
|
+
if (period === "month") return `${at.getUTCFullYear()}-${String(at.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
255
|
+
const d = new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()));
|
|
256
|
+
const day = d.getUTCDay() || 7;
|
|
257
|
+
d.setUTCDate(d.getUTCDate() + 4 - day);
|
|
258
|
+
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1);
|
|
259
|
+
const week = Math.ceil(((d.getTime() - yearStart) / 86_400_000 + 1) / 7);
|
|
260
|
+
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Count `id` against a meter: an item already counted stays free; a new one needs room. Pure. */
|
|
264
|
+
export function consume(items: string[], id: string, limit: number): { allowed: boolean; items: string[]; left: number } {
|
|
265
|
+
if (items.includes(id)) return { allowed: true, items, left: Math.max(0, limit - items.length) };
|
|
266
|
+
if (items.length >= limit) return { allowed: false, items, left: 0 };
|
|
267
|
+
const next = [...items, id];
|
|
268
|
+
return { allowed: true, items: next, left: limit - next.length };
|
|
146
269
|
}
|
|
147
270
|
|
|
148
271
|
/** Does someone holding `held` pass `required`? Signed-in membership is checked by the caller. */
|
|
@@ -287,6 +410,68 @@ export function accountUrl(returnTo = "/"): string {
|
|
|
287
410
|
return `/_emdash/api/auth/redeemid/account?return=${encodeURIComponent(safeReturn(returnTo))}`;
|
|
288
411
|
}
|
|
289
412
|
|
|
413
|
+
/** Re-read the member's tags from the store without a sign-in form when their store session is still open. */
|
|
414
|
+
export function refreshUrl(returnTo = "/"): string {
|
|
415
|
+
return `/_emdash/api/auth/redeemid/login?refresh=1&return=${encodeURIComponent(safeReturn(returnTo))}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
290
418
|
export function signOutUrl(returnTo = "/"): string {
|
|
291
419
|
return `/_emdash/api/auth/redeemid/logout?return=${encodeURIComponent(safeReturn(returnTo))}`;
|
|
292
420
|
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The entry's verdict with drip and the meter applied, for the entry's own
|
|
424
|
+
* page. Drip narrows a granted verdict; the meter opens a locked one for a
|
|
425
|
+
* signed-in reader while the period has room, and never opens a drip entry.
|
|
426
|
+
*/
|
|
427
|
+
export async function readEntry(
|
|
428
|
+
astro: AstroLike,
|
|
429
|
+
entry: { collection: string; terms?: Terms; id?: string },
|
|
430
|
+
override?: { required: string[][] },
|
|
431
|
+
): Promise<Verdict> {
|
|
432
|
+
const verdict = await canRead(astro, entry, override);
|
|
433
|
+
const locals = astro.locals as Locals;
|
|
434
|
+
const config = siteConfig(locals.emdash?.config);
|
|
435
|
+
if (!config) return verdict;
|
|
436
|
+
|
|
437
|
+
if (verdict.allowed && verdict.reason === "allowed" && verdict.viewer.member) {
|
|
438
|
+
const tree = await termTree(config.rules, entry.collection, entry.terms);
|
|
439
|
+
const days = dripDays(config.rules, entry.collection, entry.terms, tree);
|
|
440
|
+
const opensAt = dripOpensAt(memberSince(verdict.viewer.member, config.levels), days);
|
|
441
|
+
if (days > 0 && (!opensAt || opensAt.getTime() > Date.now())) {
|
|
442
|
+
return { ...verdict, allowed: false, reason: "drip", opensAt: opensAt ?? undefined };
|
|
443
|
+
}
|
|
444
|
+
return verdict;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const meter = config.meter;
|
|
448
|
+
const user = locals.user;
|
|
449
|
+
const db = locals.emdash?.db;
|
|
450
|
+
if (
|
|
451
|
+
verdict.reason !== "upgrade" ||
|
|
452
|
+
!meter ||
|
|
453
|
+
!(meter.limit > 0) ||
|
|
454
|
+
!entry.id ||
|
|
455
|
+
!user?.id ||
|
|
456
|
+
!db ||
|
|
457
|
+
(meter.collections && !meter.collections.includes(entry.collection))
|
|
458
|
+
) {
|
|
459
|
+
return verdict;
|
|
460
|
+
}
|
|
461
|
+
if (!meterable(config.rules, entry.collection, entry.terms, await termTree(config.rules, entry.collection, entry.terms))) {
|
|
462
|
+
return verdict;
|
|
463
|
+
}
|
|
464
|
+
const period = meter.period ?? "month";
|
|
465
|
+
const key = `${user.id}:${periodKey(new Date(), period)}`;
|
|
466
|
+
try {
|
|
467
|
+
const s = stores(db);
|
|
468
|
+
const record = (await s.meter.get(key)) ?? { userId: user.id, period: periodKey(new Date(), period), items: [] };
|
|
469
|
+
const used = consume(record.items, `${entry.collection}:${entry.id}`, meter.limit);
|
|
470
|
+
const info = { limit: meter.limit, period };
|
|
471
|
+
if (!used.allowed) return { ...verdict, meterLeft: 0, meter: info };
|
|
472
|
+
if (used.items !== record.items) await s.meter.put(key, { ...record, items: used.items });
|
|
473
|
+
return { ...verdict, allowed: true, reason: "metered", meterLeft: used.left, meter: info };
|
|
474
|
+
} catch {
|
|
475
|
+
return verdict;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import Plan from "../components/Plan.astro";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
|
-
node: { handle?: string; title?: string; label?: string };
|
|
6
|
+
node: { handle?: string; title?: string; label?: string; levels?: string[] };
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const { node } = Astro.props;
|
|
10
10
|
const handle = (node.handle ?? "").trim();
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
{handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} />}
|
|
13
|
+
{handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} levels={Array.isArray(node.levels) ? node.levels : []} />}
|
package/src/blocks-names.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function splitAtPaywall<T>(value: T[] | null | undefined): {
|
|
|
29
29
|
type TextNode = { _type?: unknown; children?: Array<{ text?: unknown }> };
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* The text blocks within the first `words` words of a body
|
|
32
|
+
* The text blocks within the first `words` words of a body:
|
|
33
33
|
* a block is never cut in half, so a block that would pass the count ends the
|
|
34
34
|
* teaser. Images and custom blocks are left out. 0 gives nothing.
|
|
35
35
|
*/
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* An entry's body, with RedeemID's blocks applied.
|
|
4
4
|
*
|
|
5
|
-
* <Content value={post.data.content} collection="posts" terms={post.data.terms} />
|
|
5
|
+
* <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} />
|
|
6
6
|
*
|
|
7
7
|
* - "Paywall break": everything before the first one is shown to everyone.
|
|
8
8
|
* What follows reaches the page only for readers who may read it: the
|
|
9
9
|
* levels picked on the break, or when none are picked the entry's rule
|
|
10
|
-
* (with no rule, the break changes nothing
|
|
10
|
+
* (with no rule, the break changes nothing).
|
|
11
11
|
* - "Members only" … "Members only: end": the part between is for the levels
|
|
12
12
|
* picked on the start block, or the entry's rule, or any signed-in member.
|
|
13
13
|
* Readers who can't read it see the wall in its place, and the body goes on.
|
|
@@ -15,10 +15,15 @@
|
|
|
15
15
|
* the collection's teaser (config `teaser`, in words), if any.
|
|
16
16
|
*
|
|
17
17
|
* Readers kept out get the wall (LockedPanel), with the "locked" slot on top.
|
|
18
|
+
*
|
|
19
|
+
* Pass `id` on the entry's own page so drip and the meter apply: a rule's
|
|
20
|
+
* `drip` keeps it shut until the member has held their level that many days,
|
|
21
|
+
* and config `meter` lets signed-in readers open a few locked entries each
|
|
22
|
+
* period (each entry counts once). Leave `id` off in lists.
|
|
18
23
|
*/
|
|
19
24
|
import { PortableText } from "emdash/ui";
|
|
20
25
|
|
|
21
|
-
import { canRead, entryRequirement, type Terms, type Verdict } from "../access.js";
|
|
26
|
+
import { canRead, entryRequirement, readEntry, type Terms, type Verdict } from "../access.js";
|
|
22
27
|
import { sections, splitAtPaywall, teaser } from "../blocks-names.js";
|
|
23
28
|
import { siteConfig } from "../site-config.js";
|
|
24
29
|
import LockedPanel from "./LockedPanel.astro";
|
|
@@ -27,9 +32,11 @@ interface Props {
|
|
|
27
32
|
value?: unknown[] | null;
|
|
28
33
|
collection?: string;
|
|
29
34
|
terms?: Terms;
|
|
35
|
+
/** The entry's id, on its own page: turns on drip and the meter. */
|
|
36
|
+
id?: string;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
const { value, collection = "", terms } = Astro.props;
|
|
39
|
+
const { value, collection = "", terms, id } = Astro.props;
|
|
33
40
|
const split = splitAtPaywall(value);
|
|
34
41
|
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
35
42
|
const entryRule = await entryRequirement(config, collection, terms);
|
|
@@ -39,7 +46,9 @@ const required = split.paywall ? (split.levels.length ? [split.levels] : entryRu
|
|
|
39
46
|
const verdict: Verdict | { allowed: true; reason: "open" } =
|
|
40
47
|
split.paywall && required === null
|
|
41
48
|
? { allowed: true, reason: "open" }
|
|
42
|
-
:
|
|
49
|
+
: id
|
|
50
|
+
? await readEntry(Astro, { collection, terms, id }, required ? { required } : undefined)
|
|
51
|
+
: await canRead(Astro, { collection, terms }, required ? { required } : undefined);
|
|
43
52
|
const words = config?.teaser?.[collection] ?? 0;
|
|
44
53
|
const shown = split.paywall ? split.open : verdict.allowed ? split.open : teaser(split.open, words);
|
|
45
54
|
const rest = verdict.allowed ? split.locked : [];
|
|
@@ -59,8 +68,17 @@ async function parts(blocks: unknown[]) {
|
|
|
59
68
|
}
|
|
60
69
|
const before = await parts(shown);
|
|
61
70
|
const after = await parts(rest);
|
|
71
|
+
const metered = verdict.reason === "metered" ? (verdict as Verdict) : null;
|
|
72
|
+
const per = metered?.meter?.period === "week" ? "this week" : "this month";
|
|
62
73
|
---
|
|
63
74
|
|
|
75
|
+
{metered && (
|
|
76
|
+
<p class="redeemid-meter" role="status" data-redeemid-left={metered.meterLeft}>
|
|
77
|
+
{metered.meterLeft === 0
|
|
78
|
+
? `This is your last free read ${per}.`
|
|
79
|
+
: `You have ${metered.meterLeft} free ${metered.meterLeft === 1 ? "read" : "reads"} left ${per}.`}
|
|
80
|
+
</p>
|
|
81
|
+
)}
|
|
64
82
|
{before.map((p) =>
|
|
65
83
|
!p.verdict || p.verdict.allowed ? (
|
|
66
84
|
p.blocks.length > 0 && <PortableText value={p.blocks} />
|
|
@@ -77,7 +95,14 @@ const after = await parts(rest);
|
|
|
77
95
|
),
|
|
78
96
|
)
|
|
79
97
|
) : (
|
|
80
|
-
<LockedPanel
|
|
98
|
+
<LockedPanel
|
|
99
|
+
reason={verdict.reason}
|
|
100
|
+
required={verdict.required}
|
|
101
|
+
held={verdict.viewer.levels}
|
|
102
|
+
opensAt={verdict.opensAt}
|
|
103
|
+
meterLeft={verdict.meterLeft}
|
|
104
|
+
meterPeriod={verdict.meter?.period}
|
|
105
|
+
>
|
|
81
106
|
<slot name="locked" />
|
|
82
107
|
</LockedPanel>
|
|
83
108
|
)}
|
|
@@ -17,9 +17,17 @@ interface Props {
|
|
|
17
17
|
required?: string[][];
|
|
18
18
|
/** Levels the reader holds (Verdict.viewer.levels). */
|
|
19
19
|
held?: string[];
|
|
20
|
+
/** For "drip": when it opens (Verdict.opensAt). */
|
|
21
|
+
opensAt?: Date;
|
|
22
|
+
/** Free reads left when a meter ran (Verdict.meterLeft); 0 shows the meter-used text. */
|
|
23
|
+
meterLeft?: number;
|
|
24
|
+
meterPeriod?: "month" | "week";
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
const { reason, required = [], held = [] } = Astro.props;
|
|
27
|
+
const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod } = Astro.props;
|
|
28
|
+
const opens = opensAt
|
|
29
|
+
? new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(opensAt)
|
|
30
|
+
: null;
|
|
23
31
|
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
24
32
|
const missing = required.find((clause) => clause.length > 0 && !passes(held, [clause]));
|
|
25
33
|
const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : undefined;
|
|
@@ -37,6 +45,14 @@ const here = Astro.url.pathname + Astro.url.search;
|
|
|
37
45
|
{joinHref && <> Not a member yet? <a href={joinHref}>{joinLabel}</a>.</>}
|
|
38
46
|
</p>
|
|
39
47
|
)}
|
|
48
|
+
{reason === "upgrade" && meterLeft === 0 && (
|
|
49
|
+
<p class="redeemid-locked__meter">You've used your free reads {meterPeriod === "week" ? "this week" : "this month"}.</p>
|
|
50
|
+
)}
|
|
51
|
+
{reason === "drip" && (
|
|
52
|
+
<p>
|
|
53
|
+
{opens ? <>This opens for you on <time datetime={opensAt?.toISOString()}>{opens}</time>.</> : "This opens for you after you've been a member a little longer."}
|
|
54
|
+
</p>
|
|
55
|
+
)}
|
|
40
56
|
{reason === "upgrade" && (
|
|
41
57
|
<p>
|
|
42
58
|
{level ? `This is for ${level.name} members.` : "Your membership doesn't include this yet."}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The member's own account card, for an account page: their levels with
|
|
4
|
+
* when each started, levels they held before, the meter, and links to the
|
|
5
|
+
* store account, Refresh membership and Sign out. Visitors get a sign-in link.
|
|
6
|
+
*
|
|
7
|
+
* <MemberCard />
|
|
8
|
+
*
|
|
9
|
+
* Everything shown comes from the mirrored Shopify customer; Refresh
|
|
10
|
+
* membership reads it from the store again.
|
|
11
|
+
*/
|
|
12
|
+
import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
13
|
+
import { siteConfig } from "../site-config.js";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Heading text; the member's first name is added after it. */
|
|
17
|
+
title?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { title = "Your membership" } = Astro.props;
|
|
21
|
+
const v = await viewer(Astro);
|
|
22
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
23
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
24
|
+
const m = v.member;
|
|
25
|
+
const disabled = m?.status === "disabled";
|
|
26
|
+
const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
|
|
27
|
+
const meter = v.signedIn ? await meterUsage(Astro) : null;
|
|
28
|
+
const date = (iso: string | null) => {
|
|
29
|
+
if (!iso) return null;
|
|
30
|
+
const t = Date.parse(iso);
|
|
31
|
+
return Number.isNaN(t) ? null : new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(new Date(t));
|
|
32
|
+
};
|
|
33
|
+
const name = [m?.firstName, m?.lastName].filter(Boolean).join(" ");
|
|
34
|
+
const join = config?.join;
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<section class="redeemid-member" data-redeemid-signed-in={v.signedIn ? "true" : "false"}>
|
|
38
|
+
{v.signedIn ? (
|
|
39
|
+
<>
|
|
40
|
+
<h2 class="redeemid-member__title">{title}</h2>
|
|
41
|
+
{(name || m?.email) && (
|
|
42
|
+
<p class="redeemid-member__who">
|
|
43
|
+
{name && <strong>{name}</strong>}
|
|
44
|
+
{m?.email && <span>{m.email}</span>}
|
|
45
|
+
</p>
|
|
46
|
+
)}
|
|
47
|
+
{disabled ? (
|
|
48
|
+
<p class="redeemid-member__disabled">Your account is turned off. Contact the store for help.</p>
|
|
49
|
+
) : current.length > 0 ? (
|
|
50
|
+
<ul class="redeemid-member__levels">
|
|
51
|
+
{current.map((l) => (
|
|
52
|
+
<li data-redeemid-level={l.slug}>
|
|
53
|
+
<strong>{l.name}</strong>
|
|
54
|
+
{date(l.since) && <> since <time datetime={l.since ?? undefined}>{date(l.since)}</time></>}
|
|
55
|
+
</li>
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
) : (
|
|
59
|
+
<p class="redeemid-member__none">
|
|
60
|
+
You don't have a membership yet.{join && <> <a href={join.href}>{join.label ?? "Become a member"}</a></>}
|
|
61
|
+
</p>
|
|
62
|
+
)}
|
|
63
|
+
{past.length > 0 && (
|
|
64
|
+
<>
|
|
65
|
+
<h3 class="redeemid-member__subtitle">Before</h3>
|
|
66
|
+
<ul class="redeemid-member__past">
|
|
67
|
+
{past.map((l) => (
|
|
68
|
+
<li data-redeemid-level={l.slug}>
|
|
69
|
+
{l.name}
|
|
70
|
+
{date(l.from) ? `, ${date(l.from)} to ${date(l.to)}` : `, until ${date(l.to)}`}
|
|
71
|
+
</li>
|
|
72
|
+
))}
|
|
73
|
+
</ul>
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
{meter && current.length === 0 && !disabled && (
|
|
77
|
+
<p class="redeemid-member__meter" data-redeemid-used={meter.used}>
|
|
78
|
+
Free reads {meter.period === "week" ? "this week" : "this month"}: {meter.used} of {meter.limit} used.
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
<p class="redeemid-member__actions">
|
|
82
|
+
<a href={accountUrl(here)}>Orders and details</a>
|
|
83
|
+
<a href={refreshUrl(here)}>Refresh membership</a>
|
|
84
|
+
</p>
|
|
85
|
+
<form method="post" action={signOutUrl("/")}>
|
|
86
|
+
<button type="submit">Sign out</button>
|
|
87
|
+
</form>
|
|
88
|
+
<p class="redeemid-member__note">Just bought a plan? It can take a minute to show here. Refresh membership checks again.</p>
|
|
89
|
+
</>
|
|
90
|
+
) : (
|
|
91
|
+
<p>
|
|
92
|
+
<a href={signInUrl(here)}>{config?.label ?? "Sign in"}</a> to see your membership.
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
</section>
|
|
@@ -21,6 +21,7 @@ const DEFAULTS: Record<string, string> = {
|
|
|
21
21
|
plan_unavailable: "That plan isn't available right now.",
|
|
22
22
|
checkout_failed: "Checkout didn't open. Try again.",
|
|
23
23
|
cart_empty: "Your cart is empty.",
|
|
24
|
+
refreshed: "Your membership is up to date.",
|
|
24
25
|
account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
|
|
25
26
|
};
|
|
26
27
|
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* checkout. Prices come from the Storefront API; the default slot shows if
|
|
5
5
|
* the product can't be loaded.
|
|
6
6
|
*
|
|
7
|
-
* <Plan handle="gold-membership" />
|
|
7
|
+
* <Plan handle="gold-membership" levels={["gold"]} />
|
|
8
|
+
*
|
|
9
|
+
* With `levels`, members who hold one of them see "You're on this plan"
|
|
10
|
+
* in place of the button.
|
|
8
11
|
*/
|
|
12
|
+
import { viewer } from "../access.js";
|
|
9
13
|
import { siteConfig } from "../site-config.js";
|
|
10
14
|
import { plan as loadPlan, type Plan } from "../storefront.js";
|
|
11
15
|
|
|
@@ -15,9 +19,12 @@ interface Props {
|
|
|
15
19
|
label?: string;
|
|
16
20
|
/** Heading override; defaults to the Shopify product title. */
|
|
17
21
|
title?: string;
|
|
22
|
+
/** The levels this plan gives. Members holding one see `heldLabel`, not the button. */
|
|
23
|
+
levels?: string[];
|
|
24
|
+
heldLabel?: string;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
const { handle, label = "Join", title } = Astro.props;
|
|
27
|
+
const { handle, label = "Join", title, levels = [], heldLabel = "You're on this plan." } = Astro.props;
|
|
21
28
|
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
22
29
|
let p: Plan | null = null;
|
|
23
30
|
if (config) {
|
|
@@ -31,14 +38,17 @@ const price = p?.price
|
|
|
31
38
|
? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
|
|
32
39
|
: null;
|
|
33
40
|
const here = Astro.url.pathname + Astro.url.search;
|
|
41
|
+
const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => levels.includes(l));
|
|
34
42
|
---
|
|
35
43
|
|
|
36
44
|
{p ? (
|
|
37
|
-
<div class="redeemid-plan" data-redeemid-plan={p.handle}>
|
|
45
|
+
<div class:list={["redeemid-plan", { "is-held": held }]} data-redeemid-plan={p.handle}>
|
|
38
46
|
<h2 class="redeemid-plan__title">{title ?? p.title}</h2>
|
|
39
47
|
{price && <p class="redeemid-plan__price">{price}</p>}
|
|
40
48
|
{p.description && <p class="redeemid-plan__description">{p.description}</p>}
|
|
41
|
-
{
|
|
49
|
+
{held ? (
|
|
50
|
+
<p class="redeemid-plan__held">{heldLabel}</p>
|
|
51
|
+
) : p.availableForSale ? (
|
|
42
52
|
<form method="post" action="/_emdash/api/auth/redeemid/buy">
|
|
43
53
|
<input type="hidden" name="plan" value={p.handle} />
|
|
44
54
|
<input type="hidden" name="return" value={here} />
|
package/src/config.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface LevelConfig {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Who may read entries
|
|
25
|
+
* Who may read entries:
|
|
26
26
|
*
|
|
27
27
|
* - A rule with `taxonomy` and `term` covers entries that carry that term, or
|
|
28
28
|
* a child of it in a hierarchical taxonomy (a child's own rule wins over its
|
|
@@ -45,9 +45,30 @@ export interface RuleConfig {
|
|
|
45
45
|
levels: string[];
|
|
46
46
|
/**
|
|
47
47
|
* A term rule that opens its entries to everyone, whatever else covers them
|
|
48
|
-
*
|
|
48
|
+
* `levels` is ignored.
|
|
49
49
|
*/
|
|
50
50
|
open?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Drip: readers who pass the rule may read its entries only once they have
|
|
53
|
+
* been members this many days (from when they first held a level they
|
|
54
|
+
* hold now, or else when they first signed in).
|
|
55
|
+
*/
|
|
56
|
+
drip?: number;
|
|
57
|
+
/** `false`: the meter never opens this rule's entries. */
|
|
58
|
+
meter?: false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The meter: signed-in readers without access may open a few locked entries
|
|
63
|
+
* free each month (or week). Guests get the wall; an entry counts once per
|
|
64
|
+
* period, when its own page renders through <Content> with an `id`.
|
|
65
|
+
*/
|
|
66
|
+
export interface MeterConfig {
|
|
67
|
+
/** Free entries per period. */
|
|
68
|
+
limit: number;
|
|
69
|
+
period?: "month" | "week";
|
|
70
|
+
/** Collections the meter covers. All of them when left out. */
|
|
71
|
+
collections?: string[];
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
export interface RedeemIDConfig {
|
|
@@ -61,6 +82,7 @@ export interface RedeemIDConfig {
|
|
|
61
82
|
label?: string;
|
|
62
83
|
levels?: LevelConfig[];
|
|
63
84
|
rules?: RuleConfig[];
|
|
85
|
+
meter?: MeterConfig;
|
|
64
86
|
/** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
|
|
65
87
|
join?: { href: string; label?: string };
|
|
66
88
|
/**
|
package/src/plugin.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - "Paywall break" (`redeemid-paywall`): everything after it in the body is
|
|
14
14
|
* for members. Pick the levels that may read on ("Signed in" for any
|
|
15
15
|
* member); none picked means the entry's rule, and nothing is locked when
|
|
16
|
-
* no rule covers the entry
|
|
16
|
+
* no rule covers the entry.
|
|
17
17
|
* - "Members only" (`redeemid-members`) and "Members only: end"
|
|
18
18
|
* (`redeemid-members-end`): the part between is for the levels picked on
|
|
19
19
|
* the start block; everyone else sees the wall there and reads on after.
|
|
@@ -89,6 +89,12 @@ function blocks(config: RedeemIDConfig) {
|
|
|
89
89
|
{ type: "text_input" as const, action_id: "handle", label: "Shopify product handle", placeholder: "gold-membership" },
|
|
90
90
|
{ type: "text_input" as const, action_id: "title", label: "Title (optional, Shopify's title otherwise)" },
|
|
91
91
|
{ type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Join" },
|
|
92
|
+
{
|
|
93
|
+
type: "checkbox" as const,
|
|
94
|
+
action_id: "levels",
|
|
95
|
+
label: "Levels this plan gives (members on them see \"You're on this plan\")",
|
|
96
|
+
options: (config.levels ?? []).map((l) => ({ label: l.name, value: l.slug })),
|
|
97
|
+
},
|
|
92
98
|
],
|
|
93
99
|
},
|
|
94
100
|
];
|
|
@@ -281,6 +287,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
281
287
|
columns: [
|
|
282
288
|
{ key: "where", label: "Covers" },
|
|
283
289
|
{ key: "levels", label: "Who can read" },
|
|
290
|
+
{ key: "extra", label: "Drip and meter" },
|
|
284
291
|
],
|
|
285
292
|
rows: rules.map((r) => ({
|
|
286
293
|
where:
|
|
@@ -290,9 +297,16 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
290
297
|
? "Site default (collections with no rule)"
|
|
291
298
|
: `All ${r.collection}`,
|
|
292
299
|
levels: r.open ? "Everyone (opens the entry)" : r.levels.length ? describe([r.levels], levels) : "Any signed-in member",
|
|
300
|
+
extra: [r.drip ? `Opens ${r.drip} days into membership` : "", r.meter === false ? "Never metered" : ""].filter(Boolean).join("; "),
|
|
293
301
|
})),
|
|
294
302
|
}
|
|
295
303
|
: { type: "context", text: "No rules. Entries are open unless their body has a paywall break." },
|
|
304
|
+
{
|
|
305
|
+
type: "context",
|
|
306
|
+
text: config.meter?.limit
|
|
307
|
+
? `Meter: signed-in readers may open ${config.meter.limit} locked ${config.meter.limit === 1 ? "entry" : "entries"} a ${config.meter.period ?? "month"}${config.meter.collections?.length ? ` (${config.meter.collections.join(", ")})` : ""}.`
|
|
308
|
+
: "Meter: off.",
|
|
309
|
+
},
|
|
296
310
|
],
|
|
297
311
|
};
|
|
298
312
|
}
|
package/src/routes/callback.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { Kysely } from "kysely";
|
|
|
17
17
|
|
|
18
18
|
import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
|
|
19
19
|
import { discover, exchangeCode, identity, ShopifyError, verifyIdToken } from "../shopify.js";
|
|
20
|
-
import { emptyMember, stores } from "../storage.js";
|
|
20
|
+
import { emptyMember, stampTags, stores } from "../storage.js";
|
|
21
21
|
import { callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
|
|
22
22
|
|
|
23
23
|
export const prerender = false;
|
|
@@ -33,7 +33,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
33
33
|
const clientId = env(ENV.clientId);
|
|
34
34
|
if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
|
|
35
35
|
|
|
36
|
-
if (url.searchParams.get("error"))
|
|
36
|
+
if (url.searchParams.get("error")) {
|
|
37
|
+
// Refresh found no open store session: sign in as usual.
|
|
38
|
+
if (flow?.refresh) return redirect(`/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(returnTo)}`);
|
|
39
|
+
return fail(redirect, returnTo, "sign_in_cancelled");
|
|
40
|
+
}
|
|
37
41
|
|
|
38
42
|
const state = url.searchParams.get("state");
|
|
39
43
|
const code = url.searchParams.get("code");
|
|
@@ -83,21 +87,29 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
83
87
|
|
|
84
88
|
const s = stores(emdash.db);
|
|
85
89
|
const member = (await s.members.get(who.customerId)) ?? emptyMember(who.customerId);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
await s.members.put(
|
|
92
|
+
who.customerId,
|
|
93
|
+
stampTags(
|
|
94
|
+
{
|
|
95
|
+
...member,
|
|
96
|
+
userId: user.id,
|
|
97
|
+
email,
|
|
98
|
+
firstName: who.firstName,
|
|
99
|
+
lastName: who.lastName,
|
|
100
|
+
tags: who.tags,
|
|
101
|
+
syncedAt: now,
|
|
102
|
+
source: "sign-in",
|
|
103
|
+
linkedAt: member.linkedAt ?? now,
|
|
104
|
+
},
|
|
105
|
+
now,
|
|
106
|
+
),
|
|
107
|
+
);
|
|
96
108
|
|
|
97
109
|
session.regenerate?.();
|
|
98
110
|
session.set("user", { id: user.id });
|
|
99
111
|
session.set(SESSION_ID_TOKEN, tokens.idToken);
|
|
100
|
-
return redirect(returnTo);
|
|
112
|
+
return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
|
|
101
113
|
} catch (e) {
|
|
102
114
|
const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
|
|
103
115
|
console.error("[redeemid] sign-in failed:", (e as Error).message);
|
package/src/routes/login.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* GET /_emdash/api/auth/redeemid/login?return=/path
|
|
3
3
|
*
|
|
4
4
|
* Starts Shopify customer account sign-in (authorization code + PKCE).
|
|
5
|
+
* With `refresh=1` it asks the store not to show a form (`prompt=none`):
|
|
6
|
+
* a member whose store session is still open comes straight back with their
|
|
7
|
+
* tags read again; anyone else is sent through the usual sign-in.
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
import type { APIRoute } from "astro";
|
|
@@ -16,6 +19,7 @@ export const prerender = false;
|
|
|
16
19
|
export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
17
20
|
const url = new URL(request.url);
|
|
18
21
|
const returnTo = safeReturn(url.searchParams.get("return"));
|
|
22
|
+
const refresh = url.searchParams.get("refresh") === "1";
|
|
19
23
|
const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
|
|
20
24
|
const config = readConfig(emdash?.config);
|
|
21
25
|
const clientId = env(ENV.clientId);
|
|
@@ -30,6 +34,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
30
34
|
verifier: randomToken(48),
|
|
31
35
|
returnTo,
|
|
32
36
|
at: Date.now(),
|
|
37
|
+
...(refresh ? { refresh: true } : {}),
|
|
33
38
|
};
|
|
34
39
|
session.set(SESSION_FLOW, flow);
|
|
35
40
|
return redirect(
|
|
@@ -40,6 +45,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
40
45
|
nonce: flow.nonce,
|
|
41
46
|
challenge: await challengeFor(flow.verifier),
|
|
42
47
|
locale: request.headers.get("accept-language")?.split(",")[0]?.trim(),
|
|
48
|
+
...(refresh ? { prompt: "none" as const } : {}),
|
|
43
49
|
}),
|
|
44
50
|
);
|
|
45
51
|
} catch (e) {
|
package/src/routes/shared.ts
CHANGED
package/src/shopify.ts
CHANGED
|
@@ -97,7 +97,16 @@ export function base64url(bytes: Uint8Array): string {
|
|
|
97
97
|
|
|
98
98
|
export function authorizeUrl(
|
|
99
99
|
d: Discovery,
|
|
100
|
-
args: {
|
|
100
|
+
args: {
|
|
101
|
+
clientId: string;
|
|
102
|
+
redirectUri: string;
|
|
103
|
+
state: string;
|
|
104
|
+
nonce: string;
|
|
105
|
+
challenge: string;
|
|
106
|
+
locale?: string;
|
|
107
|
+
/** "none": no sign-in form; the store answers with an error when the customer isn't signed in there. */
|
|
108
|
+
prompt?: "none";
|
|
109
|
+
},
|
|
101
110
|
): string {
|
|
102
111
|
const url = new URL(d.authorizationEndpoint);
|
|
103
112
|
url.searchParams.set("scope", SCOPES);
|
|
@@ -109,6 +118,7 @@ export function authorizeUrl(
|
|
|
109
118
|
url.searchParams.set("code_challenge", args.challenge);
|
|
110
119
|
url.searchParams.set("code_challenge_method", "S256");
|
|
111
120
|
if (args.locale) url.searchParams.set("ui_locales", args.locale);
|
|
121
|
+
if (args.prompt) url.searchParams.set("prompt", args.prompt);
|
|
112
122
|
return url.toString();
|
|
113
123
|
}
|
|
114
124
|
|
package/src/storage.ts
CHANGED
|
@@ -31,6 +31,45 @@ export interface MemberRecord {
|
|
|
31
31
|
syncedAt: string;
|
|
32
32
|
/** What wrote it last: sign-in or a webhook topic. */
|
|
33
33
|
source: string;
|
|
34
|
+
/** When each tag (lowercased) was first seen on the customer, ISO. Kept for drip; a removed tag is forgotten. */
|
|
35
|
+
tagsSince?: Record<string, string>;
|
|
36
|
+
/** When the customer first signed in here, ISO. */
|
|
37
|
+
linkedAt?: string | null;
|
|
38
|
+
/** Tags Shopify has removed, with when each was held (newest last, at most 50). */
|
|
39
|
+
pastTags?: PastTag[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A tag the customer held from `from` (when known) until `to`. */
|
|
43
|
+
export interface PastTag {
|
|
44
|
+
tag: string;
|
|
45
|
+
from: string | null;
|
|
46
|
+
to: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PAST_TAGS_KEPT = 50;
|
|
50
|
+
|
|
51
|
+
/** A signed-in reader's meter for one period: the locked items they opened free. */
|
|
52
|
+
export interface MeterRecord {
|
|
53
|
+
userId: string;
|
|
54
|
+
/** "2026-11" for a month, "2026-W45" for a week. */
|
|
55
|
+
period: string;
|
|
56
|
+
items: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Update `tagsSince` to the record's current tags: new tags start now;
|
|
61
|
+
* removed tags move to `pastTags` with the dates they were held.
|
|
62
|
+
*/
|
|
63
|
+
export function stampTags(m: MemberRecord, now = new Date().toISOString()): MemberRecord {
|
|
64
|
+
const before = m.tagsSince ?? {};
|
|
65
|
+
const since: Record<string, string> = {};
|
|
66
|
+
for (const t of m.tags.map((t) => t.toLowerCase())) since[t] = before[t] ?? now;
|
|
67
|
+
const removed = Object.keys(before).filter((t) => !(t in since));
|
|
68
|
+
if (removed.length) {
|
|
69
|
+
m.pastTags = [...(m.pastTags ?? []), ...removed.map((tag) => ({ tag, from: before[tag] ?? null, to: now }))].slice(-PAST_TAGS_KEPT);
|
|
70
|
+
}
|
|
71
|
+
m.tagsSince = since;
|
|
72
|
+
return m;
|
|
34
73
|
}
|
|
35
74
|
|
|
36
75
|
export interface DeliveryRecord {
|
|
@@ -41,11 +80,13 @@ export interface DeliveryRecord {
|
|
|
41
80
|
export const STORAGE_CONFIG = {
|
|
42
81
|
members: { indexes: ["email", "userId"] },
|
|
43
82
|
deliveries: { indexes: ["receivedAt"] },
|
|
83
|
+
meter: { indexes: ["userId"] },
|
|
44
84
|
};
|
|
45
85
|
|
|
46
86
|
export interface Stores {
|
|
47
87
|
members: StorageCollection<MemberRecord>;
|
|
48
88
|
deliveries: StorageCollection<DeliveryRecord>;
|
|
89
|
+
meter: StorageCollection<MeterRecord>;
|
|
49
90
|
}
|
|
50
91
|
|
|
51
92
|
export function stores(db: unknown): Stores {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.7";
|
package/src/webhooks.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* only; a profile payload without `tags` never means "tags removed".
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { cleanTags, emptyMember, numericId, type MemberRecord, type Stores } from "./storage.js";
|
|
9
|
+
import { cleanTags, emptyMember, numericId, stampTags, type MemberRecord, type Stores } from "./storage.js";
|
|
10
10
|
|
|
11
11
|
export type Kind = "profile" | "delete" | "status" | "tags" | "merge" | "compliance" | "ignored";
|
|
12
12
|
|
|
@@ -114,7 +114,7 @@ export async function apply(s: Stores, topic: string, payload: Record<string, un
|
|
|
114
114
|
if (!survivor.userId) survivor.userId = old.userId;
|
|
115
115
|
survivor.syncedAt = new Date().toISOString();
|
|
116
116
|
survivor.source = topic;
|
|
117
|
-
await s.members.put(kept, survivor);
|
|
117
|
+
await s.members.put(kept, stampTags(survivor));
|
|
118
118
|
}
|
|
119
119
|
await s.members.delete(gone);
|
|
120
120
|
return "processed";
|
|
@@ -156,6 +156,6 @@ export async function apply(s: Stores, topic: string, payload: Record<string, un
|
|
|
156
156
|
|
|
157
157
|
current.syncedAt = new Date().toISOString();
|
|
158
158
|
current.source = topic;
|
|
159
|
-
await s.members.put(id, current);
|
|
159
|
+
await s.members.put(id, stampTags(current));
|
|
160
160
|
return "processed";
|
|
161
161
|
}
|