From 16b6b81642a2bae8e7d80174262e7385c27a5ec7 Mon Sep 17 00:00:00 2001 From: Helen Lin Date: Tue, 17 Jan 2023 16:16:45 -0800 Subject: [PATCH] Shopify analytics (#108) * working shopify analytics * add to cart analytics * workign all events * file renames * file paths * lint * lint * fix nextjs app lint * fix nextjs app lint * test for schemas * lint test * fix nextjs app * add analytic-utils tests and refactor (#117) * add analytic-utils tests and refactor * fix name * fix test * add test for coverage * fix no product payload test * @juanpprieto/fix-failing-tests (#120) * add support for parsing complex gids and fix failing test * shorten cond checks * remove incorrect complex id parsing * fix typo * sendShopifyAnalytics tests * better test naming * lint * some PR feedbacks * more PR feedbacks * lint * more test * more test * better name test * @juanpprieto/cookie util test (#121) * fix lint complains * fix weird ts complain * fix format * clean up constants * convert ShopifyCookie to a hook * ts clean up * ts clean up * more ts clean up * more feedback * update ShopPayButton * make sure monorail endpoint can be updated to the shop domain alternative * mock failed response * add doc * prettier * ci browser different? * return explicit type * see if this works * fix type prettier * fix package path * full cookie test * prettier * move shopify cookie constants back into cart constant * missed a return type * Update .changeset/plenty-moles-listen.md Co-authored-by: Anthony Frehner * remove console log * update exposed methods, constants, and types * prettier * fix file name * fix file name again * Small updates Co-authored-by: Juan P. Prieto Co-authored-by: Anthony Frehner --- .changeset/plenty-moles-listen.md | 30 ++ apps/nextjs/gql/fragment-masking.ts | 10 +- apps/nextjs/gql/gql.ts | 41 +++ apps/nextjs/gql/graphql.ts | 148 +++++++- apps/nextjs/pages/_app.tsx | 65 +++- apps/nextjs/pages/collection.tsx | 113 ++++++ apps/nextjs/pages/index.tsx | 16 +- apps/nextjs/pages/product.tsx | 170 +++++++++ apps/nextjs/pages/search.tsx | 107 ++++++ packages/react/src/ShopPayButton.test.tsx | 25 +- packages/react/src/ShopPayButton.tsx | 20 +- packages/react/src/analytics-constants.ts | 39 ++ ...ustom-storefront-customer-tracking.test.ts | 313 ++++++++++++++++ ...ema-custom-storefront-customer-tracking.ts | 167 +++++++++ ...chema-trekkie-storefront-page-view.test.ts | 108 ++++++ ...ics-schema-trekkie-storefront-page-view.ts | 72 ++++ .../src/analytics-schema.test.helpers.ts | 24 ++ packages/react/src/analytics-types.ts | 94 +++++ packages/react/src/analytics-utils.test.ts | 88 +++++ packages/react/src/analytics-utils.ts | 94 +++++ packages/react/src/analytics.test.ts | 343 ++++++++++++++++++ packages/react/src/analytics.ts | 228 ++++++++++++ packages/react/src/cart-hooks.tsx | 10 +- packages/react/src/cookies-utils.test.ts | 27 ++ packages/react/src/cookies-utils.tsx | 75 ++++ packages/react/src/index.ts | 19 + packages/react/src/useShopifyCookies.test.tsx | 179 +++++++++ packages/react/src/useShopifyCookies.tsx | 55 +++ 28 files changed, 2648 insertions(+), 32 deletions(-) create mode 100644 .changeset/plenty-moles-listen.md create mode 100644 apps/nextjs/pages/collection.tsx create mode 100644 apps/nextjs/pages/product.tsx create mode 100644 apps/nextjs/pages/search.tsx create mode 100644 packages/react/src/analytics-constants.ts create mode 100644 packages/react/src/analytics-schema-custom-storefront-customer-tracking.test.ts create mode 100644 packages/react/src/analytics-schema-custom-storefront-customer-tracking.ts create mode 100644 packages/react/src/analytics-schema-trekkie-storefront-page-view.test.ts create mode 100644 packages/react/src/analytics-schema-trekkie-storefront-page-view.ts create mode 100644 packages/react/src/analytics-schema.test.helpers.ts create mode 100644 packages/react/src/analytics-types.ts create mode 100644 packages/react/src/analytics-utils.test.ts create mode 100644 packages/react/src/analytics-utils.ts create mode 100644 packages/react/src/analytics.test.ts create mode 100644 packages/react/src/analytics.ts create mode 100644 packages/react/src/cookies-utils.test.ts create mode 100644 packages/react/src/cookies-utils.tsx create mode 100644 packages/react/src/useShopifyCookies.test.tsx create mode 100644 packages/react/src/useShopifyCookies.tsx diff --git a/.changeset/plenty-moles-listen.md b/.changeset/plenty-moles-listen.md new file mode 100644 index 00000000..d8298015 --- /dev/null +++ b/.changeset/plenty-moles-listen.md @@ -0,0 +1,30 @@ +--- +'@shopify/storefront-kit-react': patch +--- + +Shopify Analytics + +Methods: + +- `useShopifyCookies(hasUserConsent = true, domain = ''): void` - sets and refreshes Shopify cookies +- `getShopifyCookie(cookieString: string): ShopifyCookie` - returns Shopify cookies +- `sendShopifyAnalytics({eventName: AnalyticsEventName, payload: ShopifyAnalytics}, domain?): Promise` - sends Shopify analytics +- `getClientBrowserParameters(): ClientBrowserParameters` - returns commonly tracked client browser values + +Constants: + +- `AnalyticsEventName` - list of Shopify accepted analytics events +- `AnalyticsPageType` - list of Shopify accepted page type names +- `ShopifyAppSource` - list of Shopify accepted application source + +Types: + +- `ShopifyCookies` +- `ClientBrowserParameters` +- `ShopifyAnalytics` - generic type for `ShopifyPageView` and `ShopifyAddToCart` +- `ShopifyAnalyticsPayload` - generic type for `ShopifyPageViewPayload` and `ShopifyAddToCartPayload` +- `ShopifyPageView` +- `ShopifyPageViewPayload` +- `ShopifyAddToCart` +- `ShopifyAddToCartPayload` +- `ShopifyAnalyticsProduct` diff --git a/apps/nextjs/gql/fragment-masking.ts b/apps/nextjs/gql/fragment-masking.ts index af0fecab..0df1ecfb 100644 --- a/apps/nextjs/gql/fragment-masking.ts +++ b/apps/nextjs/gql/fragment-masking.ts @@ -1,4 +1,4 @@ -import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +import { TypedDocumentNode as DocumentNode, ResultOf } from '@graphql-typed-document-node/core'; export type FragmentType> = TDocumentType extends DocumentNode< @@ -38,3 +38,11 @@ export function useFragment( ): TType | ReadonlyArray | null | undefined { return fragmentType as any } + + +export function makeFragmentData< + F extends DocumentNode, + FT extends ResultOf +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType; +} \ No newline at end of file diff --git a/apps/nextjs/gql/gql.ts b/apps/nextjs/gql/gql.ts index 257616a8..720e48cb 100644 --- a/apps/nextjs/gql/gql.ts +++ b/apps/nextjs/gql/gql.ts @@ -2,13 +2,54 @@ import * as types from './graphql'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel-plugin for production. + */ const documents = { + "\n query Collection($handle: String!) {\n collection(handle: $handle) {\n id\n handle\n title\n description\n }\n }\n": types.CollectionDocument, "\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n": types.IndexQueryDocument, + "\n query Product {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n vendor\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n title\n price {\n amount\n }\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n": types.ProductDocument, + "\n query Search($searchTerm: String) {\n products(first: 1, sortKey: RELEVANCE, query: $searchTerm) {\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n": types.SearchDocument, }; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query Collection($handle: String!) {\n collection(handle: $handle) {\n id\n handle\n title\n description\n }\n }\n"): (typeof documents)["\n query Collection($handle: String!) {\n collection(handle: $handle) {\n id\n handle\n title\n description\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ export function graphql(source: "\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query IndexQuery {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query Product {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n vendor\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n title\n price {\n amount\n }\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query Product {\n shop {\n name\n }\n products(first: 1) {\n nodes {\n # if you uncomment 'blah', it should have a GraphQL validation error in your IDE if you have a GraphQL plugin. It should also give an error during 'npm run dev'\n # blah\n id\n title\n vendor\n publishedAt\n handle\n variants(first: 1) {\n nodes {\n id\n title\n price {\n amount\n }\n image {\n url\n altText\n width\n height\n }\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query Search($searchTerm: String) {\n products(first: 1, sortKey: RELEVANCE, query: $searchTerm) {\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n"): (typeof documents)["\n query Search($searchTerm: String) {\n products(first: 1, sortKey: RELEVANCE, query: $searchTerm) {\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = gql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. +**/ export function graphql(source: string): unknown; + export function graphql(source: string) { return (documents as any)[source] ?? {}; } diff --git a/apps/nextjs/gql/graphql.ts b/apps/nextjs/gql/graphql.ts index 6deda5d1..b048b3f1 100644 --- a/apps/nextjs/gql/graphql.ts +++ b/apps/nextjs/gql/graphql.ts @@ -37,7 +37,7 @@ export type Scalars = { * A string containing HTML code. Refer to the [HTML spec](https://html.spec.whatwg.org/#elements-3) for a * complete list of HTML elements. * - * Example value: `"

Grey cotton knit sweater.

"`. + * Example value: `"

Grey cotton knit sweater.

"` * */ HTML: string; @@ -1366,6 +1366,8 @@ export enum CheckoutErrorCode { CustomerAlreadyUsedOncePerCustomerDiscountNotice = 'CUSTOMER_ALREADY_USED_ONCE_PER_CUSTOMER_DISCOUNT_NOTICE', /** Discount already applied. */ DiscountAlreadyApplied = 'DISCOUNT_ALREADY_APPLIED', + /** Discount code isn't working right now. Please contact us for help. */ + DiscountCodeApplicationFailed = 'DISCOUNT_CODE_APPLICATION_FAILED', /** Discount disabled. */ DiscountDisabled = 'DISCOUNT_DISABLED', /** Discount expired. */ @@ -4402,7 +4404,7 @@ export type MetafieldParentResource = Article | Blog | Collection | Customer | O * Returns the resource which is being referred to by a metafield. * */ -export type MetafieldReference = Collection | GenericFile | MediaImage | Page | Product | ProductVariant | Video; +export type MetafieldReference = Collection | GenericFile | MediaImage | Metaobject | Page | Product | ProductVariant | Video; /** * An auto-generated type for paginating through multiple MetafieldReferences. @@ -4430,6 +4432,95 @@ export type MetafieldReferenceEdge = { node: MetafieldReference; }; +/** An instance of a user-defined model based on a MetaobjectDefinition. */ +export type Metaobject = Node & { + __typename?: 'Metaobject'; + /** Accesses a field of the object by key. */ + field?: Maybe; + /** + * All object fields with defined values. + * Omitted object keys can be assumed null, and no guarantees are made about field order. + * + */ + fields: Array; + /** The unique handle of the metaobject. Useful as a custom ID. */ + handle: Scalars['String']; + /** A globally-unique identifier. */ + id: Scalars['ID']; + /** The type of the metaobject. Defines the namespace of its associated metafields. */ + type: Scalars['String']; + /** The date and time when the metaobject was last updated. */ + updatedAt: Scalars['DateTime']; +}; + + +/** An instance of a user-defined model based on a MetaobjectDefinition. */ +export type MetaobjectFieldArgs = { + key: Scalars['String']; +}; + +/** + * An auto-generated type for paginating through multiple Metaobjects. + * + */ +export type MetaobjectConnection = { + __typename?: 'MetaobjectConnection'; + /** A list of edges. */ + edges: Array; + /** A list of the nodes contained in MetaobjectEdge. */ + nodes: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + +/** + * An auto-generated type which holds one Metaobject and a cursor during pagination. + * + */ +export type MetaobjectEdge = { + __typename?: 'MetaobjectEdge'; + /** A cursor for use in pagination. */ + cursor: Scalars['String']; + /** The item at the end of MetaobjectEdge. */ + node: Metaobject; +}; + +/** Provides the value of a Metaobject field. */ +export type MetaobjectField = { + __typename?: 'MetaobjectField'; + /** The field key. */ + key: Scalars['String']; + /** A referenced object if the field type is a resource reference. */ + reference?: Maybe; + /** A list of referenced objects if the field type is a resource reference list. */ + references?: Maybe; + /** + * The type name of the field. + * See the list of [supported types](https://shopify.dev/apps/metafields/definitions/types). + * + */ + type: Scalars['String']; + /** The field value. */ + value?: Maybe; +}; + + +/** Provides the value of a Metaobject field. */ +export type MetaobjectFieldReferencesArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + +/** The input fields used to retrieve a metaobject by handle. */ +export type MetaobjectHandleInput = { + /** The handle of the metaobject. */ + handle: Scalars['String']; + /** The type of the metaobject. */ + type: Scalars['String']; +}; + /** Represents a Shopify hosted 3D model. */ export type Model3d = Media & Node & { __typename?: 'Model3d'; @@ -4928,6 +5019,8 @@ export type Order = HasMetafields & Node & { currentTotalPrice: MoneyV2; /** The total of all taxes applied to the order, excluding taxes for returned line items. */ currentTotalTax: MoneyV2; + /** A list of the custom attributes added to the order. */ + customAttributes: Array; /** The locale code in which this specific order happened. */ customerLocale?: Maybe; /** The unique URL that the customer can use to access the order. */ @@ -5667,6 +5760,8 @@ export type ProductFilter = { productType?: InputMaybe; /** The product vendor to filter on. */ productVendor?: InputMaybe; + /** A product tag to filter on. */ + tag?: InputMaybe; /** A variant metafield to filter on. */ variantMetafield?: InputMaybe; /** A variant option to filter on. */ @@ -5847,6 +5942,7 @@ export type ProductVariantStoreAvailabilityArgs = { before?: InputMaybe; first?: InputMaybe; last?: InputMaybe; + near?: InputMaybe; reverse?: InputMaybe; }; @@ -5936,6 +6032,10 @@ export type QueryRoot = { locations: LocationConnection; /** A storefront menu. */ menu?: Maybe; + /** Fetch a specific Metaobject by one of its unique identifiers. */ + metaobject?: Maybe; + /** All active metaobjects for the shop. */ + metaobjects: MetaobjectConnection; /** Returns a specific node by ID. */ node?: Maybe; /** Returns the list of nodes with the given IDs. */ @@ -6074,6 +6174,25 @@ export type QueryRootMenuArgs = { }; +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootMetaobjectArgs = { + handle?: InputMaybe; + id?: InputMaybe; +}; + + +/** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ +export type QueryRootMetaobjectsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + reverse?: InputMaybe; + sortKey?: InputMaybe; + type: Scalars['String']; +}; + + /** The schema’s entry-point for queries. This acts as the public, top-level API from which all queries must start. */ export type QueryRootNodeArgs = { id: Scalars['ID']; @@ -6160,6 +6279,7 @@ export type QueryRootUrlRedirectsArgs = { before?: InputMaybe; first?: InputMaybe; last?: InputMaybe; + query?: InputMaybe; reverse?: InputMaybe; }; @@ -6842,10 +6962,32 @@ export enum WeightUnit { Pounds = 'POUNDS' } +export type CollectionQueryVariables = Exact<{ + handle: Scalars['String']; +}>; + + +export type CollectionQuery = { __typename?: 'QueryRoot', collection?: { __typename?: 'Collection', id: string, handle: string, title: string, description: string } | null }; + export type IndexQueryQueryVariables = Exact<{ [key: string]: never; }>; export type IndexQueryQuery = { __typename?: 'QueryRoot', shop: { __typename?: 'Shop', name: string }, products: { __typename?: 'ProductConnection', nodes: Array<{ __typename?: 'Product', id: string, title: string, publishedAt: string, handle: string, variants: { __typename?: 'ProductVariantConnection', nodes: Array<{ __typename?: 'ProductVariant', id: string, image?: { __typename?: 'Image', url: string, altText?: string | null, width?: number | null, height?: number | null } | null }> } }> } }; +export type ProductQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ProductQuery = { __typename?: 'QueryRoot', shop: { __typename?: 'Shop', name: string }, products: { __typename?: 'ProductConnection', nodes: Array<{ __typename?: 'Product', id: string, title: string, vendor: string, publishedAt: string, handle: string, variants: { __typename?: 'ProductVariantConnection', nodes: Array<{ __typename?: 'ProductVariant', id: string, title: string, price: { __typename?: 'MoneyV2', amount: string }, image?: { __typename?: 'Image', url: string, altText?: string | null, width?: number | null, height?: number | null } | null }> } }> } }; + +export type SearchQueryVariables = Exact<{ + searchTerm?: InputMaybe; +}>; + + +export type SearchQuery = { __typename?: 'QueryRoot', products: { __typename?: 'ProductConnection', pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; + -export const IndexQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"IndexQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shop"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"products"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"variants"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"altText"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const CollectionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Collection"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"handle"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"collection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"handle"},"value":{"kind":"Variable","name":{"kind":"Name","value":"handle"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]} as unknown as DocumentNode; +export const IndexQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"IndexQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shop"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"products"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"variants"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"altText"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const ProductDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shop"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"products"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"vendor"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"variants"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"price"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"altText"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const SearchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Search"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"products"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}},{"kind":"Argument","name":{"kind":"Name","value":"sortKey"},"value":{"kind":"EnumValue","value":"RELEVANCE"}},{"kind":"Argument","name":{"kind":"Name","value":"query"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/apps/nextjs/pages/_app.tsx b/apps/nextjs/pages/_app.tsx index 80717dad..91de5dfd 100644 --- a/apps/nextjs/pages/_app.tsx +++ b/apps/nextjs/pages/_app.tsx @@ -1,8 +1,58 @@ import '../styles/globals.css'; import type {AppProps} from 'next/app'; -import {ShopifyProvider, CartProvider} from '@shopify/storefront-kit-react'; +import { + ShopifyProvider, + CartProvider, + sendShopifyAnalytics, + getClientBrowserParameters, + AnalyticsEventName, + type ShopifyPageViewPayload, + useShopifyCookies, +} from '@shopify/storefront-kit-react'; +import {useRouter} from 'next/router'; +import {useEffect} from 'react'; + +const analyticsShopData = { + shopId: 'gid://shopify/Shop/55145660472', + currency: 'USD', + acceptedLanguage: 'en', +}; +let isInit = false; export default function App({Component, pageProps}: AppProps) { + const router = useRouter(); + const hasUserConsent = true; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const analytics: ShopifyPageViewPayload = { + hasUserConsent, + ...analyticsShopData, + ...pageProps.analytics, + }; + const pagePropsWithAppAnalytics = { + ...pageProps, + analytics, + }; + + useEffect(() => { + const handleRouteChange = () => { + sendPageView(analytics); + }; + + router.events.on('routeChangeComplete', handleRouteChange); + + // First load event guard + if (!isInit) { + isInit = true; + sendPageView(analytics); + } + + return () => { + router.events.off('routeChangeComplete', handleRouteChange); + }; + }, [analytics, router.events]); + useShopifyCookies(); + return ( - + ); } + +function sendPageView(analyticsPageData: ShopifyPageViewPayload) { + const payload: ShopifyPageViewPayload = { + ...getClientBrowserParameters(), + ...analyticsPageData, + }; + sendShopifyAnalytics({ + eventName: AnalyticsEventName.PAGE_VIEW, + payload, + }); +} diff --git a/apps/nextjs/pages/collection.tsx b/apps/nextjs/pages/collection.tsx new file mode 100644 index 00000000..ac4d1304 --- /dev/null +++ b/apps/nextjs/pages/collection.tsx @@ -0,0 +1,113 @@ +import Head from 'next/head'; +import Image from 'next/image'; +import styles from '../styles/Home.module.css'; +import {graphql} from '../gql/gql'; +import {request} from 'graphql-request'; +import type {GetServerSideProps} from 'next'; +import {shopClient} from '../src/shopify-client'; +import type {CollectionQuery} from '../gql/graphql'; +import { + type StorefrontApiResponseOk, + useShop, + AnalyticsPageType, +} from '@shopify/storefront-kit-react'; +import Link from 'next/link'; + +export const getServerSideProps: GetServerSideProps = async () => { + // @TODO figure out how to get the client's IP address correctly and accurately. + // const buyerIp = + // req.headers["x-real-ip"] ?? + // req.headers["x-forwarded-for"] ?? + // req.socket.remoteAddress; + + try { + const response = await request({ + url: shopClient.getStorefrontApiUrl(), + document: query, + variables: { + handle: 'freestyle', + }, + // @TODO: convert to 'getPrivateTokenHeaders({buyerIp})' + requestHeaders: shopClient.getPublicTokenHeaders(), + }); + + if (!response.collection) { + return {props: {data: null, errors: ['No collection found']}}; + } + + const collection = response.collection; + + // @TODO I don't love how we do this with 'errors' and 'data' + return { + props: { + data: response, + errors: null, + analytics: { + pageType: AnalyticsPageType.collection, + resourceId: collection.id, + collectionHandle: collection.handle, + }, + }, + }; + } catch (err) { + console.error(err); + return {props: {data: null, errors: [(err as Error).toString()]}}; + } +}; + +export default function Collection({ + data, + errors, +}: StorefrontApiResponseOk) { + const {storeDomain} = useShop(); + + if (!data || errors) { + console.error(errors); + return
Whoops there was an error! Please refresh and try again.
; + } + + return ( +
+ + Create Next App + + + + +
+

Collection Page

+
Storefront API Domain: {storeDomain}
+
+ +
+ Back to Home + Go to Product + Go to Search +
+ + +
+ ); +} + +const query = graphql(` + query Collection($handle: String!) { + collection(handle: $handle) { + id + handle + title + description + } + } +`); diff --git a/apps/nextjs/pages/index.tsx b/apps/nextjs/pages/index.tsx index 6f3a4d83..7b5e5a28 100644 --- a/apps/nextjs/pages/index.tsx +++ b/apps/nextjs/pages/index.tsx @@ -10,7 +10,9 @@ import { Image as ShopifyImage, type StorefrontApiResponseOk, useShop, + AnalyticsPageType, } from '@shopify/storefront-kit-react'; +import Link from 'next/link'; export const getServerSideProps: GetServerSideProps = async () => { // @TODO figure out how to get the client's IP address correctly and accurately. @@ -28,7 +30,15 @@ export const getServerSideProps: GetServerSideProps = async () => { }); // @TODO I don't love how we do this with 'errors' and 'data' - return {props: {data: response, errors: null}}; + return { + props: { + data: response, + errors: null, + analytics: { + pageType: AnalyticsPageType.home, + }, + }, + }; } catch (err) { console.error(err); return {props: {data: null, errors: [(err as Error).toString()]}}; @@ -63,6 +73,10 @@ export default function Home({ loading="eager" />
Storefront API Domain: {storeDomain}
+
+ Go to Collection + Go to Product + Go to Search