mirror of
https://github.com/vercel/commerce.git
synced 2025-05-16 06:26:58 +00:00
Implement metafields
This commit is contained in:
parent
90aa798891
commit
6699f2fed4
@ -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<Product, 'path'>[] }
|
||||
variables: { first?: number }
|
||||
@ -209,7 +265,40 @@ export type GetAllProductsOperation = {
|
||||
}
|
||||
}
|
||||
|
||||
export type MetafieldsIdentifiers =
|
||||
| Record<string, string[]>
|
||||
| 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
|
||||
})
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ export default function getProductOperation({
|
||||
|
||||
return {
|
||||
...(productByHandle && {
|
||||
product: normalizeProduct(productByHandle as ShopifyProduct),
|
||||
product: normalizeProduct(productByHandle as ShopifyProduct, locale),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
39
packages/shopify/src/types/metafields.ts
Normal file
39
packages/shopify/src/types/metafields.ts
Normal 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>
|
@ -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 })
|
||||
}
|
||||
|
||||
|
118
packages/shopify/src/utils/metafields.ts
Normal file
118
packages/shopify/src/utils/metafields.ts
Normal 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' ? '✓' : '✕'
|
||||
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 `<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(' ')
|
@ -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<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 {
|
||||
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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -62,10 +62,16 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
|
||||
className="pb-4 break-words w-full max-w-xl"
|
||||
html={product.descriptionHtml || product.description}
|
||||
/>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<Rating value={4} />
|
||||
<div className="text-accent-6 pr-1 font-medium text-sm">36 reviews</div>
|
||||
</div>
|
||||
|
||||
{product.metafields?.reviews?.rating && (
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<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>
|
||||
{error && <ErrorMessage error={error} className="my-5" />}
|
||||
{process.env.COMMERCE_CART_ENABLED && (
|
||||
@ -84,15 +90,28 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ product, className }) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Collapse title="Care">
|
||||
This is a limited edition production run. Printing starts when the
|
||||
drop ends.
|
||||
</Collapse>
|
||||
<Collapse title="Details">
|
||||
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.
|
||||
</Collapse>
|
||||
{product.metafields?.descriptors?.care_guide && (
|
||||
<Collapse title="Care">
|
||||
<Text
|
||||
className="leading-0"
|
||||
html={product.metafields.descriptors.care_guide.html}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user