@redeemid/emdash 0.0.9 → 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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
4
4
 
5
- **Status: 0.0.9, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout, the staff tools and outbound events are covered by an end-to-end test against a fake store.
5
+ **Status: 0.0.10, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout, the staff tools, preferences, protected files, replacement text and outbound events are covered by an end-to-end test against a fake store.
6
6
 
7
7
  ## What it does
8
8
 
@@ -22,6 +22,9 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
22
22
  - **Shop.** `<Product>`, `<Collection>` and `<CartView>` (with the Product and Collection blocks) give the site product pages, collection grids and a cart page with quantities and discount codes. `<Contribution>` (and its block) takes pay-what-you-want amounts. Carts note the page each sale started on.
23
23
  - **Features.** Name perks members hold through levels or tags (ad-free, a supporter thank-you) and show them with `<Feature>` or `hasFeature()`.
24
24
  - **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels; open a member to resync them or add and remove tags, which are written to Shopify), a bulk resync you start yourself, a Status page (secrets, store discovery, webhook subscriptions with a Register button, recent deliveries, levels and rules), an Access test and a dashboard widget.
25
+ - **Member preferences.** Members see and change customer fields you define in Shopify, such as topics or a newsletter choice, with `<Preferences>` or its block. Answers are saved on the Shopify customer, ready for Flow, segments and email; the site keeps no copy.
26
+ - **Protected files.** Put uploads in a media folder named in `files`, and every download checks the reader's levels, features or the entries that use the file. Copied links are no use to anyone else.
27
+ - **Replacement text.** Put any string members and visitors see in your own words, one string at a time, with `text`.
25
28
  - **Outbound events.** Signed JSON POSTs to a URL of yours when a member signs in, their levels change, or they use the meter, for a CRM, an email tool or your own code.
26
29
  - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
27
30
 
@@ -135,6 +138,74 @@ import { hasFeature } from "@redeemid/emdash/features";
135
138
  {!(await hasFeature(Astro, "ad-free")) && <aside class="ad">…</aside>}
136
139
  ```
137
140
 
141
+ ### Preferences
142
+
143
+ ```ts
144
+ preferences: [
145
+ { key: "preferences.topics" }, // members can change it
146
+ { key: "preferences.level", access: "view" }, // members only see it
147
+ ],
148
+ ```
149
+
150
+ Define each field in Shopify (**Settings → Custom data → Customers**). The definition sets its name, type and limits, such as allowed choices or a minimum and maximum. List the ones members get in `preferences`, in the order to show them. Reading and saving use the Admin API app (`REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID`, with `read_customers` and `write_customers`), always on the signed-in member's own customer. Fields in the `membership` namespace are never offered. Preferences never open content.
151
+
152
+ `<Preferences>` (or the Preferences block) is a form with a control for each field. It works without JavaScript and comes back with the `preferences_saved`, `preferences_refused` or `preferences_unavailable` notice. Visitors see its `signed-out` slot.
153
+
154
+ | Shopify type | Value |
155
+ | --- | --- |
156
+ | Single line text | A string; with choices, one of them |
157
+ | Multi-line text | A string |
158
+ | List of single line text | A list of strings; with choices, each one of them |
159
+ | True or false | `true` or `false` |
160
+ | Integer | A whole number |
161
+ | Decimal | A number, as a string |
162
+ | Date | `YYYY-MM-DD` |
163
+
164
+ Every value is checked against the definition before it goes to Shopify; one refusal saves nothing, and an empty value clears the field. Scripts can use `/_emdash/api/auth/redeemid/preferences` too: `GET` returns `{ fields }` with values, `POST` with JSON `{ values: { "namespace.key": value } }` saves. A refused save answers `400` with `fields` mapping each key to `unknown_field`, `read_only`, `invalid_type`, `invalid_choice`, `invalid_length` or `invalid_range`. Saves are limited to 10 a minute per member (`429`). From your own code, `readPreferences()` and `savePreferences()` in `@redeemid/emdash/preferences` do the same.
165
+
166
+ A save sends the `preferences.saved` event with the field names changed, never the answers.
167
+
168
+ ### Protected files
169
+
170
+ ```ts
171
+ files: [
172
+ { folder: "Gold downloads", levels: ["gold"] },
173
+ { folder: "Member downloads", levels: [] }, // any signed-in member
174
+ { folder: "Supporter extras", features: ["supporter"] },
175
+ { folder: "Story files", entries: true }, // whoever can read an entry that uses it
176
+ ],
177
+ ```
178
+
179
+ Upload files into a folder in the EmDash media library. Folder names match without regard to case. Every request for a file in a protected folder at `/_emdash/api/media/file/…` is checked before EmDash serves it:
180
+
181
+ - **Allowed:** the file, sent with `Cache-Control: private, no-store`.
182
+ - **Signed out:** a 401 page with a sign-in link that comes back to the file.
183
+ - **Signed in without access:** a 403 page.
184
+
185
+ A rule lets in members who hold any of its `levels` (an empty list means any signed-in member) or `features`. With `entries: true`, it also lets in whoever can read a published entry that uses the file: its rule, its drip, and the levels picked on its Paywall break or Members only parts. Staff can always download. Disabled members can't. If a file can't be checked, it isn't served.
186
+
187
+ Limits:
188
+
189
+ - Only EmDash's file route is checked. Don't give the storage bucket a public URL of its own.
190
+ - Image services that fetch the file without the reader's cookies, such as a CDN image resizer, get refused, so keep protected images out of resized `<Image>` output.
191
+ - Moving a file out of the folder makes it public again.
192
+
193
+ ### Replacement text
194
+
195
+ ```ts
196
+ text: {
197
+ "Sign in": "Log in",
198
+ "This is for members. {signIn} to read on.": "Members only. {signIn} to keep reading.",
199
+ "This is for {level} members.": "{level} members only.",
200
+ },
201
+ ```
202
+
203
+ Each key is a string's original English; its value is the new text. Only the strings you name change. There is no find-and-replace across strings, so replacing "Sign in" leaves "Sign in to download this file." alone: to change a word everywhere, replace each string that uses it. `STRINGS` in `@redeemid/emdash/text` lists every string, grouped by where it shows (account, badge, cart, wall, meter, member card, notices, plan, preferences, product, files).
204
+
205
+ Some strings have placeholders such as `{level}` or `{signIn}` (a link), filled in when the text shows. A replacement may use its original's placeholders, in any order or not at all, but no others. Text is plain: HTML in a replacement shows as typed. A replacement that uses another placeholder, or names a string RedeemID doesn't show, is ignored, so the original shows, and the Status page lists it with the reason.
206
+
207
+ Props win over `text`: a `label` or `title` you pass, or Notices `messages`, is used as given. Staff screens in the admin aren't covered.
208
+
138
209
  ### Events
139
210
 
140
211
  ```ts
