@redeemid/emdash 0.0.7 → 0.0.9

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.7, 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 are covered by an end-to-end test against a fake store.
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.
6
6
 
7
7
  ## What it does
8
8
 
@@ -19,7 +19,10 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
19
19
  - **Members only block.** For a members part in the middle of a body: "Members only" (pick the levels) and "Members only: end". Everyone else sees the wall in its place and reads on after it.
20
20
  - **The wall.** Readers kept out see why and what to do next: sign in, or join, with each level's own wall text. `<Notices>` shows the messages sign-in, Buy, Cart and Account send readers back with.
21
21
  - **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
22
- - **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels), a Status page (secrets, store discovery, the URLs to give Shopify, levels and rules) and a dashboard widget.
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
+ - **Features.** Name perks members hold through levels or tags (ad-free, a supporter thank-you) and show them with `<Feature>` or `hasFeature()`.
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
+ - **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.
23
26
  - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
24
27
 
25
28
  ## Install
@@ -75,6 +78,8 @@ Set these secrets in the environment (`.env` locally, `wrangler secret put` on W
75
78
  | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Customer Account API | Client ID of the Headless storefront's Customer Account API client |
76
79
  | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Customer Account API | Its client secret. Confidential clients only; leave unset for a public client. |
77
80
  | `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks (Admin API app) | Client secret of the Shopify app that sends the webhooks. Shopify signs each webhook with it. |
81
+ | `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Admin API (staff tools) | Client ID of that same app. Optional; with it, staff can resync members, edit tags in Shopify and register webhooks. |
82
+ | `REDEEMID_EVENTS_SECRET` | None | Optional. Signs outbound events; set it with `events.url`. |
78
83
  | `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Storefront API | Private access token, for the plan cards and checkout (server-side). |
79
84
  | `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Storefront API | Public access token. Only used when the private token isn't set. |
80
85
 
@@ -110,6 +115,55 @@ The meter lets a signed-in reader who can't read an entry open it anyway, `limit
110
115
 
111
116
  Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had its tag (for a `match: "all"` level, the last of its tags); with several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives from sign-in and webhooks, and forgets a tag when Shopify removes it. The wall shows the date the entry opens.
112
117
 
118
+ ### Features
119
+
120
+ ```ts
121
+ features: [
122
+ { slug: "ad-free", name: "Ad-free", levels: ["gold", "premium"] },
123
+ { slug: "supporter", name: "Supporter", tags: ["supporter"] },
124
+ ],
125
+ ```
126
+
127
+ A member holds a feature through any of its levels or tags; like levels, features come only from the mirrored tags. `<MemberCard>` lists them under the member's levels.
128
+
129
+ ```astro
130
+ ---
131
+ import Feature from "@redeemid/emdash/components/Feature.astro";
132
+ import { hasFeature } from "@redeemid/emdash/features";
133
+ ---
134
+ <Feature slug="supporter"><p>Thanks for your support.</p><p slot="else">Become a supporter.</p></Feature>
135
+ {!(await hasFeature(Astro, "ad-free")) && <aside class="ad">…</aside>}
136
+ ```
137
+
138
+ ### Events
139
+
140
+ ```ts
141
+ events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
142
+ ```
143
+
144
+ With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL. Headers: `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body with the secret>`. Check the signature before you trust a body. Sending waits at most 3 seconds and never breaks the page; a failed send is logged and dropped, not retried.
145
+
146
+ | Event | `data` |
147
+ | --- | --- |
148
+ | `member.signed_in` | `customerId`, `levels`, `first` (first sign-in), `refresh` |
149
+ | `member.levels_changed` | `customerId`, `levels`, `added`, `removed`, `source` (`sign-in`, the webhook topic, or a staff resync or tag edit) |
150
+ | `meter.used` | `userId` (EmDash), `entry` (`collection:id`), `used`, `limit`, `period` (`2026-11`, or `2026-W45` by week) |
151
+ | `meter.exhausted` | the same, sent with the last free read |
152
+
153
+ 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
+
155
+ ### Staff tools
156
+
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:
158
+
159
+ - **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
+ - **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.
161
+ - **Webhooks.** The Status page lists the store's subscriptions to this site and registers any that are missing (`customers/redact` stays in the app's own settings).
162
+ - **Recent deliveries.** The last webhooks the site took, with their topics and times.
163
+ - **Access test.** Enter a collection, terms and a reader's tags to see what the entry needs and whether that reader gets in.
164
+
165
+ Resync updates email, name and tags. Status stays with the enable and disable webhooks.
166
+
113
167
  ## Use it in templates
