@redeemid/emdash 0.0.9 → 0.0.11

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 (42) hide show
  1. package/README.md +138 -2
  2. package/bin/redeemid-emdash.mjs +144 -0
  3. package/docs/shopify-setup.md +4 -2
  4. package/package.json +8 -2
  5. package/src/access.ts +2 -2
  6. package/src/agents.ts +179 -0
  7. package/src/blocks/ContributionBlock.astro +1 -1
  8. package/src/blocks/PlanBlock.astro +1 -1
  9. package/src/blocks/PreferencesBlock.astro +12 -0
  10. package/src/blocks/index.ts +3 -0
  11. package/src/blocks-names.ts +27 -0
  12. package/src/components/AccessBadge.astro +10 -6
  13. package/src/components/Account.astro +5 -4
  14. package/src/components/Cart.astro +4 -1
  15. package/src/components/CartView.astro +14 -13
  16. package/src/components/Collection.astro +2 -1
  17. package/src/components/Content.astro +25 -10
  18. package/src/components/Contribution.astro +4 -3
  19. package/src/components/LockedPanel.astro +35 -9
  20. package/src/components/MemberCard.astro +15 -14
  21. package/src/components/Notices.astro +12 -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/components/YourData.astro +42 -0
  26. package/src/config.ts +38 -1
  27. package/src/descriptor.ts +1 -0
  28. package/src/events.ts +3 -2
  29. package/src/files.ts +196 -0
  30. package/src/guard.ts +43 -2
  31. package/src/index.ts +2 -0
  32. package/src/plugin.ts +169 -4
  33. package/src/preferences.ts +298 -0
  34. package/src/privacy.ts +148 -0
  35. package/src/routes/preferences.ts +82 -0
  36. package/src/routes/privacy.ts +59 -0
  37. package/src/routes/webhook.ts +1 -1
  38. package/src/staff.ts +92 -0
  39. package/src/storage.ts +17 -0
  40. package/src/text.ts +185 -0
  41. package/src/version.ts +1 -1
  42. package/src/webhooks.ts +17 -4
@@ -4,9 +4,36 @@ export const PLAN_BLOCK = "redeemid-plan";
4
4
  export const PRODUCT_BLOCK = "redeemid-product";
5
5
  export const COLLECTION_BLOCK = "redeemid-collection";
6
6
  export const CONTRIBUTION_BLOCK = "redeemid-contribution";
7
+ export const PREFERENCES_BLOCK = "redeemid-preferences";
7
8
  /** Starts a members-only part of the body; it runs to the next end block. */
8
9
  export const MEMBERS_BLOCK = "redeemid-members";
9
10
  export const MEMBERS_END_BLOCK = "redeemid-members-end";
11
+ /** Sells the entry on its own: readers holding its tag read all of it. */
12
+ export const ITEM_BLOCK = "redeemid-item";
13
+
14
+ export interface SoldItem {
15
+ /** The Shopify customer tag buyers get, lowercased. */
16
+ tag: string;
17
+ /** The Shopify product handle that sells it, or null. */
18
+ product: string | null;
19
+ }
20
+
21
+ /** The entry's "Sold on its own" block, if it has one with a tag. */
22
+ export function soldItem(value: unknown): SoldItem | null {
23
+ const blocks = Array.isArray(value) ? value : [];
24
+ const b = blocks.find((x) => (x as { _type?: unknown })?._type === ITEM_BLOCK) as { tag?: unknown; product?: unknown } | undefined;
25
+ const tag = typeof b?.tag === "string" ? b.tag.trim().toLowerCase() : "";
26
+ if (!tag) return null;
27
+ const raw = typeof b?.product === "string" ? b.product.trim() : "";
28
+ // A handle, or a product link: /products/<handle>.
29
+ const handle = (/\/products\/([a-z0-9][a-z0-9-]*)/i.exec(raw)?.[1] ?? raw).toLowerCase();
30
+ return { tag, product: /^[a-z0-9][a-z0-9-]*$/.test(handle) ? handle : null };
31
+ }
32
+
33
+ /** Does someone with these tags hold the item? */
34
+ export function holdsItem(item: SoldItem | null, tags: string[] | undefined): boolean {
35
+ return Boolean(item && tags?.some((t) => t.toLowerCase() === item.tag));
36
+ }
10
37
 
11
38
  type Node = { _type?: unknown; levels?: unknown };
12
39
 
