@redeemid/emdash 0.0.8 → 0.0.10

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.
Files changed (41) hide show
  1. package/README.md +127 -2
  2. package/docs/shopify-setup.md +5 -1
  3. package/package.json +5 -1
  4. package/src/access.ts +9 -3
  5. package/src/admin-api.ts +191 -0
  6. package/src/blocks/ContributionBlock.astro +1 -1
  7. package/src/blocks/PlanBlock.astro +1 -1
  8. package/src/blocks/PreferencesBlock.astro +12 -0
  9. package/src/blocks/index.ts +2 -0
  10. package/src/blocks-names.ts +1 -0
  11. package/src/components/AccessBadge.astro +4 -3
  12. package/src/components/Account.astro +5 -4
  13. package/src/components/Cart.astro +4 -1
  14. package/src/components/CartView.astro +14 -13
  15. package/src/components/Collection.astro +2 -1
  16. package/src/components/Content.astro +10 -4
  17. package/src/components/Contribution.astro +4 -3
  18. package/src/components/Feature.astro +20 -0
  19. package/src/components/LockedPanel.astro +10 -8
  20. package/src/components/MemberCard.astro +22 -13
  21. package/src/components/Notices.astro +10 -2
  22. package/src/components/Plan.astro +3 -2
  23. package/src/components/Preferences.astro +102 -0
  24. package/src/components/Product.astro +6 -5
  25. package/src/config.ts +53 -0
  26. package/src/descriptor.ts +1 -0
  27. package/src/events.ts +75 -0
  28. package/src/features.ts +39 -0
  29. package/src/files.ts +191 -0
  30. package/src/guard.ts +43 -2
  31. package/src/index.ts +1 -0
  32. package/src/plugin.ts +142 -3
  33. package/src/preferences.ts +298 -0
  34. package/src/routes/callback.ts +8 -1
  35. package/src/routes/preferences.ts +82 -0
  36. package/src/routes/webhook.ts +5 -3
  37. package/src/staff.ts +220 -0
  38. package/src/storage.ts +3 -1
  39. package/src/text.ts +174 -0
  40. package/src/version.ts +1 -1
  41. package/src/webhooks.ts +11 -2
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { siteConfig } from "../site-config.js";
10
10
  import { collection as loadCollection, money, type Collection } from "../storefront.js";
11
+ import { text } from "../text.js";
11
12
 
12
13
  interface Props {
13
14
  handle: string;
@@ -45,7 +46,7 @@ const href = (h: string) => productHref?.replaceAll("{handle}", encodeURICompone
45
46
  <li class="redeemid-collection__item" data-redeemid-product={p.handle}>
46
47
  {p.image && <img src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
47
48
  <h3>{href(p.handle) ? <a href={href(p.handle)}>{p.title}</a> : p.title}</h3>
48
- <p class="redeemid-collection__price">{p.availableForSale ? (p.price ? `From ${money(p.price)}` : "") : "Sold out"}</p>
49
+ <p class="redeemid-collection__price">{p.availableForSale ? (p.price ? text(config, "From {price}", { price: money(p.price) }) : "") : text(config, "Sold out")}</p>
49
50
  </li>
50
51
  ))}
51
52
  </ul>
@@ -26,6 +26,7 @@ import { PortableText } from "emdash/ui";
26
26
  import { canRead, entryRequirement, readEntry, type Terms, type Verdict } from "../access.js";
27
27
  import { sections, splitAtPaywall, teaser } from "../blocks-names.js";
28
28
  import { siteConfig } from "../site-config.js";
29
+ import { text } from "../text.js";
29
30
  import LockedPanel from "./LockedPanel.astro";
30
31
 