114
168
 
115
169
  EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
@@ -168,6 +222,34 @@ import Plan from "@redeemid/emdash/components/Plan.astro";
168
222
  </Plan>
169
223
  ```
170
224
 
225
+ ### Shop
226
+
227
+ Products, collections and the cart come from the Storefront API; Shopify works out prices, discounts, taxes and stock.
228
+
229
+ ```astro
230
+ ---
231
+ import Product from "@redeemid/emdash/components/Product.astro";
232
+ import Collection from "@redeemid/emdash/components/Collection.astro";
233
+ import CartView from "@redeemid/emdash/components/CartView.astro";
234
+ import Contribution from "@redeemid/emdash/components/Contribution.astro";
235
+ ---
236
+ <!-- /shop/ -->
237
+ <Collection handle="shop" productHref="/shop/{handle}/" showTitle />
238
+ <!-- /shop/[handle]/ -->
239
+ <Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
240
+ <!-- /cart/ -->
241
+ <CartView productHref="/shop/{handle}/" />
242
+ <!-- anywhere -->
243
+ <Contribution handle="support" amounts={[5, 10, 25]} />
244
+ ```
245
+
246
+ - `<Product>`: image, title, price, description, the variants (sold-out ones shown but not choosable), a quantity, Add to cart (back to the page with the `cart_added` notice) and Buy now (straight to checkout).
247
+ - `<Collection>`: the collection's products (`limit`, up to 50) as cards linking to `productHref`.
248
+ - `<CartView>`: each line with its price, a quantity to change (0 removes it) and Remove, a discount code field, the subtotal and total, and Checkout. Pages with it are sent `Cache-Control: private, no-store`.
249
+ - `<Contribution>`: pay what you want. Make a Shopify product priced at 1.00; the amount the reader picks becomes the quantity, so Shopify charges exactly that.
250
+
251
+ The forms post to `/_emdash/api/auth/redeemid/cart-edit`, which reads every product and variant from Shopify again and only changes lines in the visitor's own cart. A new cart carries the `_redeemid_page` attribute (the page the first item was added on), which shows on the order in Shopify admin; Shopify hides attributes starting with `_` at checkout. Editors get the same with the Product, Collection and Contribution blocks.
252
+
171
253
  Or ask directly:
172
254
 
173
255
  ```ts
@@ -29,6 +29,8 @@ 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.
33
+
32
34
  ## 3. The site
33
35
 
