@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/src/access.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * "entitled".
11
11
  */
12
12
 
13
- import type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
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 function levelsFor(tags: string[], levels: LevelConfig[] = []): string[] {
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
- .filter((l) => {
79
- if (l.tags.length === 0) return false;
80
- const has = (t: string) => held.has(t.toLowerCase());
81
- return l.match === "all" ? l.tags.every(has) : l.tags.some(has);
82
- })
83
- .map((l) => l.slug);
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
- const sorted = c.includes(SIGNED_IN) ? [] : [...new Set(c)].sort();
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
- return levelled.filter((c) => !levelled.some((o) => o !== c && o.length < c.length && o.every((l) => c.includes(l))));
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 a member's membership started, for drip: the earliest start among the
233
- * levels they hold now (a level starts when its last needed tag arrived),
234
- * else when they first signed in. Null when neither is known.
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 memberSince(member: MemberRecord, levels: LevelConfig[] = []): string | null {
311
+ export function levelStarts(member: MemberRecord, levels: LevelConfig[] = []): Map<string, string | null> {
237
312
  const since = member.tagsSince ?? {};
238
- const starts: string[] = [];
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 level = levels.find((l) => l.slug === slug);
241
- const dates = (level?.tags ?? []).map((t) => since[t.toLowerCase()]).filter((d): d is string => Boolean(d)).sort();
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
- starts.sort();
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 memberSince), ISO, or null when unknown. */
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
- return { slug, name: level.name, since: memberSince({ ...member, linkedAt: null }, [level]) };
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.tags.map((t) => t.toLowerCase()));
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, tags: l.tags, match: l.match ?? "any", wall: l.wall ?? null, joinUrl: l.joinUrl ?? null }));
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> {
@@ -3,4 +3,4 @@
3
3
  * when something is added, the major part when something stable changes or
4
4
  * goes. Separate from the package version.
5
5
  */
6
- export const API_VERSION = "1.1.0";
6
+ export const API_VERSION = "1.3.0";
@@ -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. 0 gives nothing.
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">🔒&#xFE0E; </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
- const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).map((l) => l.name);
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
- {names.length > 0 && <span class="redeemid-account__levels">{names.join(", ")}</span>}
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
- if (p?.availableForSale && p.price) {
53
- single = { handle: p.handle, price: new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount)) };
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
- * With `levels`, members who hold one of them see "You're on this plan"
10
- * in place of the button, and a member who held one before (Shopify removed
11
- * its tag) sees "Renew" instead of "Join".
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. Members holding one see `heldLabel`, not the button. */
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, levels = [], heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew") } = Astro.props;
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
- p = await loadPlan(config, handle);
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 held = Boolean(v?.levels.some((l) => levels.includes(l)));
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 && v?.member && v.member.status !== "disabled" && membership(v.member, config?.levels).past.some((p) => levels.includes(p.slug)));
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={held ? "held" : lapsed ? "renew" : "join"}>
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
- /** Shopify customer tags that grant this level. Matched case-insensitively. */
19
- tags: string[];
20
- /** "any" (default): one of the tags is enough. "all": every tag is needed. */
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