31
32
  interface Props {
@@ -69,14 +70,19 @@ async function parts(blocks: unknown[]) {
69
70
  const before = await parts(shown);
70
71
  const after = await parts(rest);
71
72
  const metered = verdict.reason === "metered" ? (verdict as Verdict) : null;
72
- const per = metered?.meter?.period === "week" ? "this week" : "this month";
73
+ const week = metered?.meter?.period === "week";
74
+ const left = metered?.meterLeft ?? 0;
75
+ const meterText =
76
+ left === 0
77
+ ? text(config, week ? "This is your last free read this week." : "This is your last free read this month.")
78
+ : left === 1
79
+ ? text(config, week ? "You have 1 free read left this week." : "You have 1 free read left this month.")
80
+ : text(config, week ? "You have {count} free reads left this week." : "You have {count} free reads left this month.", { count: left });
73
81
  ---
74
82
 
75
83
  {metered && (
76
84
  <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}.`}
85
+ {meterText}
80
86
  </p>
81
87
  )}
82
88
  {before.map((p) =>
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { siteConfig } from "../site-config.js";
11
11
  import { money, product as loadProduct, type Product } from "../storefront.js";
12
+ import { text } from "../text.js";
12
13
 
13
14
  interface Props {
14
15
  handle: string;
@@ -20,8 +21,8 @@ interface Props {
20
21
  title?: string;
21
22
  }
22
23
 
23
- const { handle, amounts = [5, 10, 25], initial = 10, label = "Give", title } = Astro.props;
24
24
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
25
+ const { handle, amounts = [5, 10, 25], initial = 10, label = text(config, "Give"), title } = Astro.props;
25
26
  let p: Product | null = null;
26
27
  if (config) {
27
28
  try {
@@ -57,13 +58,13 @@ const id = `redeemid-give-${handle}`;
57
58
  </p>
58
59
  )}
59
60
  <p>
60
- <label for={`${id}-amount`}>Another amount ({currency})</label>
61
+ <label for={`${id}-amount`}>{text(config, "Another amount ({currency})", { currency })}</label>
61
62
  <input id={`${id}-amount`} name="quantity" type="number" min="1" max="1000" step="1" value={initial} inputmode="numeric" />
62
63
  </p>
63
64
  </>
64
65
  ) : (
65
66
  <p>
66
- <label for={`${id}-amount`}>Quantity ({money(variant.price)} each)</label>
67
+ <label for={`${id}-amount`}>{text(config, "Quantity ({price} each)", { price: money(variant.price) })}</label>
67
68
  <input id={`${id}-amount`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
68
69
  </p>
69
70
  )}
@@ -0,0 +1,20 @@
1
+ ---
2
+ /**
3
+ * Shows its default slot only to members who hold the feature (see config
4
+ * `features`), and the "else" slot to everyone else.
5
+ *
6
+ * <Feature slug="supporter">
7
+ * <p>Thanks for supporting us.</p>
8
+ * <a slot="else" href="/support/">Support us</a>
9
+ * </Feature>
10
+ */
11
+ import { hasFeature } from "../features.js";
12
+
13
+ interface Props {
14
+ slug: string;
15
+ }
16
+
17
+ const held = await hasFeature(Astro, Astro.props.slug);
18
+ ---
19
+
20
+ {held ? <slot /> : <slot name="else" />}
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { passes, signInUrl, type Reason } from "../access.js";
12
12
  import { siteConfig } from "../site-config.js";
13
+ import { text, textParts } from "../text.js";
13
14
 
14
15
  interface Props {
15
16
  reason: Reason;
@@ -32,7 +33,8 @@ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).em
32
33
  const missing = required.find((clause) => clause.length > 0 && !passes(held, [clause]));
33
34
  const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : undefined;
34
35
  const joinHref = level?.joinUrl ?? config?.join?.href;
35
- const joinLabel = config?.join?.label ?? "Become a member";
36
+ const joinLabel = config?.join?.label ?? text(config, "Become a member");
37
+ const week = meterPeriod === "week";
36
38
  const here = Astro.url.pathname + Astro.url.search;
37
39
  ---
38
40
 
@@ -41,24 +43,24 @@ const here = Astro.url.pathname + Astro.url.search;
41
43
  {level?.wall && (reason === "sign_in" || reason === "upgrade") && <p class="redeemid-locked__wall">{level.wall}</p>}
42
44
  {reason === "sign_in" && (
43
45
  <p>
44
- This is for members. <a href={signInUrl(here)}>Sign in</a> to read on.
45
- {joinHref && <> Not a member yet? <a href={joinHref}>{joinLabel}</a>.</>}
46
+ {textParts(config, "This is for members. {signIn} to read on.", { signIn: <a href={signInUrl(here)}>{text(config, "Sign in")}</a> })}
47
+ {joinHref && <> {textParts(config, "Not a member yet? {join}.", { join: <a href={joinHref}>{joinLabel}</a> })}</>}
46
48
  </p>
47
49
  )}
48
50
  {reason === "upgrade" && meterLeft === 0 && (
49
- <p class="redeemid-locked__meter">You've used your free reads {meterPeriod === "week" ? "this week" : "this month"}.</p>
51
+ <p class="redeemid-locked__meter">{text(config, week ? "You've used your free reads this week." : "You've used your free reads this month.")}</p>
50
52
  )}
51
53
  {reason === "drip" && (
52
54
  <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."}
55
+ {opens ? textParts(config, "This opens for you on {date}.", { date: <time datetime={opensAt?.toISOString()}>{opens}</time> }) : text(config, "This opens for you after you've been a member a little longer.")}
54
56
  </p>
55
57
  )}
56
58
  {reason === "upgrade" && (
57
59
  <p>
58
- {level ? `This is for ${level.name} members.` : "Your membership doesn't include this yet."}
60
+ {level ? text(config, "This is for {level} members.", { level: level.name }) : text(config, "Your membership doesn't include this yet.")}
59
61
  {joinHref && <> <a href={joinHref}>{joinLabel}</a></>}
60
62
  </p>
61
63
  )}
62
- {reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
63
- {reason === "unconfigured" && <p>This is for members.</p>}
64
+ {reason === "disabled" && <p>{text(config, "Your account is turned off. Contact the store for help.")}</p>}
65
+ {reason === "unconfigured" && <p>{text(config, "This is for members.")}</p>}
64
66
  </div>
@@ -10,21 +10,25 @@
10
10
  * membership reads it from the store again.
11
11
  */
12
12
  import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
13
+ import { featuresFor } from "../features.js";
13
14
  import { siteConfig } from "../site-config.js";
15
+ import { text, textParts } from "../text.js";
14
16
 
15
17
  interface Props {
16
18
  /** Heading text; the member's first name is added after it. */
17
19
  title?: string;
18
20
  }
19
21
 
20
- const { title = "Your membership" } = Astro.props;
21
- const v = await viewer(Astro);
22
22
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
23
+ const { title = text(config, "Your membership") } = Astro.props;
24
+ const v = await viewer(Astro);
23
25
  const here = Astro.url.pathname + Astro.url.search;
24
26
  const m = v.member;
25
27
  const disabled = m?.status === "disabled";
26
28
  const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
27
29
  const meter = v.signedIn ? await meterUsage(Astro) : null;
30
+ const held = m ? featuresFor(m, config?.features, config?.levels) : [];
31
+ const features = (config?.features ?? []).filter((f) => held.includes(f.slug));
28
32
  const date = (iso: string | null) => {
29
33
  if (!iso) return null;
30
34
  const t = Date.parse(iso);
@@ -45,29 +49,34 @@ const join = config?.join;
45
49
  </p>
46
50
  )}
47
51
  {disabled ? (
48
- <p class="redeemid-member__disabled">Your account is turned off. Contact the store for help.</p>
52
+ <p class="redeemid-member__disabled">{text(config, "Your account is turned off. Contact the store for help.")}</p>
49
53
  ) : current.length > 0 ? (
50
54
  <ul class="redeemid-member__levels">
51
55
  {current.map((l) => (
52
56
  <li data-redeemid-level={l.slug}>
53
57
  <strong>{l.name}</strong>
54
- {date(l.since) && <> since <time datetime={l.since ?? undefined}>{date(l.since)}</time></>}
58
+ {date(l.since) && <> {textParts(config, "since {date}", { date: <time datetime={l.since ?? undefined}>{date(l.since)}</time> })}</>}
55
59
  </li>
56
60
  ))}
57
61
  </ul>
58
62
  ) : (
59
63
  <p class="redeemid-member__none">
60
- You don't have a membership yet.{join && <> <a href={join.href}>{join.label ?? "Become a member"}</a></>}
64
+ {text(config, "You don't have a membership yet.")}{join && <> <a href={join.href}>{join.label ?? text(config, "Become a member")}</a></>}
65
+ </p>
66
+ )}
67
+ {features.length > 0 && (
68
+ <p class="redeemid-member__features">
69
+ {text(config, "Includes: {features}", { features: features.map((f) => f.name).join(", ") })}
61
70
  </p>
62
71
  )}
63
72
  {past.length > 0 && (
64
73
  <>
65
- <h3 class="redeemid-member__subtitle">Before</h3>
74
+ <h3 class="redeemid-member__subtitle">{text(config, "Before")}</h3>
66
75
  <ul class="redeemid-member__past">
67
76
  {past.map((l) => (
68
77
  <li data-redeemid-level={l.slug}>
69
78
  {l.name}
70
- {date(l.from) ? `, ${date(l.from)} to ${date(l.to)}` : `, until ${date(l.to)}`}
79
+ {", "}{date(l.from) ? text(config, "{from} to {to}", { from: date(l.from) ?? "", to: date(l.to) ?? "" }) : text(config, "until {to}", { to: date(l.to) ?? "" })}
71
80
  </li>
72
81
  ))}
73
82
  </ul>
@@ -75,21 +84,21 @@ const join = config?.join;
75
84
  )}
76
85
  {meter && current.length === 0 && !disabled && (
77
86
  <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.
87
+ {text(config, meter.period === "week" ? "Free reads this week: {used} of {limit} used." : "Free reads this month: {used} of {limit} used.", { used: meter.used, limit: meter.limit })}
79
88
  </p>
80
89
  )}
81
90
  <p class="redeemid-member__actions">
82
- <a href={accountUrl(here)}>Orders and details</a>
83
- <a href={refreshUrl(here)}>Refresh membership</a>
91
+ <a href={accountUrl(here)}>{text(config, "Orders and details")}</a>
92
+ <a href={refreshUrl(here)}>{text(config, "Refresh membership")}</a>
84
93
  </p>
85
94
  <form method="post" action={signOutUrl("/")}>
86
- <button type="submit">Sign out</button>
95
+ <button type="submit">{text(config, "Sign out")}</button>
87
96
  </form>
88
- <p class="redeemid-member__note">Just bought a plan? It can take a minute to show here. Refresh membership checks again.</p>
97
+ <p class="redeemid-member__note">{text(config, "Just bought a plan? It can take a minute to show here. Refresh membership checks again.")}</p>
89
98
  </>
90
99
  ) : (
91
100
  <p>
92
- <a href={signInUrl(here)}>{config?.label ?? "Sign in"}</a> to see your membership.
101
+ {textParts(config, "{signIn} to see your membership.", { signIn: <a href={signInUrl(here)}>{config?.label ?? text(config, "Sign in")}</a> })}
93
102
  </p>
94
103
  )}
95
104
  </section>
@@ -2,8 +2,11 @@
2
2
  /**
3
3
  * The message for a `?redeemid_notice=` code that sign-in, Buy, Cart and
4
4
  * Account send readers back with. Renders nothing without one. Pass
5
- * `messages` to reword any of them.
5
+ * `messages` to reword any of them (config `text` does too, for every page).
6
6
  */
7
+ import { siteConfig } from "../site-config.js";
8
+ import { text as replace } from "../text.js";
9
+
7
10
  interface Props {
8
11
  messages?: Record<string, string>;
9
12
  }
@@ -26,12 +29,17 @@ const DEFAULTS: Record<string, string> = {
26
29
  discount_applied: "Discount applied.",
27
30
  discount_not_applicable: "That discount code doesn't apply to this cart.",
28
31
  refreshed: "Your membership is up to date.",
32
+ preferences_saved: "Your preferences are saved.",
33
+ preferences_refused: "Some answers weren't saved. Check them and try again.",
34
+ preferences_unavailable: "Your preferences can't be saved right now. Try again in a few minutes.",
29
35
  account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
30
36
  };
31
37
 
32
38
  const { messages = {} } = Astro.props;
33
39
  const code = Astro.url.searchParams.get("redeemid_notice") ?? "";
34
- const text = code ? (messages[code] ?? DEFAULTS[code]) : undefined;
40
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
41
+ const fallback = DEFAULTS[code];
42
+ const text = code ? (messages[code] ?? (fallback ? replace(config, fallback) : undefined)) : undefined;
35
43
  ---
36
44
 
37
45
  {text && (
@@ -12,6 +12,7 @@
12
12
  import { viewer } from "../access.js";
13
13
  import { siteConfig } from "../site-config.js";
14
14
  import { plan as loadPlan, type Plan } from "../storefront.js";
15
+ import { text } from "../text.js";
15
16
 
16
17
  interface Props {
17
18
  handle: string;
@@ -24,8 +25,8 @@ interface Props {
24
25
  heldLabel?: string;
25
26
  }
26
27
 
27
- const { handle, label = "Join", title, levels = [], heldLabel = "You're on this plan." } = Astro.props;
28
28
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
29
+ const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan.") } = Astro.props;
29
30
  let p: Plan | null = null;
30
31
  if (config) {
31
32
  try {
@@ -55,7 +56,7 @@ const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => level
55
56
  <button type="submit">{label}</button>
56
57
  </form>
57
58
  ) : (
58
- <p class="redeemid-plan__unavailable">Not available right now.</p>
59
+ <p class="redeemid-plan__unavailable">{text(config, "Not available right now.")}</p>
59
60
  )}
60
61
  </div>
61
62
  ) : (
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * The member's preferences form: every field in `preferences`, read from
4
+ * their Shopify customer, with a control for each type. Fields set to "view"
5
+ * are shown, not editable. Works without JavaScript: it posts to the
6
+ * preferences route and comes back with a notice. Visitors and sites without
7
+ * preferences get nothing (or the "signed-out" slot).
8
+ *
9
+ * <Preferences title="Your preferences" />
10
+ */
11
+ import { viewer } from "../access.js";
12
+ import { preferencesConfigured, readPreferences, type PreferenceField } from "../preferences.js";
13
+ import { siteConfig } from "../site-config.js";
14
+ import { text } from "../text.js";
15
+
16
+ interface Props {
17
+ title?: string;
18
+ /** Heading level for the title. */
19
+ heading?: "h2" | "h3" | "h4";
20
+ label?: string;
21
+ }
22
+
23
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
24
+ const { title = text(config, "Your preferences"), heading = "h2", label = text(config, "Save preferences") } = Astro.props;
25
+ const v = await viewer(Astro);
26
+ const m = v.member;
27
+ let fields: PreferenceField[] = [];
28
+ let failed = false;
29
+ if (config && preferencesConfigured(config) && m && m.status !== "disabled") {
30
+ try {
31
+ fields = await readPreferences(config, m.customerId);
32
+ } catch (e) {
33
+ failed = true;
34
+ console.error("[redeemid] preferences not read:", (e as Error).message);
35
+ }
36
+ }
37
+ const here = Astro.url.pathname + Astro.url.search.replace(/[?&]redeemid_notice=[^&]*/, "").replace(/^&/, "?");
38
+ const H = heading;
39
+ const id = (key: string) => `redeemid-pref-${key.replace(/[^\w-]/g, "-")}`;
40
+ const show = (f: PreferenceField) =>
41
+ f.value === null || (Array.isArray(f.value) && f.value.length === 0) ? text(config, "Not set") : Array.isArray(f.value) ? f.value.join(", ") : f.type === "boolean" ? text(config, f.value ? "Yes" : "No") : String(f.value);
42
+ const editable = fields.filter((f) => f.editable);
43
+ ---
44
+
45
+ {v.signedIn && (fields.length > 0 || failed) ? (
46
+ <section class="redeemid-preferences">
47
+ <H class="redeemid-preferences__title">{title}</H>
48
+ {failed ? (
49
+ <p>{text(config, "Your preferences can't be loaded right now. Try again in a few minutes.")}</p>
50
+ ) : (
51
+ <form method="post" action="/_emdash/api/auth/redeemid/preferences">
52
+ <input type="hidden" name="return" value={here} />
53
+ {fields.map((f) => (
54
+ <div class="redeemid-preferences__field" data-redeemid-pref={f.key}>
55
+ {f.editable && <input type="hidden" name={`present:${f.key}`} value="1" />}
56
+ {!f.editable ? (
57
+ <p>
58
+ <strong>{f.name}:</strong> {show(f)}
59
+ </p>
60
+ ) : f.type === "boolean" ? (
61
+ <label>
62
+ <input type="checkbox" name={`pref:${f.key}`} value="true" checked={f.value === true} /> {f.name}
63
+ </label>
64
+ ) : f.type === "list.single_line_text_field" && f.choices ? (
65
+ <fieldset>
66
+ <legend>{f.name}</legend>
67
+ {f.choices.map((c) => (
68
+ <label>
69
+ <input type="checkbox" name={`pref:${f.key}`} value={c} checked={Array.isArray(f.value) && f.value.includes(c)} /> {c}
70
+ </label>
71
+ ))}
72
+ </fieldset>
73
+ ) : (
74
+ <>
75
+ <label for={id(f.key)}>{f.name}</label>
76
+ {f.choices ? (
77
+ <select id={id(f.key)} name={`pref:${f.key}`}>
78
+ <option value="">{text(config, "Not set")}</option>
79
+ {f.choices.map((c) => <option value={c} selected={f.value === c}>{c}</option>)}
80
+ </select>
81
+ ) : f.type === "multi_line_text_field" || f.type === "list.single_line_text_field" ? (
82
+ <textarea id={id(f.key)} name={`pref:${f.key}`} rows="3" maxlength={f.max && f.type === "multi_line_text_field" ? Number(f.max) : undefined}>{Array.isArray(f.value) ? f.value.join("\n") : (f.value ?? "")}</textarea>
83
+ ) : f.type === "number_integer" || f.type === "number_decimal" ? (
84
+ <input id={id(f.key)} type="number" name={`pref:${f.key}`} step={f.type === "number_integer" ? "1" : "any"} min={f.min ?? undefined} max={f.max ?? undefined} value={f.value === null ? "" : String(f.value)} />
85
+ ) : f.type === "date" ? (
86
+ <input id={id(f.key)} type="date" name={`pref:${f.key}`} min={f.min?.slice(0, 10)} max={f.max?.slice(0, 10)} value={typeof f.value === "string" ? f.value : ""} />
87
+ ) : (
88
+ <input id={id(f.key)} type="text" name={`pref:${f.key}`} maxlength={f.max ? Number(f.max) : undefined} value={typeof f.value === "string" ? f.value : ""} />
89
+ )}
90
+ {f.type === "list.single_line_text_field" && !f.choices && <small>{text(config, "One per line.")}</small>}
91
+ </>
92
+ )}
93
+ {f.description && f.editable && <small class="redeemid-preferences__help">{f.description}</small>}
94
+ </div>
95
+ ))}
96
+ {editable.length > 0 && <button type="submit">{label}</button>}
97
+ </form>
98
+ )}
99
+ </section>
100
+ ) : (
101
+ !v.signedIn && <slot name="signed-out" />
102
+ )}
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { siteConfig } from "../site-config.js";
11
11
  import { money, product as loadProduct, type Product } from "../storefront.js";
12
+ import { text } from "../text.js";
12
13
 
13
14
  interface Props {
14
15
  handle: string;
@@ -20,8 +21,8 @@ interface Props {
20
21
  buyLabel?: string;
21
22
  }
22
23
 
23
- const { handle, heading = "h2", compact = false, addLabel = "Add to cart", buyLabel = "Buy now" } = Astro.props;
24
24
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
25
+ const { handle, heading = "h2", compact = false, addLabel = text(config, "Add to cart"), buyLabel = text(config, "Buy now") } = Astro.props;
25
26
  let p: Product | null = null;
26
27
  if (config) {
27
28
  try {
@@ -50,11 +51,11 @@ const id = `redeemid-product-${handle}`;
50
51
  <input type="hidden" name="return" value={here} />
51
52
  {p.variants.length > 1 ? (
52
53
  <p>
53
- <label for={`${id}-variant`}>Option</label>
54
+ <label for={`${id}-variant`}>{text(config, "Option")}</label>
54
55
  <select id={`${id}-variant`} name="variant">
55
56
  {p.variants.map((v) => (
56
57
  <option value={v.id} disabled={!v.availableForSale}>
57
- {v.title} ({money(v.price)}){!v.availableForSale && " sold out"}
58
+ {text(config, v.availableForSale ? "{option} ({price})" : "{option} ({price}) sold out", { option: v.title, price: money(v.price) })}
58
59
  </option>
59
60
  ))}
60
61
  </select>
@@ -63,14 +64,14 @@ const id = `redeemid-product-${handle}`;
63
64
  <input type="hidden" name="variant" value={first?.id} />
64
65
  )}
65
66
  <p>
66
- <label for={`${id}-quantity`}>Quantity</label>
67
+ <label for={`${id}-quantity`}>{text(config, "Quantity")}</label>
67
68
  <input id={`${id}-quantity`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
68
69
  </p>
69
70
  <button type="submit" name="then" value="back">{addLabel}</button>
70
71
  <button type="submit" name="then" value="checkout">{buyLabel}</button>
71
72
  </form>
72
73
  ) : (
73
- <p class="redeemid-product__unavailable">Sold out.</p>
74
+ <p class="redeemid-product__unavailable">{text(config, "Sold out.")}</p>
74
75
  )}
75
76
  </div>
76
77
  ) : (
package/src/config.ts CHANGED
@@ -63,6 +63,40 @@ export interface RuleConfig {
63
63
  * free each month (or week). Guests get the wall; an entry counts once per
64
64
  * period, when its own page renders through <Content> with an `id`.
65
65
  */
66
+ /** A perk members hold through any of its levels or Shopify tags. */
67
+ export interface FeatureConfig {
68
+ slug: string;
69
+ name: string;
70
+ levels?: string[];
71
+ tags?: string[];
72
+ }
73
+
74
+ /** Where to send signed member events (see events.ts). The signing secret is REDEEMID_EVENTS_SECRET. */
75
+ /** A media folder whose files only some readers may download. Any of the given ways in is enough. */
76
+ export interface FileRule {
77
+ /** The media folder's name in the EmDash media library. */
78
+ folder: string;
79
+ /** Members holding any of these levels. An empty list means any signed-in member. */
80
+ levels?: string[];
81
+ /** Members holding any of these features. */
82
+ features?: string[];
83
+ /** Whoever can read a published entry that uses the file (its rule, drip and the levels picked in its body). */
84
+ entries?: boolean;
85
+ }
86
+
87
+ export interface PreferenceConfig {
88
+ /** "namespace.key" of a customer metafield definition. */
89
+ key: string;
90
+ /** "edit" (default): members can change it. "view": members only see it. */
91
+ access?: "view" | "edit";
92
+ }
93
+
94
+ export interface EventsConfig {
95
+ url: string;
96
+ /** Send only these events. All of them when left out. */
97
+ only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved">;
98
+ }
99
+
66
100
  export interface MeterConfig {
67
101
  /** Free entries per period. */
68
102
  limit: number;
@@ -83,6 +117,21 @@ export interface RedeemIDConfig {
83
117
  levels?: LevelConfig[];
84
118
  rules?: RuleConfig[];
85
119
  meter?: MeterConfig;
120
+ features?: FeatureConfig[];
121
+ events?: EventsConfig;
122
+ /**
123
+ * Customer metafields members may see or change, in this order, by
124
+ * "namespace.key". Define them in Shopify (Settings → Custom data →
125
+ * Customers). Needs the Admin API app (staff tools) for reading and saving.
126
+ */
127
+ preferences?: PreferenceConfig[];
128
+ /** Protected files, by media folder. See FileRule. */
129
+ files?: FileRule[];
130
+ /**
131
+ * Replacement text, by each string's original English, e.g.
132
+ * { "Sign in": "Log in" }. One string at a time; see text.ts for the list.
133
+ */
134
+ text?: Record<string, string>;
86
135
  /** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
87
136
  join?: { href: string; label?: string };
88
137
  /**
@@ -123,6 +172,10 @@ export const ENV = {
123
172
  clientSecret: "REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET",
124
173
  /** Client secret of the Shopify (Admin API) app whose webhooks the site receives. Shopify signs webhooks with it. */
125
174
  webhookSecret: "REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET",
175
+ /** Client ID of that Shopify (Admin API) app. With the secret it lets staff tools call the Admin API (client credentials). */
176
+ adminClientId: "REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID",
177
+ /** Secret for signing the outbound events RedeemID sends to `events.url`. */
178
+ eventsSecret: "REDEEMID_EVENTS_SECRET",
126
179
  /** Storefront API private token, for server-side plan and checkout calls. */
127
180
  storefrontPrivateToken: "REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN",
128
181
  /** Storefront API public token, used only when the private token isn't set. */
package/src/descriptor.ts CHANGED
@@ -11,6 +11,7 @@ import { VERSION } from "./version.js";
11
11
  export const PAGES = [
12
12
  { path: "/", label: "Members", icon: "users" },
13
13
  { path: "/status", label: "Status", icon: "pulse" },
14
+ { path: "/test", label: "Access test", icon: "search" },
14
15
  ];
15
16
  export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
16
17
 
package/src/events.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Outbound events: RedeemID POSTs JSON to `events.url` when something
3
+ * happens to a member, signed so the receiver can trust it.
4
+ *
5
+ * events: { url: "https://hooks.example.com/redeemid" } // + REDEEMID_EVENTS_SECRET
6
+ *
7
+ * Headers: X-RedeemID-Event (the name), X-RedeemID-Delivery (a fresh id) and
8
+ * X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
9
+ * member.signed_in, member.levels_changed, meter.used, meter.exhausted,
10
+ * preferences.saved.
11
+ * Sending never blocks or breaks the page: failures are logged and dropped.
12
+ * No event carries an email address or a name; match people by `customerId`.
13
+ */
14
+
15
+ import { env, ENV, type RedeemIDConfig } from "./config.js";
16
+ import { USER_AGENT } from "./shopify.js";
17
+
18
+ export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved";
19
+
20
+ const TIMEOUT_MS = 3000;
21
+
22
+ export async function signBody(body: string, secret: string): Promise<string> {
23
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
24
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)));
25
+ return [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
26
+ }
27
+
28
+ /** Send one event. Returns whether the receiver answered 2xx; never throws. */
29
+ export async function emit(config: RedeemIDConfig | null, name: EventName, data: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<boolean> {
30
+ const url = config?.events?.url;
31
+ const secret = env(ENV.eventsSecret);
32
+ if (!url || !secret) return false;
33
+ if (config.events?.only && !config.events.only.includes(name)) return false;
34
+ const id = crypto.randomUUID();
35
+ const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
36
+ try {
37
+ const res = await fetcher(url, {
38
+ method: "POST",
39
+ headers: {
40
+ "Content-Type": "application/json",
41
+ "User-Agent": USER_AGENT,
42
+ "X-RedeemID-Event": name,
43
+ "X-RedeemID-Delivery": id,
44
+ "X-RedeemID-Signature": `sha256=${await signBody(body, secret)}`,
45
+ },
46
+ body,
47
+ signal: AbortSignal.timeout(TIMEOUT_MS),
48
+ });
49
+ return res.ok;
50
+ } catch (e) {
51
+ console.error("[redeemid] event not sent:", name, (e as Error).message);
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /** Send member.levels_changed when a mirror write changed the member's levels. */
57
+ export async function levelChange(
58
+ config: RedeemIDConfig | null,
59
+ beforeTags: string[] | null,
60
+ after: { customerId: string; tags: string[]; status: string },
61
+ source: string,
62
+ fetcher: typeof fetch = fetch,
63
+ ): Promise<void> {
64
+ if (!config?.events?.url) return;
65
+ const { levelsFor } = await import("./access.js");
66
+ const was = beforeTags ? levelsFor(beforeTags, config.levels) : [];
67
+ const now = after.status === "disabled" ? [] : levelsFor(after.tags, config.levels);
68
+ if (was.length === now.length && was.every((l) => now.includes(l))) return;
69
+ await emit(
70
+ config,
71
+ "member.levels_changed",
72
+ { customerId: after.customerId, levels: now, added: now.filter((l) => !was.includes(l)), removed: was.filter((l) => !now.includes(l)), source },
73
+ fetcher,
74
+ );
75
+ }