@@ -149,12 +220,13 @@ With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to t
149
220
  | `member.levels_changed` | `customerId`, `levels`, `added`, `removed`, `source` (`sign-in`, the webhook topic, or a staff resync or tag edit) |
150
221
  | `meter.used` | `userId` (EmDash), `entry` (`collection:id`), `used`, `limit`, `period` (`2026-11`, or `2026-W45` by week) |
151
222
  | `meter.exhausted` | the same, sent with the last free read |
223
+ | `preferences.saved` | `customerId`, `fields` (the `namespace.key` names changed) |
152
224
 
153
225
  No event carries an email address or a name: match people by the Shopify `customerId`. `only` limits which events are sent; leave it out for all of them.
154
226
 
155
227
  ### Staff tools
156
228
 
157
- With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API token for the webhook app with the client credentials grant (the app must belong to the same Shopify organization as the store, as Dev Dashboard apps do) and needs the `read_customers` and `write_customers` scopes. Then, in the admin:
229
+ With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set (it also runs member preferences), RedeemID gets an Admin API token for the webhook app with the client credentials grant (the app must belong to the same Shopify organization as the store, as Dev Dashboard apps do) and needs the `read_customers` and `write_customers` scopes. Then, in the admin:
158
230
 
159
231
  - **Member detail.** Open a member from the Members page to see their levels, tags and features, resync them from Shopify, or add and remove tags. Tag edits are made in Shopify, then read back; nothing is granted on the EmDash side.
160
232
  - **Bulk resync.** Reads the 50 members synced longest ago from Shopify in one request. Run it again for the next 50. It never runs on its own.
