From 6699f2fed437ffd07b9a1a6e330c2808ea2582b5 Mon Sep 17 00:00:00 2001 From: cond0r <1243434+cond0r@users.noreply.github.com> Date: Mon, 28 Nov 2022 08:49:27 +0200 Subject: [PATCH] Implement metafields --- packages/commerce/src/types/product.ts | 93 +++++++++++++- .../shopify/src/api/operations/get-product.ts | 2 +- .../src/api/utils/fetch-graphql-api.ts | 9 +- packages/shopify/src/types/metafields.ts | 39 ++++++ .../src/utils/handle-fetch-response.ts | 7 +- packages/shopify/src/utils/metafields.ts | 118 ++++++++++++++++++ packages/shopify/src/utils/normalize.ts | 90 ++++++++++--- .../src/utils/queries/get-product-query.ts | 16 ++- .../product/ProductSidebar/ProductSidebar.tsx | 45 +++++-- site/pages/product/[slug].tsx | 19 ++- site/tsconfig.json | 4 +- 11 files changed, 394 insertions(+), 48 deletions(-) create mode 100644 packages/shopify/src/types/metafields.ts create mode 100644 packages/shopify/src/utils/metafields.ts diff --git a/packages/commerce/src/types/product.ts b/packages/commerce/src/types/product.ts index 2f6c34acb..1c5708e3c 100644 --- a/packages/commerce/src/types/product.ts +++ b/packages/commerce/src/types/product.ts @@ -83,7 +83,59 @@ export interface ProductVariant { */ image?: Image } +export interface ProductMetafield { + /** + * The key name for the metafield. + */ + key: string + /** + * The namespace for the metafield. + * @example `rating` + */ + namespace: string + + /** + * The value of the metafield. + * @example `{"value": 5, "scale_max": 5}` + */ + value: any + + /** + * Automatically transformed value of the metafield. + */ + html?: string + + /** + * The type of the metafield. + * @example `date` + */ + type?: string + + /** + * The name of the metafield, that can be used as a label. + */ + name?: string +} + +/** + * Product Metafields, grouped by namespace. + * The namespace is the key of the object, and the value is an object with the metafield key and an object with the metafield data. + * @example + * { + * reviews: { + * rating: { + * key: 'rating', + * value: 5, + * // ... other metafield properties + * } + * } + */ +export interface ProductMetafields { + [namespace: string]: { + [key: string]: ProductMetafield + } +} export interface Product { /** * The unique identifier for the product. @@ -117,6 +169,11 @@ export interface Product { * List of images associated with the product. */ images: Image[] + /** + * The product’s metafields. This is a list of metafields that are attached to the product. + * + */ + metafields?: ProductMetafields /** * List of the product’s variants. */ @@ -194,7 +251,6 @@ export type ProductsSchema = { /** * Product operations */ - export type GetAllProductPathsOperation = { data: { products: Pick[] } variables: { first?: number } @@ -209,7 +265,40 @@ export type GetAllProductsOperation = { } } +export type MetafieldsIdentifiers = + | Record + | Array<{ + namespace: string + key: string + }> + export type GetProductOperation = { data: { product?: Product } - variables: { path: string; slug?: never } | { path?: never; slug: string } + variables: + | { + path: string + slug?: never + } + | ({ + path?: never + slug: string + } & { + /** + * Metafields identifiers used to fetch the product metafields. + * It can be an array of objects with the namespace and key, or an object with the namespace as the key and an array of keys as the value. + * + * @example + * metafields: { + * reviews: ['rating', 'count'] + * } + * + * // or + * + * metafields: [ + * {namespace: 'reviews', key: 'rating'}, + * {namespace: 'reviews', key: 'count'}, + * ] + */ + withMetafields?: MetafieldsIdentifiers + }) } diff --git a/packages/shopify/src/api/operations/get-product.ts b/packages/shopify/src/api/operations/get-product.ts index e8aa28120..0e1793d5a 100644 --- a/packages/shopify/src/api/operations/get-product.ts +++ b/packages/shopify/src/api/operations/get-product.ts @@ -55,7 +55,7 @@ export default function getProductOperation({ return { ...(productByHandle && { - product: normalizeProduct(productByHandle as ShopifyProduct), + product: normalizeProduct(productByHandle as ShopifyProduct, locale), }), } } diff --git a/packages/shopify/src/api/utils/fetch-graphql-api.ts b/packages/shopify/src/api/utils/fetch-graphql-api.ts index 1eac16ef1..bbc3de675 100644 --- a/packages/shopify/src/api/utils/fetch-graphql-api.ts +++ b/packages/shopify/src/api/utils/fetch-graphql-api.ts @@ -30,14 +30,7 @@ const fetchGraphqlApi: GraphQLFetcher = async ( return { data, res } } catch (err) { - throw getError( - [ - { - message: `${err} \n Most likely related to an unexpected output. E.g: NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN & NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN might be incorect.`, - }, - ], - 500 - ) + throw getError([err], 500) } } export default fetchGraphqlApi diff --git a/packages/shopify/src/types/metafields.ts b/packages/shopify/src/types/metafields.ts new file mode 100644 index 000000000..d3b4cb53a --- /dev/null +++ b/packages/shopify/src/types/metafields.ts @@ -0,0 +1,39 @@ +type LiteralUnion = T | Omit + +export type MetafieldTypes = + | 'integer' + | 'boolean' + | 'color' + | 'json' + | 'date' + | 'file_reference' + | 'date_time' + | 'dimension' + | 'multi_line_text_field' + | 'number_decimal' + | 'number_integer' + | 'page_reference' + | 'product_reference' + | 'rating' + | 'single_line_text_field' + | 'url' + | 'variant_reference' + | 'volume' + | 'weight' + | 'list.color' + | 'list.date' + | 'list.date_time' + | 'list.dimension' + | 'list.file_reference' + | 'list.number_integer' + | 'list.number_decimal' + | 'list.page_reference' + | 'list.product_reference' + | 'list.rating' + | 'list.single_line_text_field' + | 'list.url' + | 'list.variant_reference' + | 'list.volume' + | 'list.weight' + +export type MetafieldType = LiteralUnion diff --git a/packages/shopify/src/utils/handle-fetch-response.ts b/packages/shopify/src/utils/handle-fetch-response.ts index 927ab54f1..ff2b516a1 100644 --- a/packages/shopify/src/utils/handle-fetch-response.ts +++ b/packages/shopify/src/utils/handle-fetch-response.ts @@ -1,7 +1,12 @@ import { FetcherError } from '@vercel/commerce/utils/errors' export function getError(errors: any[] | null, status: number) { - errors = errors ?? [{ message: 'Failed to fetch Shopify API' }] + errors = errors ?? [ + { + message: + 'Failed to fetch Shopify API, most likely related to an unexpected output. E.g: NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN & NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN might be incorect', + }, + ] return new FetcherError({ errors, status }) } diff --git a/packages/shopify/src/utils/metafields.ts b/packages/shopify/src/utils/metafields.ts new file mode 100644 index 000000000..fbdc5f392 --- /dev/null +++ b/packages/shopify/src/utils/metafields.ts @@ -0,0 +1,118 @@ +import type { MetafieldType } from '../types/metafields' + +export const parseJson = (value: string): any => { + try { + return JSON.parse(value) + } catch (e) { + return value + } +} + +const unitConversion: Record = { + mm: 'millimeter', + cm: 'centimeter', + m: 'meter', + in: 'inch', + ft: 'foot', + yd: 'yard', + + ml: 'milliliter', + l: 'liter', + us_fl_oz: 'fluid-ounce', + us_gal: 'gallon', + + kg: 'kilogram', + g: 'gram', + lb: 'pound', + oz: 'ounce', + + MILLIMETERS: 'millimeter', + CENTIMETERS: 'centimeter', + METERS: 'meter', + + MILLILITERS: 'milliliter', + LITERS: 'liter', + FLUID_OUNCES: 'fluid-ounce', + IMPERIAL_FLUID_OUNCES: 'fluid-ounce', + GALLONS: 'gallon', + + KILOGRAMS: 'kilogram', + GRAMS: 'gram', + OUNCES: 'ounce', + POUNDS: 'pound', + + FEET: 'foot', +} + +export const getMeasurment = (input: string, locale: string = 'en-US') => { + try { + let { unit, value } = JSON.parse(input) + return new Intl.NumberFormat(locale, { + unit: unitConversion[unit], + style: 'unit', + }).format(parseFloat(value)) + } catch (e) { + console.error(e) + return input + } +} + +export const getMetafieldValue = ( + type: MetafieldType, + value: string, + locale: string = 'en-US' +) => { + switch (type) { + case 'boolean': + return value === 'true' ? '✓' : '✕' + case 'number_integer': + return parseInt(value).toLocaleString(locale) + case 'number_decimal': + return parseFloat(value).toLocaleString(locale) + case 'date': + return Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + }).format(new Date(value)) + case 'date_time': + return Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'long', + }).format(new Date(value)) + case 'dimension': + case 'volume': + case 'weight': + return getMeasurment(value, locale) + case 'rating': + const { scale_max, value: val } = JSON.parse(value) + return Array.from({ length: scale_max }, (_, i) => + i <= val - 1 ? '★' : '☆' + ).join('') + case 'color': + return `
` + case 'url': + return `${value}` + case 'multi_line_text_field': + return value + .split('\n') + .map((line) => `${line}
`) + .join('') + case 'json': + case 'single_line_text_field': + case 'product_reference': + case 'page_reference': + case 'variant_reference': + case 'file_reference': + default: + return value + } +} + +export const toLabel = (string: string) => + string + .toLowerCase() + .replace(/[_-]+/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim() + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') diff --git a/packages/shopify/src/utils/normalize.ts b/packages/shopify/src/utils/normalize.ts index 066daff33..60708c8ae 100644 --- a/packages/shopify/src/utils/normalize.ts +++ b/packages/shopify/src/utils/normalize.ts @@ -1,7 +1,8 @@ import type { Page } from '@vercel/commerce/types/page' -import type { Product } from '@vercel/commerce/types/product' +import type { Product, ProductMetafield } from '@vercel/commerce/types/product' import type { Cart, LineItem } from '@vercel/commerce/types/cart' import type { Category } from '@vercel/commerce/types/site' +import type { MetafieldType } from '../types/metafields' import type { Product as ShopifyProduct, @@ -15,9 +16,12 @@ import type { Page as ShopifyPage, PageEdge, Collection, + Maybe, + Metafield, } from '../../schema' import { colorMap } from './colors' +import { getMetafieldValue, toLabel, parseJson } from './metafields' const money = ({ amount, currencyCode }: MoneyV2) => { return { @@ -87,7 +91,6 @@ const normalizeProductVariants = ({ edges }: ProductVariantConnection) => { name, values: [value], }) - return options }), } @@ -95,20 +98,23 @@ const normalizeProductVariants = ({ edges }: ProductVariantConnection) => { ) } -export function normalizeProduct({ - id, - title: name, - vendor, - images, - variants, - description, - descriptionHtml, - handle, - priceRange, - options, - metafields, - ...rest -}: ShopifyProduct): Product { +export function normalizeProduct( + { + id, + title: name, + vendor, + images, + variants, + description, + descriptionHtml, + handle, + priceRange, + options, + metafields, + ...rest + }: ShopifyProduct, + locale?: string +): Product { return { id, name, @@ -123,12 +129,48 @@ export function normalizeProduct({ .filter((o) => o.name !== 'Title') // By default Shopify adds a 'Title' name when there's only one option. We don't need it. https://community.shopify.com/c/Shopify-APIs-SDKs/Adding-new-product-variant-is-automatically-adding-quot-Default/td-p/358095 .map((o) => normalizeProductOption(o)) : [], + metafields: normalizeMetafields(metafields, locale), description: description || '', ...(descriptionHtml && { descriptionHtml }), ...rest, } } +export function normalizeMetafields( + metafields: Maybe[], + locale?: string +) { + const output: Record> = {} + + if (!metafields) return output + + for (const metafield of metafields) { + if (!metafield) continue + + const { key, type, namespace, value, ...rest } = metafield + + const newField = { + ...rest, + key, + name: toLabel(key), + type, + namespace, + value, + html: getMetafieldValue(type, value, locale), + } + + if (!output[namespace]) { + output[namespace] = { + [key]: newField, + } + } else { + output[namespace][key] = newField + } + } + + return output +} + export function normalizeCart(checkout: Checkout): Cart { return { id: checkout.id, @@ -197,3 +239,19 @@ export const normalizeCategory = ({ slug: handle, path: `/${handle}`, }) + +export const normalizeMetafieldValue = ( + type: MetafieldType, + value: string, + locale?: string +) => { + if (type.startsWith('list.')) { + const arr = parseJson(value) + return Array.isArray(arr) + ? arr + .map((v) => getMetafieldValue(type.split('.')[1], v, locale)) + .join(' • ') + : value + } + return getMetafieldValue(type, value, locale) +} diff --git a/packages/shopify/src/utils/queries/get-product-query.ts b/packages/shopify/src/utils/queries/get-product-query.ts index 1c4458432..85ca68a16 100644 --- a/packages/shopify/src/utils/queries/get-product-query.ts +++ b/packages/shopify/src/utils/queries/get-product-query.ts @@ -1,5 +1,8 @@ const getProductQuery = /* GraphQL */ ` - query getProductBySlug($slug: String!) { + query getProductBySlug( + $slug: String! + $withMetafields: [HasMetafieldsIdentifier!] = [] + ) { productByHandle(handle: $slug) { id handle @@ -24,7 +27,7 @@ const getProductQuery = /* GraphQL */ ` currencyCode } } - variants(first: 250) { + variants(first: 25) { pageInfo { hasNextPage hasPreviousPage @@ -51,7 +54,7 @@ const getProductQuery = /* GraphQL */ ` } } } - images(first: 250) { + images(first: 25) { pageInfo { hasNextPage hasPreviousPage @@ -65,6 +68,13 @@ const getProductQuery = /* GraphQL */ ` } } } + metafields(identifiers: $withMetafields) { + key + value + namespace + description + type + } } } ` diff --git a/site/components/product/ProductSidebar/ProductSidebar.tsx b/site/components/product/ProductSidebar/ProductSidebar.tsx index 67c3dab9f..7399f5e65 100644 --- a/site/components/product/ProductSidebar/ProductSidebar.tsx +++ b/site/components/product/ProductSidebar/ProductSidebar.tsx @@ -62,10 +62,16 @@ const ProductSidebar: FC = ({ product, className }) => { className="pb-4 break-words w-full max-w-xl" html={product.descriptionHtml || product.description} /> -
- -
36 reviews
-
+ + {product.metafields?.reviews?.rating && ( +
+ +
+ {product.metafields.reviews.count?.value || 2} reviews +
+
+ )} +
{error && } {process.env.COMMERCE_CART_ENABLED && ( @@ -84,15 +90,28 @@ const ProductSidebar: FC = ({ product, className }) => { )}
- - This is a limited edition production run. Printing starts when the - drop ends. - - - This is a limited edition production run. Printing starts when the - drop ends. Reminder: Bad Boys For Life. Shipping may take 10+ days due - to COVID-19. - + {product.metafields?.descriptors?.care_guide && ( + + + + )} + + {product.metafields?.my_fields && ( + + {Object.entries(product.metafields.my_fields).map(([_, field]) => ( +
+ {field.name}: + +
+ ))} +
+ )}
) diff --git a/site/pages/product/[slug].tsx b/site/pages/product/[slug].tsx index 9d8d153e4..4a85d7fe2 100644 --- a/site/pages/product/[slug].tsx +++ b/site/pages/product/[slug].tsx @@ -3,11 +3,23 @@ import type { GetStaticPropsContext, InferGetStaticPropsType, } from 'next' -import { useRouter } from 'next/router' + import commerce from '@lib/api/commerce' + +import { useRouter } from 'next/router' import { Layout } from '@components/common' import { ProductView } from '@components/product' +// Used by the Shopify Example +const withMetafields = [ + { namespace: 'reviews', key: 'rating' }, + { namespace: 'descriptors', key: 'care_guide' }, + { namespace: 'my_fields', key: 'weight' }, + { namespace: 'my_fields', key: 'width' }, + { namespace: 'my_fields', key: 'length' }, + { namespace: 'my_fields', key: 'manufacturer_url' }, +] + export async function getStaticProps({ params, locale, @@ -18,7 +30,10 @@ export async function getStaticProps({ const pagesPromise = commerce.getAllPages({ config, preview }) const siteInfoPromise = commerce.getSiteInfo({ config, preview }) const productPromise = commerce.getProduct({ - variables: { slug: params!.slug }, + variables: { + slug: params!.slug, + withMetafields, + }, config, preview, }) diff --git a/site/tsconfig.json b/site/tsconfig.json index 7c91afd6f..2de809a44 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -23,8 +23,8 @@ "@components/*": ["components/*"], "@commerce": ["../packages/commerce/src"], "@commerce/*": ["../packages/commerce/src/*"], - "@framework": ["../packages/local/src"], - "@framework/*": ["../packages/local/src/*"] + "@framework": ["../packages/shopify/src"], + "@framework/*": ["../packages/shopify/src/*"] } }, "include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],