@@ -9,9 +9,10 @@
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, type Terms } from "../access.js";
13
- import { splitAtPaywall } from "../blocks-names.js";
12
+ import { canRead, describe, entryRequirement, viewer, type Terms } from "../access.js";
13
+ import { holdsItem, soldItem, splitAtPaywall } from "../blocks-names.js";
14
14
  import { siteConfig } from "../site-config.js";
15
+ import { text } from "../text.js";
15
16
 
16
17
  interface Props {
17
18
  collection: string;
@@ -22,14 +23,17 @@ interface Props {
22
23
  class?: string;
23
24
  }
24
25
 
25
- const { collection, terms, content, free = "Free", class: className } = Astro.props;
26
26
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
27
+ const { collection, terms, content, free = text(config, "Free"), class: className } = Astro.props;
27
28
  const split = splitAtPaywall(content);
28
29
  const required = split.paywall && split.levels.length
29
30
  ? [split.levels]
30
31
  : await entryRequirement(config, collection, terms);
31
- const label = describe(required, config?.levels, free);
32
- const locked = required !== null && !(await canRead(Astro, { collection, terms }, { required })).allowed;
32
+ const label = describe(required, config?.levels, free, text(config, "Signed in"));
33
+ const item = soldItem(content);
34
+ const owner = item ? (await viewer(Astro)).member : null;
35
+ const owned = Boolean(owner && owner.status !== "disabled" && holdsItem(item, owner.tags));
36
+ const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms }, { required })).allowed;
33
37
  const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
34
38
  ---
35
39
 
@@ -40,6 +44,6 @@ const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
40
44
  >
41
45
  {locked && <span class="redeemid-badge__lock" aria-hidden="true">🔒&#xFE0E; </span>}
42
46
  {label}
43
- {locked && <span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"> (locked)</span>}
47
+ {locked && <span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap">{text(config, " (locked)")}</span>}
44
48
  </span>
45
49
  )}
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { siteConfig } from "../site-config.js";
6
6
  import { accountUrl, signInUrl, signOutUrl, viewer } from "../access.js";
7
+ import { text } from "../text.js";
7
8
 
8
9
  const v = await viewer(Astro);
9
10
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
@@ -14,14 +15,14 @@ const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).ma
14
15
  <div class="redeemid-account" data-redeemid-signed-in={v.signedIn ? "true" : "false"}>
15
16
  {v.signedIn ? (
16
17
  <>
17
- <span class="redeemid-account__who">{v.member?.firstName ?? v.member?.email ?? "Signed in"}</span>
18
+ <span class="redeemid-account__who">{v.member?.firstName ?? v.member?.email ?? text(config, "Signed in")}</span>
18
19
  {names.length > 0 && <span class="redeemid-account__levels">{names.join(", ")}</span>}
19
- <a class="redeemid-account__link" href={accountUrl(here)}>Account</a>
20
+ <a class="redeemid-account__link" href={accountUrl(here)}>{text(config, "Account")}</a>
20
21
  <form method="post" action={signOutUrl(here)}>
21
- <button type="submit">Sign out</button>
22
+ <button type="submit">{text(config, "Sign out")}</button>
22
23
  </form>
23
24
  </>
24
25
  ) : (
25
- <a href={signInUrl(here)}>{config?.label ?? "Sign in"}</a>
26
+ <a href={signInUrl(here)}>{config?.label ?? text(config, "Sign in")}</a>
26
27
  )}
27
28
  </div>
@@ -5,12 +5,15 @@
5
5
  */
6
6
  import { cartUrl } from "../access.js";
7
7
  import { readCart } from "../cart.js";
8
+ import { siteConfig } from "../site-config.js";
9
+ import { text } from "../text.js";
8
10
 
9
11
  interface Props {
10
12
  label?: string;
11
13
  }
12
14
 
13
- const { label = "Cart" } = Astro.props;
15
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
16
+ const { label = text(config, "Cart") } = Astro.props;
14
17
  const ref = await readCart(Astro.session);
15
18
  const count = ref?.count ?? 0;
16
19
  const here = Astro.url.pathname + Astro.url.search;
@@ -11,6 +11,7 @@ import { cartUrl } from "../access.js";
11
11
  import { readCart, saveCart } from "../cart.js";
12
12
  import { siteConfig } from "../site-config.js";
13
13
  import { cartDetails, money, type CartDetails } from "../storefront.js";
14
+ import { text } from "../text.js";
14
15
 
