@redeemid/emdash 0.0.21 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -13
- package/docs/developer-api.md +6 -3
- package/package.json +2 -1
- package/src/access-admin.ts +83 -12
- package/src/access.ts +128 -28
- package/src/agents.ts +4 -4
- package/src/api-version.ts +1 -1
- package/src/blocks-names.ts +3 -1
- package/src/components/AccessBadge.astro +4 -1
- package/src/components/Account.astro +14 -3
- package/src/components/LockedPanel.astro +4 -5
- package/src/components/MemberCard.astro +4 -3
- package/src/components/Plan.astro +34 -15
- package/src/config.ts +54 -3
- package/src/contract.ts +2 -1
- package/src/events.ts +1 -1
- package/src/files.ts +9 -5
- package/src/guard.ts +15 -0
- package/src/log.ts +1 -0
- package/src/plugin.ts +43 -4
- package/src/privacy.ts +10 -7
- package/src/routes/buy.ts +3 -2
- package/src/routes/callback.ts +7 -3
- package/src/routes/webhook.ts +21 -1
- package/src/search-guard.ts +100 -0
- package/src/setup.ts +2 -2
- package/src/staff.ts +89 -2
- package/src/storefront.ts +8 -4
- package/src/text.ts +1 -1
- package/src/version.ts +1 -1
- package/src/webhooks.ts +10 -3
package/src/access.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* "entitled".
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type
|
|
13
|
+
import { groupsOf, levelTags, type LevelConfig, type RedeemIDConfig, type RuleConfig, type TagGroup } from "./config.js";
|
|
14
14
|
import { UNAVAILABLE, type EntryRule } from "./settings.js";
|
|
15
15
|
import { siteConfig } from "./site-config.js";
|
|
16
16
|
import { memberForUser, stores, type MemberRecord, type MeterRecord } from "./storage.js";
|
|
@@ -72,15 +72,78 @@ export type Terms = Record<string, TermRef[] | undefined>;
|
|
|
72
72
|
/** Parent slug by term slug, per taxonomy, for walking up a hierarchy. */
|
|
73
73
|
export type TermTree = Record<string, Record<string, string | null>>;
|
|
74
74
|
|
|
75
|
-
export
|
|
75
|
+
export { groupsOf, levelTags, type TagGroup };
|
|
76
|
+
|
|
77
|
+
function groupPasses(g: TagGroup, held: Set<string>): boolean {
|
|
78
|
+
const has = (t: string) => held.has(t.toLowerCase());
|
|
79
|
+
return g.match === "all" ? g.tags.every(has) : g.tags.some(has);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Levels the tags grant directly, through one of their own groups, in config order. */
|
|
83
|
+
export function directLevels(tags: string[], levels: LevelConfig[] = []): string[] {
|
|
76
84
|
const held = new Set(tags.map((t) => t.toLowerCase()));
|
|
77
|
-
return levels
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
return levels.filter((l) => groupsOf(l).some((g) => groupPasses(g, held))).map((l) => l.slug);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every level holding `slug` opens, itself first: the levels it includes, and
|
|
90
|
+
* theirs (Gold includes Silver includes Members). Unknown slugs are skipped and
|
|
91
|
+
* loops stop, so a bad config can't hang.
|
|
92
|
+
*/
|
|
93
|
+
export function closure(slug: string, levels: LevelConfig[] = []): string[] {
|
|
94
|
+
const bySlug = new Map(levels.map((l) => [l.slug, l]));
|
|
95
|
+
const seen: string[] = [];
|
|
96
|
+
const queue = [slug];
|
|
97
|
+
while (queue.length) {
|
|
98
|
+
const next = queue.shift()!;
|
|
99
|
+
if (seen.includes(next) || !bySlug.has(next)) continue;
|
|
100
|
+
seen.push(next);
|
|
101
|
+
queue.push(...(bySlug.get(next)!.includes ?? []));
|
|
102
|
+
}
|
|
103
|
+
return seen;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Does holding `holder` open what needs `needed`? */
|
|
107
|
+
export function covers(holder: string, needed: string, levels: LevelConfig[] = []): boolean {
|
|
108
|
+
return holder === needed || closure(holder, levels).includes(needed);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The levels a member with these tags holds: the ones their tags grant, and
|
|
113
|
+
* every level those include. In config order.
|
|
114
|
+
*/
|
|
115
|
+
export function levelsFor(tags: string[], levels: LevelConfig[] = []): string[] {
|
|
116
|
+
const held = new Set(directLevels(tags, levels).flatMap((s) => closure(s, levels)));
|
|
117
|
+
return levels.filter((l) => held.has(l.slug)).map((l) => l.slug);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The held levels no other held level includes, for showing a member's
|
|
122
|
+
* levels: "Gold", not "Gold, Silver", when Gold includes Silver.
|
|
123
|
+
*/
|
|
124
|
+
export function topLevels(held: string[], levels: LevelConfig[] = []): string[] {
|
|
125
|
+
return held.filter((slug) => !held.some((o) => o !== slug && covers(o, slug, levels) && !covers(slug, o, levels)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where a member lands after signing in from the home page or a plain
|
|
130
|
+
* sign-in link: the `home` of their first top level (in level order) that has
|
|
131
|
+
* one, or null. Only same-site paths count.
|
|
132
|
+
*/
|
|
133
|
+
export function memberHome(tags: string[], levels: LevelConfig[] = []): string | null {
|
|
134
|
+
const top = topLevels(levelsFor(tags, levels), levels);
|
|
135
|
+
for (const level of levels) {
|
|
136
|
+
if (!top.includes(level.slug) || !level.home) continue;
|
|
137
|
+
const home = safeReturn(level.home.trim());
|
|
138
|
+
if (home !== "/") return home;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A level's badge colour when it is a hex colour, else null. */
|
|
144
|
+
export function badgeColor(level: LevelConfig | undefined): string | null {
|
|
145
|
+
const c = level?.badge?.trim() ?? "";
|
|
146
|
+
return /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(c) ? c : null;
|
|
84
147
|
}
|
|
85
148
|
|
|
86
149
|
const slugOf = (t: TermRef) => (typeof t === "string" ? t : t.slug);
|
|
@@ -89,12 +152,16 @@ const slugOf = (t: TermRef) => (typeof t === "string" ? t : t.slug);
|
|
|
89
152
|
* The clauses, simplified: duplicates go, a clause that contains another
|
|
90
153
|
* clause goes (needing Pro already passes "Member or Pro"), and an empty
|
|
91
154
|
* clause goes when a level clause is there (any level implies signed in).
|
|
155
|
+
*
|
|
156
|
+
* With `levels`, inclusion counts too: in "Silver or Gold" Gold goes when it
|
|
157
|
+
* includes Silver, and "Silver + Gold" needs only Gold.
|
|
92
158
|
*/
|
|
93
|
-
function reduce(clauses: string[][]): string[][] {
|
|
159
|
+
function reduce(clauses: string[][], levels?: LevelConfig[]): string[][] {
|
|
94
160
|
const seen = new Set<string>();
|
|
95
161
|
const out: string[][] = [];
|
|
96
162
|
for (const c of clauses) {
|
|
97
|
-
|
|
163
|
+
let sorted = c.includes(SIGNED_IN) ? [] : [...new Set(c)].sort();
|
|
164
|
+
if (levels?.length) sorted = sorted.filter((l) => !sorted.some((o) => o !== l && covers(l, o, levels) && !covers(o, l, levels)));
|
|
98
165
|
const key = sorted.join("|");
|
|
99
166
|
if (seen.has(key)) continue;
|
|
100
167
|
seen.add(key);
|
|
@@ -102,7 +169,14 @@ function reduce(clauses: string[][]): string[][] {
|
|
|
102
169
|
}
|
|
103
170
|
const levelled = out.filter((c) => c.length > 0);
|
|
104
171
|
if (levelled.length === 0) return out;
|
|
105
|
-
|
|
172
|
+
// `o` implies `c` when every level in `o` opens some level in `c`.
|
|
173
|
+
const implies = (o: string[], c: string[]) => o.every((l) => c.some((x) => (levels?.length ? covers(l, x, levels) : l === x)));
|
|
174
|
+
return levelled.filter((c) => !levelled.some((o) => o !== c && implies(o, c) && !(implies(c, o) && levelled.indexOf(c) < levelled.indexOf(o))));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A requirement simplified with the levels' includes (see reduce). */
|
|
178
|
+
export function simplify(required: Requirement, levels: LevelConfig[] = []): Requirement {
|
|
179
|
+
return required === null ? null : reduce(required, levels);
|
|
106
180
|
}
|
|
107
181
|
|
|
108
182
|
function combined(rules: RuleConfig[], mode: "all" | "any"): string[][] {
|
|
@@ -229,29 +303,52 @@ export function meterable(rules: RuleConfig[] = [], collection: string, terms: T
|
|
|
229
303
|
}
|
|
230
304
|
|
|
231
305
|
/**
|
|
232
|
-
* When
|
|
233
|
-
*
|
|
234
|
-
*
|
|
306
|
+
* When each held level started, by slug (ISO, or null when unknown). A group
|
|
307
|
+
* starts when its first tag arrived ("any") or its last one ("all"); a level
|
|
308
|
+
* starts with its earliest passing group, and a level held only because
|
|
309
|
+
* another includes it starts with the earliest of those.
|
|
235
310
|
*/
|
|
236
|
-
export function
|
|
311
|
+
export function levelStarts(member: MemberRecord, levels: LevelConfig[] = []): Map<string, string | null> {
|
|
237
312
|
const since = member.tagsSince ?? {};
|
|
238
|
-
const
|
|
313
|
+
const heldTags = new Set(member.tags.map((t) => t.toLowerCase()));
|
|
314
|
+
const direct = new Map<string, string | null>();
|
|
315
|
+
for (const level of levels) {
|
|
316
|
+
const starts: string[] = [];
|
|
317
|
+
let passing = false;
|
|
318
|
+
for (const g of groupsOf(level)) {
|
|
319
|
+
if (!groupPasses(g, heldTags)) continue;
|
|
320
|
+
passing = true;
|
|
321
|
+
const dates = g.tags.filter((t) => heldTags.has(t.toLowerCase())).map((t) => since[t.toLowerCase()]).filter((d): d is string => Boolean(d)).sort();
|
|
322
|
+
const pick = g.match === "all" ? dates.at(-1) : dates[0];
|
|
323
|
+
if (pick) starts.push(pick);
|
|
324
|
+
}
|
|
325
|
+
if (passing) direct.set(level.slug, starts.sort()[0] ?? null);
|
|
326
|
+
}
|
|
327
|
+
const out = new Map<string, string | null>();
|
|
239
328
|
for (const slug of levelsFor(member.tags, levels)) {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
if (dates.length === 0) continue;
|
|
243
|
-
const pick = level?.match === "all" ? dates[dates.length - 1] : dates[0];
|
|
244
|
-
if (pick) starts.push(pick);
|
|
329
|
+
const dates = [...direct.entries()].filter(([d]) => closure(d, levels).includes(slug)).map(([, at]) => at).filter((d): d is string => Boolean(d)).sort();
|
|
330
|
+
out.set(slug, dates[0] ?? null);
|
|
245
331
|
}
|
|
246
|
-
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* When a member's membership started, for drip: the earliest start among the
|
|
337
|
+
* levels they hold now (see levelStarts), else when they first signed in.
|
|
338
|
+
* Null when neither is known.
|
|
339
|
+
*/
|
|
340
|
+
export function memberSince(member: MemberRecord, levels: LevelConfig[] = []): string | null {
|
|
341
|
+
const starts = [...levelStarts(member, levels).values()].filter((d): d is string => Boolean(d)).sort();
|
|
247
342
|
return starts[0] ?? member.linkedAt ?? null;
|
|
248
343
|
}
|
|
249
344
|
|
|
250
345
|
export interface HeldLevel {
|
|
251
346
|
slug: string;
|
|
252
347
|
name: string;
|
|
253
|
-
/** When the level started (see
|
|
348
|
+
/** When the level started (see levelStarts), ISO, or null when unknown. */
|
|
254
349
|
since: string | null;
|
|
350
|
+
/** Set when the member holds it only because a level they hold includes it: that level's slug. */
|
|
351
|
+
via?: string;
|
|
255
352
|
}
|
|
256
353
|
|
|
257
354
|
export interface PastLevel {
|
|
@@ -267,15 +364,18 @@ export interface PastLevel {
|
|
|
267
364
|
*/
|
|
268
365
|
export function membership(member: MemberRecord, levels: LevelConfig[] = []): { current: HeldLevel[]; past: PastLevel[] } {
|
|
269
366
|
const held = levelsFor(member.tags, levels);
|
|
367
|
+
const direct = directLevels(member.tags, levels);
|
|
368
|
+
const starts = levelStarts(member, levels);
|
|
270
369
|
const current = held.map((slug) => {
|
|
271
370
|
const level = levels.find((l) => l.slug === slug)!;
|
|
272
|
-
|
|
371
|
+
const via = direct.includes(slug) ? undefined : direct.find((d) => closure(d, levels).includes(slug));
|
|
372
|
+
return { slug, name: level.name, since: starts.get(slug) ?? null, ...(via ? { via } : {}) };
|
|
273
373
|
});
|
|
274
374
|
const pastTags = member.pastTags ?? [];
|
|
275
375
|
const past: PastLevel[] = [];
|
|
276
376
|
for (const level of levels) {
|
|
277
377
|
if (held.includes(level.slug)) continue;
|
|
278
|
-
const wanted = new Set(level
|
|
378
|
+
const wanted = new Set(levelTags(level));
|
|
279
379
|
const spans = pastTags.filter((p) => wanted.has(p.tag));
|
|
280
380
|
if (spans.length === 0) continue;
|
|
281
381
|
const froms = spans.map((p) => p.from).filter((d): d is string => Boolean(d)).sort();
|
|
@@ -340,7 +440,7 @@ export function passes(held: string[], required: string[][]): boolean {
|
|
|
340
440
|
export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free", signedIn = "Signed in"): string {
|
|
341
441
|
if (required === null) return open;
|
|
342
442
|
const name = (slug: string) => (slug === UNAVAILABLE ? "Members" : (levels.find((l) => l.slug === slug)?.name ?? slug));
|
|
343
|
-
const parts = reduce(required).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
|
|
443
|
+
const parts = reduce(required, levels).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
|
|
344
444
|
return parts.length ? parts.join(" + ") : signedIn;
|
|
345
445
|
}
|
|
346
446
|
|
|
@@ -392,7 +492,7 @@ export async function entryRequirement(
|
|
|
392
492
|
const own = entryRuleFor(config, collection, id);
|
|
393
493
|
if (own && (own.mode === "open" || own.mode === "levels") && !own.openUntil && !own.lockedUntil) return applyEntryRule(null, own);
|
|
394
494
|
const tree = await termTree(config.rules, collection, terms);
|
|
395
|
-
return applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
|
|
495
|
+
return simplify(applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own), config.levels);
|
|
396
496
|
}
|
|
397
497
|
|
|
398
498
|
const viewers = new WeakMap<object, Promise<Viewer>>();
|
package/src/agents.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { adminConfigured } from "./admin-api.js";
|
|
13
|
-
import { applyEntryRule, entryRuleFor, describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
|
|
13
|
+
import { applyEntryRule, entryRuleFor, groupsOf, simplify, describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
|
|
14
14
|
import { ENV, env, storeDomain, unknownEnvNames, type RedeemIDConfig } from "./config.js";
|
|
15
15
|
import { featuresFor } from "./features.js";
|
|
16
16
|
import { bodyClauses } from "./files.js";
|
|
@@ -69,10 +69,10 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
|
|
|
69
69
|
const disabled = member?.status === "disabled";
|
|
70
70
|
const held = disabled ? [] : levelsFor(tags, levels);
|
|
71
71
|
const tree = await termTree(config.rules, collection, terms);
|
|
72
|
-
const required: Requirement = applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
|
|
72
|
+
const required: Requirement = simplify(applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own), levels);
|
|
73
73
|
const owned = !disabled && holdsItem(item, tags);
|
|
74
74
|
const canReadRule = owned || required === null || (signedIn && !disabled && passes(held, required));
|
|
75
|
-
const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
|
|
75
|
+
const name = (slug: string) => (slug === "signed-in" ? "Signed in" : (levels.find((l) => l.slug === slug)?.name ?? slug));
|
|
76
76
|
const partResults = parts.map((clause) => ({
|
|
77
77
|
needs: clause.map(name),
|
|
78
78
|
canRead: owned || (signedIn && !disabled && (clause.includes("signed-in") || clause.some((l) => held.includes(l)))),
|
|
@@ -101,7 +101,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
export function listLevels(config: RedeemIDConfig): Array<Record<string, unknown>> {
|
|
104
|
-
return (config.levels ?? []).map((l) => ({ slug: l.slug, name: l.name,
|
|
104
|
+
return (config.levels ?? []).map((l) => ({ slug: l.slug, name: l.name, groups: groupsOf(l).map((g) => ({ tags: g.tags, match: g.match ?? "any" })), includes: l.includes ?? [], wall: l.wall ?? null, joinUrl: l.joinUrl ?? null }));
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export function getRules(config: RedeemIDConfig): Record<string, unknown> {
|
package/src/api-version.ts
CHANGED
package/src/blocks-names.ts
CHANGED
|
@@ -62,7 +62,8 @@ type TextNode = { _type?: unknown; children?: Array<{ text?: unknown }> };
|
|
|
62
62
|
/**
|
|
63
63
|
* The text blocks within the first `words` words of a body:
|
|
64
64
|
* a block is never cut in half, so a block that would pass the count ends the
|
|
65
|
-
* teaser. Images and custom blocks are left out.
|
|
65
|
+
* teaser. Images and custom blocks are left out. A "Members only" part ends
|
|
66
|
+
* it too: its text is never teaser. 0 gives nothing.
|
|
66
67
|
*/
|
|
67
68
|
export function teaser<T>(value: T[] | null | undefined, words: number): T[] {
|
|
68
69
|
if (!Array.isArray(value) || !(words > 0)) return [];
|
|
@@ -70,6 +71,7 @@ export function teaser<T>(value: T[] | null | undefined, words: number): T[] {
|
|
|
70
71
|
let count = 0;
|
|
71
72
|
for (const b of value) {
|
|
72
73
|
const node = b as TextNode;
|
|
74
|
+
if (node?._type === MEMBERS_BLOCK) break;
|
|
73
75
|
if (node?._type !== "block") continue;
|
|
74
76
|
const text = (node.children ?? []).map((c) => (typeof c.text === "string" ? c.text : "")).join("");
|
|
75
77
|
count += text.split(/\s+/).filter(Boolean).length;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* carries `data-redeemid-level` (the level slugs) for styling, and
|
|
10
10
|
* `is-locked` with a lock for readers who can't read the members part.
|
|
11
11
|
*/
|
|
12
|
-
import { canRead, describe, entryRequirement, viewer, type Terms } from "../access.js";
|
|
12
|
+
import { badgeColor, canRead, describe, entryRequirement, simplify, viewer, type Terms } from "../access.js";
|
|
13
13
|
import { holdsItem, soldItem, splitAtPaywall } from "../blocks-names.js";
|
|
14
14
|
import { siteConfig } from "../site-config.js";
|
|
15
15
|
import { text } from "../text.js";
|
|
@@ -37,12 +37,15 @@ const owner = item ? (await viewer(Astro)).member : null;
|
|
|
37
37
|
const owned = Boolean(owner && owner.status !== "disabled" && holdsItem(item, owner.tags));
|
|
38
38
|
const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms, id }, { required })).allowed;
|
|
39
39
|
const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
|
|
40
|
+
// The colour of the level the badge names first, when that level has one.
|
|
41
|
+
const color = badgeColor(config?.levels?.find((l) => l.slug === simplify(required, config?.levels)?.find((c) => c.length)?.[0]));
|
|
40
42
|
---
|
|
41
43
|
|
|
42
44
|
{label && (
|
|
43
45
|
<span
|
|
44
46
|
class:list={["redeemid-badge", { "is-locked": locked }, className]}
|
|
45
47
|
data-redeemid-level={slugs}
|
|
48
|
+
style={color ? `--redeemid-level-color: ${color}` : undefined}
|
|
46
49
|
>
|
|
47
50
|
{locked && <span class="redeemid-badge__lock" aria-hidden="true">🔒︎ </span>}
|
|
48
51
|
{label}
|
|
@@ -3,20 +3,31 @@
|
|
|
3
3
|
* Sign in link for visitors; name, levels, Account and Sign out for members.
|
|
4
4
|
*/
|
|
5
5
|
import { siteConfig } from "../site-config.js";
|
|
6
|
-
import { accountUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
6
|
+
import { accountUrl, badgeColor, signInUrl, signOutUrl, topLevels, viewer } from "../access.js";
|
|
7
7
|
import { text } from "../text.js";
|
|
8
8
|
|
|
9
9
|
const v = await viewer(Astro);
|
|
10
10
|
const config = await siteConfig(Astro.locals);
|
|
11
11
|
const here = Astro.url.pathname + Astro.url.search;
|
|
12
|
-
|
|
12
|
+
// "Gold", not "Gold, Silver", when Gold includes Silver.
|
|
13
|
+
const top = topLevels(v.levels, config?.levels);
|
|
14
|
+
const held = (config?.levels ?? []).filter((l) => top.includes(l.slug));
|
|
13
15
|
---
|
|
14
16
|
|
|
15
17
|
<div class="redeemid-account" data-redeemid-signed-in={v.signedIn ? "true" : "false"}>
|
|
16
18
|
{v.signedIn ? (
|
|
17
19
|
<>
|
|
18
20
|
<span class="redeemid-account__who">{v.member?.firstName ?? v.member?.email ?? text(config, "Signed in")}</span>
|
|
19
|
-
{
|
|
21
|
+
{held.length > 0 && (
|
|
22
|
+
<span class="redeemid-account__levels">
|
|
23
|
+
{held.map((l, i) => (
|
|
24
|
+
<>
|
|
25
|
+
{i > 0 && ", "}
|
|
26
|
+
<span data-redeemid-level={l.slug} style={badgeColor(l) ? `--redeemid-level-color: ${badgeColor(l)}` : undefined}>{l.name}</span>
|
|
27
|
+
</>
|
|
28
|
+
))}
|
|
29
|
+
</span>
|
|
30
|
+
)}
|
|
20
31
|
<a class="redeemid-account__link" href={accountUrl(here)}>{text(config, "Account")}</a>
|
|
21
32
|
<form method="post" action={signOutUrl(here)}>
|
|
22
33
|
<button type="submit">{text(config, "Sign out")}</button>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { membership, passes, refreshUrl, signInUrl, viewer, type Reason } from "../access.js";
|
|
12
12
|
import type { SoldItem } from "../blocks-names.js";
|
|
13
|
-
import { plan as loadPlan } from "../storefront.js";
|
|
13
|
+
import { money, plan as loadPlan, storeContext } from "../storefront.js";
|
|
14
14
|
import { siteConfig } from "../site-config.js";
|
|
15
15
|
import { text, textParts } from "../text.js";
|
|
16
16
|
|
|
@@ -48,10 +48,9 @@ const week = meterPeriod === "week";
|
|
|
48
48
|
let single: { handle: string; price: string } | null = null;
|
|
49
49
|
if (item?.product && config && (reason === "sign_in" || reason === "upgrade" || reason === "drip")) {
|
|
50
50
|
try {
|
|
51
|
-
const p = await loadPlan(config, item.product);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
51
|
+
const p = await loadPlan(config, item.product, undefined, storeContext(config, Astro.request));
|
|
52
|
+
const price = p?.availableForSale ? money(p.price) : null;
|
|
53
|
+
if (p && price) single = { handle: p.handle, price };
|
|
55
54
|
} catch {
|
|
56
55
|
single = null;
|
|
57
56
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Everything shown comes from the mirrored Shopify customer; Refresh
|
|
12
12
|
* membership reads it from the store again.
|
|
13
13
|
*/
|
|
14
|
-
import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
14
|
+
import { accountUrl, badgeColor, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
15
15
|
import { featuresFor } from "../features.js";
|
|
16
16
|
import { siteConfig } from "../site-config.js";
|
|
17
17
|
import { text, textParts } from "../text.js";
|
|
@@ -29,6 +29,7 @@ const m = v.member;
|
|
|
29
29
|
const disabled = m?.status === "disabled";
|
|
30
30
|
const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
|
|
31
31
|
const meter = v.signedIn ? await meterUsage(Astro) : null;
|
|
32
|
+
const color = (slug: string) => badgeColor(config?.levels?.find((l) => l.slug === slug));
|
|
32
33
|
const held = m ? featuresFor(m, config?.features, config?.levels) : [];
|
|
33
34
|
const features = (config?.features ?? []).filter((f) => held.includes(f.slug));
|
|
34
35
|
const date = (iso: string | null) => {
|
|
@@ -59,8 +60,8 @@ const join = config?.join;
|
|
|
59
60
|
<p class="redeemid-member__disabled">{text(config, "Your account is turned off. Contact the store for help.")}</p>
|
|
60
61
|
) : current.length > 0 ? (
|
|
61
62
|
<ul class="redeemid-member__levels">
|
|
62
|
-
{current.map((l) => (
|
|
63
|
-
<li data-redeemid-level={l.slug}>
|
|
63
|
+
{current.filter((l) => !l.via).map((l) => (
|
|
64
|
+
<li data-redeemid-level={l.slug} style={color(l.slug) ? `--redeemid-level-color: ${color(l.slug)}` : undefined}>
|
|
64
65
|
<strong>{l.name}</strong>
|
|
65
66
|
{(dates.since ? day(dates.since) : date(l.since)) && <> {textParts(config, "since {date}", { date: <time datetime={dates.since ?? l.since ?? undefined}>{dates.since ? day(dates.since) : date(l.since)}</time> })}</>}
|
|
66
67
|
</li>
|
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
*
|
|
7
7
|
* <Plan handle="gold-membership" levels={["gold"]} />
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `levels` are the levels the plan gives; left out, they are the levels
|
|
10
|
+
* whose `products` list the handle. Then the card fits the member:
|
|
11
|
+
*
|
|
12
|
+
* - held: they hold one of them through its own tags: "You're on this plan."
|
|
13
|
+
* - included: a level they hold includes it: "Included in your Gold membership."
|
|
14
|
+
* - upgrade: the plan's level includes one they hold: the button says "Upgrade".
|
|
15
|
+
* - renew: they held one before (Shopify removed its tag): "Renew".
|
|
16
|
+
* - join: anyone else.
|
|
12
17
|
*/
|
|
13
|
-
import { membership, viewer } from "../access.js";
|
|
18
|
+
import { closure, directLevels, membership, topLevels, viewer } from "../access.js";
|
|
14
19
|
import { siteConfig } from "../site-config.js";
|
|
15
|
-
import { plan as loadPlan, type Plan } from "../storefront.js";
|
|
20
|
+
import { money, plan as loadPlan, storeContext, type Plan } from "../storefront.js";
|
|
16
21
|
import { text } from "../text.js";
|
|
17
22
|
|
|
18
23
|
interface Props {
|
|
@@ -21,45 +26,59 @@ interface Props {
|
|
|
21
26
|
label?: string;
|
|
22
27
|
/** Heading override; defaults to the Shopify product title. */
|
|
23
28
|
title?: string;
|
|
24
|
-
/** The levels this plan gives.
|
|
29
|
+
/** The levels this plan gives. Defaults to the levels whose `products` list `handle`. */
|
|
25
30
|
levels?: string[];
|
|
26
31
|
heldLabel?: string;
|
|
27
32
|
/** Button text for a member whose plan lapsed. */
|
|
28
33
|
renewLabel?: string;
|
|
34
|
+
/** Button text for a member this plan would move up. */
|
|
35
|
+
upgradeLabel?: string;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
const config = await siteConfig(Astro.locals);
|
|
32
|
-
const { handle, label = text(config, "Join"), title,
|
|
39
|
+
const { handle, label = text(config, "Join"), title, heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew"), upgradeLabel = text(config, "Upgrade") } = Astro.props;
|
|
40
|
+
const all = config?.levels ?? [];
|
|
41
|
+
const levels = Astro.props.levels?.length ? Astro.props.levels : all.filter((l) => l.products?.some((h) => h.toLowerCase() === handle.toLowerCase())).map((l) => l.slug);
|
|
33
42
|
let p: Plan | null = null;
|
|
34
43
|
if (config) {
|
|
35
44
|
try {
|
|
36
|
-
|
|
45
|
+
// The visitor's market, so the price is the one checkout charges.
|
|
46
|
+
p = await loadPlan(config, handle, undefined, storeContext(config, Astro.request));
|
|
37
47
|
} catch {
|
|
38
48
|
p = null;
|
|
39
49
|
}
|
|
40
50
|
}
|
|
41
|
-
const price = p?.price
|
|
42
|
-
? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
|
|
43
|
-
: null;
|
|
51
|
+
const price = money(p?.price);
|
|
44
52
|
const here = Astro.url.pathname + Astro.url.search;
|
|
45
53
|
const v = levels.length > 0 ? await viewer(Astro) : null;
|
|
46
|
-
const
|
|
54
|
+
const live = v?.member && v.member.status !== "disabled" ? v.member : null;
|
|
55
|
+
const direct = live ? directLevels(live.tags, all) : [];
|
|
56
|
+
const top = topLevels(v?.levels ?? [], all);
|
|
57
|
+
const held = levels.some((l) => direct.includes(l));
|
|
58
|
+
// Held only through a bigger level: name that one.
|
|
59
|
+
const via = held ? undefined : top.find((t) => levels.some((l) => t !== l && closure(t, all).includes(l)));
|
|
60
|
+
const viaName = via ? (all.find((l) => l.slug === via)?.name ?? via) : null;
|
|
61
|
+
// The plan's level includes one the member holds: it moves them up.
|
|
62
|
+
const upgrade = !held && !via && top.length > 0 && levels.some((l) => top.some((t) => t !== l && closure(l, all).includes(t)));
|
|
47
63
|
// Lapsed: held one of these levels before. Offered their plan back, not "Join" as if new.
|
|
48
|
-
const lapsed = Boolean(!held &&
|
|
64
|
+
const lapsed = Boolean(!held && !via && live && membership(live, all).past.some((p) => levels.includes(p.slug)));
|
|
65
|
+
const state = held ? "held" : via ? "included" : upgrade ? "upgrade" : lapsed ? "renew" : "join";
|
|
49
66
|
---
|
|
50
67
|
|
|
51
68
|
{p ? (
|
|
52
|
-
<div class:list={["redeemid-plan", { "is-held": held, "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={
|
|
69
|
+
<div class:list={["redeemid-plan", { "is-held": held || Boolean(via), "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={state}>
|
|
53
70
|
<h2 class="redeemid-plan__title">{title ?? p.title}</h2>
|
|
54
71
|
{price && <p class="redeemid-plan__price">{price}</p>}
|
|
55
72
|
{p.description && <p class="redeemid-plan__description">{p.description}</p>}
|
|
56
73
|
{held ? (
|
|
57
74
|
<p class="redeemid-plan__held">{heldLabel}</p>
|
|
75
|
+
) : viaName ? (
|
|
76
|
+
<p class="redeemid-plan__held">{text(config, "Included in your {level} membership.", { level: viaName })}</p>
|
|
58
77
|
) : p.availableForSale ? (
|
|
59
78
|
<form method="post" action="/_emdash/api/auth/redeemid/buy">
|
|
60
79
|
<input type="hidden" name="plan" value={p.handle} />
|
|
61
80
|
<input type="hidden" name="return" value={here} />
|
|
62
|
-
<button type="submit">{lapsed ? renewLabel : label}</button>
|
|
81
|
+
<button type="submit">{upgrade ? upgradeLabel : lapsed ? renewLabel : label}</button>
|
|
63
82
|
</form>
|
|
64
83
|
) : (
|
|
65
84
|
<p class="redeemid-plan__unavailable">{text(config, "Not available right now.")}</p>
|
package/src/config.ts
CHANGED
|
@@ -15,14 +15,65 @@ export interface LevelConfig {
|
|
|
15
15
|
slug: string;
|
|
16
16
|
/** Name shown to members. */
|
|
17
17
|
name: string;
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Shopify customer tags that grant this level. Matched case-insensitively.
|
|
20
|
+
* This is the level's first tag group; `groups` adds more.
|
|
21
|
+
*/
|
|
22
|
+
tags?: string[];
|
|
23
|
+
/** For `tags`: "any" (default): one of the tags is enough. "all": every tag is needed. */
|
|
21
24
|
match?: "any" | "all";
|
|
25
|
+
/**
|
|
26
|
+
* More tag groups, any one of which also grants the level: a level that is
|
|
27
|
+
* "gold", or "silver" and "annual", is `tags: ["gold"]` with
|
|
28
|
+
* `groups: [{ tags: ["silver", "annual"], match: "all" }]`.
|
|
29
|
+
*/
|
|
30
|
+
groups?: TagGroup[];
|
|
31
|
+
/**
|
|
32
|
+
* Slugs of levels this one also opens: Gold with `includes: ["silver"]`
|
|
33
|
+
* reads everything for Silver. Inclusion carries on (Gold includes Silver
|
|
34
|
+
* includes Members), so rules name only the lowest level that may read.
|
|
35
|
+
*/
|
|
36
|
+
includes?: string[];
|
|
22
37
|
/** What the wall says to readers this level would let in, e.g. "Gold members read every investigation." */
|
|
23
38
|
wall?: string;
|
|
24
39
|
/** Where the wall's join link goes for this level. Falls back to `join.href`. */
|
|
25
40
|
joinUrl?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Handles of the Shopify products whose purchase leads to this level
|
|
43
|
+
* (Flow or an app tags the buyer). Display only: plan cards use it to say
|
|
44
|
+
* "You're on this plan", "Included in your Gold membership" or "Upgrade".
|
|
45
|
+
* It grants nothing.
|
|
46
|
+
*/
|
|
47
|
+
products?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Members' home: a same-site path members of this level land on after
|
|
50
|
+
* signing in from the home page or a plain sign-in link, e.g. "/members/gold/".
|
|
51
|
+
* Signing in from an entry returns them to it.
|
|
52
|
+
*/
|
|
53
|
+
home?: string;
|
|
54
|
+
/** Badge colour, a hex such as "#c9a227", set as `--redeemid-level-color` on badges and level names. Badges always show the name too. */
|
|
55
|
+
badge?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One way to hold a level: any one of `tags`, or every one with `match: "all"`. */
|
|
59
|
+
export interface TagGroup {
|
|
60
|
+
tags: string[];
|
|
61
|
+
match?: "any" | "all";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A level's tag groups: `tags`/`match` first, then `groups`. Empty groups are dropped. */
|
|
65
|
+
export function groupsOf(level: LevelConfig): TagGroup[] {
|
|
66
|
+
const out: TagGroup[] = [];
|
|
67
|
+
if (level.tags?.length) out.push({ tags: level.tags, ...(level.match === "all" ? { match: "all" as const } : {}) });
|
|
68
|
+
for (const g of level.groups ?? []) {
|
|
69
|
+
if (g && Array.isArray(g.tags) && g.tags.length) out.push({ tags: g.tags, ...(g.match === "all" ? { match: "all" as const } : {}) });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every tag any of the level's groups names, lower case. */
|
|
75
|
+
export function levelTags(level: LevelConfig): string[] {
|
|
76
|
+
return [...new Set(groupsOf(level).flatMap((g) => g.tags.map((t) => t.toLowerCase())))];
|
|
26
77
|
}
|
|
27
78
|
|
|
28
79
|
/**
|
package/src/contract.ts
CHANGED
|
@@ -16,13 +16,14 @@ export { API_VERSION } from "./api-version.js";
|
|
|
16
16
|
/** Stable exports, by import path. */
|
|
17
17
|
export const MODULES = {
|
|
18
18
|
"@redeemid/emdash": ["redeemid", "redeemidGuard", "redeemidPlugin"],
|
|
19
|
-
"@redeemid/emdash/access": ["siteConfig", "viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
|
|
19
|
+
"@redeemid/emdash/access": ["siteConfig", "viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "topLevels", "closure", "simplify", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
|
|
20
20
|
"@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
|
|
21
21
|
"@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
|
|
22
22
|
"@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
|
|
23
23
|
"@redeemid/emdash/catalog": ["searchProducts", "collectionPage", "suggest", "recommendations", "browseFromUrl", "browseHref", "parseFilters", "priceFilter", "SORTS"],
|
|
24
24
|
"@redeemid/emdash/events": ["signBody"],
|
|
25
25
|
"@redeemid/emdash/text": ["STRINGS", "text", "textParts"],
|
|
26
|
+
"@redeemid/emdash/search": ["readableResults"],
|
|
26
27
|
} as const;
|
|
27
28
|
|
|
28
29
|
/** Public JSON routes under /_emdash/api/auth/redeemid/. */
|
package/src/events.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Headers: X-RedeemID-Event (the name), X-RedeemID-Delivery (a fresh id) and
|
|
8
8
|
* X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
|
|
9
9
|
* member.signed_in, member.levels_changed, meter.used, meter.exhausted,
|
|
10
|
-
* preferences.saved.
|
|
10
|
+
* preferences.saved, member.erased.
|
|
11
11
|
* Sending never blocks or breaks the page: events go out after the response
|
|
12
12
|
* (waitUntil on Workers), are tried three times, and one that fails every
|
|
13
13
|
* try is kept under App events → Failed events, where staff can send it
|