@redeemid/emdash 0.0.17 → 0.0.19
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 +50 -18
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/package.json +3 -1
- package/src/agents.ts +55 -0
- package/src/api-version.ts +6 -0
- package/src/blocks/CollectionBlock.astro +4 -1
- package/src/blocks/SearchBlock.astro +14 -0
- package/src/blocks/index.ts +2 -0
- package/src/blocks-names.ts +1 -0
- package/src/cart-ops.ts +164 -0
- package/src/catalog.ts +287 -0
- package/src/checkout.ts +73 -0
- package/src/client/redeemid.ts +563 -0
- package/src/components/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +11 -5
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +1 -0
- package/src/components/Collection.astro +22 -17
- package/src/components/Content.astro +3 -2
- package/src/components/LockedPanel.astro +4 -2
- package/src/components/Notices.astro +2 -27
- package/src/components/Product.astro +115 -24
- package/src/components/ProductGrid.astro +52 -0
- package/src/components/ProductSearch.astro +83 -0
- package/src/components/Recommendations.astro +45 -0
- package/src/components/RedeemIDClient.astro +45 -0
- package/src/config.ts +14 -0
- package/src/contract.ts +50 -0
- package/src/files.ts +39 -1
- package/src/guard.ts +10 -3
- package/src/index.ts +3 -0
- package/src/log.ts +10 -3
- package/src/notices.ts +29 -0
- package/src/plugin.ts +32 -2
- package/src/rate-limit.ts +7 -0
- package/src/routes/api/cart.ts +84 -0
- package/src/routes/api/search.ts +49 -0
- package/src/routes/api/shared.ts +4 -0
- package/src/routes/api/state.ts +69 -0
- package/src/routes/buy.ts +4 -3
- package/src/routes/callback.ts +4 -2
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/shared.ts +11 -0
- package/src/routes/webhook.ts +20 -2
- package/src/shopify.ts +37 -1
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -1
package/src/routes/cart-edit.ts
CHANGED
|
@@ -2,95 +2,44 @@
|
|
|
2
2
|
* POST /_emdash/api/auth/redeemid/cart-edit: change the visitor's Shopify cart.
|
|
3
3
|
*
|
|
4
4
|
* Fields: `action` (add | update | remove | discount), `return`, and
|
|
5
|
-
* - add: `product` (handle)
|
|
6
|
-
* product's
|
|
5
|
+
* - add: `product` (handle); the variant by `variant` (an id of that
|
|
6
|
+
* product's, read again from Shopify) or by one `option.<Name>` field per
|
|
7
|
+
* option (e.g. option.Size=M); `selling_plan` (one of the product's
|
|
8
|
+
* selling plans, or empty for one-time); `quantity` (1 to 1000);
|
|
7
9
|
* `then` = "checkout" to go straight to checkout;
|
|
8
10
|
* - update: `line` and `quantity` (0 removes the line);
|
|
9
11
|
* - remove: `line`;
|
|
10
12
|
* - discount: `code` (empty clears the codes).
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* plan_unavailable, cart_empty or checkout_failed.
|
|
14
|
+
* Back to `return` with a notice: cart_added, cart_updated,
|
|
15
|
+
* discount_applied, discount_not_applicable, plan_unavailable, cart_empty or
|
|
16
|
+
* checkout_failed. The JSON twin is api/cart.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import type { APIRoute } from "astro";
|
|
19
20
|
|
|
20
21
|
import { safeReturn } from "../access.js";
|
|
21
|
-
import {
|
|
22
|
+
import { cartInputFromForm, editCart } from "../cart-ops.js";
|
|
23
|
+
import { checkoutFor } from "../checkout.js";
|
|
22
24
|
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
25
|
import { fail } from "./shared.js";
|
|
26
26
|
|
|
27
27
|
export const prerender = false;
|
|
28
28
|
|
|
29
|
-
const MAX_QUANTITY = 1000;
|
|
30
|
-
|
|
31
29
|
export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
32
30
|
const form = await request.formData().catch(() => null);
|
|
33
|
-
const
|
|
34
|
-
const returnTo = safeReturn(field("return") || "/");
|
|
31
|
+
const returnTo = safeReturn(String(form?.get("return") ?? "").trim() || "/");
|
|
35
32
|
const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
|
|
36
33
|
const config = readConfig(emdash?.config);
|
|
37
34
|
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
35
|
|
|
41
36
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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");
|
|
37
|
+
const input = { ...cartInputFromForm(form), page: returnTo };
|
|
38
|
+
const out = await editCart(input, { config, session, request, db: emdash?.db });
|
|
39
|
+
if (out.base && String(form?.get("then") ?? "") === "checkout") {
|
|
40
|
+
return redirect(await checkoutFor(config, out.base, { session, request, emdashConfig: emdash?.config }), 303);
|
|
91
41
|
}
|
|
92
|
-
|
|
93
|
-
return fail(redirect, returnTo, "cart_updated");
|
|
42
|
+
return fail(redirect, returnTo, out.notice);
|
|
94
43
|
} catch {
|
|
95
44
|
return fail(redirect, returnTo, "checkout_failed");
|
|
96
45
|
}
|
package/src/routes/cart.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GET /_emdash/api/auth/redeemid/cart?return=/path
|
|
3
3
|
*
|
|
4
|
-
* Sends the visitor to their Shopify cart's checkout
|
|
4
|
+
* Sends the visitor to their Shopify cart's checkout, signed in for members
|
|
5
|
+
* (see checkout.ts). With no cart, or one
|
|
5
6
|
* Shopify has closed (its checkout completed), they go back with the
|
|
6
7
|
* `cart_empty` notice and the stored cart is cleared.
|
|
7
8
|
*/
|
|
@@ -11,6 +12,7 @@ import type { APIRoute } from "astro";
|
|
|
11
12
|
import { safeReturn } from "../access.js";
|
|
12
13
|
import { readCart, saveCart } from "../cart.js";
|
|
13
14
|
import { readConfig, storeDomain } from "../config.js";
|
|
15
|
+
import { checkoutFor } from "../checkout.js";
|
|
14
16
|
import { getCart } from "../storefront.js";
|
|
15
17
|
import { fail } from "./shared.js";
|
|
16
18
|
|
|
@@ -30,7 +32,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
30
32
|
return fail(redirect, returnTo, "cart_empty");
|
|
31
33
|
}
|
|
32
34
|
saveCart(session, { id: cart.id, count: cart.totalQuantity });
|
|
33
|
-
return redirect(cart.
|
|
35
|
+
return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: (locals as { emdash?: { config?: unknown } }).emdash?.config }), 303);
|
|
34
36
|
} catch {
|
|
35
37
|
return fail(redirect, returnTo, "checkout_failed");
|
|
36
38
|
}
|
package/src/routes/shared.ts
CHANGED
|
@@ -2,6 +2,17 @@ import { getPublicOrigin } from "emdash/api/route-utils";
|
|
|
2
2
|
|
|
3
3
|
export const SESSION_FLOW = "redeemid:flow";
|
|
4
4
|
export const SESSION_ID_TOKEN = "redeemid:id_token";
|
|
5
|
+
/**
|
|
6
|
+
* The member's Customer Account API tokens, kept only in the server-side
|
|
7
|
+
* session, to open Shopify's checkout signed in. Never sent to the browser.
|
|
8
|
+
*/
|
|
9
|
+
export const SESSION_CUSTOMER_TOKEN = "redeemid:customer_token";
|
|
10
|
+
|
|
11
|
+
export interface CustomerToken {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
refreshToken: string | null;
|
|
14
|
+
expiresAt: number | null;
|
|
15
|
+
}
|
|
5
16
|
/** A sign-in started more than this long ago is refused. */
|
|
6
17
|
export const FLOW_TTL_MS = 10 * 60 * 1000;
|
|
7
18
|
|
package/src/routes/webhook.ts
CHANGED
|
@@ -19,6 +19,11 @@ import { apply, customerOf, kindOf, verifyHmac } from "../webhooks.js";
|
|
|
19
19
|
export const prerender = false;
|
|
20
20
|
|
|
21
21
|
const MAX_BYTES = 1024 * 1024;
|
|
22
|
+
/** A delivery still "pending" after this long never finished. */
|
|
23
|
+
const STALE_PENDING_MS = 60 * 1000;
|
|
24
|
+
/** At most one "Bad signature" entry per this long, so junk requests can't fill App events. */
|
|
25
|
+
const REJECT_LOG_MS = 60 * 1000;
|
|
26
|
+
let lastRejectLogged = 0;
|
|
22
27
|
|
|
23
28
|
export const POST: APIRoute = async ({ request, locals }) => {
|
|
24
29
|
const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
|
|
@@ -31,7 +36,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
31
36
|
const ok = await verifyHmac(body, request.headers.get("x-shopify-hmac-sha256") ?? "", secret);
|
|
32
37
|
const topic = (request.headers.get("x-shopify-topic") ?? "").toLowerCase();
|
|
33
38
|
if (!ok) {
|
|
34
|
-
|
|
39
|
+
if (Date.now() - lastRejectLogged > REJECT_LOG_MS) {
|
|
40
|
+
lastRejectLogged = Date.now();
|
|
41
|
+
await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
|
|
42
|
+
}
|
|
35
43
|
return new Response("Bad signature", { status: 401 });
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -51,7 +59,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
51
59
|
const customerId = topic === "shop/redact" ? null : customerOf(payload);
|
|
52
60
|
const receivedAt = new Date().toISOString();
|
|
53
61
|
if (delivery) {
|
|
54
|
-
const
|
|
62
|
+
const claim = () => s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
|
|
63
|
+
let claimed = await claim();
|
|
64
|
+
if (!claimed.applied) {
|
|
65
|
+
// A delivery left "pending" was cut off before it finished (a
|
|
66
|
+
// timeout, a crash): let Shopify's retry run it again.
|
|
67
|
+
const earlier = await s.deliveries.get(delivery);
|
|
68
|
+
if (earlier?.outcome === "pending" && Date.now() - Date.parse(earlier.receivedAt) > STALE_PENDING_MS) {
|
|
69
|
+
await s.deliveries.delete(delivery);
|
|
70
|
+
claimed = await claim();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
55
73
|
if (!claimed.applied) {
|
|
56
74
|
await record({ event: "webhook.received", label: topic, status: "duplicate", origin: "webhook", customerId });
|
|
57
75
|
return new Response("Duplicate", { status: 200 });
|
package/src/shopify.ts
CHANGED
|
@@ -127,6 +127,42 @@ export function authorizeUrl(
|
|
|
127
127
|
export interface Tokens {
|
|
128
128
|
accessToken: string;
|
|
129
129
|
idToken: string;
|
|
130
|
+
/** Present when Shopify issued one; used to renew the access token for a signed-in checkout. */
|
|
131
|
+
refreshToken?: string | null;
|
|
132
|
+
/** When the access token expires (ms since epoch), when Shopify said. */
|
|
133
|
+
expiresAt?: number | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function tokenTimes(data: Record<string, unknown>): { refreshToken: string | null; expiresAt: number | null } {
|
|
137
|
+
const ttl = typeof data.expires_in === "number" ? data.expires_in : Number(data.expires_in);
|
|
138
|
+
return {
|
|
139
|
+
refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : null,
|
|
140
|
+
expiresAt: Number.isFinite(ttl) && ttl > 0 ? Date.now() + ttl * 1000 : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Renew a Customer Account API access token with its refresh token. Throws
|
|
146
|
+
* when Shopify refuses (the member's store session ended).
|
|
147
|
+
*/
|
|
148
|
+
export async function refreshTokens(
|
|
149
|
+
d: Discovery,
|
|
150
|
+
args: { clientId: string; clientSecret: string; origin?: string; refreshToken: string },
|
|
151
|
+
fetcher: typeof fetch = fetch,
|
|
152
|
+
): Promise<{ accessToken: string; refreshToken: string | null; expiresAt: number | null }> {
|
|
153
|
+
const body = new URLSearchParams({ grant_type: "refresh_token", client_id: args.clientId, refresh_token: args.refreshToken });
|
|
154
|
+
const headers: Record<string, string> = {
|
|
155
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
156
|
+
Accept: "application/json",
|
|
157
|
+
"User-Agent": USER_AGENT,
|
|
158
|
+
};
|
|
159
|
+
if (args.origin) headers.Origin = args.origin;
|
|
160
|
+
if (args.clientSecret) headers.Authorization = `Basic ${btoa(`${args.clientId}:${args.clientSecret}`)}`;
|
|
161
|
+
const res = await fetcher(d.tokenEndpoint, { method: "POST", headers, body });
|
|
162
|
+
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
163
|
+
if (!res.ok || typeof data.access_token !== "string") throw new ShopifyError("token_failed", `Token refresh failed (${res.status}).`);
|
|
164
|
+
const t = tokenTimes(data);
|
|
165
|
+
return { accessToken: data.access_token, refreshToken: t.refreshToken ?? args.refreshToken, expiresAt: t.expiresAt };
|
|
130
166
|
}
|
|
131
167
|
|
|
132
168
|
export async function exchangeCode(
|
|
@@ -155,7 +191,7 @@ export async function exchangeCode(
|
|
|
155
191
|
if (!res.ok || typeof data.access_token !== "string" || typeof data.id_token !== "string") {
|
|
156
192
|
throw new ShopifyError("token_failed", `Token exchange failed (${res.status}).`);
|
|
157
193
|
}
|
|
158
|
-
return { accessToken: data.access_token, idToken: data.id_token };
|
|
194
|
+
return { accessToken: data.access_token, idToken: data.id_token, ...tokenTimes(data) };
|
|
159
195
|
}
|
|
160
196
|
|
|
161
197
|
/** Verify the ID token's signature, issuer, audience, expiry and nonce. */
|
package/src/storefront.ts
CHANGED
|
@@ -30,12 +30,61 @@ export function resetStorefrontCache(): void {
|
|
|
30
30
|
shopCache.clear();
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Where the buyer is, for local prices and currency (Shopify Markets). Both
|
|
35
|
+
* are optional; Shopify uses the store's defaults without them.
|
|
36
|
+
*/
|
|
37
|
+
export interface StoreContext {
|
|
38
|
+
/** ISO 3166 two-letter country code, e.g. "CA". */
|
|
39
|
+
country?: string | null;
|
|
40
|
+
/** Shopify language code, e.g. "FR" or "PT_BR". */
|
|
41
|
+
language?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A clean country code, or null. */
|
|
45
|
+
export function countryCode(raw: unknown): string | null {
|
|
46
|
+
const c = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
|
47
|
+
return /^[A-Z]{2}$/.test(c) && c !== "XX" && c !== "T1" ? c : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function languageCode(raw: unknown): string | null {
|
|
51
|
+
const l = typeof raw === "string" ? raw.trim().toUpperCase().replace("-", "_") : "";
|
|
52
|
+
return /^[A-Z]{2}(_[A-Z]{2})?$/.test(l) ? l : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The buyer's context from config `market`: a fixed country, or "visitor"
|
|
57
|
+
* for the country Cloudflare reports for the request (CF-IPCountry).
|
|
58
|
+
*/
|
|
59
|
+
export function storeContext(config: RedeemIDConfig | null | undefined, request?: Request | null): StoreContext {
|
|
60
|
+
const m = config?.market;
|
|
61
|
+
if (!m) return {};
|
|
62
|
+
const country = m.country === "visitor" ? countryCode(request?.headers.get("cf-ipcountry")) : countryCode(m.country);
|
|
63
|
+
return { country, language: languageCode(m.language) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function contextKey(ctx: StoreContext | undefined): string {
|
|
67
|
+
return `${ctx?.country ?? ""}|${ctx?.language ?? ""}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Add Shopify's @inContext directive to an operation, when there is a context. */
|
|
71
|
+
export function withContext(query: string, ctx: StoreContext | undefined): string {
|
|
72
|
+
const parts: string[] = [];
|
|
73
|
+
const country = countryCode(ctx?.country);
|
|
74
|
+
const language = languageCode(ctx?.language);
|
|
75
|
+
if (country) parts.push(`country: ${country}`);
|
|
76
|
+
if (language) parts.push(`language: ${language}`);
|
|
77
|
+
if (!parts.length) return query;
|
|
78
|
+
return query.replace(/^(\s*(?:query|mutation)\s+\w+(?:\([^)]*\))?)/, `$1 @inContext(${parts.join(", ")})`);
|
|
79
|
+
}
|
|
80
|
+
|
|
33
81
|
export async function storefront<T>(
|
|
34
82
|
config: RedeemIDConfig,
|
|
35
83
|
query: string,
|
|
36
84
|
variables: Record<string, unknown> = {},
|
|
37
|
-
opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
|
|
85
|
+
opts: { buyerIp?: string | null; fetcher?: typeof fetch; context?: StoreContext } = {},
|
|
38
86
|
): Promise<T> {
|
|
87
|
+
query = withContext(query, opts.context);
|
|
39
88
|
const headers: Record<string, string> = {
|
|
40
89
|
"Content-Type": "application/json",
|
|
41
90
|
Accept: "application/json",
|
|
@@ -177,6 +226,8 @@ export async function addToCart(
|
|
|
177
226
|
attributes?: Record<string, string>;
|
|
178
227
|
buyerIp?: string | null;
|
|
179
228
|
fetcher?: typeof fetch;
|
|
229
|
+
/** A new cart's country, for local prices. */
|
|
230
|
+
context?: StoreContext;
|
|
180
231
|
} = {},
|
|
181
232
|
): Promise<Cart> {
|
|
182
233
|
const quantity = Math.max(1, Math.floor(line.quantity ?? 1));
|
|
@@ -186,7 +237,11 @@ export async function addToCart(
|
|
|
186
237
|
if (data?.cartLinesAdd.cart) return data.cartLinesAdd.cart;
|
|
187
238
|
}
|
|
188
239
|
const input: Record<string, unknown> = { lines };
|
|
189
|
-
|
|
240
|
+
const identity: Record<string, string> = {};
|
|
241
|
+
if (opts.email) identity.email = opts.email;
|
|
242
|
+
const country = countryCode(opts.context?.country);
|
|
243
|
+
if (country) identity.countryCode = country;
|
|
244
|
+
if (Object.keys(identity).length) input.buyerIdentity = identity;
|
|
190
245
|
const attributes = Object.entries(opts.attributes ?? {}).filter(([, v]) => v);
|
|
191
246
|
if (attributes.length) input.attributes = attributes.map(([key, value]) => ({ key, value }));
|
|
192
247
|
const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
|
|
@@ -212,17 +267,57 @@ export interface Variant {
|
|
|
212
267
|
title: string;
|
|
213
268
|
availableForSale: boolean;
|
|
214
269
|
price: Money;
|
|
270
|
+
/** The price before a sale, when Shopify has one above `price`. */
|
|
271
|
+
compareAtPrice?: Money | null;
|
|
272
|
+
sku?: string | null;
|
|
273
|
+
image?: Image | null;
|
|
274
|
+
/** Option name and value pairs, e.g. [{ name: "Size", value: "M" }]. */
|
|
275
|
+
selectedOptions?: Array<{ name: string; value: string }>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface SellingPlan {
|
|
279
|
+
id: string;
|
|
280
|
+
name: string;
|
|
281
|
+
/** The group's name, e.g. "Subscribe and save". */
|
|
282
|
+
group: string;
|
|
215
283
|
}
|
|
216
284
|
|
|
217
285
|
export interface Product {
|
|
286
|
+
/** Shopify's product id (gid). */
|
|
287
|
+
id?: string;
|
|
218
288
|
handle: string;
|
|
219
289
|
title: string;
|
|
220
290
|
description: string;
|
|
291
|
+
/** Shopify's HTML description, as the merchant wrote it. */
|
|
292
|
+
descriptionHtml?: string;
|
|
293
|
+
vendor?: string;
|
|
294
|
+
productType?: string;
|
|
221
295
|
availableForSale: boolean;
|
|
222
296
|
image: Image | null;
|
|
297
|
+
/** Every image, featured first (up to 20). */
|
|
298
|
+
images?: Image[];
|
|
299
|
+
/** The product's options and their values, e.g. Size: S, M, L. Absent or one "Title" option means none. */
|
|
300
|
+
options?: Array<{ name: string; values: string[] }>;
|
|
223
301
|
variants: Variant[];
|
|
224
302
|
/** The first selling plan, for subscription-only products. */
|
|
225
303
|
sellingPlanId: string | null;
|
|
304
|
+
/** True when the product can only be bought on a selling plan. */
|
|
305
|
+
requiresSellingPlan?: boolean;
|
|
306
|
+
/** Every selling plan offered (subscriptions, pre-orders), in Shopify's order. */
|
|
307
|
+
sellingPlans?: SellingPlan[];
|
|
308
|
+
seo?: { title: string | null; description: string | null };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Real options to choose from: none for a product with only Shopify's "Default Title". */
|
|
312
|
+
export function realOptions(p: Product): Array<{ name: string; values: string[] }> {
|
|
313
|
+
const opts = p.options ?? [];
|
|
314
|
+
if (opts.length === 1 && opts[0]?.values.length === 1 && opts[0].values[0] === "Default Title") return [];
|
|
315
|
+
return opts.filter((o) => o.values.length > 0);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The variant matching the chosen option values, or undefined. */
|
|
319
|
+
export function variantFor(p: Product, chosen: Record<string, string>): Variant | undefined {
|
|
320
|
+
return p.variants.find((v) => (v.selectedOptions ?? []).every((o) => chosen[o.name] === o.value));
|
|
226
321
|
}
|
|
227
322
|
|
|
228
323
|
export interface ProductCard {
|
|
@@ -232,6 +327,10 @@ export interface ProductCard {
|
|
|
232
327
|
image: Image | null;
|
|
233
328
|
/** The lowest variant price. */
|
|
234
329
|
price: Money | null;
|
|
330
|
+
/** The lowest compare-at price, when the product is on sale. */
|
|
331
|
+
compareAtPrice?: Money | null;
|
|
332
|
+
/** The variant to add straight from a card: set only when the product has exactly one and needs no selling plan. */
|
|
333
|
+
quickAddVariantId?: string | null;
|
|
235
334
|
}
|
|
236
335
|
|
|
237
336
|
export interface Collection {
|
|
@@ -243,18 +342,53 @@ export interface Collection {
|
|
|
243
342
|
|
|
244
343
|
const PRODUCT_QUERY = `query Product($handle: String!) {
|
|
245
344
|
product(handle: $handle) {
|
|
246
|
-
handle title description availableForSale requiresSellingPlan
|
|
345
|
+
id handle title description descriptionHtml vendor productType availableForSale requiresSellingPlan
|
|
247
346
|
featuredImage { url altText }
|
|
248
|
-
|
|
249
|
-
|
|
347
|
+
images(first: 20) { nodes { url altText } }
|
|
348
|
+
options { name optionValues { name } }
|
|
349
|
+
variants(first: 100) { nodes { id title availableForSale sku price { amount currencyCode } compareAtPrice { amount currencyCode } image { url altText } selectedOptions { name value } } }
|
|
350
|
+
sellingPlanGroups(first: 5) { nodes { name sellingPlans(first: 10) { nodes { id name } } } }
|
|
351
|
+
seo { title description }
|
|
250
352
|
}
|
|
251
353
|
}`;
|
|
252
354
|
|
|
355
|
+
/** The fields a product card needs. */
|
|
356
|
+
export const CARD_FIELDS = `handle title availableForSale requiresSellingPlan featuredImage { url altText }
|
|
357
|
+
priceRange { minVariantPrice { amount currencyCode } }
|
|
358
|
+
compareAtPriceRange { minVariantPrice { amount currencyCode } }
|
|
359
|
+
variants(first: 2) { nodes { id availableForSale } }`;
|
|
360
|
+
|
|
361
|
+
export type RawCard = {
|
|
362
|
+
handle: string;
|
|
363
|
+
title: string;
|
|
364
|
+
availableForSale: boolean;
|
|
365
|
+
requiresSellingPlan?: boolean;
|
|
366
|
+
featuredImage: Image | null;
|
|
367
|
+
priceRange?: { minVariantPrice: Money } | null;
|
|
368
|
+
compareAtPriceRange?: { minVariantPrice: Money } | null;
|
|
369
|
+
variants?: { nodes: Array<{ id: string; availableForSale: boolean }> };
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export function toCard(p: RawCard): ProductCard {
|
|
373
|
+
const price = p.priceRange?.minVariantPrice ?? null;
|
|
374
|
+
const compare = p.compareAtPriceRange?.minVariantPrice ?? null;
|
|
375
|
+
const only = p.variants?.nodes.length === 1 ? p.variants.nodes[0] : null;
|
|
376
|
+
return {
|
|
377
|
+
handle: p.handle,
|
|
378
|
+
title: p.title,
|
|
379
|
+
availableForSale: p.availableForSale,
|
|
380
|
+
image: p.featuredImage ?? null,
|
|
381
|
+
price,
|
|
382
|
+
compareAtPrice: compare && price && Number(compare.amount) > Number(price.amount) ? compare : null,
|
|
383
|
+
quickAddVariantId: only?.availableForSale && !p.requiresSellingPlan ? only.id : null,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
253
387
|
const COLLECTION_QUERY = `query Collection($handle: String!, $first: Int!) {
|
|
254
388
|
collection(handle: $handle) {
|
|
255
389
|
handle title description
|
|
256
390
|
products(first: $first) {
|
|
257
|
-
nodes {
|
|
391
|
+
nodes { ${CARD_FIELDS} }
|
|
258
392
|
}
|
|
259
393
|
}
|
|
260
394
|
}`;
|
|
@@ -269,66 +403,74 @@ async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
|
|
|
269
403
|
return value;
|
|
270
404
|
}
|
|
271
405
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
406
|
+
type RawProduct = {
|
|
407
|
+
id: string;
|
|
408
|
+
handle: string;
|
|
409
|
+
title: string;
|
|
410
|
+
description: string;
|
|
411
|
+
descriptionHtml?: string;
|
|
412
|
+
vendor?: string;
|
|
413
|
+
productType?: string;
|
|
414
|
+
availableForSale: boolean;
|
|
415
|
+
requiresSellingPlan: boolean;
|
|
416
|
+
featuredImage: Image | null;
|
|
417
|
+
images?: { nodes: Image[] };
|
|
418
|
+
options?: Array<{ name: string; optionValues?: Array<{ name: string }> }>;
|
|
419
|
+
variants: { nodes: Variant[] };
|
|
420
|
+
sellingPlanGroups: { nodes: Array<{ name?: string; sellingPlans: { nodes: Array<{ id: string; name?: string }> } }> };
|
|
421
|
+
seo?: { title: string | null; description: string | null } | null;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
function toProduct(p: RawProduct): Product {
|
|
425
|
+
const plans: SellingPlan[] = p.sellingPlanGroups.nodes.flatMap((g) => g.sellingPlans.nodes.map((sp) => ({ id: sp.id, name: sp.name ?? "", group: g.name ?? "" })));
|
|
426
|
+
const images = p.images?.nodes ?? [];
|
|
427
|
+
const featured = p.featuredImage ?? null;
|
|
428
|
+
return {
|
|
429
|
+
id: p.id,
|
|
430
|
+
handle: p.handle,
|
|
431
|
+
title: p.title,
|
|
432
|
+
description: p.description,
|
|
433
|
+
descriptionHtml: p.descriptionHtml ?? "",
|
|
434
|
+
vendor: p.vendor ?? "",
|
|
435
|
+
productType: p.productType ?? "",
|
|
436
|
+
availableForSale: p.availableForSale,
|
|
437
|
+
image: featured,
|
|
438
|
+
images: featured ? [featured, ...images.filter((i) => i.url !== featured.url)] : images,
|
|
439
|
+
options: (p.options ?? []).map((o) => ({ name: o.name, values: (o.optionValues ?? []).map((v) => v.name) })),
|
|
440
|
+
variants: p.variants.nodes,
|
|
441
|
+
sellingPlanId: p.requiresSellingPlan ? (plans[0]?.id ?? null) : null,
|
|
442
|
+
requiresSellingPlan: p.requiresSellingPlan,
|
|
443
|
+
sellingPlans: plans,
|
|
444
|
+
seo: p.seo ?? { title: null, description: null },
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** A product with its variants, options, images and selling plans, cached for five minutes. Null when Shopify has no such product. */
|
|
449
|
+
export async function product(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch, context?: StoreContext): Promise<Product | null> {
|
|
450
|
+
return cached(`${shopOrigin(config)}|product|${handle}|${contextKey(context)}`, async () => {
|
|
451
|
+
const { product: p } = await storefront<{ product: RawProduct | null }>(config, PRODUCT_QUERY, { handle }, { fetcher, context });
|
|
452
|
+
return p ? toProduct(p) : null;
|
|
298
453
|
});
|
|
299
454
|
}
|
|
300
455
|
|
|
301
456
|
/** 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> {
|
|
457
|
+
export async function collection(config: RedeemIDConfig, handle: string, first = 12, fetcher?: typeof fetch, context?: StoreContext): Promise<Collection | null> {
|
|
303
458
|
const n = Math.max(1, Math.min(50, Math.floor(first)));
|
|
304
|
-
return cached(`${shopOrigin(config)}|collection|${handle}|${n}`, async () => {
|
|
305
|
-
type Data = {
|
|
306
|
-
|
|
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 });
|
|
459
|
+
return cached(`${shopOrigin(config)}|collection|${handle}|${n}|${contextKey(context)}`, async () => {
|
|
460
|
+
type Data = { collection: { handle: string; title: string; description: string; products: { nodes: RawCard[] } } | null };
|
|
461
|
+
const { collection: c } = await storefront<Data>(config, COLLECTION_QUERY, { handle, first: n }, { fetcher, context });
|
|
316
462
|
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
|
-
};
|
|
463
|
+
return { handle: c.handle, title: c.title, description: c.description, products: c.products.nodes.map(toCard) };
|
|
329
464
|
});
|
|
330
465
|
}
|
|
331
466
|
|
|
467
|
+
/** Cache a Storefront read for five minutes, by key. Used by the catalog module. */
|
|
468
|
+
export function cachedRead<T>(key: string, load: () => Promise<T>): Promise<T> {
|
|
469
|
+
return cached(key, load);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export { contextKey };
|
|
473
|
+
|
|
332
474
|
/** Format Shopify money for display. */
|
|
333
475
|
export function money(m: Money | null | undefined, locale = "en-US"): string | null {
|
|
334
476
|
if (!m) return null;
|
|
@@ -349,6 +491,10 @@ export interface CartLine {
|
|
|
349
491
|
image: Image | null;
|
|
350
492
|
price: Money | null;
|
|
351
493
|
total: Money | null;
|
|
494
|
+
/** The selling plan the line is bought on (a subscription), by name. */
|
|
495
|
+
sellingPlan?: string | null;
|
|
496
|
+
/** The line's price per item before a sale, when there is one. */
|
|
497
|
+
compareAtPrice?: Money | null;
|
|
352
498
|
}
|
|
353
499
|
|
|
354
500
|
export interface CartDetails extends Cart {
|
|
@@ -363,7 +509,8 @@ const CART_DETAIL_FIELDS = `${CART_FIELDS}
|
|
|
363
509
|
discountCodes { code applicable }
|
|
364
510
|
lines(first: 100) { nodes {
|
|
365
511
|
id quantity
|
|
366
|
-
cost { totalAmount { amount currencyCode } }
|
|
512
|
+
cost { totalAmount { amount currencyCode } compareAtAmountPerQuantity { amount currencyCode } }
|
|
513
|
+
sellingPlanAllocation { sellingPlan { name } }
|
|
367
514
|
merchandise { ... on ProductVariant { id title price { amount currencyCode } image { url altText } product { title handle featuredImage { url altText } } } }
|
|
368
515
|
} }`;
|
|
369
516
|
|
|
@@ -374,7 +521,8 @@ type RawCart = Cart & {
|
|
|
374
521
|
nodes: Array<{
|
|
375
522
|
id: string;
|
|
376
523
|
quantity: number;
|
|
377
|
-
cost?: { totalAmount?: Money | null } | null;
|
|
524
|
+
cost?: { totalAmount?: Money | null; compareAtAmountPerQuantity?: Money | null } | null;
|
|
525
|
+
sellingPlanAllocation?: { sellingPlan?: { name?: string } | null } | null;
|
|
378
526
|
merchandise: {
|
|
379
527
|
id: string;
|
|
380
528
|
title: string;
|
|
@@ -404,6 +552,8 @@ function details(c: RawCart): CartDetails {
|
|
|
404
552
|
image: l.merchandise.image ?? l.merchandise.product.featuredImage ?? null,
|
|
405
553
|
price: l.merchandise.price ?? null,
|
|
406
554
|
total: l.cost?.totalAmount ?? null,
|
|
555
|
+
sellingPlan: l.sellingPlanAllocation?.sellingPlan?.name ?? null,
|
|
556
|
+
compareAtPrice: l.cost?.compareAtAmountPerQuantity ?? null,
|
|
407
557
|
})),
|
|
408
558
|
};
|
|
409
559
|
}
|
|
@@ -471,3 +621,25 @@ export async function setDiscountCodes(
|
|
|
471
621
|
);
|
|
472
622
|
return result(data.cartDiscountCodesUpdate);
|
|
473
623
|
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Sign the cart in as the member, with their Customer Account API access
|
|
627
|
+
* token, so Shopify's checkout opens signed in and the order lands on their
|
|
628
|
+
* customer. Returns the cart's fresh checkout URL, or null when Shopify
|
|
629
|
+
* refused (the visitor then checks out as a guest).
|
|
630
|
+
*/
|
|
631
|
+
export async function signInCart(
|
|
632
|
+
config: RedeemIDConfig,
|
|
633
|
+
cartId: string,
|
|
634
|
+
customerAccessToken: string,
|
|
635
|
+
opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
|
|
636
|
+
): Promise<string | null> {
|
|
637
|
+
const data = await storefront<{ cartBuyerIdentityUpdate: { cart: { checkoutUrl: string } | null; userErrors: Array<{ message: string }> } }>(
|
|
638
|
+
config,
|
|
639
|
+
`mutation CartIdentity($id: ID!, $identity: CartBuyerIdentityInput!) { cartBuyerIdentityUpdate(cartId: $id, buyerIdentity: $identity) { cart { checkoutUrl } userErrors { message } } }`,
|
|
640
|
+
{ id: cartId, identity: { customerAccessToken } },
|
|
641
|
+
opts,
|
|
642
|
+
).catch(() => null);
|
|
643
|
+
const cart = data?.cartBuyerIdentityUpdate.cart;
|
|
644
|
+
return cart && !data?.cartBuyerIdentityUpdate.userErrors.length ? cart.checkoutUrl : null;
|
|
645
|
+
}
|