15
16
  interface Props {
16
17
  /** Link for each line's product; `{handle}` is replaced. */
@@ -19,8 +20,8 @@ interface Props {
19
20
  empty?: string;
20
21
  }
21
22
 
22
- const { productHref, empty = "Your cart is empty." } = Astro.props;
23
23
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
24
+ const { productHref, empty = text(config, "Your cart is empty.") } = Astro.props;
24
25
  const ref = await readCart(Astro.session);
25
26
  let cart: CartDetails | null = null;
26
27
  let failed = false;
@@ -43,7 +44,7 @@ const code = cart?.discountCodes[0];
43
44
 
44
45
  <section class="redeemid-cartview" data-redeemid-cart-count={cart?.totalQuantity ?? 0}>
45
46
  {failed ? (
46
- <p>Your cart isn't available right now. Try again in a few minutes.</p>
47
+ <p>{text(config, "Your cart isn't available right now. Try again in a few minutes.")}</p>
47
48
  ) : !cart || cart.lines.length === 0 ? (
48
49
  <p class="redeemid-cartview__empty">{empty}</p>
49
50
  ) : (
@@ -57,20 +58,20 @@ const code = cart?.discountCodes[0];
57
58
  {href(l.productHandle) ? <a href={href(l.productHandle)}>{l.productTitle}</a> : l.productTitle}
58
59
  {l.variantTitle && l.variantTitle !== "Default Title" && <span> · {l.variantTitle}</span>}
59
60
  </p>
60
- <p class="redeemid-cartview__price">{money(l.price)} each · {money(l.total)}</p>
61
+ <p class="redeemid-cartview__price">{text(config, "{price} each · {total}", { price: money(l.price), total: money(l.total) })}</p>
61
62
  <form method="post" action={action} class="redeemid-cartview__quantity">
62
63
  <input type="hidden" name="action" value="update" />
63
64
  <input type="hidden" name="line" value={l.id} />
64
65
  <input type="hidden" name="return" value={here} />
65
- <label for={`q-${l.id}`}>Quantity</label>
66
+ <label for={`q-${l.id}`}>{text(config, "Quantity")}</label>
66
67
  <input id={`q-${l.id}`} name="quantity" type="number" min="0" max="1000" value={l.quantity} inputmode="numeric" />
67
- <button type="submit">Update</button>
68
+ <button type="submit">{text(config, "Update")}</button>
68
69
  </form>
69
70
  <form method="post" action={action}>
70
71
  <input type="hidden" name="action" value="remove" />
71
72
  <input type="hidden" name="line" value={l.id} />
72
73
  <input type="hidden" name="return" value={here} />
73
- <button type="submit" class="redeemid-cartview__remove">Remove<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"> {l.productTitle}</span></button>
74
+ <button type="submit" class="redeemid-cartview__remove">{text(config, "Remove")}<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"> {l.productTitle}</span></button>
74
75
  </form>
75
76
  </div>
76
77
  </li>
@@ -79,19 +80,19 @@ const code = cart?.discountCodes[0];
79
80
  <form method="post" action={action} class="redeemid-cartview__discount">
80
81
  <input type="hidden" name="action" value="discount" />
81
82
  <input type="hidden" name="return" value={here} />
82
- <label for="redeemid-discount">Discount code</label>
83
+ <label for="redeemid-discount">{text(config, "Discount code")}</label>
83
84
  <input id="redeemid-discount" name="code" type="text" value={code?.code ?? ""} autocomplete="off" />
84
- <button type="submit">Apply</button>
85
- {code && !code.applicable && <p class="redeemid-cartview__code-note">{code.code} doesn't apply to this cart.</p>}
85
+ <button type="submit">{text(config, "Apply")}</button>
86
+ {code && !code.applicable && <p class="redeemid-cartview__code-note">{text(config, "{code} doesn't apply to this cart.", { code: code.code })}</p>}
86
87
  </form>
87
88
  <dl class="redeemid-cartview__totals">
88
- <dt>Subtotal</dt>
89
+ <dt>{text(config, "Subtotal")}</dt>
89
90
  <dd>{money(cart.subtotal)}</dd>
90
- <dt>Total</dt>
91
+ <dt>{text(config, "Total")}</dt>
91
92
  <dd>{money(cart.total)}</dd>
92
93
  </dl>
93
- <p class="redeemid-cartview__note">Taxes and shipping are worked out at checkout.</p>
94
- <a class="redeemid-cartview__checkout" href={cartUrl(here)}>Checkout</a>
94
+ <p class="redeemid-cartview__note">{text(config, "Taxes and shipping are worked out at checkout.")}</p>
95
+ <a class="redeemid-cartview__checkout" href={cartUrl(here)}>{text(config, "Checkout")}</a>
95
96
  </>
96
97
  )}
97
98
  </section>
@@ -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>
@@ -14,6 +14,10 @@
14
14
  * - No break: the entry's rule covers the whole body; readers it keeps out see
15
15
  * the collection's teaser (config `teaser`, in words), if any.
16
16
  *
17
+ * - "Sold on its own": readers holding the block's Shopify tag read the whole
18
+ * entry, whatever its rule, breaks and drip say. Others see "Buy just this"
19
+ * on the wall when the block names a product.
20
+ *
17
21
  * Readers kept out get the wall (LockedPanel), with the "locked" slot on top.
18
22
  *
19
23
  * Pass `id` on the entry's own page so drip and the meter apply: a rule's
@@ -23,9 +27,10 @@
23
27
  */
24
28
  import { PortableText } from "emdash/ui";
25
29
 
26
- import { canRead, entryRequirement, readEntry, type Terms, type Verdict } from "../access.js";
27
- import { sections, splitAtPaywall, teaser } from "../blocks-names.js";
30
+ import { canRead, entryRequirement, readEntry, viewer, type Terms, type Verdict } from "../access.js";
31
+ import { holdsItem, sections, soldItem, splitAtPaywall, teaser } from "../blocks-names.js";
28
32
  import { siteConfig } from "../site-config.js";
33
+ import { text } from "../text.js";
29
34
  import LockedPanel from "./LockedPanel.astro";
30
35
 
31
36
  interface Props {
@@ -40,11 +45,15 @@ const { value, collection = "", terms, id } = Astro.props;
40
45
  const split = splitAtPaywall(value);
41
46
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
42
47
  const entryRule = await entryRequirement(config, collection, terms);
48
+ // Bought on its own: the whole entry, no meter, no drip.
49
+ const item = soldItem(value);
50
+ const reader = item ? (await viewer(Astro)).member : null;
51
+ const owned = Boolean(reader && reader.status !== "disabled" && holdsItem(item, reader.tags));
43
52
  // A break with levels picked locks the rest to them; with none picked it
44
53
  // follows the entry's rule, and does nothing when no rule covers the entry.
45
54
  const required = split.paywall ? (split.levels.length ? [split.levels] : entryRule) : undefined;
46
55
  const verdict: Verdict | { allowed: true; reason: "open" } =
47
- split.paywall && required === null
56
+ owned || (split.paywall && required === null)
48
57
  ? { allowed: true, reason: "open" }
49
58
  : id
50
59
  ? await readEntry(Astro, { collection, terms, id }, required ? { required } : undefined)
@@ -57,7 +66,7 @@ const rest = verdict.allowed ? split.locked : [];
57
66
  async function parts(blocks: unknown[]) {
58
67
  const out: Array<{ blocks: unknown[]; verdict?: Verdict }> = [];
59
68
  for (const section of sections(blocks)) {
60
- if (!section.members) {
69
+ if (!section.members || owned) {
61
70
  out.push({ blocks: section.blocks });
62
71
  continue;
63
72
  }
@@ -69,21 +78,26 @@ async function parts(blocks: unknown[]) {
69
78
  const before = await parts(shown);
70
79
  const after = await parts(rest);
71
80
  const metered = verdict.reason === "metered" ? (verdict as Verdict) : null;
72
- const per = metered?.meter?.period === "week" ? "this week" : "this month";
81
+ const week = metered?.meter?.period === "week";
82
+ const left = metered?.meterLeft ?? 0;
83
+ const meterText =
84
+ left === 0
85
+ ? text(config, week ? "This is your last free read this week." : "This is your last free read this month.")
86
+ : left === 1
87
+ ? text(config, week ? "You have 1 free read left this week." : "You have 1 free read left this month.")
88
+ : text(config, week ? "You have {count} free reads left this week." : "You have {count} free reads left this month.", { count: left });
73
89
  ---
74
90
 
75
91
  {metered && (
76
92
  <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}.`}
93
+ {meterText}
80
94
  </p>
81
95
  )}
82
96
  {before.map((p) =>
83
97
  !p.verdict || p.verdict.allowed ? (
84
98
  p.blocks.length > 0 && <PortableText value={p.blocks} />
85
99
  ) : (
86
- <LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} />
100
+ <LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} item={item} />
87
101
  ),
88
102
  )}
89
103
  {verdict.allowed ? (
@@ -91,7 +105,7 @@ const per = metered?.meter?.period === "week" ? "this week" : "this month";
91
105
  !p.verdict || p.verdict.allowed ? (
92
106
  p.blocks.length > 0 && <PortableText value={p.blocks} />
93
107
  ) : (
94
- <LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} />
108
+ <LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} item={item} />
95
109
  ),
96
110
  )
97
111
  ) : (
@@ -102,6 +116,7 @@ const per = metered?.meter?.period === "week" ? "this week" : "this month";
102
116
  opensAt={verdict.opensAt}
103
117
  meterLeft={verdict.meterLeft}
104
118
  meterPeriod={verdict.meter?.period}
119
+ item={item}
105
120
  >
106
121
  <slot name="locked" />
107
122
  </LockedPanel>
@@ -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
  )}
@@ -9,7 +9,10 @@
9
9
  * Anything in the default slot is shown above the built-in text.
10
10
  */
11
11
  import { passes, signInUrl, type Reason } from "../access.js";
12
+ import type { SoldItem } from "../blocks-names.js";
13
+ import { plan as loadPlan } from "../storefront.js";
12
14
  import { siteConfig } from "../site-config.js";
15
+ import { text, textParts } from "../text.js";
13
16
 
14
17
  interface Props {
15
18
  reason: Reason;
@@ -22,9 +25,11 @@ interface Props {
22
25
  /** Free reads left when a meter ran (Verdict.meterLeft); 0 shows the meter-used text. */
23
26
  meterLeft?: number;
24
27
  meterPeriod?: "month" | "week";
28
+ /** The entry's "Sold on its own" block: offers "Buy just this". */
29
+ item?: SoldItem | null;
25
30
  }
26
31
 
27
- const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod } = Astro.props;
32
+ const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod, item } = Astro.props;
28
33
  const opens = opensAt
29
34
  ? new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(opensAt)
30
35
  : null;
@@ -32,7 +37,20 @@ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).em
32
37
  const missing = required.find((clause) => clause.length > 0 && !passes(held, [clause]));
33
38
  const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : undefined;
34
39
  const joinHref = level?.joinUrl ?? config?.join?.href;
35
- const joinLabel = config?.join?.label ?? "Become a member";
40
+ const joinLabel = config?.join?.label ?? text(config, "Become a member");
41
+ const week = meterPeriod === "week";
42
+ // "Buy just this": the item's product and price, read from Shopify.
43
+ let single: { handle: string; price: string } | null = null;
44
+ if (item?.product && config && (reason === "sign_in" || reason === "upgrade" || reason === "drip")) {
45
+ try {
46
+ const p = await loadPlan(config, item.product);
47
+ if (p?.availableForSale && p.price) {
48
+ single = { handle: p.handle, price: new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount)) };
49
+ }
50
+ } catch {
51
+ single = null;
52
+ }
53
+ }
36
54
  const here = Astro.url.pathname + Astro.url.search;
37
55
  ---
38
56
 
@@ -41,24 +59,32 @@ const here = Astro.url.pathname + Astro.url.search;
41
59
  {level?.wall && (reason === "sign_in" || reason === "upgrade") && <p class="redeemid-locked__wall">{level.wall}</p>}
42
60
  {reason === "sign_in" && (
43
61
  <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>.</>}
62
+ {textParts(config, "This is for members. {signIn} to read on.", { signIn: <a href={signInUrl(here)}>{text(config, "Sign in")}</a> })}
63
+ {joinHref && <> {textParts(config, "Not a member yet? {join}.", { join: <a href={joinHref}>{joinLabel}</a> })}</>}
46
64
  </p>
47
65
  )}
48
66
  {reason === "upgrade" && meterLeft === 0 && (
49
- <p class="redeemid-locked__meter">You've used your free reads {meterPeriod === "week" ? "this week" : "this month"}.</p>
67
+ <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
68
  )}
51
69
  {reason === "drip" && (
52
70
  <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."}
71
+ {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
72
  </p>
55
73
  )}
56
74
  {reason === "upgrade" && (
57
75
  <p>
58
- {level ? `This is for ${level.name} members.` : "Your membership doesn't include this yet."}
76
+ {level ? text(config, "This is for {level} members.", { level: level.name }) : text(config, "Your membership doesn't include this yet.")}
59
77
  {joinHref && <> <a href={joinHref}>{joinLabel}</a></>}
60
78
  </p>
61
79
  )}
62
- {reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
63
- {reason === "unconfigured" && <p>This is for members.</p>}
80
+ {single && (
81
+ <form class="redeemid-locked__single" method="post" action="/_emdash/api/auth/redeemid/buy" data-redeemid-item={item?.tag}>
82
+ <input type="hidden" name="plan" value={single.handle} />
83
+ <input type="hidden" name="return" value={here} />
84
+ <p>{text(config, "Or buy just this for {price}.", { price: single.price })}</p>
85
+ <button type="submit">{text(config, "Buy just this")}</button>
86
+ </form>
87
+ )}
88
+ {reason === "disabled" && <p>{text(config, "Your account is turned off. Contact the store for help.")}</p>}
89
+ {reason === "unconfigured" && <p>{text(config, "This is for members.")}</p>}
64
90
  </div>
@@ -12,15 +12,16 @@
12
12
  import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
13
13
  import { featuresFor } from "../features.js";
14
14
  import { siteConfig } from "../site-config.js";
15
+ import { text, textParts } from "../text.js";
15
16
 
16
17
  interface Props {
17
18
  /** Heading text; the member's first name is added after it. */
18
19
  title?: string;
19
20
  }
20
21
 
21
- const { title = "Your membership" } = Astro.props;
22
- const v = await viewer(Astro);
23
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);
24
25
  const here = Astro.url.pathname + Astro.url.search;
25
26
  const m = v.member;
26
27
  const disabled = m?.status === "disabled";
@@ -48,34 +49,34 @@ const join = config?.join;
48
49
  </p>
49
50
  )}
50
51
  {disabled ? (
51
- <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>
52
53
  ) : current.length > 0 ? (
53
54
  <ul class="redeemid-member__levels">
54
55
  {current.map((l) => (
55
56
  <li data-redeemid-level={l.slug}>
56
57
  <strong>{l.name}</strong>
57
- {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> })}</>}
58
59
  </li>
59
60
  ))}
60
61
  </ul>
61
62
  ) : (
62
63
  <p class="redeemid-member__none">
63
- 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></>}
64
65
  </p>
65
66
  )}
66
67
  {features.length > 0 && (
67
68
  <p class="redeemid-member__features">
68
- Includes: {features.map((f) => f.name).join(", ")}
69
+ {text(config, "Includes: {features}", { features: features.map((f) => f.name).join(", ") })}
69
70
  </p>
70
71
  )}
71
72
  {past.length > 0 && (
72
73
  <>
73
- <h3 class="redeemid-member__subtitle">Before</h3>
74
+ <h3 class="redeemid-member__subtitle">{text(config, "Before")}</h3>
74
75
  <ul class="redeemid-member__past">
75
76
  {past.map((l) => (
76
77
  <li data-redeemid-level={l.slug}>
77
78
  {l.name}
78
- {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) ?? "" })}
79
80
  </li>
80
81
  ))}
81
82
  </ul>
@@ -83,21 +84,21 @@ const join = config?.join;
83
84
  )}
84
85
  {meter && current.length === 0 && !disabled && (
85
86
  <p class="redeemid-member__meter" data-redeemid-used={meter.used}>
86
- 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 })}
87
88
  </p>
88
89
  )}
89
90
  <p class="redeemid-member__actions">
90
- <a href={accountUrl(here)}>Orders and details</a>
91
- <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>
92
93
  </p>
93
94
  <form method="post" action={signOutUrl("/")}>
94
- <button type="submit">Sign out</button>
95
+ <button type="submit">{text(config, "Sign out")}</button>
95
96
  </form>
96
- <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>
97
98
  </>
98
99
  ) : (
99
100
  <p>
100
- <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> })}
101
102
  </p>
102
103
  )}
103
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,19 @@ 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.",
35
+ data_erased: "Your data on this site is deleted, and you're signed out.",
36
+ erase_unconfirmed: "Tick the box to confirm, then try again.",
29
37
  account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
30
38
  };
31
39
 
32
40
  const { messages = {} } = Astro.props;
33
41
  const code = Astro.url.searchParams.get("redeemid_notice") ?? "";
34
- const text = code ? (messages[code] ?? DEFAULTS[code]) : undefined;
42
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
43
+ const fallback = DEFAULTS[code];
44
+ const text = code ? (messages[code] ?? (fallback ? replace(config, fallback) : undefined)) : undefined;
35
45
  ---
36
46
 
37
47
  {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
  ) : (