Implement metafields

This commit is contained in:
cond0r 2022-11-28 08:49:27 +02:00
parent 90aa798891
commit 6699f2fed4
11 changed files with 394 additions and 48 deletions

View File

@ -83,7 +83,59 @@ export interface ProductVariant {
*/ */
image?: Image 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 { export interface Product {
/** /**
* The unique identifier for the product. * The unique identifier for the product.
@ -117,6 +169,11 @@ export interface Product {
* List of images associated with the product. * List of images associated with the product.
*/ */
images: Image[] images: Image[]
/**
* The products metafields. This is a list of metafields that are attached to the product.
*
*/
metafields?: ProductMetafields
/** /**
* List of the products variants. * List of the products variants.
*/ */
@ -194,7 +251,6 @@ export type ProductsSchema = {
/** /**
* Product operations * Product operations
*/ */
export type GetAllProductPathsOperation = { export type GetAllProductPathsOperation = {
data: { products: Pick<Product, 'path'>[] } data: { products: Pick<Product, 'path'>[] }
variables: { first?: number } variables: { first?: number }
@ -209,7 +265,40 @@ export type GetAllProductsOperation = {
} }
} }
export type MetafieldsIdentifiers =
| Record<string, string[]>
| Array<{
namespace: string
key: string
}>
export type GetProductOperation = { export type GetProductOperation = {
data: { product?: Product } 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
})
} }

View File

@ -55,7 +55,7 @@ export default function getProductOperation({
return { return {
...(productByHandle && { ...(productByHandle && {
product: normalizeProduct(productByHandle as ShopifyProduct), product: normalizeProduct(productByHandle as ShopifyProduct, locale),
}), }),
} }
} }

View File

@ -30,14 +30,7 @@ const fetchGraphqlApi: GraphQLFetcher = async (
return { data, res } return { data, res }
} catch (err) { } catch (err) {
throw getError( throw getError([err], 500)
[
{
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
)
} }
} }
export default fetchGraphqlApi export default fetchGraphqlApi

View File

@ -0,0 +1,39 @@
type LiteralUnion<T extends string> = T | Omit<T, T>
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<MetafieldTypes>

View File

@ -1,7 +1,12 @@
import { FetcherError } from '@vercel/commerce/utils/errors' import { FetcherError } from '@vercel/commerce/utils/errors'
export function getError(errors: any[] | null, status: number) { 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 }) return new FetcherError({ errors, status })
} }

View File

@ -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<string, string> = {
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' ? '&#10003;' : '&#10005;'
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 ? '&#9733;' : '&#9734;'
).join('')
case 'color':
return `<figure style="background-color: ${value}; width: 1rem; height:1rem; display:block; margin-top: 2px; border-radius: 100%;"/>`
case 'url':
return `<a href="${value}" target="_blank" rel="norreferrer">${value}</a>`
case 'multi_line_text_field':
return value
.split('\n')
.map((line) => `${line}<br/>`)
.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(' ')

View File

@ -1,7 +1,8 @@
import type { Page } from '@vercel/commerce/types/page' 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 { Cart, LineItem } from '@vercel/commerce/types/cart'
import type { Category } from '@vercel/commerce/types/site' import type { Category } from '@vercel/commerce/types/site'
import type { MetafieldType } from '../types/metafields'
import type { import type {
Product as ShopifyProduct, Product as ShopifyProduct,
@ -15,9 +16,12 @@ import type {
Page as ShopifyPage, Page as ShopifyPage,
PageEdge, PageEdge,
Collection, Collection,
Maybe,
Metafield,
} from '../../schema' } from '../../schema'
import { colorMap } from './colors' import { colorMap } from './colors'
import { getMetafieldValue, toLabel, parseJson } from './metafields'
const money = ({ amount, currencyCode }: MoneyV2) => { const money = ({ amount, currencyCode }: MoneyV2) => {
return { return {
@ -87,7 +91,6 @@ const normalizeProductVariants = ({ edges }: ProductVariantConnection) => {
name, name,
values: [value], values: [value],
}) })
return options return options
}), }),
} }
@ -95,20 +98,23 @@ const normalizeProductVariants = ({ edges }: ProductVariantConnection) => {
) )
} }
export function normalizeProduct({ export function normalizeProduct(
id, {
title: name, id,
vendor, title: name,
images, vendor,
variants, images,
description, variants,
descriptionHtml, description,
handle, descriptionHtml,
priceRange, handle,
options, priceRange,
metafields, options,
...rest metafields,
}: ShopifyProduct): Product { ...rest
}: ShopifyProduct,
locale?: string
): Product {
return { return {
id, id,
name, 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 .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)) .map((o) => normalizeProductOption(o))
: [], : [],
metafields: normalizeMetafields(metafields, locale),
description: description || '', description: description || '',
...(descriptionHtml && { descriptionHtml }), ...(descriptionHtml && { descriptionHtml }),
...rest, ...rest,
} }
} }
export function normalizeMetafields(
metafields: Maybe<Metafield>[],
locale?: string
) {
const output: Record<string, Record<string, ProductMetafield>> = {}
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 { export function normalizeCart(checkout: Checkout): Cart {
return { return {
id: checkout.id, id: checkout.id,
@ -197,3 +239,19 @@ export const normalizeCategory = ({
slug: handle, slug: handle,
path: `/${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(' &#8226; ')
: value
}
return getMetafieldValue(type, value, locale)
}

View File

@ -1,5 +1,8 @@
const getProductQuery = /* GraphQL */ ` const getProductQuery = /* GraphQL */ `
query getProductBySlug($slug: String!) { query getProductBySlug(
$slug: String!
$withMetafields: [HasMetafieldsIdentifier!] = []
) {
productByHandle(handle: $slug) { productByHandle(handle: $slug) {
id id
handle handle
@ -24,7 +27,7 @@ const getProductQuery = /* GraphQL */ `
currencyCode currencyCode
} }
} }
variants(first: 250) { variants(first: 25) {
pageInfo { pageInfo {
hasNextPage hasNextPage
hasPreviousPage hasPreviousPage
@ -51,7 +54,7 @@ const getProductQuery = /* GraphQL */ `
} }
} }
} }
images(first: 250) { images(first: 25) {
pageInfo { pageInfo {
hasNextPage hasNextPage
hasPreviousPage hasPreviousPage
@ -65,6 +68,13 @@ const getProductQuery = /* GraphQL */ `
} }
} }
} }
metafields(identifiers: $withMetafields) {
key
value
namespace
description
type
}
} }
} }
` `

