@spindlecommerce/storefront-client 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The SpindleCommerce authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @spindlecommerce/storefront-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A typed client for the [SpindleCommerce](https://spindlecommerce.com) Storefront API. It runs wherever `fetch` does: Node, edge runtimes and browsers.
4
+
5
+ ```ts
6
+ import { createCartSession, createStorefrontClient } from "@spindlecommerce/storefront-client";
7
+
8
+ const client = createStorefrontClient({ url: "https://api.example.com", channel: "eu", locale: "de-DE", currency: "EUR" });
9
+
10
+ const route = await client.route("/products/oxford-shirt");
11
+ const { products, endCursor } = await client.categoryProducts(categoryId, { first: 24 });
12
+
13
+ // The cart's id lives where you keep it: here, browser storage.
14
+ const cart = createCartSession(client, {
15
+ get: () => localStorage.getItem("cart"),
16
+ set: (c) => localStorage.setItem("cart", c.id),
17
+ clear: () => localStorage.removeItem("cart"),
18
+ });
19
+ await cart.add([{ variantId, quantity: 1 }]);
20
+ const result = await cart.checkout.placeOrder({ idempotencyKey, payment: { token } });
21
+ ```
22
+
23
+ - **Reads**: `route`, `routeTemplate`, `routeSections`, `site`, `newestProducts`, `categoryProducts`, `pages`, `productSlugs`, `categoryTree`, `localizedPath`, `previewSession`, `countries` and `guestOrder`. `query(document, variables)` runs your own, and `fragments` holds the client's to spread in them.
24
+ - **Errors**: `StorefrontApiError` (`status` 0 when the API can't be reached), and `PreviewExpiredError` for a preview token the API no longer accepts. Cart and checkout problems come back as values, such as `{ ok: false, reason: "invalid", fieldErrors }`.
25
+ - **Helpers**: `chooseValue` and `inStock` for variant pickers, `verifyWebhookSignature` and `changesFor` for platform webhooks, `editable` for visual editing markup.
26
+ - **Schema**: `@spindlecommerce/storefront-client/schema.graphql` is the Storefront API's schema, for generating types for your own queries.
27
+
28
+ React: [`@spindlecommerce/storefront-react`](https://www.npmjs.com/package/@spindlecommerce/storefront-react). Next.js: [`@spindlecommerce/storefront-next`](https://www.npmjs.com/package/@spindlecommerce/storefront-next).
@@ -0,0 +1,6 @@
1
+ import type { ContentNode } from "./types.ts";
2
+ export declare const isContentNode: (value: unknown) => value is ContentNode;
3
+ /** The nodes in a node's blocks fields, in field order. */
4
+ export declare const blockNodes: (node: ContentNode) => ContentNode[];
5
+ /** Whether a component appears among these nodes, nested ones included. */
6
+ export declare const containsComponent: (nodes: readonly ContentNode[], code: string) => boolean;
@@ -0,0 +1,5 @@
1
+ export const isContentNode = (value) => typeof value === "object" && value !== null && typeof value.id === "string" && typeof value.component === "string" && typeof value.fields === "object";
2
+ /** The nodes in a node's blocks fields, in field order. */
3
+ export const blockNodes = (node) => Object.values(node.fields).flatMap((value) => (Array.isArray(value) ? value.filter(isContentNode) : []));
4
+ /** Whether a component appears among these nodes, nested ones included. */
5
+ export const containsComponent = (nodes, code) => nodes.some((node) => node.component === code || containsComponent(blockNodes(node), code));
package/dist/cart.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { StorefrontClient } from "./client.ts";
2
+ import type { Cart, CartRequirement, CartUpdateInput, CheckoutCart, FieldError, Money, PlacedOrderRef } from "./types.ts";
3
+ /** Where the cart's id is kept between requests, such as a cookie or browser storage. */
4
+ export type CartStore = {
5
+ get: () => string | null | Promise<string | null>;
6
+ /** After every change, with the cart as it now is. */
7
+ set: (cart: Cart) => void | Promise<void>;
8
+ clear: () => void | Promise<void>;
9
+ };
10
+ /** A failed cart change. `available` is set when there is not enough stock. */
11
+ export declare class CartError extends Error {
12
+ readonly code: string;
13
+ readonly available: number | null;
14
+ constructor(message: string, code: string, available?: number | null);
15
+ }
16
+ /** "invalid": fix the input. "expired": the cart is gone. "closed": it became an order. */
17
+ export type CartChange = {
18
+ ok: true;
19
+ } | {
20
+ ok: false;
21
+ reason: "invalid" | "expired" | "closed" | "error";
22
+ /** The API's result type, such as ValidationError or CouponNotApplicableError. */
23
+ type: string | null;
24
+ /** The API's message, in English. */
25
+ message: string | null;
26
+ fieldErrors: FieldError[];
27
+ /** Why a coupon does not apply, in the API's English. */
28
+ couponReason: string | null;
29
+ };
30
+ export type PaymentSession = {
31
+ provider: string;
32
+ methodCode: string;
33
+ amount: Money | null;
34
+ clientData: Record<string, unknown>;
35
+ };
36
+ /** "not_ready": the cart still lacks details. "refused": it became an order, or the provider said no. */
37
+ export type PaymentSessionResult = {
38
+ ok: true;
39
+ session: PaymentSession;
40
+ } | {
41
+ ok: false;
42
+ reason: "expired" | "not_ready" | "refused";
43
+ message: string | null;
44
+ };
45
+ /**
46
+ * After "error" the outcome is unknown, so a retry reuses the idempotency key. After the others, it
47
+ * is a new attempt.
48
+ */
49
+ export type PlaceOrderFailure = "declined" | "not_ready" | "stock" | "closed" | "expired" | "invalid" | "error";
50
+ export type PlaceOrderResult = {
51
+ ok: true;
52
+ order: PlacedOrderRef;
53
+ cartId: string;
54
+ } | {
55
+ ok: false;
56
+ reason: PlaceOrderFailure;
57
+ type: string | null;
58
+ message: string | null;
59
+ missing: CartRequirement[];
60
+ fieldErrors: FieldError[];
61
+ };
62
+ export type CartSessionOptions = {
63
+ /** Whether a stored cart can still be used, e.g. not one in another channel's currency. By default, while it is open. */
64
+ isUsable?: (cart: {
65
+ isOpen: boolean;
66
+ currencyCode: string | null;
67
+ }) => boolean;
68
+ };
69
+ /** The shopper's cart and checkout, with the cart's id kept in `store`. Nothing is read from a cache. */
70
+ export declare const createCartSession: (client: StorefrontClient, store: CartStore, options?: CartSessionOptions) => {
71
+ id: () => string | Promise<string | null> | null;
72
+ get: () => Promise<Cart | null>;
73
+ add: (lines: {
74
+ variantId: string;
75
+ quantity: number;
76
+ }[]) => Promise<Cart>;
77
+ update: (lineId: string, quantity: number) => Promise<Cart>;
78
+ move: (to: {
79
+ locale: string;
80
+ currency: string;
81
+ }) => Promise<void>;
82
+ forget: () => void | Promise<void>;
83
+ checkout: {
84
+ /** The cart with everything checkout shows; null when there is none to check out. */
85
+ get: () => Promise<CheckoutCart | null>;
86
+ /** Saves any of the shopper's details, complete or not. */
87
+ update: (input: CartUpdateInput) => Promise<CartChange>;
88
+ applyCoupon: (code: string) => Promise<CartChange>;
89
+ removeCoupon: (code: string) => Promise<CartChange>;
90
+ /** Starts the payment session, or updates it after the total changed. For Stripe, a PaymentIntent's client secret and the publishable key. */
91
+ createPaymentSession: () => Promise<PaymentSessionResult>;
92
+ /**
93
+ * Saves `input` and places the order in one call, then forgets the cart. Retrying with the same
94
+ * idempotency key returns the same order instead of placing another.
95
+ */
96
+ placeOrder: (order: {
97
+ idempotencyKey: string;
98
+ payment?: {
99
+ token?: string;
100
+ };
101
+ input?: CartUpdateInput;
102
+ }) => Promise<PlaceOrderResult>;
103
+ };
104
+ };
105
+ export type CartSession = ReturnType<typeof createCartSession>;
package/dist/cart.js ADDED
@@ -0,0 +1,230 @@
1
+ import { CART_CREATE, CART_LINES_ADD, CART_LINES_REMOVE, CART_LINES_UPDATE, CART_MOVE, CART_QUERY, CART_UPDATE, checkoutCart, CONVERTED_CART, COUPON_APPLY, COUPON_REMOVE, LEGACY_PAYMENT_FIELDS, PAYMENT_FIELDS, PAYMENT_SESSION_CREATE, PLACE_ORDER, } from "./documents.js";
2
+ import { StorefrontApiError } from "./errors.js";
3
+ /** A failed cart change. `available` is set when there is not enough stock. */
4
+ export class CartError extends Error {
5
+ code;
6
+ available;
7
+ constructor(message, code, available = null) {
8
+ super(message);
9
+ this.name = "CartError";
10
+ this.code = code;
11
+ this.available = available;
12
+ }
13
+ }
14
+ const FRESH = { init: { cache: "no-store" } };
15
+ /** NotFoundError: the cart is gone. InvalidStateError: it became an order. */
16
+ const isGone = (typename) => typename === "NotFoundError" || typename === "InvalidStateError";
17
+ const unwrap = (result) => {
18
+ if (result?.cart)
19
+ return result.cart;
20
+ throw new CartError(result?.message ?? "The cart could not be updated", result?.code ?? result?.__typename ?? "UNKNOWN", result?.available ?? null);
21
+ };
22
+ const fieldErrorsOf = (errors) => (errors ?? []).flatMap((e) => (e.path?.length && e.message ? [{ path: e.path, message: e.message }] : []));
23
+ const lacksPaymentFields = (error) => error instanceof StorefrontApiError && /Cannot query field "(provider|sessionRequired)"/.test(error.message);
24
+ const toPaymentMethod = (m) => ({
25
+ code: m.code,
26
+ name: m.name || m.code,
27
+ instructions: m.instructions,
28
+ provider: m.provider ?? null,
29
+ sessionRequired: m.sessionRequired ?? false,
30
+ });
31
+ const toChange = (result) => {
32
+ const failure = (reason) => ({
33
+ ok: false,
34
+ reason,
35
+ type: result?.__typename ?? null,
36
+ message: result?.message ?? null,
37
+ fieldErrors: fieldErrorsOf(result?.fieldErrors),
38
+ couponReason: result?.reason ?? null,
39
+ });
40
+ switch (result?.__typename) {
41
+ case "CartPayload":
42
+ return { ok: true };
43
+ case "ValidationError":
44
+ case "CouponNotApplicableError":
45
+ return failure("invalid");
46
+ case "NotFoundError":
47
+ return failure("expired");
48
+ case "InvalidStateError":
49
+ return failure("closed");
50
+ default:
51
+ return failure("error");
52
+ }
53
+ };
54
+ /** The shopper's cart and checkout, with the cart's id kept in `store`. Nothing is read from a cache. */
55
+ export const createCartSession = (client, store, options = {}) => {
56
+ const isUsable = options.isUsable ?? ((cart) => cart.isOpen);
57
+ const run = (document, variables) => client.query(document, variables, FRESH);
58
+ const keep = async (cart) => {
59
+ await store.set(cart);
60
+ return cart;
61
+ };
62
+ const get = async () => {
63
+ const id = await store.get();
64
+ if (!id)
65
+ return null;
66
+ const { cart } = await run(CART_QUERY, { id });
67
+ return cart && isUsable(cart) ? cart : null;
68
+ };
69
+ /** Creates a cart on the first add, or when the stored one became an order or was removed. */
70
+ const add = async (lines) => {
71
+ const id = await store.get();
72
+ if (id && (await get())) {
73
+ const { result } = await run(CART_LINES_ADD, { id, lines });
74
+ if (!isGone(result.__typename))
75
+ return keep(unwrap(result));
76
+ }
77
+ return keep(unwrap((await run(CART_CREATE, { lines })).result));
78
+ };
79
+ /** Changes a line's quantity; 0 removes the line. */
80
+ const update = async (lineId, quantity) => {
81
+ const id = await store.get();
82
+ if (!id)
83
+ throw new CartError("The cart has expired", "NOT_FOUND");
84
+ const { result } = quantity > 0
85
+ ? await run(CART_LINES_UPDATE, { id, lines: [{ lineId, quantity }] })
86
+ : await run(CART_LINES_REMOVE, { id, lineIds: [lineId] });
87
+ if (isGone(result.__typename)) {
88
+ await store.clear();
89
+ throw new CartError("The cart has expired", "NOT_FOUND");
90
+ }
91
+ return keep(unwrap(result));
92
+ };
93
+ /** Moves the cart to another of the channel's languages and currencies, keeping its items, codes and details. */
94
+ const move = async (to) => {
95
+ const id = await store.get();
96
+ if (!id)
97
+ return;
98
+ const { result } = await run(CART_MOVE, { id, input: { locale: to.locale, currencyCode: to.currency } });
99
+ if (result.cart)
100
+ await store.set(result.cart);
101
+ else if (isGone(result.__typename))
102
+ await store.clear();
103
+ else
104
+ throw new CartError(result.message ?? "The cart could not be moved", result.code ?? result.__typename);
105
+ };
106
+ /** A change to the stored cart. A cart that is gone is forgotten. */
107
+ const change = async (document, variables) => {
108
+ const id = await store.get();
109
+ if (!id)
110
+ return toChange({ __typename: "NotFoundError" });
111
+ const { result } = await run(document, { cartId: id, ...variables });
112
+ const outcome = toChange(result);
113
+ if (!outcome.ok && outcome.reason === "expired")
114
+ await store.clear();
115
+ return outcome;
116
+ };
117
+ /** The order a cart already became, e.g. when a payment webhook placed it first. */
118
+ const convertedOrder = async (id) => {
119
+ try {
120
+ const { cart } = await run(CONVERTED_CART, { id });
121
+ return cart && !cart.isOpen ? cart.order : null;
122
+ }
123
+ catch (error) {
124
+ console.error("Could not check whether the cart became an order", error);
125
+ return null;
126
+ }
127
+ };
128
+ const checkout = {
129
+ /** The cart with everything checkout shows; null when there is none to check out. */
130
+ get: async () => {
131
+ const id = await store.get();
132
+ if (!id)
133
+ return null;
134
+ let data;
135
+ try {
136
+ data = await run(checkoutCart(PAYMENT_FIELDS), { id });
137
+ }
138
+ catch (error) {
139
+ // An older API without these fields: everything but the payment form works.
140
+ if (!lacksPaymentFields(error))
141
+ throw error;
142
+ data = await run(checkoutCart(LEGACY_PAYMENT_FIELDS), { id });
143
+ }
144
+ const cart = data.cart;
145
+ if (!cart || !isUsable(cart))
146
+ return null;
147
+ const paymentMethods = (cart.availablePaymentMethods ?? []).map(toPaymentMethod);
148
+ return {
149
+ id: cart.id,
150
+ version: cart.version,
151
+ email: cart.email,
152
+ requiresShipping: cart.requiresShipping,
153
+ missing: cart.missing ?? [],
154
+ shippingAddress: cart.shippingAddress,
155
+ billingAddress: cart.billingAddress,
156
+ billingSameAsShipping: cart.billingSameAsShipping,
157
+ shippingMethods: cart.availableShippingMethods ?? [],
158
+ selectedShipping: cart.selectedShippingMethod,
159
+ paymentMethods,
160
+ selectedPayment: paymentMethods.find((m) => m.code === cart.selectedPaymentMethod?.code) ?? null,
161
+ coupons: cart.coupons ?? [],
162
+ discounts: cart.discounts ?? [],
163
+ cost: cart.cost,
164
+ lines: cart.lines ?? [],
165
+ };
166
+ },
167
+ /** Saves any of the shopper's details, complete or not. */
168
+ update: (input) => change(CART_UPDATE, { input }),
169
+ applyCoupon: (code) => change(COUPON_APPLY, { code }),
170
+ removeCoupon: (code) => change(COUPON_REMOVE, { code }),
171
+ /** Starts the payment session, or updates it after the total changed. For Stripe, a PaymentIntent's client secret and the publishable key. */
172
+ createPaymentSession: async () => {
173
+ const id = await store.get();
174
+ if (!id)
175
+ return { ok: false, reason: "expired", message: null };
176
+ const { result } = await run(PAYMENT_SESSION_CREATE, { cartId: id });
177
+ if (result?.__typename === "CartPaymentSessionPayload" && result.session)
178
+ return { ok: true, session: { ...result.session, clientData: result.session.clientData ?? {} } };
179
+ if (result?.__typename === "CartNotReadyError")
180
+ return { ok: false, reason: "not_ready", message: result.message ?? null };
181
+ // InvalidStateError: the cart became an order, or the provider refused (the message says why).
182
+ return { ok: false, reason: "refused", message: result?.message ?? null };
183
+ },
184
+ /**
185
+ * Saves `input` and places the order in one call, then forgets the cart. Retrying with the same
186
+ * idempotency key returns the same order instead of placing another.
187
+ */
188
+ placeOrder: async (order) => {
189
+ const fail = (reason, result) => ({
190
+ ok: false,
191
+ reason,
192
+ type: result?.__typename ?? null,
193
+ message: result?.message ?? null,
194
+ missing: result?.missing ?? [],
195
+ fieldErrors: fieldErrorsOf(result?.fieldErrors),
196
+ });
197
+ const id = await store.get();
198
+ if (!id)
199
+ return fail("expired");
200
+ const placed = async (ref) => {
201
+ await store.clear();
202
+ return { ok: true, order: ref, cartId: id };
203
+ };
204
+ const { result } = await run(PLACE_ORDER, { cartId: id, idempotencyKey: order.idempotencyKey, payment: order.payment ?? {}, input: order.input ?? null });
205
+ switch (result?.__typename) {
206
+ case "CheckoutPayload":
207
+ if (result.order)
208
+ return placed(result.order);
209
+ break;
210
+ case "PaymentDeclinedError":
211
+ return fail("declined", result);
212
+ case "CartNotReadyError":
213
+ return fail("not_ready", result);
214
+ case "InsufficientStockError":
215
+ return fail("stock", result);
216
+ case "InvalidStateError": {
217
+ const ref = await convertedOrder(id);
218
+ return ref ? placed(ref) : fail("closed", result);
219
+ }
220
+ case "NotFoundError":
221
+ await store.clear();
222
+ return fail("expired", result);
223
+ case "ValidationError":
224
+ return fail("invalid", result);
225
+ }
226
+ return fail("error", result);
227
+ },
228
+ };
229
+ return { id: () => store.get(), get, add, update, move, forget: () => store.clear(), checkout };
230
+ };
@@ -0,0 +1,102 @@
1
+ import type { BuilderPage, CategoryTreeNode, Connection, ContentNode, Country, PageEntry, PlacedOrder, PreviewSession, ProductSort, ProductSummary, RouteData, SiteData, TemplatePage } from "./types.ts";
2
+ export type StorefrontScope = {
3
+ /** The channel's code, sent as x-channel. */
4
+ channel: string;
5
+ /** One of the channel's languages; null for its default. */
6
+ locale?: string | null;
7
+ /** One of the channel's currencies; null for its default. */
8
+ currency?: string | null;
9
+ };
10
+ export type StorefrontClientOptions = StorefrontScope & {
11
+ /** The platform's URL. It serves GraphQL at /graphql. */
12
+ url: string;
13
+ /** Sent as x-preview-token, so reads return draft content. */
14
+ previewToken?: string | null;
15
+ /** Fetch options for every request, other than method, headers and body. */
16
+ init?: RequestInit;
17
+ fetch?: (input: string, init: RequestInit) => Promise<Response>;
18
+ };
19
+ /** Fetch options for one request, such as Next.js's `next: { tags }`. */
20
+ export type RequestOptions = {
21
+ init?: RequestInit;
22
+ };
23
+ /** Every node of a connection, up to `limit` pages. */
24
+ export declare const allNodes: <N>(load: (after: string | null) => Promise<Connection<N> | null>, limit?: number) => Promise<N[]>;
25
+ type Root = {
26
+ id: string;
27
+ component: string;
28
+ };
29
+ export type StorefrontClient = ReturnType<typeof createStorefrontClient>;
30
+ /** The Storefront API for one channel, language and currency. Failed requests throw StorefrontApiError. */
31
+ export declare const createStorefrontClient: (options: StorefrontClientOptions) => {
32
+ url: string;
33
+ scope: {
34
+ channel: string;
35
+ locale: string | null;
36
+ currency: string | null;
37
+ };
38
+ previewToken: string | null;
39
+ /** The same client with another scope, preview token or fetch options. */
40
+ with: (changes: Partial<StorefrontClientOptions>) => /*elided*/ any;
41
+ query: <T>(document: string, variables?: Record<string, unknown>, request?: RequestOptions) => Promise<T>;
42
+ /** A path on the API host, such as /media/…, as an absolute URL. */
43
+ mediaUrl: (src: string) => string;
44
+ /** A storefront URL path as a page, product, category or redirect; null when nothing is there. */
45
+ route: (path: string, request?: RequestOptions) => Promise<RouteData | null>;
46
+ /** The template a product or category page renders with; null for the storefront's own layout. */
47
+ routeTemplate: (path: string, request?: RequestOptions) => Promise<TemplatePage | null>;
48
+ /** A product's or category's own sections, which its template places. */
49
+ routeSections: (path: string, request?: RequestOptions) => Promise<BuilderPage | null>;
50
+ /** The header and footer (singleton pages of these content types), and the request's language and currency. */
51
+ site: <Header extends Root = ContentNode, Footer extends Root = ContentNode>(types?: {
52
+ header?: string;
53
+ footer?: string;
54
+ }, request?: RequestOptions) => Promise<SiteData<Header, Footer>>;
55
+ newestProducts: (first: number, request?: RequestOptions) => Promise<ProductSummary[]>;
56
+ /** A page of a category's products, its subcategories' included. `endCursor` is null on the last page. */
57
+ categoryProducts: (id: string, page: {
58
+ first: number;
59
+ after?: string | null;
60
+ sort?: ProductSort | null;
61
+ }, request?: RequestOptions) => Promise<{
62
+ products: ProductSummary[];
63
+ totalCount: number;
64
+ endCursor: string | null;
65
+ }>;
66
+ /** A page of published pages, of one content type or all of them. */
67
+ pages: (page: {
68
+ type?: string | null;
69
+ first: number;
70
+ after?: string | null;
71
+ }, request?: RequestOptions) => Promise<Connection<PageEntry> | null>;
72
+ /** A page of product slugs, 100 at a time. */
73
+ productSlugs: (after: string | null, request?: RequestOptions) => Promise<Connection<{
74
+ slug: string;
75
+ }> | null>;
76
+ /** Category paths, four levels deep. */
77
+ categoryTree: (request?: RequestOptions) => Promise<CategoryTreeNode[]>;
78
+ /** A product's slug or a category's path in the client's language, for hreflang links. */
79
+ localizedPath: (id: string, request?: RequestOptions) => Promise<string | null>;
80
+ /** The session of the client's preview token; null when the API refuses the token. */
81
+ previewSession: (request?: RequestOptions) => Promise<PreviewSession | null>;
82
+ /** The countries the channel ships to, named in the client's language. */
83
+ countries: (request?: RequestOptions) => Promise<{
84
+ locale: string | null;
85
+ countries: Country[];
86
+ }>;
87
+ /** An order looked up as its guest shopper would, with the payment method of the cart it came from. */
88
+ guestOrder: (lookup: {
89
+ email: string;
90
+ number: string;
91
+ postalCode: string;
92
+ cartId: string;
93
+ }, request?: RequestOptions) => Promise<{
94
+ order: PlacedOrder;
95
+ paymentMethod: {
96
+ code: string;
97
+ name: string | null;
98
+ instructions: string | null;
99
+ } | null;
100
+ } | null>;
101
+ };
102
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,108 @@
1
+ import { CATEGORY_PRODUCTS, CATEGORY_TREE, COUNTRIES, GUEST_ORDER, LOCALIZED_PATH, NEWEST_PRODUCTS, PAGES, PREVIEW_SESSION, PRODUCT_SLUGS, ROUTE, SECTIONS, SITE, TEMPLATE, } from "./documents.js";
2
+ import { isPreviewExpired, PreviewExpiredError, StorefrontApiError } from "./errors.js";
3
+ import { mediaUrl } from "./media.js";
4
+ const nodesOf = (connection) => (connection?.edges ?? []).flatMap((e) => (e.node ? [e.node] : []));
5
+ /** Every node of a connection, up to `limit` pages. */
6
+ export const allNodes = async (load, limit = 50) => {
7
+ const items = [];
8
+ let after = null;
9
+ for (let i = 0; i < limit; i++) {
10
+ const connection = await load(after);
11
+ items.push(...nodesOf(connection));
12
+ if (!connection?.pageInfo.hasNextPage || !connection.pageInfo.endCursor)
13
+ break;
14
+ after = connection.pageInfo.endCursor;
15
+ }
16
+ return items;
17
+ };
18
+ /** The Storefront API for one channel, language and currency. Failed requests throw StorefrontApiError. */
19
+ export const createStorefrontClient = (options) => {
20
+ const url = options.url.replace(/\/+$/, "");
21
+ const scope = { channel: options.channel, locale: options.locale ?? null, currency: options.currency ?? null };
22
+ const previewToken = options.previewToken ?? null;
23
+ // Looked up per request: frameworks such as Next.js replace the global fetch.
24
+ const send = options.fetch ?? ((input, init) => fetch(input, init));
25
+ const query = async (document, variables = {}, request = {}) => {
26
+ const headers = { "content-type": "application/json", accept: "application/json", "x-channel": scope.channel };
27
+ if (scope.locale)
28
+ headers["x-locale"] = scope.locale;
29
+ if (scope.currency)
30
+ headers["x-currency"] = scope.currency;
31
+ if (previewToken)
32
+ headers["x-preview-token"] = previewToken;
33
+ let response;
34
+ try {
35
+ response = await send(`${url}/graphql`, { ...options.init, ...request.init, method: "POST", headers, body: JSON.stringify({ query: document, variables }) });
36
+ }
37
+ catch (cause) {
38
+ throw new StorefrontApiError(`The Storefront API at ${url} could not be reached`, 0, [], { cause });
39
+ }
40
+ const body = (await response.json().catch(() => null));
41
+ const codes = (body?.errors ?? []).map((e) => e.extensions?.code ?? "UNKNOWN");
42
+ // An invalid preview token fails the whole request with 401 UNAUTHENTICATED.
43
+ if (previewToken && (response.status === 401 || codes.includes("UNAUTHENTICATED")))
44
+ throw new PreviewExpiredError();
45
+ if (body?.errors?.length)
46
+ throw new StorefrontApiError(body.errors.map((e) => e.message).join("; "), response.status, codes);
47
+ if (!response.ok || !body?.data)
48
+ throw new StorefrontApiError(`The Storefront API answered HTTP ${response.status}`, response.status, codes);
49
+ return body.data;
50
+ };
51
+ return {
52
+ url,
53
+ scope,
54
+ previewToken,
55
+ /** The same client with another scope, preview token or fetch options. */
56
+ with: (changes) => createStorefrontClient({ ...options, ...changes }),
57
+ query,
58
+ /** A path on the API host, such as /media/…, as an absolute URL. */
59
+ mediaUrl: (src) => mediaUrl(url, src),
60
+ /** A storefront URL path as a page, product, category or redirect; null when nothing is there. */
61
+ route: async (path, request) => (await query(ROUTE, { path }, request)).route,
62
+ /** The template a product or category page renders with; null for the storefront's own layout. */
63
+ routeTemplate: async (path, request) => (await query(TEMPLATE, { path }, request)).route?.template ?? null,
64
+ /** A product's or category's own sections, which its template places. */
65
+ routeSections: async (path, request) => (await query(SECTIONS, { path }, request)).route?.sections ?? null,
66
+ /** The header and footer (singleton pages of these content types), and the request's language and currency. */
67
+ site: (types = {}, request) => query(SITE, { header: types.header ?? "site_header", footer: types.footer ?? "site_footer" }, request),
68
+ newestProducts: async (first, request) => nodesOf((await query(NEWEST_PRODUCTS, { first }, request)).products),
69
+ /** A page of a category's products, its subcategories' included. `endCursor` is null on the last page. */
70
+ categoryProducts: async (id, page, request) => {
71
+ const data = await query(CATEGORY_PRODUCTS, { id, first: page.first, after: page.after ?? null, sort: page.sort ?? null }, request);
72
+ const connection = data.category?.products;
73
+ return { products: nodesOf(connection), totalCount: connection?.totalCount ?? 0, endCursor: connection?.pageInfo.hasNextPage ? connection.pageInfo.endCursor : null };
74
+ },
75
+ /** A page of published pages, of one content type or all of them. */
76
+ pages: async (page, request) => (await query(PAGES, { type: page.type ?? null, first: page.first, after: page.after ?? null }, request)).pages,
77
+ /** A page of product slugs, 100 at a time. */
78
+ productSlugs: async (after, request) => (await query(PRODUCT_SLUGS, { after }, request)).products,
79
+ /** Category paths, four levels deep. */
80
+ categoryTree: async (request) => (await query(CATEGORY_TREE, {}, request)).categoryTree ?? [],
81
+ /** A product's slug or a category's path in the client's language, for hreflang links. */
82
+ localizedPath: async (id, request) => {
83
+ const data = await query(LOCALIZED_PATH, { id }, request);
84
+ return data.node?.slug ?? data.node?.path ?? null;
85
+ },
86
+ /** The session of the client's preview token; null when the API refuses the token. */
87
+ previewSession: async (request) => {
88
+ try {
89
+ return (await query(PREVIEW_SESSION, {}, request)).previewSession;
90
+ }
91
+ catch (error) {
92
+ if (isPreviewExpired(error))
93
+ return null;
94
+ throw error;
95
+ }
96
+ },
97
+ /** The countries the channel ships to, named in the client's language. */
98
+ countries: async (request) => {
99
+ const data = await query(COUNTRIES, {}, request);
100
+ return { locale: data.localization?.locale ?? null, countries: (data.localization?.channel?.countries ?? []).filter((c) => c.code) };
101
+ },
102
+ /** An order looked up as its guest shopper would, with the payment method of the cart it came from. */
103
+ guestOrder: async (lookup, request) => {
104
+ const data = await query(GUEST_ORDER, lookup, request);
105
+ return data.order ? { order: data.order, paymentMethod: data.cart?.selectedPaymentMethod ?? null } : null;
106
+ },
107
+ };
108
+ };