@decocms/apps-shopify 7.2.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.
Files changed (37) hide show
  1. package/package.json +48 -0
  2. package/src/actions/cart/addItems.ts +36 -0
  3. package/src/actions/cart/updateCoupons.ts +31 -0
  4. package/src/actions/cart/updateItems.ts +31 -0
  5. package/src/actions/user/signIn.ts +40 -0
  6. package/src/actions/user/signUp.ts +31 -0
  7. package/src/client.ts +58 -0
  8. package/src/index.ts +43 -0
  9. package/src/init.ts +39 -0
  10. package/src/loaders/ProductDetailsPage.ts +31 -0
  11. package/src/loaders/ProductList.ts +102 -0
  12. package/src/loaders/ProductListingPage.ts +172 -0
  13. package/src/loaders/RelatedProducts.ts +42 -0
  14. package/src/loaders/cart.ts +74 -0
  15. package/src/loaders/shop.ts +35 -0
  16. package/src/loaders/user.ts +39 -0
  17. package/src/mod.ts +67 -0
  18. package/src/registry.ts +9 -0
  19. package/src/utils/__tests__/graphqlOperationName.test.ts +80 -0
  20. package/src/utils/__tests__/instrumentedFetch.test.ts +84 -0
  21. package/src/utils/__tests__/operationRouter.test.ts +56 -0
  22. package/src/utils/__tests__/transform.test.ts +216 -0
  23. package/src/utils/admin/admin.ts +57 -0
  24. package/src/utils/admin/queries.ts +29 -0
  25. package/src/utils/cart.ts +32 -0
  26. package/src/utils/cookies.ts +82 -0
  27. package/src/utils/enums.ts +438 -0
  28. package/src/utils/graphql.ts +69 -0
  29. package/src/utils/graphqlOperationName.ts +67 -0
  30. package/src/utils/instrumentedFetch.ts +55 -0
  31. package/src/utils/operationRouter.ts +70 -0
  32. package/src/utils/storefront/queries.ts +525 -0
  33. package/src/utils/transform.ts +430 -0
  34. package/src/utils/types.ts +191 -0
  35. package/src/utils/user.ts +23 -0
  36. package/src/utils/utils.ts +159 -0
  37. package/tsconfig.json +7 -0
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@decocms/apps-shopify",
3
+ "version": "7.2.0",
4
+ "type": "module",
5
+ "description": "Deco commerce app: Shopify integration",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/decocms/blocks.git",
9
+ "directory": "packages/apps-shopify"
10
+ },
11
+ "main": "./src/index.ts",
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./mod": "./src/mod.ts",
15
+ "./client": "./src/client.ts",
16
+ "./loaders/*": "./src/loaders/*.ts",
17
+ "./actions/*": "./src/actions/*.ts",
18
+ "./actions/cart/*": "./src/actions/cart/*.ts",
19
+ "./actions/user/*": "./src/actions/user/*.ts",
20
+ "./utils/*": "./src/utils/*.ts",
21
+ "./registry": "./src/registry.ts"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "test": "vitest run --root ../.. packages/apps-shopify/",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint:unused": "knip"
28
+ },
29
+ "dependencies": {
30
+ "@decocms/blocks": "7.2.0",
31
+ "@decocms/apps-commerce": "7.2.0",
32
+ "@decocms/tanstack": "7.2.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^19.0.0",
36
+ "react-dom": "^19.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^19.0.0",
40
+ "@types/react-dom": "^19.0.0",
41
+ "knip": "^5.86.0",
42
+ "typescript": "^5.9.0"
43
+ },
44
+ "publishConfig": {
45
+ "registry": "https://registry.npmjs.org",
46
+ "access": "public"
47
+ }
48
+ }
@@ -0,0 +1,36 @@
1
+ import { getShopifyClient } from "../../client";
2
+ import type { ShopifyCart } from "../../loaders/cart";
3
+ import { getCartCookie, setCartCookie } from "../../utils/cart";
4
+ import { AddItemToCart } from "../../utils/storefront/queries";
5
+
6
+ export interface AddItemProps {
7
+ lines: {
8
+ merchandiseId: string;
9
+ attributes?: Array<{ key: string; value: string }>;
10
+ quantity?: number;
11
+ sellingPlanId?: string;
12
+ };
13
+ requestHeaders: Headers;
14
+ responseHeaders?: Headers;
15
+ }
16
+
17
+ export default async function addItems({
18
+ lines,
19
+ requestHeaders,
20
+ responseHeaders,
21
+ }: AddItemProps): Promise<ShopifyCart | null> {
22
+ const client = getShopifyClient();
23
+ const cartId = getCartCookie(requestHeaders);
24
+
25
+ if (!cartId) throw new Error("Missing cart cookie");
26
+
27
+ const data = await client.query<{
28
+ payload?: { cart?: ShopifyCart };
29
+ }>(AddItemToCart, { cartId, lines });
30
+
31
+ if (responseHeaders) {
32
+ setCartCookie(responseHeaders, cartId);
33
+ }
34
+
35
+ return data.payload?.cart ?? null;
36
+ }
@@ -0,0 +1,31 @@
1
+ import { getShopifyClient } from "../../client";
2
+ import type { ShopifyCart } from "../../loaders/cart";
3
+ import { getCartCookie, setCartCookie } from "../../utils/cart";
4
+ import { AddCoupon } from "../../utils/storefront/queries";
5
+
6
+ export interface UpdateCouponsProps {
7
+ discountCodes: string[];
8
+ requestHeaders: Headers;
9
+ responseHeaders?: Headers;
10
+ }
11
+
12
+ export default async function updateCoupons({
13
+ discountCodes,
14
+ requestHeaders,
15
+ responseHeaders,
16
+ }: UpdateCouponsProps): Promise<ShopifyCart | null> {
17
+ const client = getShopifyClient();
18
+ const cartId = getCartCookie(requestHeaders);
19
+
20
+ if (!cartId) throw new Error("Missing cart cookie");
21
+
22
+ const data = await client.query<{
23
+ payload?: { cart?: ShopifyCart };
24
+ }>(AddCoupon, { cartId, discountCodes });
25
+
26
+ if (responseHeaders) {
27
+ setCartCookie(responseHeaders, cartId);
28
+ }
29
+
30
+ return data.payload?.cart ?? null;
31
+ }
@@ -0,0 +1,31 @@
1
+ import { getShopifyClient } from "../../client";
2
+ import type { ShopifyCart } from "../../loaders/cart";
3
+ import { getCartCookie, setCartCookie } from "../../utils/cart";
4
+ import { UpdateItems } from "../../utils/storefront/queries";
5
+
6
+ export interface UpdateItemsProps {
7
+ lines: Array<{ id: string; quantity: number }>;
8
+ requestHeaders: Headers;
9
+ responseHeaders?: Headers;
10
+ }
11
+
12
+ export default async function updateItems({
13
+ lines,
14
+ requestHeaders,
15
+ responseHeaders,
16
+ }: UpdateItemsProps): Promise<ShopifyCart | null> {
17
+ const client = getShopifyClient();
18
+ const cartId = getCartCookie(requestHeaders);
19
+
20
+ if (!cartId) throw new Error("Missing cart cookie");
21
+
22
+ const data = await client.query<{
23
+ payload?: { cart?: ShopifyCart };
24
+ }>(UpdateItems, { cartId, lines });
25
+
26
+ if (responseHeaders) {
27
+ setCartCookie(responseHeaders, cartId);
28
+ }
29
+
30
+ return data.payload?.cart ?? null;
31
+ }
@@ -0,0 +1,40 @@
1
+ import { getShopifyClient } from "../../client";
2
+ import { SignInWithEmailAndPassword } from "../../utils/storefront/queries";
3
+ import { getUserCookie, setUserCookie } from "../../utils/user";
4
+
5
+ export interface SignInProps {
6
+ email: string;
7
+ password: string;
8
+ requestHeaders: Headers;
9
+ responseHeaders?: Headers;
10
+ }
11
+
12
+ export interface SignInResult {
13
+ customerAccessTokenCreate: {
14
+ customerAccessToken?: { accessToken: string; expiresAt: string } | null;
15
+ customerUserErrors?: Array<{ code?: string; message: string }>;
16
+ };
17
+ }
18
+
19
+ export default async function signIn(props: SignInProps): Promise<SignInResult | null> {
20
+ const client = getShopifyClient();
21
+ const { email, password, requestHeaders, responseHeaders } = props;
22
+
23
+ const existingToken = getUserCookie(requestHeaders);
24
+ if (existingToken) return null;
25
+
26
+ try {
27
+ const data = await client.query<SignInResult>(SignInWithEmailAndPassword, { email, password });
28
+
29
+ if (data.customerAccessTokenCreate.customerAccessToken && responseHeaders) {
30
+ setUserCookie(
31
+ responseHeaders,
32
+ data.customerAccessTokenCreate.customerAccessToken.accessToken,
33
+ );
34
+ }
35
+
36
+ return data;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
@@ -0,0 +1,31 @@
1
+ import { getShopifyClient } from "../../client";
2
+ import { RegisterAccount } from "../../utils/storefront/queries";
3
+
4
+ export interface SignUpProps {
5
+ email: string;
6
+ password: string;
7
+ firstName?: string;
8
+ lastName?: string;
9
+ acceptsMarketing?: boolean;
10
+ }
11
+
12
+ export interface SignUpResult {
13
+ customerCreate: {
14
+ customer?: { id: string } | null;
15
+ customerUserErrors?: Array<{ code?: string; message: string }>;
16
+ };
17
+ }
18
+
19
+ export default async function signUp(props: SignUpProps): Promise<SignUpResult> {
20
+ const client = getShopifyClient();
21
+
22
+ const data = await client.query<SignUpResult>(RegisterAccount, {
23
+ email: props.email,
24
+ password: props.password,
25
+ firstName: props.firstName,
26
+ lastName: props.lastName,
27
+ acceptsMarketing: props.acceptsMarketing,
28
+ });
29
+
30
+ return data;
31
+ }
package/src/client.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { createGraphqlClient, type GraphQLClient } from "./utils/graphql";
2
+
3
+ export interface ShopifyConfig {
4
+ storeName: string;
5
+ storefrontAccessToken: string;
6
+ publicUrl?: string;
7
+ }
8
+
9
+ let _client: GraphQLClient | null = null;
10
+ let _config: ShopifyConfig | null = null;
11
+ let _fetch: typeof fetch | undefined;
12
+
13
+ /**
14
+ * Override the fetch function used by the Shopify GraphQL client.
15
+ * Use this to plug in instrumented fetch for logging/tracing.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { createInstrumentedFetch } from "@decocms/blocks/sdk/instrumentedFetch";
20
+ * import { setShopifyFetch } from "@decocms/apps/shopify";
21
+ * setShopifyFetch(createInstrumentedFetch("shopify"));
22
+ * ```
23
+ */
24
+ export function setShopifyFetch(fetchFn: typeof fetch) {
25
+ _fetch = fetchFn;
26
+ if (_config) configureShopify(_config);
27
+ }
28
+
29
+ export function configureShopify(config: ShopifyConfig) {
30
+ _config = config;
31
+ _client = createGraphqlClient(
32
+ `https://${config.storeName}.myshopify.com/api/2025-04/graphql.json`,
33
+ {
34
+ "X-Shopify-Storefront-Access-Token": config.storefrontAccessToken,
35
+ },
36
+ _fetch,
37
+ );
38
+ }
39
+
40
+ export function getShopifyClient(): GraphQLClient {
41
+ if (!_client || !_config) {
42
+ throw new Error(
43
+ "Shopify not configured. Call configureShopify() first or check deco-shopify.json block.",
44
+ );
45
+ }
46
+ return _client;
47
+ }
48
+
49
+ export function getShopifyConfig(): ShopifyConfig {
50
+ if (!_config) {
51
+ throw new Error("Shopify not configured.");
52
+ }
53
+ return _config;
54
+ }
55
+
56
+ export function getBaseUrl(): string {
57
+ return _config?.publicUrl || "";
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ // App contract
2
+ export { configure, type ShopifyState } from "./mod";
3
+
4
+ // Client & Config
5
+
6
+ export { default as addItems } from "./actions/cart/addItems";
7
+ export { default as updateCoupons } from "./actions/cart/updateCoupons";
8
+ export { default as updateItems } from "./actions/cart/updateItems";
9
+ export { default as signIn } from "./actions/user/signIn";
10
+ export { default as signUp } from "./actions/user/signUp";
11
+ export type { ShopifyConfig } from "./client";
12
+ export {
13
+ configureShopify,
14
+ getBaseUrl,
15
+ getShopifyClient,
16
+ getShopifyConfig,
17
+ setShopifyFetch,
18
+ } from "./client";
19
+ export { initShopify, initShopifyFromBlocks } from "./init";
20
+ export type { CartLine, ShopifyCart } from "./loaders/cart";
21
+ // Cart
22
+ export { createCart, getCart } from "./loaders/cart";
23
+ export { default as productDetailsPageLoader } from "./loaders/ProductDetailsPage";
24
+ // Product Loaders
25
+ export { default as productListLoader } from "./loaders/ProductList";
26
+ export { default as productListingPageLoader } from "./loaders/ProductListingPage";
27
+ export { default as relatedProductsLoader } from "./loaders/RelatedProducts";
28
+ export type { Shop } from "./loaders/shop";
29
+ // Shop
30
+ export { default as shopLoader } from "./loaders/shop";
31
+ export type { ShopifyUser } from "./loaders/user";
32
+ // User
33
+ export { default as userLoader } from "./loaders/user";
34
+ export { getCartCookie, setCartCookie } from "./utils/cart";
35
+ // Cookie utils
36
+ export { getCookies, setCookie } from "./utils/cookies";
37
+ export { extractGraphqlOperationName } from "./utils/graphqlOperationName";
38
+ export {
39
+ type CreateShopifyFetchOptions,
40
+ createShopifyFetch,
41
+ } from "./utils/instrumentedFetch";
42
+ export { shopifyOperationRouter } from "./utils/operationRouter";
43
+ export { getUserCookie, setUserCookie } from "./utils/user";
package/src/init.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { configureShopify } from "./client";
2
+
3
+ let initialized = false;
4
+
5
+ /**
6
+ * Initialize Shopify from raw block data.
7
+ * The site is responsible for reading the blocks and passing the config here.
8
+ */
9
+ export function initShopify(config: { storeName: string; storefrontAccessToken: string }) {
10
+ if (initialized) return;
11
+
12
+ if (!config.storeName || !config.storefrontAccessToken) {
13
+ console.warn("[Shopify] Missing storeName or storefrontAccessToken.");
14
+ return;
15
+ }
16
+
17
+ console.log(`[Shopify] Initializing: ${config.storeName}.myshopify.com`);
18
+ configureShopify(config);
19
+ initialized = true;
20
+ }
21
+
22
+ /**
23
+ * Initialize Shopify from a blocks map (convenience wrapper).
24
+ * Looks for the "deco-shopify" block and extracts credentials.
25
+ */
26
+ export function initShopifyFromBlocks(blocks: Record<string, unknown>) {
27
+ const shopifyBlock = blocks["deco-shopify"] as
28
+ | { storeName: string; storefrontAccessToken: string }
29
+ | undefined;
30
+ if (!shopifyBlock) {
31
+ console.warn("[Shopify] No deco-shopify block found.");
32
+ return;
33
+ }
34
+
35
+ initShopify({
36
+ storeName: shopifyBlock.storeName,
37
+ storefrontAccessToken: shopifyBlock.storefrontAccessToken,
38
+ });
39
+ }
@@ -0,0 +1,31 @@
1
+ import type { ProductDetailsPage } from "@decocms/apps-commerce/types";
2
+ import { getShopifyClient } from "../client";
3
+ import { GetProduct } from "../utils/storefront/queries";
4
+ import { type ProductShopify, toProductPage } from "../utils/transform";
5
+ import type { Metafield } from "../utils/types";
6
+
7
+ export interface Props {
8
+ slug: string;
9
+ metafields?: Metafield[];
10
+ }
11
+
12
+ export default async function productDetailsPageLoader(
13
+ props: Props,
14
+ url?: URL,
15
+ ): Promise<ProductDetailsPage | null> {
16
+ const client = getShopifyClient();
17
+ const { slug, metafields = [] } = props;
18
+
19
+ const splitted = slug?.split("-") ?? [];
20
+ const maybeSkuId = Number(splitted[splitted.length - 1]);
21
+ const handle = splitted.slice(0, maybeSkuId ? -1 : undefined).join("-");
22
+
23
+ const data = await client.query<{ product?: ProductShopify }>(GetProduct, {
24
+ handle,
25
+ identifiers: metafields,
26
+ });
27
+
28
+ if (!data?.product) return null;
29
+
30
+ return toProductPage(data.product, url ?? new URL("https://localhost"), maybeSkuId || undefined);
31
+ }
@@ -0,0 +1,102 @@
1
+ import type { Product } from "@decocms/apps-commerce/types";
2
+ import { getShopifyClient } from "../client";
3
+ import { ProductsByCollection, SearchProducts } from "../utils/storefront/queries";
4
+ import { type ProductShopify, toProduct } from "../utils/transform";
5
+ import type { Metafield } from "../utils/types";
6
+ import {
7
+ type CollectionSortKeys,
8
+ type SearchSortKeys,
9
+ searchSortShopify,
10
+ sortShopify,
11
+ } from "../utils/utils";
12
+
13
+ export interface QueryProps {
14
+ query: string;
15
+ count: number;
16
+ sort?: SearchSortKeys;
17
+ }
18
+
19
+ export interface CollectionProps {
20
+ collection: string;
21
+ count: number;
22
+ sort?: CollectionSortKeys;
23
+ }
24
+
25
+ export interface FilterProps {
26
+ tags?: string[];
27
+ productTypes?: string[];
28
+ productVendors?: string[];
29
+ priceMin?: number;
30
+ priceMax?: number;
31
+ variantOptions?: { name: string; value: string }[];
32
+ }
33
+
34
+ export type Props = {
35
+ props: QueryProps | CollectionProps;
36
+ filters?: FilterProps;
37
+ metafields?: Metafield[];
38
+ };
39
+
40
+ const isQueryList = (p: QueryProps | CollectionProps): p is QueryProps =>
41
+ "query" in p && typeof p.query === "string" && typeof p.count === "number";
42
+
43
+ export default async function productListLoader(
44
+ expandedProps: Props,
45
+ url?: URL,
46
+ ): Promise<Product[] | null> {
47
+ const client = getShopifyClient();
48
+
49
+ const props = expandedProps.props ?? (expandedProps as unknown as Props["props"]);
50
+
51
+ const count = props.count ?? 12;
52
+ const metafields = expandedProps.metafields || [];
53
+ const sort = props.sort ?? "";
54
+
55
+ const filters: Record<string, unknown>[] = [];
56
+ for (const tag of expandedProps.filters?.tags ?? []) {
57
+ filters.push({ tag });
58
+ }
59
+ for (const productType of expandedProps.filters?.productTypes ?? []) {
60
+ filters.push({ productType });
61
+ }
62
+ for (const productVendor of expandedProps.filters?.productVendors ?? []) {
63
+ filters.push({ productVendor });
64
+ }
65
+ if (expandedProps.filters?.priceMin != null)
66
+ filters.push({ price: { min: expandedProps.filters.priceMin } });
67
+ if (expandedProps.filters?.priceMax != null)
68
+ filters.push({ price: { max: expandedProps.filters.priceMax } });
69
+ for (const variantOption of expandedProps.filters?.variantOptions ?? []) {
70
+ filters.push({ variantOption });
71
+ }
72
+
73
+ let shopifyProducts: { nodes: ProductShopify[] } | undefined;
74
+
75
+ if (isQueryList(props)) {
76
+ const data = await client.query<{ search: { nodes: ProductShopify[] } }>(SearchProducts, {
77
+ first: count,
78
+ query: props.query,
79
+ productFilters: filters,
80
+ identifiers: metafields,
81
+ ...searchSortShopify[sort],
82
+ });
83
+ shopifyProducts = data.search;
84
+ } else {
85
+ const data = await client.query<{
86
+ collection?: { products: { nodes: ProductShopify[] } };
87
+ }>(ProductsByCollection, {
88
+ first: count,
89
+ handle: (props as CollectionProps).collection,
90
+ filters,
91
+ identifiers: metafields,
92
+ ...sortShopify[sort],
93
+ });
94
+ shopifyProducts = data.collection?.products;
95
+ }
96
+
97
+ const baseUrl = url ?? new URL("https://localhost");
98
+
99
+ const products = shopifyProducts?.nodes.map((p) => toProduct(p, p.variants.nodes[0], baseUrl));
100
+
101
+ return products ?? [];
102
+ }
@@ -0,0 +1,172 @@
1
+ import type { ProductListingPage } from "@decocms/apps-commerce/types";
2
+ import { getShopifyClient } from "../client";
3
+ import { ProductsByCollection, SearchProducts } from "../utils/storefront/queries";
4
+ import { type ProductShopify, toFilter, toProduct } from "../utils/transform";
5
+ import type { Metafield } from "../utils/types";
6
+ import {
7
+ getFiltersByUrl,
8
+ searchSortOptions,
9
+ searchSortShopify,
10
+ sortOptions,
11
+ sortShopify,
12
+ } from "../utils/utils";
13
+
14
+ interface PageInfo {
15
+ hasNextPage: boolean;
16
+ hasPreviousPage: boolean;
17
+ endCursor?: string;
18
+ startCursor?: string;
19
+ }
20
+
21
+ interface FilterNode {
22
+ id: string;
23
+ label: string;
24
+ type: string;
25
+ values: Array<{ id: string; label: string; count: number; input: string }>;
26
+ }
27
+
28
+ interface ProductConnection {
29
+ nodes: ProductShopify[];
30
+ pageInfo: PageInfo;
31
+ filters?: FilterNode[];
32
+ }
33
+
34
+ export interface Props {
35
+ query?: string;
36
+ collectionName?: string;
37
+ count: number;
38
+ metafields?: Metafield[];
39
+ pageOffset?: number;
40
+ page?: number;
41
+ startCursor?: string;
42
+ endCursor?: string;
43
+ pageHref?: string;
44
+ }
45
+
46
+ export default async function productListingPageLoader(
47
+ props: Props,
48
+ url?: URL,
49
+ ): Promise<ProductListingPage | null> {
50
+ const pageUrl = url ?? new URL(props.pageHref || "https://localhost");
51
+ const client = getShopifyClient();
52
+
53
+ const count = props.count ?? 12;
54
+ const query = props.query || pageUrl.searchParams.get("q") || "";
55
+ const currentPageoffset = props.pageOffset ?? 1;
56
+ const pageParam = pageUrl.searchParams.get("page")
57
+ ? Number(pageUrl.searchParams.get("page")) - currentPageoffset
58
+ : 0;
59
+ const page = props.page || pageParam;
60
+ const endCursor = props.endCursor || pageUrl.searchParams.get("endCursor") || "";
61
+ const startCursor = props.startCursor || pageUrl.searchParams.get("startCursor") || "";
62
+ const metafields = props.metafields || [];
63
+
64
+ const isSearch = Boolean(query);
65
+ let hasNextPage = false;
66
+ let hasPreviousPage = false;
67
+ let shopifyProducts: ProductConnection | undefined;
68
+ let shopifyFilters: FilterNode[] | undefined;
69
+ let records: number | undefined;
70
+ let collectionTitle: string | undefined;
71
+ let collectionDescription: string | undefined;
72
+
73
+ const sort = pageUrl.searchParams.get("sort") ?? "";
74
+
75
+ if (isSearch) {
76
+ const data = await client.query<{
77
+ search: ProductConnection & { totalCount?: number; productFilters?: FilterNode[] };
78
+ }>(SearchProducts, {
79
+ ...(!endCursor && { first: count }),
80
+ ...(endCursor && { last: count }),
81
+ ...(startCursor && { after: startCursor }),
82
+ ...(endCursor && { before: endCursor }),
83
+ query,
84
+ productFilters: getFiltersByUrl(pageUrl),
85
+ identifiers: metafields,
86
+ ...searchSortShopify[sort],
87
+ });
88
+
89
+ shopifyProducts = data.search;
90
+ shopifyFilters = data.search?.productFilters;
91
+ records = data.search?.totalCount;
92
+ hasNextPage = Boolean(data.search?.pageInfo.hasNextPage);
93
+ hasPreviousPage = Boolean(data.search?.pageInfo.hasPreviousPage);
94
+ } else {
95
+ const pathname = props.collectionName || pageUrl.pathname.split("/")[1];
96
+
97
+ const data = await client.query<{
98
+ collection?: {
99
+ title?: string;
100
+ description?: string;
101
+ products: ProductConnection;
102
+ };
103
+ }>(ProductsByCollection, {
104
+ ...(!endCursor && { first: count }),
105
+ ...(endCursor && { last: count }),
106
+ ...(startCursor && { after: startCursor }),
107
+ ...(endCursor && { before: endCursor }),
108
+ identifiers: metafields,
109
+ handle: pathname,
110
+ filters: getFiltersByUrl(pageUrl),
111
+ ...sortShopify[sort],
112
+ });
113
+
114
+ shopifyProducts = data.collection?.products;
115
+ shopifyFilters = data.collection?.products?.filters;
116
+ hasNextPage = Boolean(data.collection?.products.pageInfo.hasNextPage);
117
+ hasPreviousPage = Boolean(data.collection?.products.pageInfo.hasPreviousPage);
118
+ collectionTitle = data.collection?.title;
119
+ collectionDescription = data.collection?.description;
120
+ }
121
+
122
+ const products = shopifyProducts?.nodes?.map((p) => toProduct(p, p.variants.nodes[0], pageUrl));
123
+
124
+ const nextPage = new URLSearchParams(pageUrl.searchParams);
125
+ const previousPage = new URLSearchParams(pageUrl.searchParams);
126
+
127
+ if (hasNextPage) {
128
+ nextPage.set("page", (page + currentPageoffset + 1).toString());
129
+ nextPage.set("startCursor", shopifyProducts?.pageInfo.endCursor ?? "");
130
+ nextPage.delete("endCursor");
131
+ }
132
+
133
+ if (hasPreviousPage) {
134
+ previousPage.set("page", (page + currentPageoffset - 1).toString());
135
+ previousPage.set("endCursor", shopifyProducts?.pageInfo.startCursor ?? "");
136
+ previousPage.delete("startCursor");
137
+ }
138
+
139
+ const filters = shopifyFilters?.map((filter) => toFilter(filter, pageUrl));
140
+ const currentPage = page + currentPageoffset;
141
+
142
+ return {
143
+ "@type": "ProductListingPage",
144
+ breadcrumb: {
145
+ "@type": "BreadcrumbList",
146
+ itemListElement: [
147
+ {
148
+ "@type": "ListItem" as const,
149
+ name: isSearch ? query : pageUrl.pathname.split("/")[1],
150
+ item: isSearch ? pageUrl.href : pageUrl.pathname,
151
+ position: 2,
152
+ },
153
+ ],
154
+ numberOfItems: 1,
155
+ },
156
+ filters: filters ?? [],
157
+ products: products ?? [],
158
+ pageInfo: {
159
+ nextPage: hasNextPage ? `?${nextPage}` : undefined,
160
+ previousPage: hasPreviousPage ? `?${previousPage}` : undefined,
161
+ currentPage,
162
+ records,
163
+ recordPerPage: count,
164
+ },
165
+ sortOptions: isSearch ? searchSortOptions : sortOptions,
166
+ seo: {
167
+ title: collectionTitle || "",
168
+ description: collectionDescription || "",
169
+ canonical: `${pageUrl.origin}${pageUrl.pathname}${page >= 1 ? `?page=${page}` : ""}`,
170
+ },
171
+ };
172
+ }