34
36
  Add the package and config as in the [README](../README.md#install), with `shop` set to the store's domain. A custom customer-account domain works as well as `*.myshopify.com`.
@@ -40,6 +42,8 @@ npx wrangler secret put REDEEMID_SHOPIFY_STORE_DOMAIN # e.g. m
40
42
  npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID # Customer Account API client ID
41
43
  npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET # confidential clients only
42
44
  npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the webhook app's client secret
45
+ npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID # optional; the webhook app's client ID, for the staff tools
46
+ npx wrangler secret put REDEEMID_EVENTS_SECRET # optional; signs outbound events (events.url)
43
47
  npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN # Headless storefront's Storefront API private token (plans, checkout)
44
48
  npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN # optional; used only without the private token
45
49
  ```
@@ -53,4 +57,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
53
57
  3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
54
58
  4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
55
59
 
56
- 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. 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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",
@@ -15,6 +15,8 @@
15
15
  "./access": "./src/access.ts",
16
16
  "./guard.ts": "./src/guard.ts",
17
17
  "./storefront": "./src/storefront.ts",
18
+ "./features": "./src/features.ts",
19
+ "./events": "./src/events.ts",
18
20
  "./plugin": "./src/plugin.ts",
19
21
  "./blocks": "./src/blocks/index.ts",
20
22
  "./components/*": "./src/components/*",
package/src/access.ts CHANGED
@@ -469,7 +469,13 @@ export async function readEntry(
469
469
  const used = consume(record.items, `${entry.collection}:${entry.id}`, meter.limit);
470
470
  const info = { limit: meter.limit, period };
471
471
  if (!used.allowed) return { ...verdict, meterLeft: 0, meter: info };
472
- if (used.items !== record.items) await s.meter.put(key, { ...record, items: used.items });
472
+ if (used.items !== record.items) {
473
+ await s.meter.put(key, { ...record, items: used.items });
474
+ const { emit } = await import("./events.js");
475
+ const data = { userId: user.id, entry: `${entry.collection}:${entry.id}`, used: used.items.length, limit: meter.limit, period: record.period };
476
+ await emit(config, "meter.used", data);
477
+ if (used.left === 0) await emit(config, "meter.exhausted", data);
478
+ }
473
479
  return { ...verdict, allowed: true, reason: "metered", meterLeft: used.left, meter: info };
474
480
  } catch {
475
481
  return verdict;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Shopify Admin API, for staff tools only: resync a member, write tags to
3
+ * Shopify, register webhooks. The token comes from the client credentials
4
+ * grant of the Shopify app whose webhooks the site receives
5
+ * (REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID + _CLIENT_SECRET). Tokens last about
6
+ * a day; this keeps one per store in memory until shortly before expiry.
7
+ *
8
+ * Nothing here grants access: tag edits go to Shopify, and the mirror only
9
+ * changes from what Shopify answers.
10
+ */
11
+
12
+ import { env, ENV, shopOrigin, type RedeemIDConfig } from "./config.js";
13
+ import { USER_AGENT } from "./shopify.js";
14
+ import { cleanTags, emptyMember, stampTags, type MemberRecord, type Stores } from "./storage.js";
15
+
16
+ export const ADMIN_API_VERSION = "2026-10";
17
+
18
+ /** Webhook topics RedeemID needs, as the Admin API names them. customers/redact is set in the app's config, not here. */
19
+ export const WEBHOOK_TOPICS = [
20
+ "CUSTOMERS_CREATE",
21
+ "CUSTOMERS_UPDATE",
22
+ "CUSTOMERS_DELETE",
23
+ "CUSTOMERS_DISABLE",
24
+ "CUSTOMERS_ENABLE",
25
+ "CUSTOMERS_MERGE",
26
+ "CUSTOMER_TAGS_ADDED",
27
+ "CUSTOMER_TAGS_REMOVED",
28
+ ] as const;
29
+
30
+ const MARGIN_MS = 5 * 60 * 1000;
31
+ const tokens = new Map<string, { token: string; until: number; scope: string }>();
32
+
33
+ export function adminConfigured(): boolean {
34
+ return Boolean(env(ENV.adminClientId) && env(ENV.webhookSecret));
35
+ }
36
+
37
+ export function resetAdminToken(): void {
38
+ tokens.clear();
39
+ }
40
+
41
+ export class AdminError extends Error {}
42
+
43
+ async function token(config: RedeemIDConfig, fetcher: typeof fetch): Promise<{ token: string; scope: string }> {
44
+ const origin = shopOrigin(config);
45
+ const hit = tokens.get(origin);
46
+ if (hit && hit.until > Date.now()) return hit;
47
+ const clientId = env(ENV.adminClientId);
48
+ const secret = env(ENV.webhookSecret);
49
+ if (!clientId || !secret) throw new AdminError(`Set ${ENV.adminClientId} and ${ENV.webhookSecret} to use the staff tools.`);
50
+ const res = await fetcher(`${origin}/admin/oauth/access_token`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": USER_AGENT },
53
+ body: new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: secret }).toString(),
54
+ });
55
+ const body = (await res.json().catch(() => ({}))) as { access_token?: string; expires_in?: number; scope?: string; error?: string };
56
+ if (!res.ok || !body.access_token) {
57
+ // shop_not_permitted: the app and the store are not in the same Shopify organization.
58
+ throw new AdminError(`Shopify refused the Admin API token (${body.error ?? res.status}).`);
59
+ }
60
+ const life = (body.expires_in && body.expires_in > 0 ? body.expires_in : 3600) * 1000;
61
+ const value = { token: body.access_token, until: Date.now() + Math.max(60_000, life - MARGIN_MS), scope: body.scope ?? "" };
62
+ tokens.set(origin, value);
63
+ return value;
64
+ }
65
+
66
+ /** The scopes the Admin API token carries, or an error message. */
67
+ export async function adminScopes(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<string> {
68
+ return (await token(config, fetcher)).scope;
69
+ }
70
+
71
+ export async function admin<T>(config: RedeemIDConfig, query: string, variables: Record<string, unknown> = {}, fetcher: typeof fetch = fetch, retry = true): Promise<T> {
72
+ const t = await token(config, fetcher);
73
+ const res = await fetcher(`${shopOrigin(config)}/admin/api/${ADMIN_API_VERSION}/graphql.json`, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": USER_AGENT, "X-Shopify-Access-Token": t.token },
76
+ body: JSON.stringify({ query, variables }),
77
+ });
78
+ if (res.status === 401 && retry) {
79
+ tokens.delete(shopOrigin(config));
80
+ return admin(config, query, variables, fetcher, false);
81
+ }
82
+ const body = (await res.json().catch(() => ({}))) as { data?: T; errors?: Array<{ message?: string }> };
83
+ if (!res.ok || !body.data) throw new AdminError(body.errors?.[0]?.message ?? `Admin API request failed (${res.status}).`);
84
+ return body.data;
85
+ }
86
+
87
+ const gid = (id: string) => `gid://shopify/Customer/${id}`;
88
+
89
+ export interface AdminCustomer {
90
+ customerId: string;
91
+ email: string | null;
92
+ firstName: string | null;
93
+ lastName: string | null;
94
+ tags: string[];
95
+ updatedAt: number | null;
96
+ }
97
+
98
+ /** Up to 100 customers by numeric id. Customers Shopify no longer has are left out. */
99
+ export async function customers(config: RedeemIDConfig, ids: string[], fetcher: typeof fetch = fetch): Promise<AdminCustomer[]> {
100
+ if (ids.length === 0) return [];
101
+ type Node = { id: string; firstName: string | null; lastName: string | null; tags: string[]; updatedAt: string; defaultEmailAddress: { emailAddress: string | null } | null } | null;
102
+ const data = await admin<{ nodes: Node[] }>(
103
+ config,
104
+ `query RedeemIDCustomers($ids: [ID!]!) { nodes(ids: $ids) { ... on Customer { id firstName lastName tags updatedAt defaultEmailAddress { emailAddress } } } }`,
105
+ { ids: ids.slice(0, 100).map(gid) },
106
+ fetcher,
107
+ );
108
+ return data.nodes
109
+ .filter((n): n is NonNullable<Node> => Boolean(n?.id))
110
+ .map((n) => ({
111
+ customerId: n.id.replace(/^gid:\/\/shopify\/Customer\//, ""),
112
+ email: n.defaultEmailAddress?.emailAddress?.toLowerCase() ?? null,
113
+ firstName: n.firstName || null,
114
+ lastName: n.lastName || null,
115
+ tags: cleanTags(n.tags),
116
+ updatedAt: Number.isFinite(Date.parse(n.updatedAt)) ? Date.parse(n.updatedAt) : null,
117
+ }));
118
+ }
119
+
120
+ /**
121
+ * Read members from Shopify again and update the mirror: profile and tags.
122
+ * Status stays with the enable/disable webhooks. Members Shopify no longer
123
+ * has are reported in `missing` and left alone.
124
+ */
125
+ export async function resync(config: RedeemIDConfig, s: Stores, ids: string[], source = "resync", fetcher: typeof fetch = fetch): Promise<{ updated: MemberRecord[]; missing: string[] }> {
126
+ const found = await customers(config, ids, fetcher);
127
+ const updated: MemberRecord[] = [];
128
+ for (const c of found) {
129
+ const m = (await s.members.get(c.customerId)) ?? emptyMember(c.customerId);
130
+ m.email = c.email ?? m.email;
131
+ m.firstName = c.firstName ?? m.firstName;
132
+ m.lastName = c.lastName ?? m.lastName;
133
+ m.tags = c.tags;
134
+ m.profileUpdatedAt = c.updatedAt ?? m.profileUpdatedAt;
135
+ m.syncedAt = new Date().toISOString();
136
+ m.source = source;
137
+ await s.members.put(c.customerId, stampTags(m));
138
+ updated.push(m);
139
+ }
140
+ const seen = new Set(found.map((c) => c.customerId));
141
+ return { updated, missing: ids.filter((id) => !seen.has(id)) };
142
+ }
143
+
144
+ /** Add or remove tags on the Shopify customer. Shopify then sends the tag webhook; callers resync to show it now. */
145
+ export async function editTags(config: RedeemIDConfig, customerId: string, change: "add" | "remove", tags: string[], fetcher: typeof fetch = fetch): Promise<void> {
146
+ const clean = cleanTags(tags);
147
+ if (clean.length === 0) return;
148
+ const m = change === "add" ? "tagsAdd" : "tagsRemove";
149
+ const data = await admin<Record<string, { userErrors: Array<{ message: string }> }>>(
150
+ config,
151
+ `mutation RedeemIDTags($id: ID!, $tags: [String!]!) { ${m}(id: $id, tags: $tags) { userErrors { field message } } }`,
152
+ { id: gid(customerId), tags: clean },
153
+ fetcher,
154
+ );
155
+ const err = data[m]?.userErrors[0];
156
+ if (err) throw new AdminError(err.message);
157
+ }
158
+
159
+ export interface Subscription {
160
+ id: string;
161
+ topic: string;
162
+ uri: string;
163
+ }
164
+
165
+ export async function webhookSubscriptions(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<Subscription[]> {
166
+ type Node = { id: string; topic: string; uri?: string | null };
167
+ const data = await admin<{ webhookSubscriptions: { nodes: Node[] } }>(
168
+ config,
169
+ `query RedeemIDWebhooks { webhookSubscriptions(first: 100) { nodes { id topic uri } } }`,
170
+ {},
171
+ fetcher,
172
+ );
173
+ return data.webhookSubscriptions.nodes.map((n) => ({ id: n.id, topic: n.topic, uri: n.uri ?? "" }));
174
+ }
175
+
176
+ /** Register the topics RedeemID needs that no subscription sends to `uri` yet. Returns the topics it added. */
177
+ export async function registerWebhooks(config: RedeemIDConfig, uri: string, fetcher: typeof fetch = fetch): Promise<string[]> {
178
+ const have = await webhookSubscriptions(config, fetcher);
179
+ const missing = WEBHOOK_TOPICS.filter((t) => !have.some((h) => h.topic === t && h.uri === uri));
180
+ for (const topic of missing) {
181
+ const data = await admin<{ webhookSubscriptionCreate: { userErrors: Array<{ message: string }> } }>(
182
+ config,
183
+ `mutation RedeemIDWebhook($topic: WebhookSubscriptionTopic!, $uri: String!) { webhookSubscriptionCreate(topic: $topic, webhookSubscription: { uri: $uri, format: JSON }) { userErrors { field message } } }`,
184
+ { topic, uri },
185
+ fetcher,
186
+ );
187
+ const err = data.webhookSubscriptionCreate.userErrors[0];
188
+ if (err) throw new AdminError(`${topic}: ${err.message}`);
189
+ }
190
+ return [...missing];
191
+ }
@@ -0,0 +1,22 @@
1
+ ---
2
+ /** The "Collection" block: a Shopify collection's products as cards. */
3
+ import Collection from "../components/Collection.astro";
4
+
5
+ interface Props {
6
+ node: { handle?: string; limit?: string; link?: string };
7
+ }
8
+
9
+ const { node } = Astro.props;
10
+ const handle = (node.handle ?? "").trim();
11
+ const limit = Number.parseInt(node.limit ?? "", 10);
12
+ const link = (node.link ?? "").trim();
13
+ ---
14
+
15
+ {handle && (
16
+ <Collection
17
+ handle={handle}
18
+ limit={Number.isFinite(limit) ? limit : 12}
19
+ productHref={link.startsWith("/") && link.includes("{handle}") ? link : undefined}
20
+ showTitle
21
+ />
22
+ )}
@@ -0,0 +1,24 @@
1
+ ---
2
+ /** The "Contribution" block: pay what you want with a 1.00 Shopify product. */
3
+ import Contribution from "../components/Contribution.astro";
4
+
5
+ interface Props {
6
+ node: { handle?: string; title?: string; label?: string; amounts?: string };
7
+ }
8
+
9
+ const { node } = Astro.props;
10
+ const handle = (node.handle ?? "").trim();
11
+ const amounts = (node.amounts ?? "")
12
+ .split(",")
13
+ .map((a) => Number.parseInt(a.trim(), 10))
14
+ .filter((a) => Number.isFinite(a) && a > 0 && a <= 1000);
15
+ ---
16
+
17
+ {handle && (
18
+ <Contribution
19
+ handle={handle}
20
+ title={node.title?.trim() || undefined}
21
+ label={node.label?.trim() || "Give"}
22
+ amounts={amounts.length ? amounts : undefined}
23
+ />
24
+ )}
@@ -0,0 +1,12 @@
1
+ ---
2
+ /** The "Product" block: a Shopify product with Add to cart and Buy now. */
3
+ import Product from "../components/Product.astro";
4
+
5
+ interface Props {
6
+ node: { handle?: string };
7
+ }
8
+
9
+ const handle = (Astro.props.node.handle ?? "").trim();
10
+ ---
11
+
12
+ {handle && <Product handle={handle} heading="h3" />}
@@ -3,11 +3,17 @@
3
3
  * EmDash from the plugin's `componentsEntry`.
4
4
  */
5
5
  import Marker from "./PaywallBreak.astro";
6
+ import CollectionBlock from "./CollectionBlock.astro";
7
+ import ContributionBlock from "./ContributionBlock.astro";
6
8
  import PlanBlock from "./PlanBlock.astro";
9
+ import ProductBlock from "./ProductBlock.astro";
7
10
 
8
11
  export const blockComponents = {
9
12
  "redeemid-paywall": Marker,
10
13
  "redeemid-members": Marker,
11
14
  "redeemid-members-end": Marker,
12
15
  "redeemid-plan": PlanBlock,
16
+ "redeemid-product": ProductBlock,
17
+ "redeemid-collection": CollectionBlock,
18
+ "redeemid-contribution": ContributionBlock,
13
19
  };
@@ -1,6 +1,9 @@
1
1
  /** Portable Text block types RedeemID adds to the editor. */
2
2
  export const PAYWALL_BLOCK = "redeemid-paywall";
3
3
  export const PLAN_BLOCK = "redeemid-plan";
4
+ export const PRODUCT_BLOCK = "redeemid-product";
5
+ export const COLLECTION_BLOCK = "redeemid-collection";
6
+ export const CONTRIBUTION_BLOCK = "redeemid-contribution";
4
7
  /** Starts a members-only part of the body; it runs to the next end block. */
5
8
  export const MEMBERS_BLOCK = "redeemid-members";
6
9
  export const MEMBERS_END_BLOCK = "redeemid-members-end";
@@ -0,0 +1,97 @@
1
+ ---
2
+ /**
3
+ * The visitor's Shopify cart for a cart page: each line with its price and
4
+ * a quantity to change or remove, a discount code, the totals, and
5
+ * Checkout. Shopify works out prices and discounts; changes go through
6
+ * the cart-edit route and come back here with a notice.
7
+ *
8
+ * <CartView />
9
+ */
10
+ import { cartUrl } from "../access.js";
11
+ import { readCart, saveCart } from "../cart.js";
12
+ import { siteConfig } from "../site-config.js";
13
+ import { cartDetails, money, type CartDetails } from "../storefront.js";
14
+
15
+ interface Props {
16
+ /** Link for each line's product; `{handle}` is replaced. */
17
+ productHref?: string;
18
+ /** Shown when the cart is empty. */
19
+ empty?: string;
20
+ }
21
+
22
+ const { productHref, empty = "Your cart is empty." } = Astro.props;
23
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
24
+ const ref = await readCart(Astro.session);
25
+ let cart: CartDetails | null = null;
26
+ let failed = false;
27
+ if (config && ref) {
28
+ try {
29
+ cart = await cartDetails(config, ref.id, { buyerIp: Astro.request.headers.get("cf-connecting-ip") });
30
+ if (!cart) saveCart(Astro.session, null);
31
+ else if (cart.totalQuantity !== ref.count) saveCart(Astro.session, { id: cart.id, count: cart.totalQuantity });
32
+ } catch {
33
+ failed = true;
34
+ }
35
+ }
36
+ // A cart page is the visitor's own.
37
+ Astro.response.headers.set("Cache-Control", "private, no-store");
38
+ const here = Astro.url.pathname;
39
+ const href = (h: string) => productHref?.replaceAll("{handle}", encodeURIComponent(h));
40
+ const action = "/_emdash/api/auth/redeemid/cart-edit";
41
+ const code = cart?.discountCodes[0];
42
+ ---
43
+
44
+ <section class="redeemid-cartview" data-redeemid-cart-count={cart?.totalQuantity ?? 0}>
45
+ {failed ? (
46
+ <p>Your cart isn't available right now. Try again in a few minutes.</p>
47
+ ) : !cart || cart.lines.length === 0 ? (
48
+ <p class="redeemid-cartview__empty">{empty}</p>
49
+ ) : (
50
+ <>
51
+ <ul class="redeemid-cartview__lines">
52
+ {cart.lines.map((l) => (
53
+ <li class="redeemid-cartview__line" data-redeemid-line={l.id}>
54
+ {l.image && <img src={l.image.url} alt={l.image.altText ?? ""} loading="lazy" />}
55
+ <div class="redeemid-cartview__item">
56
+ <p class="redeemid-cartview__name">
57
+ {href(l.productHandle) ? <a href={href(l.productHandle)}>{l.productTitle}</a> : l.productTitle}
58
+ {l.variantTitle && l.variantTitle !== "Default Title" && <span> · {l.variantTitle}</span>}
59
+ </p>
60
+ <p class="redeemid-cartview__price">{money(l.price)} each · {money(l.total)}</p>
61
+ <form method="post" action={action} class="redeemid-cartview__quantity">
62
+ <input type="hidden" name="action" value="update" />
63
+ <input type="hidden" name="line" value={l.id} />
64
+ <input type="hidden" name="return" value={here} />
65
+ <label for={`q-${l.id}`}>Quantity</label>
66
+ <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
+ </form>
69
+ <form method="post" action={action}>
70
+ <input type="hidden" name="action" value="remove" />
71
+ <input type="hidden" name="line" value={l.id} />
72
+ <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
+ </form>
75
+ </div>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ <form method="post" action={action} class="redeemid-cartview__discount">
80
+ <input type="hidden" name="action" value="discount" />
81
+ <input type="hidden" name="return" value={here} />
82
+ <label for="redeemid-discount">Discount code</label>
83
+ <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>}
86
+ </form>
87
+ <dl class="redeemid-cartview__totals">
88
+ <dt>Subtotal</dt>
89
+ <dd>{money(cart.subtotal)}</dd>
90
+ <dt>Total</dt>
91
+ <dd>{money(cart.total)}</dd>
92
+ </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>
95
+ </>
96
+ )}
97
+ </section>
@@ -0,0 +1,55 @@
1
+ ---
2
+ /**
3
+ * A Shopify collection as a grid of product cards: image, title, lowest
4
+ * price. Each card links to `productHref` with `{handle}` filled in, so the
5
+ * site can give every product its own page with <Product>.
6
+ *
7
+ * <Collection handle="shop" productHref="/shop/{handle}/" />
8
+ */
9
+ import { siteConfig } from "../site-config.js";
10
+ import { collection as loadCollection, money, type Collection } from "../storefront.js";
11
+
12
+ interface Props {
13
+ handle: string;
14
+ /** How many products, 1 to 50. */
15
+ limit?: number;
16
+ /** Link for each product; `{handle}` is replaced. Leave out for cards without links. */
17
+ productHref?: string;
18
+ /** Show the collection's title and description above the grid. */
19
+ showTitle?: boolean;
20
+ }
21
+
22
+ const { handle, limit = 12, productHref, showTitle = false } = Astro.props;
23
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
24
+ let c: Collection | null = null;
25
+ if (config) {
26
+ try {
27
+ c = await loadCollection(config, handle, limit);
28
+ } catch {
29
+ c = null;
30
+ }
31
+ }
32
+ const href = (h: string) => productHref?.replaceAll("{handle}", encodeURIComponent(h));
33
+ ---
34
+
35
+ {c && c.products.length > 0 ? (
36
+ <section class="redeemid-collection" data-redeemid-collection={c.handle}>
37
+ {showTitle && (
38
+ <>
39
+ <h2 class="redeemid-collection__title">{c.title}</h2>
40
+ {c.description && <p class="redeemid-collection__description">{c.description}</p>}
41
+ </>
42
+ )}
43
+ <ul class="redeemid-collection__grid">
44
+ {c.products.map((p) => (
45
+ <li class="redeemid-collection__item" data-redeemid-product={p.handle}>
46
+ {p.image && <img src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
47
+ <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
+ </li>
50
+ ))}
51
+ </ul>
52
+ </section>
53
+ ) : (
54
+ <slot />
55
+ )}