@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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/cart-edit: change the visitor's Shopify cart.
3
+ *
4
+ * Fields: `action` (add | update | remove | discount), `return`, and
5
+ * - add: `product` (handle), optional `variant` (must be one of that
6
+ * product's variants, read again from Shopify), `quantity` (1 to 1000),
7
+ * `then` = "checkout" to go straight to checkout;
8
+ * - update: `line` and `quantity` (0 removes the line);
9
+ * - remove: `line`;
10
+ * - discount: `code` (empty clears the codes).
11
+ *
12
+ * Prices, discounts and availability come from Shopify; nothing posted is
13
+ * trusted beyond the ids it names. Back to `return` with a notice:
14
+ * cart_added, cart_updated, discount_applied, discount_not_applicable,
15
+ * plan_unavailable, cart_empty or checkout_failed.
16
+ */
17
+
18
+ import type { APIRoute } from "astro";
19
+
20
+ import { safeReturn } from "../access.js";
21
+ import { readCart, saveCart } from "../cart.js";
22
+ import { readConfig, storeDomain } from "../config.js";
23
+ import { memberForUser, stores } from "../storage.js";
24
+ import { addToCart, cartDetails, product, removeLines, setDiscountCodes, updateLines } from "../storefront.js";
25
+ import { fail } from "./shared.js";
26
+
27
+ export const prerender = false;
28
+
29
+ const MAX_QUANTITY = 1000;
30
+
31
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
32
+ const form = await request.formData().catch(() => null);
33
+ const field = (k: string) => String(form?.get(k) ?? "").trim();
34
+ const returnTo = safeReturn(field("return") || "/");
35
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
36
+ const config = readConfig(emdash?.config);
37
+ if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
38
+ const buyerIp = request.headers.get("cf-connecting-ip");
39
+ const action = field("action");
40
+
41
+ try {
42
+ if (action === "add") {
43
+ const handle = field("product");
44
+ const quantity = Number.parseInt(field("quantity") || "1", 10);
45
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle) || !Number.isFinite(quantity) || quantity < 1 || quantity > MAX_QUANTITY) {
46
+ return fail(redirect, returnTo, "plan_unavailable");
47
+ }
48
+ const p = await product(config, handle);
49
+ const wanted = field("variant");
50
+ const variant = wanted ? p?.variants.find((v) => v.id === wanted) : p?.variants.find((v) => v.availableForSale);
51
+ if (!p || !variant?.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
52
+ const user = (await session?.get("user")) as { id?: string } | undefined;
53
+ const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
54
+ const current = await readCart(session);
55
+ const cart = await addToCart(
56
+ config,
57
+ current?.id ?? null,
58
+ { variantId: variant.id, sellingPlanId: p.sellingPlanId, quantity },
59
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp },
60
+ );
61
+ saveCart(session, { id: cart.id, count: cart.totalQuantity });
62
+ return field("then") === "checkout" ? redirect(cart.checkoutUrl, 303) : fail(redirect, returnTo, "cart_added");
63
+ }
64
+
65
+ const ref = await readCart(session);
66
+ if (!ref) return fail(redirect, returnTo, "cart_empty");
67
+ const cart = await cartDetails(config, ref.id, { buyerIp });
68
+ if (!cart) {
69
+ saveCart(session, null);
70
+ return fail(redirect, returnTo, "cart_empty");
71
+ }
72
+ const line = field("line");
73
+ const owns = cart.lines.some((l) => l.id === line);
74
+
75
+ if (action === "update" || action === "remove") {
76
+ if (!owns) return fail(redirect, returnTo, "cart_updated");
77
+ const quantity = action === "remove" ? 0 : Number.parseInt(field("quantity"), 10);
78
+ if (!Number.isFinite(quantity) || quantity < 0 || quantity > MAX_QUANTITY) return fail(redirect, returnTo, "cart_updated");
79
+ const next = quantity === 0 ? await removeLines(config, cart.id, [line], { buyerIp }) : await updateLines(config, cart.id, [{ id: line, quantity }], { buyerIp });
80
+ saveCart(session, { id: next.id, count: next.totalQuantity });
81
+ return fail(redirect, returnTo, "cart_updated");
82
+ }
83
+
84
+ if (action === "discount") {
85
+ const code = field("code").slice(0, 100);
86
+ const next = await setDiscountCodes(config, cart.id, code ? [code] : [], { buyerIp });
87
+ saveCart(session, { id: next.id, count: next.totalQuantity });
88
+ if (!code) return fail(redirect, returnTo, "cart_updated");
89
+ const applied = next.discountCodes.find((d) => d.code.toLowerCase() === code.toLowerCase());
90
+ return fail(redirect, returnTo, applied?.applicable ? "discount_applied" : "discount_not_applicable");
91
+ }
92
+
93
+ return fail(redirect, returnTo, "cart_updated");
94
+ } catch {
95
+ return fail(redirect, returnTo, "checkout_failed");
96
+ }
97
+ };
@@ -8,7 +8,8 @@
8
8
 
