@decocms/apps-shopify 8.1.0-next.0 → 8.1.0-next.2

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.
Files changed (54) hide show
  1. package/dist/client.d.ts +36 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +82 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/graphqlOperationName.d.ts +36 -0
  6. package/dist/graphqlOperationName.d.ts.map +1 -0
  7. package/dist/graphqlOperationName.js +59 -0
  8. package/dist/graphqlOperationName.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +6 -0
  12. package/dist/index.js.map +1 -0
  13. package/package.json +16 -29
  14. package/src/client.ts +113 -47
  15. package/src/index.ts +9 -51
  16. package/src/actions/cart/addItems.ts +0 -36
  17. package/src/actions/cart/updateCoupons.ts +0 -31
  18. package/src/actions/cart/updateItems.ts +0 -31
  19. package/src/actions/user/signIn.ts +0 -40
  20. package/src/actions/user/signUp.ts +0 -31
  21. package/src/init.ts +0 -39
  22. package/src/loaders/ProductDetailsPage.ts +0 -31
  23. package/src/loaders/ProductList.ts +0 -102
  24. package/src/loaders/ProductListingPage.ts +0 -172
  25. package/src/loaders/RelatedProducts.ts +0 -42
  26. package/src/loaders/cart.ts +0 -74
  27. package/src/loaders/shop.ts +0 -35
  28. package/src/loaders/user.ts +0 -39
  29. package/src/manifest.gen.ts +0 -39
  30. package/src/mod.ts +0 -67
  31. package/src/registry.ts +0 -9
  32. package/src/utils/__tests__/instrumentedFetch.test.ts +0 -84
  33. package/src/utils/__tests__/operationRouter.test.ts +0 -56
  34. package/src/utils/__tests__/transform.test.ts +0 -216
  35. package/src/utils/admin/admin.ts +0 -57
  36. package/src/utils/admin/queries.ts +0 -29
  37. package/src/utils/cart.ts +0 -32
  38. package/src/utils/cookies.ts +0 -82
  39. package/src/utils/enums.ts +0 -438
  40. package/src/utils/graphql.ts +0 -70
  41. package/src/utils/graphqlOperationName.ts +0 -2
  42. package/src/utils/instrumentedFetch.ts +0 -56
  43. package/src/utils/operationRouter.ts +0 -70
  44. package/src/utils/storefront/queries.ts +0 -525
  45. package/src/utils/storefront/storefront.graphql.gen.ts +0 -129
  46. package/src/utils/transform.ts +0 -430
  47. package/src/utils/types.ts +0 -191
  48. package/src/utils/user.ts +0 -23
  49. package/src/utils/utils.ts +0 -159
  50. package/src/v8/client.test.ts +0 -136
  51. package/src/v8/client.ts +0 -125
  52. package/src/v8/graphqlOperationName.test.ts +0 -80
  53. package/tsconfig.json +0 -7
  54. /package/src/{v8/graphqlOperationName.ts → graphqlOperationName.ts} +0 -0