View File

@ -62,10 +62,16 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
className="pb-4 break-words w-full max-w-xl" className="pb-4 break-words w-full max-w-xl"
html={product.descriptionHtml || product.description} html={product.descriptionHtml || product.description}
/> />
<div className="flex flex-row justify-between items-center">
<Rating value={4} /> {product.metafields?.reviews?.rating && (
<div className="text-accent-6 pr-1 font-medium text-sm">36 reviews</div> <div className="flex flex-row justify-between items-center">
</div> <Rating value={product.metafields.reviews.rating.value} />
<div className="text-accent-6 pr-1 font-medium text-sm">
{product.metafields.reviews.count?.value || 2} reviews
</div>
</div>
)}
<div> <div>
{error && <ErrorMessage error={error} className="my-5" />} {error && <ErrorMessage error={error} className="my-5" />}
{process.env.COMMERCE_CART_ENABLED && ( {process.env.COMMERCE_CART_ENABLED && (
@ -84,15 +90,28 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
)} )}
</div> </div>
<div className="mt-6"> <div className="mt-6">
<Collapse title="Care"> {product.metafields?.descriptors?.care_guide && (
This is a limited edition production run. Printing starts when the <Collapse title="Care">
drop ends. <Text
</Collapse> className="leading-0"
<Collapse title="Details"> html={product.metafields.descriptors.care_guide.html}
This is a limited edition production run. Printing starts when the />
drop ends. Reminder: Bad Boys For Life. Shipping may take 10+ days due </Collapse>
to COVID-19. )}
</Collapse>
{product.metafields?.my_fields && (
<Collapse title="Details">
{Object.entries(product.metafields.my_fields).map(([_, field]) => (
<div
key={field.key}
className="flex gap-2 border-b py-3 border-accent-2 border-dashed last:border-b-0"
>
<strong className="leading-7">{field.name}:</strong>
<Text html={field.html || field.value} className="!mx-0" />
</div>
))}
</Collapse>
)}
</div> </div>
</div> </div>
) )

View File

@ -3,11 +3,23 @@ import type {
GetStaticPropsContext, GetStaticPropsContext,
InferGetStaticPropsType, InferGetStaticPropsType,
} from 'next' } from 'next'
import { useRouter } from 'next/router'
import commerce from '@lib/api/commerce' import commerce from '@lib/api/commerce'
import { useRouter } from 'next/router'
import { Layout } from '@components/common' import { Layout } from '@components/common'
import { ProductView } from '@components/product' 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({ export async function getStaticProps({
params, params,
locale, locale,
@ -18,7 +30,10 @@ export async function getStaticProps({
const pagesPromise = commerce.getAllPages({ config, preview }) const pagesPromise = commerce.getAllPages({ config, preview })
const siteInfoPromise = commerce.getSiteInfo({ config, preview }) const siteInfoPromise = commerce.getSiteInfo({ config, preview })
const productPromise = commerce.getProduct({ const productPromise = commerce.getProduct({
variables: { slug: params!.slug }, variables: {
slug: params!.slug,
withMetafields,
},
config, config,
preview, preview,
}) })

View File

@ -23,8 +23,8 @@
"@components/*": ["components/*"], "@components/*": ["components/*"],
"@commerce": ["../packages/commerce/src"], "@commerce": ["../packages/commerce/src"],
"@commerce/*": ["../packages/commerce/src/*"], "@commerce/*": ["../packages/commerce/src/*"],
"@framework": ["../packages/local/src"], "@framework": ["../packages/shopify/src"],
"@framework/*": ["../packages/local/src/*"] "@framework/*": ["../packages/shopify/src/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"], "include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],