@@ -29,7 +29,7 @@ RedeemID keeps tags and status current from webhooks. Point these topics at `htt
29
29
 
30
30
  Subscribe them from a Shopify app on the store (for example a Dev Dashboard app). If the app already sends webhooks to another site, add this URL as extra subscriptions. `customers/merge` needs the `read_customer_merge` scope. Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
31
31
 
32
- For the staff tools (member resync, tag edits written to Shopify, the Register webhooks button), give the same app the `read_customers` and `write_customers` Admin API scopes and set its **client ID** as well. RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization.
32
+ For the staff tools (member resync, tag edits written to Shopify, the Register webhooks button) and member preferences, give the same app the `read_customers` and `write_customers` Admin API scopes and set its **client ID** as well. RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization.
33
33
 
34
34
  ## 3. The site
35
35
 
@@ -57,4 +57,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
57
57
  3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
58
58
  4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
59
59
 
60
- If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails. Cart changes return `cart_added`, `cart_updated`, `discount_applied` or `discount_not_applicable`. Refresh membership returns `refreshed` once it has read the member's tags again.
60
+ If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails. Cart changes return `cart_added`, `cart_updated`, `discount_applied` or `discount_not_applicable`. Refresh membership returns `refreshed` once it has read the member's tags again. Preferences return `preferences_saved`, `preferences_refused` or `preferences_unavailable`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,6 +17,8 @@
17
17
  "./storefront": "./src/storefront.ts",
18
18
  "./features": "./src/features.ts",
19
19
  "./events": "./src/events.ts",
20
+ "./preferences": "./src/preferences.ts",
21
+ "./text": "./src/text.ts",
20
22
  "./plugin": "./src/plugin.ts",
21
23
  "./blocks": "./src/blocks/index.ts",
22
24
  "./components/*": "./src/components/*",
package/src/access.ts CHANGED
@@ -277,11 +277,11 @@ export function passes(held: string[], required: string[][]): boolean {
277
277
  * The requirement in words, for badges and admin pages: "Gold + Silver",
278
278
  * "Silver or Gold", "Signed in". `null` gives `open`.
279
279
  */
280
- export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free"): string {
280
+ export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free", signedIn = "Signed in"): string {
281
281
  if (required === null) return open;
282
282
  const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
283
283
  const parts = reduce(required).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
284
- return parts.length ? parts.join(" + ") : "Signed in";
284
+ return parts.length ? parts.join(" + ") : signedIn;
285
285
  }
286
286
 
287
287
  /**
@@ -18,7 +18,7 @@ const amounts = (node.amounts ?? "")
18
18
  <Contribution
19
19
  handle={handle}
20
20
  title={node.title?.trim() || undefined}
21
- label={node.label?.trim() || "Give"}
21
+ label={node.label?.trim() || undefined}
22
22
  amounts={amounts.length ? amounts : undefined}
23
23
  />
24
24
  )}
@@ -10,4 +10,4 @@ const { node } = Astro.props;
10
10
  const handle = (node.handle ?? "").trim();
11
11
  ---
12
12
 
13
- {handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} levels={Array.isArray(node.levels) ? node.levels : []} />}
13
+ {handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || undefined} levels={Array.isArray(node.levels) ? node.levels : []} />}
@@ -0,0 +1,12 @@
1
+ ---
2
+ /** The "Preferences" block: the signed-in member's preferences form. */
3
+ import Preferences from "../components/Preferences.astro";
4
+
5
+ interface Props {
6
+ node: { title?: string; label?: string };
7
+ }
8
+
9
+ const { node } = Astro.props;
10
+ ---
11
+
12
+ <Preferences title={node.title?.trim() || undefined} label={node.label?.trim() || undefined} />
@@ -6,6 +6,7 @@ import Marker from "./PaywallBreak.astro";
6
6
  import CollectionBlock from "./CollectionBlock.astro";
7
7
  import ContributionBlock from "./ContributionBlock.astro";
8
8
  import PlanBlock from "./PlanBlock.astro";
9
+ import PreferencesBlock from "./PreferencesBlock.astro";
9
10
  import ProductBlock from "./ProductBlock.astro";
10
11
 
11
12
  export const blockComponents = {
@@ -16,4 +17,5 @@ export const blockComponents = {
16
17
  "redeemid-product": ProductBlock,
17
18
  "redeemid-collection": CollectionBlock,
18
19
  "redeemid-contribution": ContributionBlock,
20
+ "redeemid-preferences": PreferencesBlock,
19
21
  };
@@ -4,6 +4,7 @@ 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";
@@ -12,6 +12,7 @@
12
12
  import { canRead, describe, entryRequirement, type Terms } from "../access.js";
13
13
  import { 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,13 +23,13 @@ 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 label = describe(required, config?.levels, free, text(config, "Signed in"));
32
33
  const locked = required !== null && !(await canRead(Astro, { collection, terms }, { required })).allowed;
33
34
  const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
34
35
  ---
@@ -40,6 +41,6 @@ const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
40
41
  >
41
42
  {locked && <span class="redeemid-badge__lock" aria-hidden="true">🔒&#xFE0E; </span>}
42
43
  {label}
43
- {locked && <span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"> (locked)</span>}
44
+ {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
45
  </span>
45
46
  )}
@@ -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>
@@ -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
  )}
@@ -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>
@@ -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>