9
9
  import type { APIRoute } from "astro";
10
10
 
11
- import { env, ENV } from "../config.js";
11
+ import { env, ENV, readConfig } from "../config.js";
12
+ import { levelChange } from "../events.js";
12
13
  import { stores } from "../storage.js";
13
14
  import { apply, kindOf, verifyHmac } from "../webhooks.js";
14
15
 
@@ -17,7 +18,7 @@ export const prerender = false;
17
18
  const MAX_BYTES = 1024 * 1024;
18
19
 
19
20
  export const POST: APIRoute = async ({ request, locals }) => {
20
- const emdash = (locals as { emdash?: { db?: unknown } }).emdash;
21
+ const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
21
22
  const secret = env(ENV.webhookSecret);
22
23
  if (!emdash?.db || !secret) return new Response("Not configured", { status: 503 });
23
24
 
@@ -48,7 +49,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
48
49
  }
49
50
 
50
51
  try {
51
- const outcome = await apply(s, topic, payload);
52
+ const config = readConfig(emdash.config);
53
+ const outcome = await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic));
52
54
  return new Response(outcome, { status: 200 });
53
55
  } catch (e) {
54
56
  console.error("[redeemid] webhook failed:", topic, (e as Error).message);
package/src/staff.ts ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Staff tools in the admin (Block Kit): member detail with Resync and tag
3
+ * edits written to Shopify, a paced manual bulk resync, webhook
4
+ * registration and health, recent deliveries and the access test.
5
+ */
6
+
7
+ import { adminConfigured, adminScopes, editTags, registerWebhooks, resync, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
8
+ import { describe, levelsFor, membership, passes, requirement, termTree, type Terms } from "./access.js";
9
+ import type { RedeemIDConfig } from "./config.js";
10
+ import { levelChange } from "./events.js";
11
+ import { featuresFor } from "./features.js";
12
+ import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
13
+
14
+ /** Members resynced per click; well inside Shopify's query cost limits. */
15
+ export const BULK_BATCH = 50;
16
+
17
+ type Blocks = { blocks: unknown[]; toast?: { message: string; type: "success" | "error" | "info" } };
18
+
19
+ const date = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "—");
20
+
21
+ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Blocks["toast"]): Blocks {
22
+ const levels = config.levels ?? [];
23
+ const { current, past } = membership(m, levels);
24
+ const features = featuresFor(m, config.features, levels);
25
+ const featureNames = (config.features ?? []).filter((f) => features.includes(f.slug)).map((f) => f.name);
26
+ const name = [m.firstName, m.lastName].filter(Boolean).join(" ") || m.email || `Customer ${m.customerId}`;
27
+ return {
28
+ toast,
29
+ blocks: [
30
+ { type: "actions", elements: [{ type: "button", action_id: "members_back", label: "← All members", style: "secondary" }] },
31
+ { type: "header", text: name },
32
+ {
33
+ type: "fields",
34
+ fields: [
35
+ { label: "Email", value: m.email ?? "—" },
36
+ { label: "Shopify customer", value: m.customerId },
37
+ { label: "Account", value: m.status === "enabled" ? "On" : "Turned off in Shopify" },
38
+ { label: "Signed in here", value: m.userId ? `Yes, first on ${date(m.linkedAt)}` : "Not yet" },
39
+ { label: "Levels", value: current.map((l) => `${l.name} (since ${date(l.since)})`).join(", ") || "—" },
40
+ { label: "Levels before", value: past.map((l) => `${l.name} (${date(l.from)} to ${date(l.to)})`).join(", ") || "—" },
41
+ { label: "Features", value: featureNames.join(", ") || "—" },
42
+ { label: "Last synced", value: `${m.syncedAt} (${m.source || "unknown"})` },
43
+ ],
44
+ },
45
+ { type: "header", text: "Shopify tags" },
46
+ m.tags.length
47
+ ? {
48
+ type: "table",
49
+ page_action_id: "member_tags",
50
+ columns: [
51
+ { key: "tag", label: "Tag" },
52
+ { key: "since", label: "Since" },
53
+ { key: "remove", label: "", format: "element" },
54
+ ],
55
+ rows: m.tags.map((t) => ({
56
+ tag: t,
57
+ since: date(m.tagsSince?.[t.toLowerCase()]),
58
+ remove: adminConfigured()
59
+ ? {
60
+ type: "button",
61
+ action_id: "member_tag_remove",
62
+ label: "Remove in Shopify",
63
+ style: "danger",
64
+ value: { customerId: m.customerId, tag: t },
65
+ confirm: { title: "Remove this tag in Shopify?", text: `Removes "${t}" from the customer in Shopify. Access follows.`, confirm: "Remove", deny: "Cancel", style: "danger" },
66
+ }
67
+ : null,
68
+ })),
69
+ }
70
+ : { type: "context", text: "No tags." },
71
+ adminConfigured()
72
+ ? {
73
+ type: "form",
74
+ block_id: `tags:${m.customerId}`,
75
+ fields: [{ type: "text_input", action_id: "tags", label: "Add tags in Shopify (comma separated)", placeholder: "gold-member" }],
76
+ submit: { label: "Add in Shopify", action_id: "member_tag_add" },
77
+ }
78
+ : { type: "context", text: "Set the Admin API app's client ID to edit tags and resync from here." },
79
+ adminConfigured()
80
+ ? { type: "actions", elements: [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] }
81
+ : { type: "divider" },
82
+ { type: "context", text: "Tags are written to Shopify, then read back. Shopify stays the only source of access." },
83
+ ],
84
+ };
85
+ }
86
+
87
+ /** Resync the members synced longest ago, one batch per click. */
88
+ export async function bulkResync(config: RedeemIDConfig, s: Stores): Promise<{ done: number; missing: number; changed: number }> {
89
+ const page = await s.members.query({ orderBy: { syncedAt: "asc" }, limit: BULK_BATCH });
90
+ const ids = page.items.map((r) => r.data.customerId);
91
+ const before = new Map(page.items.map((r) => [r.data.customerId, [...r.data.tags]]));
92
+ const { updated, missing } = await resync(config, s, ids, "bulk resync");
93
+ let changed = 0;
94
+ for (const m of updated) {
95
+ const was = before.get(m.customerId) ?? [];
96
+ if (was.join("\n") !== m.tags.join("\n")) changed++;
97
+ await levelChange(config, was, m, "bulk resync");
98
+ }
99
+ return { done: updated.length, missing: missing.length, changed };
100
+ }
101
+
102
+ export async function resyncOne(config: RedeemIDConfig, s: Stores, customerId: string, source: string): Promise<MemberRecord | null> {
103
+ const before = await s.members.get(customerId);
104
+ const { updated } = await resync(config, s, [customerId], source);
105
+ const m = updated[0] ?? null;
106
+ if (m) await levelChange(config, before?.tags ?? null, m, source);
107
+ return m;
108
+ }
109
+
110
+ export async function tagEdit(config: RedeemIDConfig, s: Stores, customerId: string, change: "add" | "remove", raw: unknown): Promise<MemberRecord | null> {
111
+ const id = numericId(customerId);
112
+ if (!id) return null;
113
+ await editTags(config, id, change, cleanTags(raw));
114
+ return resyncOne(config, s, id, change === "add" ? "staff added tags" : "staff removed tags");
115
+ }
116
+
117
+ /** Webhooks: what Shopify sends to this site, and what's missing. */
118
+ export async function webhooksSection(config: RedeemIDConfig, uri: string): Promise<unknown[]> {
119
+ if (!adminConfigured()) {
120
+ return [{ type: "context", text: "Set the Admin API app's client ID to check and register webhooks from here. Until then, add them in the app's settings." }];
121
+ }
122
+ try {
123
+ const [subs, scope] = await Promise.all([webhookSubscriptions(config), adminScopes(config)]);
124
+ const rows = WEBHOOK_TOPICS.map((t) => {
125
+ const here = subs.find((s) => s.topic === t && s.uri === uri);
126
+ const elsewhere = subs.find((s) => s.topic === t && s.uri !== uri);
127
+ return { topic: t.toLowerCase().replace("_", "/").replace("customer/tags", "customer.tags"), state: here ? "Registered" : elsewhere ? `Goes to ${elsewhere.uri}` : "Missing" };
128
+ });
129
+ const missing = rows.filter((r) => r.state !== "Registered").length;
130
+ return [
131
+ { type: "fields", fields: [{ label: "Admin API", value: `Connected (${scope || "no scopes reported"})` }, { label: "Webhook address", value: uri }] },
132
+ { type: "table", page_action_id: "webhooks", columns: [{ key: "topic", label: "Topic" }, { key: "state", label: "Sends here", format: "badge" }], rows },
133
+ missing
134
+ ? { type: "actions", elements: [{ type: "button", action_id: "webhooks_register", label: `Register ${missing} missing`, style: "primary", value: uri }] }
135
+ : { type: "context", text: "Every topic RedeemID needs is sent here. customers/redact is set in the app's compliance settings." },
136
+ ];
137
+ } catch (e) {
138
+ return [{ type: "banner", variant: "alert", title: "Couldn't read webhooks from Shopify", description: (e as Error).message }];
139
+ }
140
+ }
141
+
142
+ export async function deliveriesSection(s: Stores): Promise<unknown[]> {
143
+ const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 20 });
144
+ return items.length
145
+ ? [
146
+ {
147
+ type: "table",
148
+ page_action_id: "deliveries",
149
+ columns: [
150
+ { key: "topic", label: "Topic", format: "code" },
151
+ { key: "at", label: "Received", format: "relative_time" },
152
+ { key: "id", label: "Delivery" },
153
+ ],
154
+ rows: items.map((r) => ({ topic: r.data.topic, at: r.data.receivedAt, id: r.id })),
155
+ },
156
+ ]
157
+ : [{ type: "context", text: "No webhooks received yet." }];
158
+ }
159
+
160
+ export { registerWebhooks };
161
+
162
+ /** "category:pro, tag:weekend" → { category: ["pro"], tag: ["weekend"] } */
163
+ export function parseTerms(raw: string): Terms {
164
+ const terms: Record<string, string[]> = {};
165
+ for (const part of raw.split(",")) {
166
+ const [tax, term] = part.split(":").map((x) => x.trim());
167
+ if (tax && term) (terms[tax] ??= []).push(term);
168
+ }
169
+ return terms;
170
+ }
171
+
172
+ /** The access test: what an entry needs, and whether a reader with these tags passes. */
173
+ export async function accessTest(config: RedeemIDConfig, values: Record<string, unknown> = {}): Promise<Blocks> {
174
+ const collection = String(values.collection ?? "").trim();
175
+ const termsRaw = String(values.terms ?? "").trim();
176
+ const tags = cleanTags(String(values.tags ?? ""));
177
+ const signedIn = values.signed_in !== false;
178
+ const levels = config.levels ?? [];
179
+ const form = {
180
+ type: "form",
181
+ block_id: "access_test",
182
+ fields: [
183
+ { type: "text_input", action_id: "collection", label: "Collection", placeholder: "posts", initial_value: collection },
184
+ { type: "text_input", action_id: "terms", label: "Terms (taxonomy:slug, comma separated)", placeholder: "category:investigations, tag:free-to-read", initial_value: termsRaw },
185
+ { type: "text_input", action_id: "tags", label: "Reader's Shopify tags (comma separated)", placeholder: "gold-member", initial_value: tags.join(", ") },
186
+ { type: "toggle", action_id: "signed_in", label: "Signed in", initial_value: signedIn },
187
+ ],
188
+ submit: { label: "Test", action_id: "access_test" },
189
+ };
190
+ const out: unknown[] = [
191
+ { type: "header", text: "Access test" },
192
+ { type: "context", text: "Check what an entry needs under the rules, and whether a reader with some Shopify tags may read it. Nothing is saved." },
193
+ form,
194
+ ];
195
+ if (collection) {
196
+ const terms = parseTerms(termsRaw);
197
+ const tree = await termTree(config.rules, collection, terms);
198
+ const required = requirement(config.rules, collection, terms, { combine: config.combine, tree });
199
+ const held = levelsFor(tags, levels);
200
+ const ok = required === null || (signedIn && passes(held, required));
201
+ out.push(
202
+ { type: "divider" },
203
+ {
204
+ type: "fields",
205
+ fields: [
206
+ { label: "Needs", value: describe(required, levels) },
207
+ { label: "Reader's levels", value: levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ") || "None" },
208
+ { label: "Result", value: ok ? "Can read" : signedIn ? "Locked: asked to upgrade" : "Locked: asked to sign in" },
209
+ ],
210
+ },
211
+ {
212
+ type: "banner",
213
+ variant: ok ? "default" : "alert",
214
+ title: ok ? "This reader can read it" : "This reader is kept out",
215
+ description: "A Paywall break with levels picked, a Members only part, drip and the meter can still change what a page shows.",
216
+ },
217
+ );
218
+ }
219
+ return { blocks: out };
220
+ }
package/src/storage.ts CHANGED
@@ -78,7 +78,7 @@ export interface DeliveryRecord {
78
78
  }
79
79
 
80
80
  export const STORAGE_CONFIG = {
81
- members: { indexes: ["email", "userId"] },
81
+ members: { indexes: ["email", "userId", "syncedAt"] },
82
82
  deliveries: { indexes: ["receivedAt"] },
83
83
  meter: { indexes: ["userId"] },
84
84
  };
package/src/storefront.ts CHANGED
@@ -27,6 +27,7 @@ const planCache = new Map<string, { at: number; value: Plan | null }>();
27
27
 
28
28
  export function resetStorefrontCache(): void {
29
29
  planCache.clear();
30
+ shopCache.clear();
30
31
  }
31
32
 
32
33
  export async function storefront<T>(
@@ -169,18 +170,304 @@ export async function getCart(
169
170
  export async function addToCart(
170
171
  config: RedeemIDConfig,
171
172
  cartId: string | null,
172
- line: { variantId: string; sellingPlanId?: string | null },
173
- opts: { email?: string | null; buyerIp?: string | null; fetcher?: typeof fetch } = {},
173
+ line: { variantId: string; sellingPlanId?: string | null; quantity?: number },
174
+ opts: {
175
+ email?: string | null;
176
+ /** Cart attributes for a new cart, e.g. where the buyer came from. Keys starting "_" are hidden at checkout. */
177
+ attributes?: Record<string, string>;
178
+ buyerIp?: string | null;
179
+ fetcher?: typeof fetch;
180
+ } = {},
174
181
  ): Promise<Cart> {
175
- const lines = [{ merchandiseId: line.variantId, quantity: 1, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }];
182
+ const quantity = Math.max(1, Math.floor(line.quantity ?? 1));
183
+ const lines = [{ merchandiseId: line.variantId, quantity, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }];
176
184
  if (cartId) {
177
185
  const data = await storefront<{ cartLinesAdd: CartResult }>(config, CART_ADD, { id: cartId, lines }, opts).catch(() => null);
178
186
  if (data?.cartLinesAdd.cart) return data.cartLinesAdd.cart;
179
187
  }
180
188
  const input: Record<string, unknown> = { lines };
181
189
  if (opts.email) input.buyerIdentity = { email: opts.email };
190
+ const attributes = Object.entries(opts.attributes ?? {}).filter(([, v]) => v);
191
+ if (attributes.length) input.attributes = attributes.map(([key, value]) => ({ key, value }));
182
192
  const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
183
193
  const cart = data.cartCreate.cart;
184
194
  if (!cart) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no cart.");
185
195
  return cart;
186
196
  }
197
+
198
+ // ---------------------------------------------------------------- shop
199
+
200
+ export interface Money {
201
+ amount: string;
202
+ currencyCode: string;
203
+ }
204
+
205
+ export interface Image {
206
+ url: string;
207
+ altText: string | null;
208
+ }
209
+
210
+ export interface Variant {
211
+ id: string;
212
+ title: string;
213
+ availableForSale: boolean;
214
+ price: Money;
215
+ }
216
+
217
+ export interface Product {
218
+ handle: string;
219
+ title: string;
220
+ description: string;
221
+ availableForSale: boolean;
222
+ image: Image | null;
223
+ variants: Variant[];
224
+ /** The first selling plan, for subscription-only products. */
225
+ sellingPlanId: string | null;
226
+ }
227
+
228
+ export interface ProductCard {
229
+ handle: string;
230
+ title: string;
231
+ availableForSale: boolean;
232
+ image: Image | null;
233
+ /** The lowest variant price. */
234
+ price: Money | null;
235
+ }
236
+
237
+ export interface Collection {
238
+ handle: string;
239
+ title: string;
240
+ description: string;
241
+ products: ProductCard[];
242
+ }
243
+
244
+ const PRODUCT_QUERY = `query Product($handle: String!) {
245
+ product(handle: $handle) {
246
+ handle title description availableForSale requiresSellingPlan
247
+ featuredImage { url altText }
248
+ variants(first: 50) { nodes { id title availableForSale price { amount currencyCode } } }
249
+ sellingPlanGroups(first: 1) { nodes { sellingPlans(first: 1) { nodes { id } } } }
250
+ }
251
+ }`;
252
+
253
+ const COLLECTION_QUERY = `query Collection($handle: String!, $first: Int!) {
254
+ collection(handle: $handle) {
255
+ handle title description
256
+ products(first: $first) {
257
+ nodes { handle title availableForSale featuredImage { url altText } priceRange { minVariantPrice { amount currencyCode } } }
258
+ }
259
+ }
260
+ }`;
261
+
262
+ const shopCache = new Map<string, { at: number; value: unknown }>();
263
+
264
+ async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
265
+ const hit = shopCache.get(key);
266
+ if (hit && Date.now() - hit.at < PLAN_TTL_MS) return hit.value as T;
267
+ const value = await load();
268
+ shopCache.set(key, { at: Date.now(), value });
269
+ return value;
270
+ }
271
+
272
+ /** A product with its variants, cached for five minutes. Null when Shopify has no such product. */
273
+ export async function product(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch): Promise<Product | null> {
274
+ return cached(`${shopOrigin(config)}|product|${handle}`, async () => {
275
+ type Data = {
276
+ product: {
277
+ handle: string;
278
+ title: string;
279
+ description: string;
280
+ availableForSale: boolean;
281
+ requiresSellingPlan: boolean;
282
+ featuredImage: Image | null;
283
+ variants: { nodes: Variant[] };
284
+ sellingPlanGroups: { nodes: Array<{ sellingPlans: { nodes: Array<{ id: string }> } }> };
285
+ } | null;
286
+ };
287
+ const { product: p } = await storefront<Data>(config, PRODUCT_QUERY, { handle }, { fetcher });
288
+ if (!p) return null;
289
+ return {
290
+ handle: p.handle,
291
+ title: p.title,
292
+ description: p.description,
293
+ availableForSale: p.availableForSale,
294
+ image: p.featuredImage ?? null,
295
+ variants: p.variants.nodes,
296
+ sellingPlanId: p.requiresSellingPlan ? (p.sellingPlanGroups.nodes[0]?.sellingPlans.nodes[0]?.id ?? null) : null,
297
+ };
298
+ });
299
+ }
300
+
301
+ /** A collection's first `first` products, cached for five minutes. Null when Shopify has no such collection. */
302
+ export async function collection(config: RedeemIDConfig, handle: string, first = 12, fetcher?: typeof fetch): Promise<Collection | null> {
303
+ const n = Math.max(1, Math.min(50, Math.floor(first)));
304
+ return cached(`${shopOrigin(config)}|collection|${handle}|${n}`, async () => {
305
+ type Data = {
306
+ collection: {
307
+ handle: string;
308
+ title: string;
309
+ description: string;
310
+ products: {
311
+ nodes: Array<{ handle: string; title: string; availableForSale: boolean; featuredImage: Image | null; priceRange: { minVariantPrice: Money } }>;
312
+ };
313
+ } | null;
314
+ };
315
+ const { collection: c } = await storefront<Data>(config, COLLECTION_QUERY, { handle, first: n }, { fetcher });
316
+ if (!c) return null;
317
+ return {
318
+ handle: c.handle,
319
+ title: c.title,
320
+ description: c.description,
321
+ products: c.products.nodes.map((p) => ({
322
+ handle: p.handle,
323
+ title: p.title,
324
+ availableForSale: p.availableForSale,
325
+ image: p.featuredImage ?? null,
326
+ price: p.priceRange?.minVariantPrice ?? null,
327
+ })),
328
+ };
329
+ });
330
+ }
331
+
332
+ /** Format Shopify money for display. */
333
+ export function money(m: Money | null | undefined, locale = "en-US"): string | null {
334
+ if (!m) return null;
335
+ const n = Number(m.amount);
336
+ if (!Number.isFinite(n)) return null;
337
+ return new Intl.NumberFormat(locale, { style: "currency", currency: m.currencyCode }).format(n);
338
+ }
339
+
340
+ // ---------------------------------------------------------------- cart details
341
+
342
+ export interface CartLine {
343
+ id: string;
344
+ quantity: number;
345
+ variantId: string;
346
+ variantTitle: string;
347
+ productTitle: string;
348
+ productHandle: string;
349
+ image: Image | null;
350
+ price: Money | null;
351
+ total: Money | null;
352
+ }
353
+
354
+ export interface CartDetails extends Cart {
355
+ lines: CartLine[];
356
+ subtotal: Money | null;
357
+ total: Money | null;
358
+ discountCodes: Array<{ code: string; applicable: boolean }>;
359
+ }
360
+
361
+ const CART_DETAIL_FIELDS = `${CART_FIELDS}
362
+ cost { subtotalAmount { amount currencyCode } totalAmount { amount currencyCode } }
363
+ discountCodes { code applicable }
364
+ lines(first: 100) { nodes {
365
+ id quantity
366
+ cost { totalAmount { amount currencyCode } }
367
+ merchandise { ... on ProductVariant { id title price { amount currencyCode } image { url altText } product { title handle featuredImage { url altText } } } }
368
+ } }`;
369
+
370
+ type RawCart = Cart & {
371
+ cost?: { subtotalAmount?: Money | null; totalAmount?: Money | null } | null;
372
+ discountCodes?: Array<{ code: string; applicable: boolean }>;
373
+ lines?: {
374
+ nodes: Array<{
375
+ id: string;
376
+ quantity: number;
377
+ cost?: { totalAmount?: Money | null } | null;
378
+ merchandise: {
379
+ id: string;
380
+ title: string;
381
+ price?: Money | null;
382
+ image?: Image | null;
383
+ product: { title: string; handle: string; featuredImage?: Image | null };
384
+ };
385
+ }>;
386
+ };
387
+ };
388
+
389
+ function details(c: RawCart): CartDetails {
390
+ return {
391
+ id: c.id,
392
+ checkoutUrl: c.checkoutUrl,
393
+ totalQuantity: c.totalQuantity,
394
+ subtotal: c.cost?.subtotalAmount ?? null,
395
+ total: c.cost?.totalAmount ?? null,
396
+ discountCodes: c.discountCodes ?? [],
397
+ lines: (c.lines?.nodes ?? []).map((l) => ({
398
+ id: l.id,
399
+ quantity: l.quantity,
400
+ variantId: l.merchandise.id,
401
+ variantTitle: l.merchandise.title,
402
+ productTitle: l.merchandise.product.title,
403
+ productHandle: l.merchandise.product.handle,
404
+ image: l.merchandise.image ?? l.merchandise.product.featuredImage ?? null,
405
+ price: l.merchandise.price ?? null,
406
+ total: l.cost?.totalAmount ?? null,
407
+ })),
408
+ };
409
+ }
410
+
411
+ type DetailResult = { cart: RawCart | null; userErrors: Array<{ message: string }> };
412
+
413
+ /** The cart with its lines, costs and discount codes. Null once Shopify no longer has it. */
414
+ export async function cartDetails(
415
+ config: RedeemIDConfig,
416
+ id: string,
417
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
418
+ ): Promise<CartDetails | null> {
419
+ const data = await storefront<{ cart: RawCart | null }>(config, `query Cart($id: ID!) { cart(id: $id) { ${CART_DETAIL_FIELDS} } }`, { id }, opts);
420
+ return data.cart ? details(data.cart) : null;
421
+ }
422
+
423
+ function result(r: DetailResult): CartDetails {
424
+ if (!r.cart) throw new Error(r.userErrors[0]?.message ?? "Shopify returned no cart.");
425
+ return details(r.cart);
426
+ }
427
+
428
+ /** Set line quantities; a quantity of 0 removes the line. */
429
+ export async function updateLines(
430
+ config: RedeemIDConfig,
431
+ cartId: string,
432
+ lines: Array<{ id: string; quantity: number }>,
433
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
434
+ ): Promise<CartDetails> {
435
+ const data = await storefront<{ cartLinesUpdate: DetailResult }>(
436
+ config,
437
+ `mutation CartUpdate($id: ID!, $lines: [CartLineUpdateInput!]!) { cartLinesUpdate(cartId: $id, lines: $lines) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
438
+ { id: cartId, lines },
439
+ opts,
440
+ );
441
+ return result(data.cartLinesUpdate);
442
+ }
443
+
444
+ export async function removeLines(
445
+ config: RedeemIDConfig,
446
+ cartId: string,
447
+ lineIds: string[],
448
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
449
+ ): Promise<CartDetails> {
450
+ const data = await storefront<{ cartLinesRemove: DetailResult }>(
451
+ config,
452
+ `mutation CartRemove($id: ID!, $lineIds: [ID!]!) { cartLinesRemove(cartId: $id, lineIds: $lineIds) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
453
+ { id: cartId, lineIds },
454
+ opts,
455
+ );
456
+ return result(data.cartLinesRemove);
457
+ }
458
+
459
+ /** Replace the cart's discount codes (an empty list clears them). */
460
+ export async function setDiscountCodes(
461
+ config: RedeemIDConfig,
462
+ cartId: string,
463
+ codes: string[],
464
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
465
+ ): Promise<CartDetails> {
466
+ const data = await storefront<{ cartDiscountCodesUpdate: DetailResult }>(
467
+ config,
468
+ `mutation CartDiscount($id: ID!, $codes: [String!]) { cartDiscountCodesUpdate(cartId: $id, discountCodes: $codes) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
469
+ { id: cartId, codes },
470
+ opts,
471
+ );
472
+ return result(data.cartDiscountCodesUpdate);
473
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.7";
1
+ export const VERSION = "0.0.9";