@redeemid/emdash 0.0.27 → 0.0.29

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.
@@ -56,7 +56,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
56
56
  - `api/state` (GET): the visitor's own state.
57
57
 
58
58
  ```json
59
- { "apiVersion": "1.5.0", "version": "0.0.27", "signedIn": true, "linked": true,
59
+ { "apiVersion": "1.5.0", "version": "0.0.29", "signedIn": true, "linked": true,
60
60
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
61
61
  "meter": { "used": 1, "limit": 3, "period": "month" } }
62
62
  ```
package/docs/shop.md CHANGED
@@ -64,7 +64,14 @@ import type { APIRoute } from "astro";
64
64
  export const GET: APIRoute = async ({ locals, url }) => productSitemap(await siteConfig(locals), url, { productHref: "/shop/{handle}/" });
65
65
  ```
66
66
 
67
- Then submit it to search engines, or list it in robots.txt. A custom robots.txt in EmDash's SEO settings replaces the generated one, so keep both lines in it: `Sitemap: https://<your site>/sitemap.xml` and `Sitemap: https://<your site>/sitemap-products.xml`. If the store's Online Store channel is open too, search engines find two pages for each product; headless stores usually password-protect the Online Store or redirect it to the site.
67
+ Then submit it to search engines, or list it in robots.txt. A custom robots.txt in EmDash's SEO settings replaces the generated one, so keep both lines in it: `Sitemap: https://<your site>/sitemap.xml` and `Sitemap: https://<your site>/sitemap-products.xml`. If the store's Online Store channel is open too, search engines find two pages for each product: publish the [redirect theme](https://github.com/webmultipliers/redeemid-emdash-shopify-redirect-theme) on the store to send its pages to the site (below).
68
+
69
+ <a id="discount-links"></a>**The store's own pages and discount links.** The [RedeemID redirect theme](https://github.com/webmultipliers/redeemid-emdash-shopify-redirect-theme) is a Shopify theme that sends the store's online store pages to the site (`/products/gold` to `/shop/gold/`, with editable path rules), while checkout and customer accounts stay on Shopify. It passes two things along, and the guard middleware (`redeemidGuard()`) takes both off the URL before the page renders:
70
+
71
+ - `?discount=CODE`, from Shopify's discount links (`https://<store>/discount/CODE?redirect=/products/gold`). The code goes on the visitor's cart straight away, or waits in their session for the first cart made for them (an Add to cart or a plan's Join button). Shopify decides at checkout whether it applies.
72
+ - `?logged_in=true`, when the customer is signed in to the store. A visitor who isn't signed in on the site is sent through sign-in with `prompt=none` (`login?quiet=1`), so they come back signed in without a form, or still signed out if the store session has ended, with no notice either way.
73
+
74
+ You can link to any site page with `?discount=CODE` yourself, too.
68
75
 
69
76
  **Shoppers' agents.** Shopify runs a Storefront MCP server for every store at `https://<store domain>/api/mcp`, for AI agents that browse, build a cart and check out. Point shopping agents there; RedeemID's own tools are for staff.
70
77
 
@@ -27,11 +27,11 @@ The store must use the new customer accounts (Settings → Customer accounts). C
27
27
 
28
28
  RedeemID keeps tags and status current from webhooks sent by a Shopify app on the store (for example a Dev Dashboard app). Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
29
29
 
30
- Give the app the `read_customers` and `write_customers` Admin API scopes, plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then the Setup page (or **Webhooks → Setup**) registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
30
+ Give the app the `read_customers` and `write_customers` Admin API scopes (`write_customers` alone is enough, since a write scope includes its read scope), plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then the Setup page (or **Webhooks → Setup**) registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
31
31
 
32
32
  `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`
33
33
 
34
- Without the client ID, subscribe them to that URL yourself. If the app already sends webhooks to another site, add this URL as extra subscriptions.
34
+ Without the client ID, subscribe them to that URL yourself. If the app already sends webhooks to another site, add this URL as extra subscriptions. Subscriptions set in the app's own configuration (its Dev Dashboard version or `shopify.app.toml`) don't show in the Admin API, so Setup counts each of those topics as sent once a delivery of it arrives.
35
35
 
36
36
  The client ID and those scopes also run the staff tools (member resync and tag edits written to Shopify), member preferences, and the reads that keep tags in order (see [staff tools](staff.md#staff-tools)). RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization. For the **Also request erasure in Shopify** button on a member's page, add the optional `write_customer_data_erasure` scope.
37
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "Shopify commerce for EmDash sites: storefront, cart, checkout, customer accounts, memberships and paywalls. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/admin-api.ts CHANGED
@@ -28,6 +28,11 @@ export const WEBHOOK_TOPICS = [
28
28
  "CUSTOMER_TAGS_REMOVED",
29
29
  ] as const;
30
30
 
31
+ /** WEBHOOK_TOPICS name → the topic Shopify shows: CUSTOMER_TAGS_ADDED → customer.tags_added. */
32
+ export function topicName(t: string): string {
33
+ return t.toLowerCase().replace("_", "/").replace("customer/tags", "customer.tags");
34
+ }
35
+
31
36
  const MARGIN_MS = 5 * 60 * 1000;
32
37
  const tokens = new Map<string, { token: string; until: number; scope: string }>();
33
38
 
@@ -64,6 +69,17 @@ async function token(config: RedeemIDConfig, fetcher: typeof fetch): Promise<{ t
64
69
  return value;
65
70
  }
66
71
 
72
+ /**
73
+ * The scopes a token's `scope` string grants. Shopify lists only write_x
74
+ * when an app holds both, because a write scope includes its read scope.
75
+ */
76
+ export function grantedScopes(scope: string): string[] {
77
+ const listed = scope.split(",").map((t) => t.trim()).filter(Boolean);
78
+ const out = new Set(listed);
79
+ for (const t of listed) if (t.startsWith("write_")) out.add(`read_${t.slice("write_".length)}`);
80
+ return [...out];
81
+ }
82
+
67
83
  /** The scopes the Admin API token carries, or an error message. */
68
84
  export async function adminScopes(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<string> {
69
85
  return (await token(config, fetcher)).scope;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Visitors sent over from the store's own pages.
3
+ *
4
+ * A store that keeps its Online Store channel usually publishes a redirect
5
+ * theme, which sends its pages to the site with two extras:
6
+ *
7
+ * - `?discount=CODE`: Shopify's discount links (`/discount/CODE`) leave the
8
+ * code in a store cookie, and the theme passes it on. The code goes on the
9
+ * visitor's cart now, or is kept in their session and goes on the first
10
+ * cart made for them.
11
+ * - `?logged_in=true`: the customer is signed in to the store. A visitor who
12
+ * isn't signed in here is signed in without a form (`prompt=none`). If the
13
+ * store session has ended, they come back signed out, with no notice.
14
+ *
15
+ * Either way the visitor then lands on the same page without those
16
+ * parameters, so the page itself, and any cache in front of it, never sees
17
+ * them.
18
+ */
19
+
20
+ import { readCart, type CartRef } from "./cart.js";
21
+ import { env, ENV, type RedeemIDConfig } from "./config.js";
22
+ import { setDiscountCodes, type Cart } from "./storefront.js";
23
+
24
+ export const SESSION_DISCOUNT = "redeemid:discount";
25
+
26
+ /** Parameters the redirect theme adds, taken off before the page renders. */
27
+ export const ARRIVAL_PARAMS = ["discount", "logged_in", "company_location_id"] as const;
28
+
29
+ type Session = { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
30
+
31
+ /** A discount code as a link carries it, or null for anything that can't be one. */
32
+ export function discountCode(raw: string | null | undefined): string | null {
33
+ const code = (raw ?? "").trim();
34
+ if (!code || code.length > 255 || /[\u0000-\u001f\u007f]/.test(code)) return null;
35
+ return code;
36
+ }
37
+
38
+ /** The login route checks `return` again (safeReturn), so this stays free of site config. */
39
+ export function quietSignInUrl(returnTo: string): string {
40
+ return `/_emdash/api/auth/redeemid/login?quiet=1&return=${encodeURIComponent(returnTo)}`;
41
+ }
42
+
43
+ /**
44
+ * The response for a page request that carries the theme's parameters, or
45
+ * null when it carries none. Only GET and HEAD requests are touched.
46
+ */
47
+ export async function arrival(
48
+ context: { url: URL; request?: Request; session?: unknown },
49
+ config: RedeemIDConfig | null,
50
+ user: unknown,
51
+ deps: { setCodes?: typeof setDiscountCodes } = {},
52
+ ): Promise<Response | null> {
53
+ const { url } = context;
54
+ const method = context.request?.method ?? "GET";
55
+ if (method !== "GET" && method !== "HEAD") return null;
56
+ if (!ARRIVAL_PARAMS.some((p) => url.searchParams.has(p))) return null;
57
+
58
+ const session = context.session as Session;
59
+ const code = discountCode(url.searchParams.get("discount"));
60
+ if (code && session && config) {
61
+ const cart = await readCart(session as never).catch(() => null);
62
+ if (cart) {
63
+ const buyerIp = context.request?.headers.get("cf-connecting-ip") ?? null;
64
+ await (deps.setCodes ?? setDiscountCodes)(config, cart.id, [code], { buyerIp }).catch((e: Error) => {
65
+ console.error("[redeemid] discount link failed:", e.message);
66
+ });
67
+ } else {
68
+ session.set(SESSION_DISCOUNT, code);
69
+ }
70
+ }
71
+
72
+ const clean = new URL(url);
73
+ for (const p of ARRIVAL_PARAMS) clean.searchParams.delete(p);
74
+ // "//host" would leave the site.
75
+ const here = /^\/[\/\\]/.test(clean.pathname) ? "/" : `${clean.pathname}${clean.search}`;
76
+
77
+ const signIn = url.searchParams.get("logged_in") === "true" && !user && session && config && env(ENV.clientId);
78
+ const location = signIn ? quietSignInUrl(here) : here;
79
+ return new Response(null, { status: 302, headers: { location, "cache-control": "private, no-store" } });
80
+ }
81
+
82
+ /** The code a discount link left for the visitor's next cart, if any. */
83
+ export async function pendingDiscount(session: Session): Promise<string | null> {
84
+ return discountCode((await session?.get(SESSION_DISCOUNT)) as string | undefined);
85
+ }
86
+
87
+ /**
88
+ * After adding to a cart with a pending code: the code is used up. A new cart
89
+ * got it when it was made; a cart that already existed gets it now.
90
+ */
91
+ export async function usedDiscount(
92
+ session: Session,
93
+ config: RedeemIDConfig,
94
+ code: string | null,
95
+ cart: Cart,
96
+ before: CartRef | null,
97
+ opts: { buyerIp?: string | null } = {},
98
+ ): Promise<void> {
99
+ if (!code || !session) return;
100
+ if (session.delete) session.delete(SESSION_DISCOUNT);
101
+ else session.set(SESSION_DISCOUNT, undefined);
102
+ if (before && before.id === cart.id) await setDiscountCodes(config, cart.id, [code], opts).catch(() => null);
103
+ }
package/src/cart-ops.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * says the product or cart holds.
9
9
  */
10
10
 
11
+ import { pendingDiscount, usedDiscount } from "./arrivals.js";
11
12
  import { readCart, saveCart } from "./cart.js";
12
13
  import type { RedeemIDConfig } from "./config.js";
13
14
  import { memberForUser, stores } from "./storage.js";
@@ -104,12 +105,14 @@ export async function editCart(input: CartInput, ctx: CartContext): Promise<Cart
104
105
  const user = await sessionUser(session, ctx.db);
105
106
  const member = user && ctx.db ? await memberForUser(stores(ctx.db), user.id) : null;
106
107
  const current = await readCart(session);
108
+ const discount = await pendingDiscount(session);
107
109
  const cart = await addToCart(
108
110
  config,
109
111
  current?.id ?? null,
110
112
  { variantId: variant.id, sellingPlanId: plan, quantity },
111
- { email: member?.email ?? null, attributes: input.page ? { _redeemid_page: input.page } : {}, buyerIp, context },
113
+ { email: member?.email ?? null, attributes: input.page ? { _redeemid_page: input.page } : {}, buyerIp, context, discountCodes: discount ? [discount] : [] },
112
114
  );
115
+ await usedDiscount(session, config, discount, cart, current, { buyerIp });
113
116
  save(cart);
114
117
  return { notice: "cart_added", cart: null, base: cart, added: { product: p.handle, variant: variant.id, quantity, sellingPlan: Boolean(plan) } };
115
118
  }
package/src/guard.ts CHANGED
@@ -28,6 +28,7 @@
28
28
  import { defineMiddleware } from "astro:middleware";
29
29
 
30
30
  import { signInUrl } from "./access.js";
31
+ import { arrival } from "./arrivals.js";
31
32
  import { readCart } from "./cart.js";
32
33
  import { keysFromUrl, pathAccess, refusal } from "./files.js";
33
34
  import { keptOut, purgeRouteCache } from "./route-cache.js";
@@ -142,6 +143,9 @@ async function guard(context: Context, next: Next): Promise<Response> {
142
143
  }
143
144
 
144
145
  if (!url.pathname.startsWith("/_emdash")) {
146
+ // Sent over from the store's own pages, with a discount code or a store sign-in.
147
+ const arrived = await arrival(context, config, user);
148
+ if (arrived) return arrived;
145
149
  const own = Boolean(user) || Boolean(await readCart(context.session as never).catch(() => null));
146
150
  if (own) return keepPrivate(context, await next());
147
151
  const res = await next();
package/src/monitor.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { levelsFor, membership } from "./access.js";
16
- import { adminConfigured, registerWebhooks } from "./admin-api.js";
16
+ import { adminConfigured, registerWebhooks, topicName } from "./admin-api.js";
17
17
  import { env, ENV, type RedeemIDConfig } from "./config.js";
18
18
  import { LOG_DAYS, LOG_EVENTS, prune, record, type LogEntry } from "./log.js";
19
19
  import { RETRY_DELAYS_MS, type FailedEvent } from "./events.js";
@@ -193,10 +193,7 @@ export async function tidy(s: Stores): Promise<void> {
193
193
  await prune(s.requests, "at", REQUEST_DAYS, { status: "done" }).catch(() => 0);
194
194
  }
195
195
 
196
- /** WEBHOOK_TOPICS name → the topic Shopify shows: CUSTOMER_TAGS_ADDED → customer.tags_added. */
197
- export function topicName(t: string): string {
198
- return t.toLowerCase().replace("_", "/").replace("customer/tags", "customer.tags");
199
- }
196
+ export { topicName };
200
197
 
201
198
  // ---------------------------------------------------------------- Webhooks page
202
199
 
package/src/routes/buy.ts CHANGED
@@ -13,6 +13,7 @@ import type { APIRoute } from "astro";
13
13
  import { readConfig, storeDomain } from "../config.js";
14
14
  import { safeReturn } from "../access.js";
15
15
  import { memberForUser, stores } from "../storage.js";
16
+ import { pendingDiscount, usedDiscount } from "../arrivals.js";
16
17
  import { saveCart, readCart } from "../cart.js";
17
18
  import { checkoutFor } from "../checkout.js";
18
19
  import { addToCart, plan, storeContext } from "../storefront.js";
@@ -37,12 +38,15 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
37
38
  const user = await sessionUser(session, emdash?.db);
38
39
  const member = user && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
39
40
  const current = await readCart(session);
41
+ const discount = await pendingDiscount(session);
42
+ const buyerIp = request.headers.get("cf-connecting-ip");
40
43
  const cart = await addToCart(
41
44
  config,
42
45
  current?.id ?? null,
43
46
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
44
- { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip"), context },
47
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp, context, discountCodes: discount ? [discount] : [] },
45
48
  );
49
+ await usedDiscount(session, config, discount, cart, current, { buyerIp });
46
50
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
47
51
  return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: emdash?.config }), 303);
48
52
  } catch {
@@ -41,6 +41,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
41
41
  if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
42
42
 
43
43
  if (url.searchParams.get("error")) {
44
+ // The store session had ended after all: back to the page, still signed out.
45
+ if (flow?.quiet) return redirect(returnTo);
44
46
  // Refresh found no open store session: sign in as usual.
45
47
  if (flow?.refresh) return redirect(`/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(returnTo)}`);
46
48
  await refused("sign_in_cancelled", "ok");
@@ -5,6 +5,9 @@
5
5
  * With `refresh=1` it asks the store not to show a form (`prompt=none`):
6
6
  * a member whose store session is still open comes straight back with their
7
7
  * tags read again; anyone else is sent through the usual sign-in.
8
+ * With `quiet=1` it asks the same, but anyone without an open store session
9
+ * just goes back, still signed out: for visitors the store's redirect theme
10
+ * says are signed in there.
8
11
  */
9
12
 
10
13
  import type { APIRoute } from "astro";
@@ -20,11 +23,12 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
20
23
  const url = new URL(request.url);
21
24
  const returnTo = safeReturn(url.searchParams.get("return"));
22
25
  const refresh = url.searchParams.get("refresh") === "1";
26
+ const quiet = url.searchParams.get("quiet") === "1";
23
27
  const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
24
28
  const config = readConfig(emdash?.config);
25
29
  const clientId = env(ENV.clientId);
26
30
 
27
- if (!config || !storeDomain(config) || !clientId || !session) return fail(redirect, returnTo, "not_configured");
31
+ if (!config || !storeDomain(config) || !clientId || !session) return quiet ? redirect(returnTo) : fail(redirect, returnTo, "not_configured");
28
32
 
29
33
  try {
30
34
  const d = await discover(config);
@@ -35,6 +39,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
35
39
  returnTo,
36
40
  at: Date.now(),
37
41
  ...(refresh ? { refresh: true } : {}),
42
+ ...(quiet ? { quiet: true } : {}),
38
43
  };
39
44
  session.set(SESSION_FLOW, flow);
40
45
  return redirect(
@@ -45,11 +50,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
45
50
  nonce: flow.nonce,
46
51
  challenge: await challengeFor(flow.verifier),
47
52
  locale: request.headers.get("accept-language")?.split(",")[0]?.trim(),
48
- ...(refresh ? { prompt: "none" as const } : {}),
53
+ ...(refresh || quiet ? { prompt: "none" as const } : {}),
49
54
  }),
50
55
  );
51
56
  } catch (e) {
52
57
  console.error("[redeemid] sign-in start failed:", (e as Error).message);
53
- return fail(redirect, returnTo, "sign_in_unavailable");
58
+ return quiet ? redirect(returnTo) : fail(redirect, returnTo, "sign_in_unavailable");
54
59
  }
55
60
  };
@@ -24,6 +24,8 @@ export interface Flow {
24
24
  at: number;
25
25
  /** Started by Refresh membership (prompt=none). */
26
26
  refresh?: boolean;
27
+ /** Started because the store said the customer is signed in there: a refusal comes back with no notice and no form. */
28
+ quiet?: boolean;
27
29
  }
28
30
 
29
31
  export const CALLBACK_PATH = "/_emdash/api/auth/redeemid/callback";
package/src/setup.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * setupSteps() and setupPage() are pure.
17
17
  */
18
18
 
19
- import { adminConfigured, adminScopes, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
19
+ import { adminConfigured, adminScopes, grantedScopes, topicName, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
20
20
  import { env, ENV, levelTags, storeDomain, type RedeemIDConfig } from "./config.js";
21
21
  import { discover } from "./shopify.js";
22
22
  import type { Stores } from "./storage.js";
@@ -43,7 +43,12 @@ export interface SetupFacts {
43
43
  /** null without the app; "ok", or why the Admin API token was refused. */
44
44
  admin: string | null;
45
45
  scopes: string[];
46
- /** Topics not yet sent to this site, or null when that can't be checked. */
46
+ /**
47
+ * Topics not yet sent to this site, or null when that can't be checked.
48
+ * A topic counts as sent when the Admin API lists a subscription for it
49
+ * here, or when a delivery of it has arrived: subscriptions set in the
50
+ * app's own configuration don't show in the Admin API.
51
+ */
47
52
  webhooksMissing: string[] | null;
48
53
  levels: Array<{ name: string; tags: string[] }>;
49
54
  /** Members who have signed in on this site. */
@@ -74,10 +79,10 @@ export async function setupFacts(config: RedeemIDConfig, s: Stores, origin: stri
74
79
  let webhooksMissing: string[] | null = null;
75
80
  if (domain && adminConfigured()) {
76
81
  try {
77
- scopes = (await adminScopes(config)).split(",").map((t) => t.trim()).filter(Boolean);
82
+ scopes = grantedScopes(await adminScopes(config));
78
83
  admin = "ok";
79
- const subs = await webhookSubscriptions(config);
80
- webhooksMissing = WEBHOOK_TOPICS.filter((t) => !subs.some((h) => h.topic === t && h.uri === `${origin}${WEBHOOK}`));
84
+ const [subs, heard] = await Promise.all([webhookSubscriptions(config), heardTopics(s)]);
85
+ webhooksMissing = WEBHOOK_TOPICS.filter((t) => !subs.some((h) => h.topic === t && h.uri === `${origin}${WEBHOOK}`) && !heard.has(topicName(t)));
81
86
  } catch (e) {
82
87
  admin = (e as Error).message || "failed";
83
88
  }
@@ -106,6 +111,16 @@ export async function setupFacts(config: RedeemIDConfig, s: Stores, origin: stri
106
111
  };
107
112
  }
108
113
 
114
+ /** Topics of the latest deliveries, as Shopify names them (customers/update). */
115
+ async function heardTopics(s: Stores): Promise<Set<string>> {
116
+ try {
117
+ const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 200 });
118
+ return new Set(items.map((r) => r.data.topic.trim().toLowerCase()));
119
+ } catch {
120
+ return new Set();
121
+ }
122
+ }
123
+
109
124
  const missingScopes = (f: SetupFacts) => SETUP_SCOPES.filter((sc) => !f.scopes.includes(sc));
110
125
 
111
126
  /** The steps and where each one stands. Pure. */
@@ -201,7 +216,11 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
201
216
  const missing = f.webhooksMissing ?? [];
202
217
  return [
203
218
  ...out,
204
- { type: "section", text: `${missing.length} of ${WEBHOOK_TOPICS.length} topics aren't sent to this site yet. RedeemID can register them through the app.` },
219
+ { type: "section", text: `${missing.length} of ${WEBHOOK_TOPICS.length} topics aren't sent to this site yet: ${missing.map(topicName).join(", ")}. RedeemID can register them through the app.` },
220
+ {
221
+ type: "context",
222
+ text: "Webhooks set in the app's own configuration (Dev Dashboard version or shopify.app.toml) don't show in the Admin API. Each of those topics counts here once Shopify has sent one, so registering them again isn't needed.",
223
+ },
205
224
  { type: "actions", elements: [{ type: "button", action_id: "setup_register", label: `Register ${missing.length} webhooks`, style: "primary" }] },
206
225
  {
207
226
  type: "context",
package/src/storefront.ts CHANGED
@@ -232,6 +232,8 @@ export async function addToCart(
232
232
  fetcher?: typeof fetch;
233
233
  /** A new cart's country, for local prices. */
234
234
  context?: StoreContext;
235
+ /** Discount codes for a new cart. */
236
+ discountCodes?: string[];
235
237
  } = {},
236
238
  ): Promise<Cart> {
237
239
  const quantity = Math.max(1, Math.floor(line.quantity ?? 1));
@@ -248,6 +250,7 @@ export async function addToCart(
248
250
  if (Object.keys(identity).length) input.buyerIdentity = identity;
249
251
  const attributes = Object.entries(opts.attributes ?? {}).filter(([, v]) => v);
250
252
  if (attributes.length) input.attributes = attributes.map(([key, value]) => ({ key, value }));
253
+ if (opts.discountCodes?.length) input.discountCodes = opts.discountCodes;
251
254
  const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
252
255
  const cart = data.cartCreate.cart;
253
256
  if (!cart) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no cart.");
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.27";
1
+ export const VERSION = "0.0.29";