From 396a708e23c8c31268fccfb3330be17a0e2339a6 Mon Sep 17 00:00:00 2001 From: cond0r Date: Thu, 10 Jun 2021 13:18:57 +0300 Subject: [PATCH] Add Shopify related products --- .../product/ProductView/ProductView.tsx | 46 ++-- framework/commerce/api/operations.ts | 20 +- framework/commerce/types/product.ts | 10 + .../api/operations/get-related-products.ts | 74 ++++++ framework/shopify/api/operations/index.ts | 1 + framework/shopify/schema.d.ts | 221 ++++++++++-------- framework/shopify/schema.graphql | 30 +-- .../utils/queries/get-all-products-query.ts | 55 +++-- .../utils/queries/get-product-query.ts | 125 +++++----- .../queries/get-related-products-query.ts | 11 + pages/product/[slug].tsx | 14 +- 11 files changed, 379 insertions(+), 228 deletions(-) create mode 100644 framework/shopify/api/operations/get-related-products.ts create mode 100644 framework/shopify/utils/queries/get-related-products-query.ts diff --git a/components/product/ProductView/ProductView.tsx b/components/product/ProductView/ProductView.tsx index f689030d6..aab6d0d19 100644 --- a/components/product/ProductView/ProductView.tsx +++ b/components/product/ProductView/ProductView.tsx @@ -61,29 +61,31 @@ const ProductView: FC = ({ product, relatedProducts }) => {
-
- Related Products -
- {relatedProducts.map((p) => ( -
- + Related Products +
+ {relatedProducts.map((p) => ( +
-
- ))} -
-
+ className="animated fadeIn bg-accent-0 border border-accent-2" + > + + + ))} + + + )} { +const noop = (_props?: any) => { throw new Error('Not implemented') } @@ -22,6 +23,7 @@ export const OPERATIONS = [ 'getCustomerWishlist', 'getAllProductPaths', 'getAllProducts', + 'getRelatedProducts', 'getProduct', ] as const @@ -139,6 +141,22 @@ export type Operations

= { ): Promise } + getRelatedProducts: { + (opts: { + variables: T['variables'] + config?: P['config'] + preview?: boolean + }): Promise + + ( + opts: { + variables: T['variables'] + config?: P['config'] + preview?: boolean + } & OperationOptions + ): Promise + } + getProduct: { (opts: { variables: T['variables'] diff --git a/framework/commerce/types/product.ts b/framework/commerce/types/product.ts index 6a68d8ad1..eb0b53e99 100644 --- a/framework/commerce/types/product.ts +++ b/framework/commerce/types/product.ts @@ -93,6 +93,16 @@ export type GetAllProductsOperation = { } } +export type GetRelatedProductsOperation< + T extends ProductTypes = ProductTypes +> = { + data: { products: T['product'][] } + variables: { + productId: string + first?: number + } +} + export type GetProductOperation = { data: { product?: T['product'] } variables: { path: string; slug?: never } | { path?: never; slug: string } diff --git a/framework/shopify/api/operations/get-related-products.ts b/framework/shopify/api/operations/get-related-products.ts new file mode 100644 index 000000000..ea60e277a --- /dev/null +++ b/framework/shopify/api/operations/get-related-products.ts @@ -0,0 +1,74 @@ +import type { + OperationContext, + OperationOptions, +} from '@commerce/api/operations' +import { GetRelatedProductsOperation } from '../../types/product' +import { + GetRelatedProductsQuery, + GetRelatedProductsQueryVariables, + Product as ShopifyProduct, +} from '../../schema' +import type { ShopifyConfig, Provider } from '..' +import getRelatedProductsQuery from '../../utils/queries/get-related-products-query' +import { normalizeProduct } from '../../utils' + +export default function getRelatedProductsOperation({ + commerce, +}: OperationContext) { + async function getRelatedProductsOperation< + T extends GetRelatedProductsOperation + >(opts: { + variables: T['variables'] + config?: Partial + preview?: boolean + }): Promise + + async function getRelatedProductsOperation< + T extends GetRelatedProductsOperation + >( + opts: { + variables: T['variables'] + config?: Partial + preview?: boolean + } & OperationOptions + ): Promise + + async function getRelatedProductsOperation< + T extends GetRelatedProductsOperation + >({ + query = getRelatedProductsQuery, + variables, + config, + }: { + query?: string + variables: T['variables'] + config?: Partial + preview?: boolean + }): Promise { + const { fetch, locale } = commerce.getConfig(config) + + const { data } = await fetch< + GetRelatedProductsQuery, + GetRelatedProductsQueryVariables + >( + query, + { variables }, + { + ...(locale && { + headers: { + 'Accept-Language': locale, + }, + }), + } + ) + + return { + products: + data.productRecommendations + ?.map((product) => normalizeProduct(product as ShopifyProduct)) + .splice(0, variables?.first || 4) ?? [], + } + } + + return getRelatedProductsOperation +} diff --git a/framework/shopify/api/operations/index.ts b/framework/shopify/api/operations/index.ts index 7872a20b6..f7fff903e 100644 --- a/framework/shopify/api/operations/index.ts +++ b/framework/shopify/api/operations/index.ts @@ -2,6 +2,7 @@ export { default as getAllPages } from './get-all-pages' export { default as getPage } from './get-page' export { default as getAllProducts } from './get-all-products' export { default as getAllProductPaths } from './get-all-product-paths' +export { default as getRelatedProducts } from './get-related-products' export { default as getProduct } from './get-product' export { default as getSiteInfo } from './get-site-info' export { default as login } from './login' diff --git a/framework/shopify/schema.d.ts b/framework/shopify/schema.d.ts index 328f0ff1b..66236ab44 100644 --- a/framework/shopify/schema.d.ts +++ b/framework/shopify/schema.d.ts @@ -2635,7 +2635,7 @@ export type FulfillmentTrackingInfo = { /** Represents information about the metafields associated to the specified resource. */ export type HasMetafields = { - /** The metafield associated with the resource. */ + /** Returns a metafield found by namespace and key. */ metafield?: Maybe /** A paginated list of metafields associated with the resource. */ metafields: MetafieldConnection @@ -3908,7 +3908,7 @@ export type Product = Node & images: ImageConnection /** The media associated with the product. */ media: MediaConnection - /** The metafield associated with the resource. */ + /** Returns a metafield found by namespace and key. */ metafield?: Maybe /** A paginated list of metafields associated with the resource. */ metafields: MetafieldConnection @@ -4235,7 +4235,7 @@ export type ProductVariant = Node & id: Scalars['ID'] /** Image associated with the product variant. This field falls back to the product image if no image is available. */ image?: Maybe - /** The metafield associated with the resource. */ + /** Returns a metafield found by namespace and key. */ metafield?: Maybe /** A paginated list of metafields associated with the resource. */ metafields: MetafieldConnection @@ -5265,6 +5265,32 @@ export type GetAllProductPathsQuery = { __typename?: 'QueryRoot' } & { } } +export type ListProductDetailsFragment = { __typename?: 'Product' } & Pick< + Product, + 'id' | 'title' | 'vendor' | 'handle' +> & { + priceRange: { __typename?: 'ProductPriceRange' } & { + minVariantPrice: { __typename?: 'MoneyV2' } & Pick< + MoneyV2, + 'amount' | 'currencyCode' + > + } + images: { __typename?: 'ImageConnection' } & { + pageInfo: { __typename?: 'PageInfo' } & Pick< + PageInfo, + 'hasNextPage' | 'hasPreviousPage' + > + edges: Array< + { __typename?: 'ImageEdge' } & { + node: { __typename?: 'Image' } & Pick< + Image, + 'originalSrc' | 'altText' | 'width' | 'height' + > + } + > + } + } + export type ProductConnectionFragment = { __typename?: 'ProductConnection' } & { pageInfo: { __typename?: 'PageInfo' } & Pick< PageInfo, @@ -5272,31 +5298,7 @@ export type ProductConnectionFragment = { __typename?: 'ProductConnection' } & { > edges: Array< { __typename?: 'ProductEdge' } & { - node: { __typename?: 'Product' } & Pick< - Product, - 'id' | 'title' | 'vendor' | 'handle' - > & { - priceRange: { __typename?: 'ProductPriceRange' } & { - minVariantPrice: { __typename?: 'MoneyV2' } & Pick< - MoneyV2, - 'amount' | 'currencyCode' - > - } - images: { __typename?: 'ImageConnection' } & { - pageInfo: { __typename?: 'PageInfo' } & Pick< - PageInfo, - 'hasNextPage' | 'hasPreviousPage' - > - edges: Array< - { __typename?: 'ImageEdge' } & { - node: { __typename?: 'Image' } & Pick< - Image, - 'originalSrc' | 'altText' | 'width' | 'height' - > - } - > - } - } + node: { __typename?: 'Product' } & ListProductDetailsFragment } > } @@ -5344,6 +5346,12 @@ export type CheckoutDetailsFragment = { __typename?: 'Checkout' } & Pick< ProductVariant, 'id' | 'sku' | 'title' > & { + selectedOptions: Array< + { __typename?: 'SelectedOption' } & Pick< + SelectedOption, + 'name' | 'value' + > + > image?: Maybe< { __typename?: 'Image' } & Pick< Image, @@ -5498,84 +5506,95 @@ export type GetPageQuery = { __typename?: 'QueryRoot' } & { > } +export type ProductDetailsFragment = { __typename?: 'Product' } & Pick< + Product, + | 'id' + | 'handle' + | 'availableForSale' + | 'title' + | 'productType' + | 'vendor' + | 'description' + | 'descriptionHtml' +> & { + options: Array< + { __typename?: 'ProductOption' } & Pick< + ProductOption, + 'id' | 'name' | 'values' + > + > + priceRange: { __typename?: 'ProductPriceRange' } & { + maxVariantPrice: { __typename?: 'MoneyV2' } & Pick< + MoneyV2, + 'amount' | 'currencyCode' + > + minVariantPrice: { __typename?: 'MoneyV2' } & Pick< + MoneyV2, + 'amount' | 'currencyCode' + > + } + variants: { __typename?: 'ProductVariantConnection' } & { + pageInfo: { __typename?: 'PageInfo' } & Pick< + PageInfo, + 'hasNextPage' | 'hasPreviousPage' + > + edges: Array< + { __typename?: 'ProductVariantEdge' } & { + node: { __typename?: 'ProductVariant' } & Pick< + ProductVariant, + 'id' | 'title' | 'sku' | 'availableForSale' | 'requiresShipping' + > & { + selectedOptions: Array< + { __typename?: 'SelectedOption' } & Pick< + SelectedOption, + 'name' | 'value' + > + > + priceV2: { __typename?: 'MoneyV2' } & Pick< + MoneyV2, + 'amount' | 'currencyCode' + > + compareAtPriceV2?: Maybe< + { __typename?: 'MoneyV2' } & Pick< + MoneyV2, + 'amount' | 'currencyCode' + > + > + } + } + > + } + images: { __typename?: 'ImageConnection' } & { + pageInfo: { __typename?: 'PageInfo' } & Pick< + PageInfo, + 'hasNextPage' | 'hasPreviousPage' + > + edges: Array< + { __typename?: 'ImageEdge' } & { + node: { __typename?: 'Image' } & Pick< + Image, + 'originalSrc' | 'altText' | 'width' | 'height' + > + } + > + } + } + export type GetProductBySlugQueryVariables = Exact<{ slug: Scalars['String'] }> export type GetProductBySlugQuery = { __typename?: 'QueryRoot' } & { - productByHandle?: Maybe< - { __typename?: 'Product' } & Pick< - Product, - | 'id' - | 'handle' - | 'title' - | 'productType' - | 'vendor' - | 'description' - | 'descriptionHtml' - > & { - options: Array< - { __typename?: 'ProductOption' } & Pick< - ProductOption, - 'id' | 'name' | 'values' - > - > - priceRange: { __typename?: 'ProductPriceRange' } & { - maxVariantPrice: { __typename?: 'MoneyV2' } & Pick< - MoneyV2, - 'amount' | 'currencyCode' - > - minVariantPrice: { __typename?: 'MoneyV2' } & Pick< - MoneyV2, - 'amount' | 'currencyCode' - > - } - variants: { __typename?: 'ProductVariantConnection' } & { - pageInfo: { __typename?: 'PageInfo' } & Pick< - PageInfo, - 'hasNextPage' | 'hasPreviousPage' - > - edges: Array< - { __typename?: 'ProductVariantEdge' } & { - node: { __typename?: 'ProductVariant' } & Pick< - ProductVariant, - 'id' | 'title' | 'sku' - > & { - selectedOptions: Array< - { __typename?: 'SelectedOption' } & Pick< - SelectedOption, - 'name' | 'value' - > - > - priceV2: { __typename?: 'MoneyV2' } & Pick< - MoneyV2, - 'amount' | 'currencyCode' - > - compareAtPriceV2?: Maybe< - { __typename?: 'MoneyV2' } & Pick< - MoneyV2, - 'amount' | 'currencyCode' - > - > - } - } - > - } - images: { __typename?: 'ImageConnection' } & { - pageInfo: { __typename?: 'PageInfo' } & Pick< - PageInfo, - 'hasNextPage' | 'hasPreviousPage' - > - edges: Array< - { __typename?: 'ImageEdge' } & { - node: { __typename?: 'Image' } & Pick< - Image, - 'originalSrc' | 'altText' | 'width' | 'height' - > - } - > - } - } + productByHandle?: Maybe<{ __typename?: 'Product' } & ProductDetailsFragment> +} + +export type GetRelatedProductsQueryVariables = Exact<{ + productId: Scalars['ID'] +}> + +export type GetRelatedProductsQuery = { __typename?: 'QueryRoot' } & { + productRecommendations?: Maybe< + Array<{ __typename?: 'Product' } & ListProductDetailsFragment> > } diff --git a/framework/shopify/schema.graphql b/framework/shopify/schema.graphql index 9c657fe43..d627cddef 100644 --- a/framework/shopify/schema.graphql +++ b/framework/shopify/schema.graphql @@ -13,16 +13,6 @@ directive @accessRestricted( reason: String = null ) on FIELD_DEFINITION | OBJECT -""" -Contextualize data. -""" -directive @inContext( - """ - The country code for context. - """ - country: CountryCode! -) on QUERY | MUTATION - """ A version of the API. """ @@ -829,7 +819,7 @@ input CheckoutAttributesUpdateInput { The required attributes are city, province, and country. Full validation of the addresses is still done at complete time. """ - allowPartialAddresses: Boolean + allowPartialAddresses: Boolean = false } """ @@ -872,7 +862,7 @@ input CheckoutAttributesUpdateV2Input { The required attributes are city, province, and country. Full validation of the addresses is still done at complete time. """ - allowPartialAddresses: Boolean + allowPartialAddresses: Boolean = false } """ @@ -3391,7 +3381,7 @@ input CreditCardPaymentInput { """ Executes the payment in test mode if possible. Defaults to `false`. """ - test: Boolean + test: Boolean = false } """ @@ -3422,7 +3412,7 @@ input CreditCardPaymentInputV2 { """ Executes the payment in test mode if possible. Defaults to `false`. """ - test: Boolean + test: Boolean = false } """ @@ -5325,7 +5315,7 @@ Represents information about the metafields associated to the specified resource """ interface HasMetafields { """ - The metafield associated with the resource. + Returns a metafield found by namespace and key. """ metafield( """ @@ -7648,7 +7638,7 @@ type Product implements Node & HasMetafields { ): MediaConnection! """ - The metafield associated with the resource. + Returns a metafield found by namespace and key. """ metafield( """ @@ -8150,7 +8140,7 @@ type ProductVariant implements Node & HasMetafields { ): Image """ - The metafield associated with the resource. + Returns a metafield found by namespace and key. """ metafield( """ @@ -9298,7 +9288,7 @@ input TokenizedPaymentInput { """ Executes the payment in test mode if possible. Defaults to `false`. """ - test: Boolean + test: Boolean = false """ Public Hash Key used for AndroidPay payments only. @@ -9334,7 +9324,7 @@ input TokenizedPaymentInputV2 { """ Whether to execute the payment in test mode, if possible. Test mode is not supported in production stores. Defaults to `false`. """ - test: Boolean + test: Boolean = false """ Public Hash Key used for AndroidPay payments only. @@ -9375,7 +9365,7 @@ input TokenizedPaymentInputV3 { """ Whether to execute the payment in test mode, if possible. Test mode is not supported in production stores. Defaults to `false`. """ - test: Boolean + test: Boolean = false """ Public Hash Key used for AndroidPay payments only. diff --git a/framework/shopify/utils/queries/get-all-products-query.ts b/framework/shopify/utils/queries/get-all-products-query.ts index 179cf9812..dcde5191a 100644 --- a/framework/shopify/utils/queries/get-all-products-query.ts +++ b/framework/shopify/utils/queries/get-all-products-query.ts @@ -1,3 +1,32 @@ +export const listProductDetailsFragment = /* GraphQL */ ` + fragment listProductDetails on Product { + id + title + vendor + handle + priceRange { + minVariantPrice { + amount + currencyCode + } + } + images(first: 1) { + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + originalSrc + altText + width + height + } + } + } + } +` + export const productConnectionFragment = /* GraphQL */ ` fragment productConnection on ProductConnection { pageInfo { @@ -6,33 +35,11 @@ export const productConnectionFragment = /* GraphQL */ ` } edges { node { - id - title - vendor - handle - priceRange { - minVariantPrice { - amount - currencyCode - } - } - images(first: 1) { - pageInfo { - hasNextPage - hasPreviousPage - } - edges { - node { - originalSrc - altText - width - height - } - } - } + ...listProductDetails } } } + ${listProductDetailsFragment} ` const getAllProductsQuery = /* GraphQL */ ` diff --git a/framework/shopify/utils/queries/get-product-query.ts b/framework/shopify/utils/queries/get-product-query.ts index b2998a40a..f0e3e3619 100644 --- a/framework/shopify/utils/queries/get-product-query.ts +++ b/framework/shopify/utils/queries/get-product-query.ts @@ -1,72 +1,79 @@ -const getProductQuery = /* GraphQL */ ` - query getProductBySlug($slug: String!) { - productByHandle(handle: $slug) { +export const productDetailsFragment = /* GraphQL */ ` + fragment productDetails on Product { + id + handle + availableForSale + title + productType + vendor + description + descriptionHtml + options { id - handle - availableForSale - title - productType - vendor - description - descriptionHtml - options { - id - name - values + name + values + } + priceRange { + maxVariantPrice { + amount + currencyCode } - priceRange { - maxVariantPrice { - amount - currencyCode - } - minVariantPrice { - amount - currencyCode - } + minVariantPrice { + amount + currencyCode } - variants(first: 250) { - pageInfo { - hasNextPage - hasPreviousPage - } - edges { - node { - id - title - sku - availableForSale - requiresShipping - selectedOptions { - name - value - } - priceV2 { - amount - currencyCode - } - compareAtPriceV2 { - amount - currencyCode - } + } + variants(first: 250) { + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + id + title + sku + availableForSale + requiresShipping + selectedOptions { + name + value + } + priceV2 { + amount + currencyCode + } + compareAtPriceV2 { + amount + currencyCode } } } - images(first: 250) { - pageInfo { - hasNextPage - hasPreviousPage - } - edges { - node { - originalSrc - altText - width - height - } + } + images(first: 250) { + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + originalSrc + altText + width + height } } } } ` +const getProductQuery = /* GraphQL */ ` + query getProductBySlug($slug: String!) { + productByHandle(handle: $slug) { + ...productDetails + } + } + ${productDetailsFragment} +` + export default getProductQuery diff --git a/framework/shopify/utils/queries/get-related-products-query.ts b/framework/shopify/utils/queries/get-related-products-query.ts new file mode 100644 index 000000000..0a6363030 --- /dev/null +++ b/framework/shopify/utils/queries/get-related-products-query.ts @@ -0,0 +1,11 @@ +import { listProductDetailsFragment } from './get-all-products-query' + +const getRelatedProductsQuery = /* GraphQL */ ` + query getRelatedProducts($productId: ID!) { + productRecommendations(productId: $productId) { + ...listProductDetails + } + } + ${listProductDetailsFragment} +` +export default getRelatedProductsQuery diff --git a/pages/product/[slug].tsx b/pages/product/[slug].tsx index bb1ecbee3..71ffaf6ff 100644 --- a/pages/product/[slug].tsx +++ b/pages/product/[slug].tsx @@ -28,15 +28,27 @@ export async function getStaticProps({ config, preview, }) + const { pages } = await pagesPromise const { categories } = await siteInfoPromise const { product } = await productPromise - const { products: relatedProducts } = await allProductsPromise if (!product) { throw new Error(`Product with slug '${params!.slug}' not found`) } + const relatedProductsPromise = commerce.getRelatedProducts({ + variables: { productId: product.id, first: 4 }, + config, + preview, + }) + + // Temporary conditional query + const { products: relatedProducts } = + process.env.COMMERCE_PROVIDER === 'shopify' + ? await relatedProductsPromise + : await allProductsPromise + return { props: { pages,