@@ -1,159 +0,0 @@
1
- import type {
2
- InputMaybe,
3
- ProductCollectionSortKeys,
4
- ProductFilter,
5
- SearchSortKeys as SearchSortKeysShopify,
6
- } from "../utils/storefront/storefront.graphql.gen";
7
-
8
- export const sortOptions = [
9
- { value: "", label: "relevance:desc" },
10
- { value: "price-ascending", label: "price:asc" },
11
- { value: "price-descending", label: "price:desc" },
12
- { value: "best-selling", label: "orders:desc" },
13
- { value: "title-ascending", label: "name:asc" },
14
- { value: "title-descending", label: "name:desc" },
15
- { value: "created-descending", label: "release:desc" },
16
- ];
17
-
18
- // only these sorts work for search at shopify
19
- export const searchSortOptions = [
20
- { value: "", label: "relevance:desc" },
21
- { value: "price-ascending", label: "price:asc" },
22
- { value: "price-descending", label: "price:desc" },
23
- ];
24
-
25
- export type CollectionSortKeys =
26
- | ""
27
- | "price-descending"
28
- | "price-ascending"
29
- | "best-selling"
30
- | "title-descending"
31
- | "title-ascending"
32
- | "created-descending";
33
-
34
- export type SearchSortKeys = "" | "price-descending" | "price-ascending";
35
-
36
- export const searchSortShopify: Record<
37
- string,
38
- { sortKey: SearchSortKeysShopify; reverse: boolean }
39
- > = {
40
- "": {
41
- sortKey: "RELEVANCE",
42
- reverse: false,
43
- },
44
- "price-descending": {
45
- sortKey: "PRICE",
46
- reverse: true,
47
- },
48
- "price-ascending": {
49
- sortKey: "PRICE",
50
- reverse: false,
51
- },
52
- };
53
-
54
- export const sortShopify: Record<
55
- string,
56
- {
57
- sortKey: ProductCollectionSortKeys | SearchSortKeysShopify;
58
- reverse: boolean;
59
- }
60
- > = {
61
- "": {
62
- sortKey: "RELEVANCE",
63
- reverse: false,
64
- },
65
- "price-descending": {
66
- sortKey: "PRICE",
67
- reverse: true,
68
- },
69
- "price-ascending": {
70
- sortKey: "PRICE",
71
- reverse: false,
72
- },
73
- "best-selling": {
74
- sortKey: "BEST_SELLING",
75
- reverse: false,
76
- },
77
- "title-descending": {
78
- sortKey: "TITLE",
79
- reverse: true,
80
- },
81
- "title-ascending": {
82
- sortKey: "TITLE",
83
- reverse: false,
84
- },
85
- "created-descending": {
86
- sortKey: "CREATED",
87
- reverse: true,
88
- },
89
- };
90
-
91
- export const filterToObject = (type: string, filter: InputMaybe<ProductFilter>) => {
92
- if (type === "tag") {
93
- return { tag: filter?.tag };
94
- }
95
- if (type === "productType") {
96
- return { productType: filter?.productType };
97
- }
98
- if (type === "productVendor") {
99
- return { productVendor: filter?.productVendor };
100
- }
101
- if (type === "priceMin") {
102
- return { price: { min: filter?.price?.min } };
103
- }
104
- if (type === "priceMax") {
105
- return { price: { max: filter?.price?.max } };
106
- }
107
- if (type === "variantOption") {
108
- return { variantOption: filter?.variantOption };
109
- }
110
- return undefined;
111
- };
112
-
113
- export const getFiltersByUrl = (url: URL) => {
114
- const filters: InputMaybe<ProductFilter[]> | undefined = [];
115
- url.searchParams.forEach((value, key) => {
116
- if (key.startsWith("filter.v.option")) {
117
- filters.push({
118
- variantOption: { name: key.split(".")[3], value: value },
119
- });
120
- } else if (key.startsWith("filter.p.tag")) {
121
- filters.push({ tag: value });
122
- } else if (key.startsWith("filter.p.type")) {
123
- filters.push({ productType: value });
124
- } else if (key.startsWith("filter.p.product_type")) {
125
- filters.push({ productType: value });
126
- } else if (key.startsWith("filter.p.vendor")) {
127
- filters.push({ productVendor: value });
128
- } else if (key.startsWith("filter.v.availability")) {
129
- filters.push({ available: value.toLowerCase() === "in stock" });
130
- } else if (key.startsWith("filter.v.price.gte")) {
131
- filters.push({ price: { min: Number(value) } });
132
- } else if (key.startsWith("filter.v.price.lte")) {
133
- filters.push({ price: { max: Number(value) } });
134
- } else if (key.startsWith("filter.p.m.custom")) {
135
- filters.push({
136
- productMetafield: {
137
- namespace: "custom",
138
- key: key.replace("filter.p.m.custom.", ""),
139
- value: value,
140
- },
141
- });
142
- } else if (key.startsWith("filter.v.t.shopify")) {
143
- filters.push({
144
- taxonomyMetafield: {
145
- namespace: "shopify",
146
- key: key.replace("filter.v.t.shopify.", ""),
147
- value: value,
148
- },
149
- });
150
- } else if (key.startsWith("filter.p.t.category")) {
151
- filters.push({
152
- category: {
153
- id: value,
154
- },
155
- });
156
- }
157
- });
158
- return filters;
159
- };
@@ -1,136 +0,0 @@
1
- // @vitest-environment node
2
- /**
3
- * createShopifyClient (/next/upstream-clients#write-a-client): every request
4
- * goes through the instrumented fetch as provider "shopify", labeled with the
5
- * GraphQL operation name, and errors carry no bodies or tokens.
6
- */
7
- import { beforeEach, describe, expect, it, vi } from "vitest";
8
-
9
- const seen = vi.hoisted(() => ({
10
- options: [] as unknown[],
11
- operations: [] as (string | undefined)[],
12
- }));
13
-
14
- vi.mock("@decocms/blocks/fetch", async (importOriginal) => {
15
- const real = await importOriginal<typeof import("@decocms/blocks/fetch")>();
16
- return {
17
- createInstrumentedFetch: (options: Parameters<typeof real.createInstrumentedFetch>[0]) => {
18
- seen.options.push(options);
19
- const request = real.createInstrumentedFetch(options);
20
- return (input: string | URL | Request, init?: RequestInit & { operation?: string }) => {
21
- seen.operations.push(init?.operation);
22
- return request(input, init);
23
- };
24
- },
25
- };
26
- });
27
-
28
- import { createShopifyClient, ShopifyError } from "./client";
29
-
30
- function upstream(body: unknown, status = 200) {
31
- return vi.fn(
32
- async (_input: string | URL | Request, _init?: RequestInit) =>
33
- new Response(JSON.stringify(body), { status }),
34
- );
35
- }
36
-
37
- const config = { storeName: "acme", storefrontAccessToken: "sf-token", apiVersion: "2026-07" };
38
-
39
- beforeEach(() => {
40
- seen.options.length = 0;
41
- seen.operations.length = 0;
42
- });
43
-
44
- describe("createShopifyClient", () => {
45
- it("posts to the Storefront API through the instrumented fetch, with no retries or circuit breaker", async () => {
46
- const fetch = upstream({ data: { product: { id: "1" } } });
47
- const shopify = createShopifyClient(config, { fetch });
48
-
49
- const data = await shopify.storefront.query<{ product: { id: string } }>(
50
- "query ProductByHandle($handle: String!) { product(handle: $handle) { id } }",
51
- { handle: "linen-shirt" },
52
- { headers: { "Shopify-Storefront-Buyer-IP": "203.0.113.7" } },
53
- );
54
-
55
- expect(data).toEqual({ product: { id: "1" } });
56
- expect(seen.options).toEqual([{ provider: "shopify", fetch }]);
57
- expect(seen.operations).toEqual(["ProductByHandle"]);
58
- const [url, init] = fetch.mock.calls[0] ?? [];
59
- expect(url).toBe("https://acme.myshopify.com/api/2026-07/graphql.json");
60
- expect(init?.method).toBe("POST");
61
- expect(init?.headers).toMatchObject({
62
- "x-shopify-storefront-access-token": "sf-token",
63
- "Shopify-Storefront-Buyer-IP": "203.0.113.7",
64
- });
65
- expect(JSON.parse(String(init?.body))).toEqual({
66
- query: "query ProductByHandle($handle: String!) { product(handle: $handle) { id } }",
67
- variables: { handle: "linen-shirt" },
68
- });
69
- });
70
-
71
- it("labels an unnamed document by its API surface", async () => {
72
- const fetch = upstream({ data: { shop: { name: "Acme" } } });
73
- const shopify = createShopifyClient(
74
- { ...config, adminAccessToken: "admin-token", apiVersion: "2025-07" },
75
- { fetch },
76
- );
77
- await shopify.storefront.query("{ shop { name } }");
78
- await shopify.admin.query("{ shop { name } }");
79
-
80
- expect(seen.operations).toEqual(["storefront.graphql", "admin.graphql"]);
81
- expect(fetch.mock.calls[1]?.[0]).toBe(
82
- "https://acme.myshopify.com/admin/api/2025-07/graphql.json",
83
- );
84
- expect(fetch.mock.calls[1]?.[1]?.headers).toMatchObject({
85
- "x-shopify-access-token": "admin-token",
86
- });
87
- });
88
-
89
- it("refuses admin calls without an admin token, without sending anything", async () => {
90
- const fetch = upstream({ data: {} });
91
- const shopify = createShopifyClient(config, { fetch });
92
- await expect(shopify.admin.query("{ shop { name } }")).rejects.toThrow(/adminAccessToken/);
93
- expect(fetch).not.toHaveBeenCalled();
94
- });
95
-
96
- it("reports HTTP failures by operation and status, never the body or token", async () => {
97
- const fetch = upstream({ errors: "secret upstream detail sf-token" }, 401);
98
- const shopify = createShopifyClient(config, { fetch });
99
- const error = (await shopify.storefront
100
- .query("query Cart { cart { id } }")
101
- .catch((e: unknown) => e)) as ShopifyError;
102
-
103
- expect(error).toBeInstanceOf(ShopifyError);
104
- expect(error).toMatchObject({ operation: "Cart", status: 401, codes: [] });
105
- expect(error.message).toBe("shopify Cart failed with HTTP 401");
106
- });
107
-
108
- it("reports a non-JSON body as INVALID_JSON, never quoting the body", async () => {
109
- const fetch = vi.fn(async () => new Response("<html>sf-token</html>", { status: 200 }));
110
- const shopify = createShopifyClient(config, { fetch });
111
- const error = (await shopify.storefront
112
- .query("query Cart { cart { id } }")
113
- .catch((e: unknown) => e)) as ShopifyError;
114
-
115
- expect(error).toBeInstanceOf(ShopifyError);
116
- expect(error).toMatchObject({ operation: "Cart", status: 200, codes: ["INVALID_JSON"] });
117
- expect(error.message).not.toContain("sf-token");
118
- expect(error.message).not.toContain("<html");
119
- });
120
-
121
- it("reports GraphQL errors by their codes, not their messages", async () => {
122
- const fetch = upstream({
123
- errors: [
124
- { message: "Throttled for customer@example.com", extensions: { code: "THROTTLED" } },
125
- { message: "again", extensions: { code: "THROTTLED" } },
126
- ],
127
- });
128
- const shopify = createShopifyClient(config, { fetch });
129
- const error = (await shopify.storefront
130
- .query("query Cart { cart { id } }")
131
- .catch((e: unknown) => e)) as ShopifyError;
132
-
133
- expect(error).toMatchObject({ operation: "Cart", status: 200, codes: ["THROTTLED"] });
134
- expect(error.message).not.toContain("customer@example.com");
135
- });
136
- });
package/src/v8/client.ts DELETED
@@ -1,125 +0,0 @@
1
- /**
2
- * The Shopify upstream client (/next/upstream-clients): the Storefront and
3
- * Admin GraphQL APIs over the framework's instrumented fetch, and nothing
4
- * else. Converters to commerce types, cart and customer flows, hooks and
5
- * loaders live in the Shopify platform template and site code.
6
- *
7
- * Every request is measured as provider "shopify", labeled with the GraphQL
8
- * operation's own name (`query ProductByHandle { … }` → "ProductByHandle"),
9
- * or "storefront.graphql" / "admin.graphql" for an unnamed document. Retries
10
- * and the circuit breaker stay off, as for every client but VTEX's; caching
11
- * is the framework binding's job.
12
- */
13
- import { createInstrumentedFetch } from "@decocms/blocks/fetch";
14
- import { extractGraphqlOperationName } from "./graphqlOperationName";
15
-
16
- export interface ShopifyClientConfig {
17
- /** The store's subdomain: "acme" for acme.myshopify.com. */
18
- storeName: string;
19
- /** Storefront API access token. */
20
- storefrontAccessToken: string;
21
- /** Admin API access token; `admin` is only available when it's set. */
22
- adminAccessToken?: string;
23
- /** The Shopify API version, e.g. "2026-07". The site owns it, so there is no default to go stale. */
24
- apiVersion: string;
25
- }
26
-
27
- interface ShopifyRequestOptions {
28
- /** Extra request headers, e.g. `Shopify-Storefront-Buyer-IP`. */
29
- headers?: Record<string, string>;
30
- signal?: AbortSignal;
31
- }
32
-
33
- /** One GraphQL endpoint: send a document, get its `data` back. */
34
- interface ShopifyGraphQL {
35
- query<T, V extends Record<string, unknown> = Record<string, unknown>>(
36
- document: string,
37
- variables?: V,
38
- options?: ShopifyRequestOptions,
39
- ): Promise<T>;
40
- }
41
-
42
- export interface ShopifyClient {
43
- storefront: ShopifyGraphQL;
44
- /** Throws when called without `adminAccessToken` in the config. */
45
- admin: ShopifyGraphQL;
46
- }
47
-
48
- /** A failed request. Carries the operation, the HTTP status and Shopify's error codes; never bodies or tokens. */
49
- export class ShopifyError extends Error {
50
- constructor(
51
- readonly operation: string,
52
- readonly status: number,
53
- readonly codes: string[] = [],
54
- ) {
55
- super(
56
- codes.length > 0
57
- ? `shopify ${operation} returned GraphQL errors (${codes.join(", ")})`
58
- : `shopify ${operation} failed with HTTP ${status}`,
59
- );
60
- this.name = "ShopifyError";
61
- }
62
- }
63
-
64
- export function createShopifyClient(
65
- config: ShopifyClientConfig,
66
- options: { fetch?: typeof globalThis.fetch } = {},
67
- ): ShopifyClient {
68
- const request = createInstrumentedFetch({ provider: "shopify", fetch: options.fetch });
69
- const origin = `https://${config.storeName}.myshopify.com`;
70
- const version = config.apiVersion;
71
-
72
- const endpoint = (surface: "storefront" | "admin", url: string, auth: Record<string, string>) =>
73
- ({
74
- async query(document, variables, { headers, signal } = {}) {
75
- const operation = extractGraphqlOperationName(document) ?? `${surface}.graphql`;
76
- const response = await request(url, {
77
- operation,
78
- method: "POST",
79
- headers: { "content-type": "application/json", ...headers, ...auth },
80
- body: JSON.stringify({ query: document, variables }),
81
- signal,
82
- });
83
- if (!response.ok) {
84
- void response.body?.cancel().catch(() => {});
85
- throw new ShopifyError(operation, response.status);
86
- }
87
- // A non-JSON body (an HTML error page) must not reach the error: the
88
- // SyntaxError's message quotes the start of the body.
89
- let body: { data?: unknown; errors?: { extensions?: { code?: unknown } }[] };
90
- try {
91
- body = await response.json();
92
- } catch {
93
- throw new ShopifyError(operation, response.status, ["INVALID_JSON"]);
94
- }
95
- // Partial data alongside errors is rejected as a whole, deliberately:
96
- // a thin client doesn't guess which fields are still trustworthy.
97
- if (body.errors?.length || body.data == null) {
98
- const codes = body.errors?.length
99
- ? body.errors.map((e) =>
100
- typeof e.extensions?.code === "string" ? e.extensions.code : "UNKNOWN",
101
- )
102
- : ["MISSING_DATA"];
103
- throw new ShopifyError(operation, response.status, [...new Set(codes)]);
104
- }
105
- return body.data as never;
106
- },
107
- }) satisfies ShopifyGraphQL;
108
-
109
- const adminToken = config.adminAccessToken;
110
- return {
111
- storefront: endpoint("storefront", `${origin}/api/${version}/graphql.json`, {
112
- "x-shopify-storefront-access-token": config.storefrontAccessToken,
113
- }),
114
- admin: adminToken
115
- ? endpoint("admin", `${origin}/admin/api/${version}/graphql.json`, {
116
- "x-shopify-access-token": adminToken,
117
- })
118
- : {
119
- query: () =>
120
- Promise.reject(
121
- new Error("shopify admin: pass adminAccessToken to createShopifyClient"),
122
- ),
123
- },
124
- };
125
- }
@@ -1,80 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { extractGraphqlOperationName } from "./graphqlOperationName";
3
-
4
- describe("extractGraphqlOperationName", () => {
5
- it("returns the explicit name when provided, regardless of body content", () => {
6
- expect(extractGraphqlOperationName("query Whatever { x }", "ForcedName")).toBe("ForcedName");
7
- expect(extractGraphqlOperationName("", "Override")).toBe("Override");
8
- });
9
-
10
- it("extracts a single named query", () => {
11
- expect(
12
- extractGraphqlOperationName("query ProductBySlug($slug: String!) { product { id } }"),
13
- ).toBe("ProductBySlug");
14
- });
15
-
16
- it("extracts a single named mutation", () => {
17
- expect(
18
- extractGraphqlOperationName("mutation CartLinesAdd($cartId: ID!) { cartLinesAdd { } }"),
19
- ).toBe("CartLinesAdd");
20
- });
21
-
22
- it("extracts a single named subscription", () => {
23
- expect(extractGraphqlOperationName("subscription OrderEvents { orderUpdated { id } }")).toBe(
24
- "OrderEvents",
25
- );
26
- });
27
-
28
- it("returns undefined for anonymous operations", () => {
29
- expect(extractGraphqlOperationName("{ product { id } }")).toBeUndefined();
30
- expect(extractGraphqlOperationName("query { product { id } }")).toBeUndefined();
31
- });
32
-
33
- it("returns undefined when document has more than one named operation (caller must disambiguate)", () => {
34
- const multi = `
35
- query OpA { a }
36
- query OpB { b }
37
- `;
38
- expect(extractGraphqlOperationName(multi)).toBeUndefined();
39
- });
40
-
41
- it("ignores the words query/mutation/subscription inside string literals", () => {
42
- const docWithStringy = `query RealName { thing(arg: "this query mutation subscription is a string") }`;
43
- expect(extractGraphqlOperationName(docWithStringy)).toBe("RealName");
44
- });
45
-
46
- it("ignores the words inside block strings (triple-quoted)", () => {
47
- const doc = `
48
- """
49
- This block string mentions query Inner and mutation Inner2.
50
- """
51
- query OuterReal { x }
52
- `;
53
- expect(extractGraphqlOperationName(doc)).toBe("OuterReal");
54
- });
55
-
56
- it("ignores the words inside # comments", () => {
57
- const doc = `
58
- # query CommentedOut { x }
59
- query Active { y }
60
- `;
61
- expect(extractGraphqlOperationName(doc)).toBe("Active");
62
- });
63
-
64
- it("returns undefined on an empty / nullish body", () => {
65
- expect(extractGraphqlOperationName("")).toBeUndefined();
66
- expect(extractGraphqlOperationName(" \n\t ")).toBeUndefined();
67
- });
68
-
69
- it("handles a real-world Shopify storefront query shape", () => {
70
- const doc = `
71
- query ProductDetails($handle: String!, $country: CountryCode!) @inContext(country: $country) {
72
- product(handle: $handle) {
73
- id
74
- title
75
- }
76
- }
77
- `;
78
- expect(extractGraphqlOperationName(doc)).toBe("ProductDetails");
79
- });
80
- });
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist"
5
- },
6
- "include": ["src/**/*"]
7
- }