Add metafields

This commit is contained in:
cond0r 2022-07-22 14:17:47 +03:00
parent a56c84375b
commit d8f6703b21
23 changed files with 423 additions and 154 deletions

View File

@ -67,6 +67,9 @@ export const productInfoFragment = /* GraphQL */ `
altText
isDefault
}
prices {
...productPrices
}
}
}
}
@ -80,6 +83,15 @@ export const productInfoFragment = /* GraphQL */ `
}
}
}
customFields @include(if: $withCustomFields) {
edges {
node {
entityId
name
value
}
}
}
localeMeta: metafields(namespace: $locale, keys: ["name", "description"])
@include(if: $hasLocale) {
edges {

View File

@ -17,6 +17,7 @@ import { normalizeProduct } from '../../lib/normalize'
export const getAllProductsQuery = /* GraphQL */ `
query getAllProducts(
$hasLocale: Boolean = false
$withCustomFields: Boolean = false
$locale: String = "null"
$entityIds: [Int!]
$first: Int = 10

View File

@ -12,6 +12,7 @@ import { normalizeProduct } from '../../lib/normalize'
export const getProductQuery = /* GraphQL */ `
query getProduct(
$hasLocale: Boolean = false
$withCustomFields: Boolean = false
$locale: String = "null"
$path: String!
) {
@ -98,6 +99,7 @@ export default function getAllProductPathsOperation({
const config = commerce.getConfig(cfg)
const { locale } = config
const variables: GetProductQueryVariables = {
...vars,
locale,
hasLocale: !!locale,
path: slug ? `/${slug}/` : vars.path!,

View File

@ -2,6 +2,7 @@
"provider": "bigcommerce",
"features": {
"wishlist": true,
"customerAuth": true
"customerAuth": true,
"customFields": true
}
}

View File

@ -1,4 +1,4 @@
import type { Product } from '../types/product'
import type { Product, ProductCustomField } from '../types/product'
import type { Cart, BigcommerceCart, LineItem } from '../types/cart'
import type { Page } from '../types/page'
import type { BCCategory, Category } from '../types/site'
@ -18,10 +18,23 @@ function normalizeProductOption(productOption: any) {
}
}
function normalizeCustomFieldsValue(field: any): ProductCustomField {
const {
node: { entityId, name, value },
} = field
return {
key: String(entityId),
name,
value,
}
}
export function normalizeProduct(productNode: any): Product {
const {
entityId: id,
productOptions,
customFields,
prices,
path,
id: _,
@ -31,28 +44,63 @@ export function normalizeProduct(productNode: any): Product {
return update(productNode, {
id: { $set: String(id) },
images: {
$apply: ({ edges }: any) =>
edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
$apply: ({ edges }: any) => [
...edges?.map(({ node: { urlOriginal, altText, ...rest } }: any) => ({
url: urlOriginal,
alt: altText,
...rest,
})),
...productNode.variants?.edges
?.map(({ node: { defaultImage } }: any) =>
defaultImage
? { url: defaultImage.urlOriginal, alt: defaultImage.altText }
: null
)
.filter(Boolean),
],
},
variants: {
$apply: ({ edges }: any) =>
edges?.map(({ node: { entityId, productOptions, ...rest } }: any) => ({
edges?.map(
({
node: { entityId, productOptions, prices, defaultImage, ...rest },
}: any) => ({
id: entityId,
...(defaultImage && {
image: {
url: defaultImage.urlOriginal,
alt: defaultImage.altText,
},
}),
price: {
value: prices?.price.value,
currencyCode: prices?.price.currencyCode,
...(prices?.salePrice?.value && {
salePrice: prices?.salePrice.value,
}),
...(prices?.retailPrice?.value && {
retailPrice: prices?.retailPrice.value,
}),
},
options: productOptions?.edges
? productOptions.edges.map(normalizeProductOption)
: [],
...rest,
})),
})
),
},
options: {
$set: productOptions.edges
$set: productOptions?.edges
? productOptions?.edges.map(normalizeProductOption)
: [],
},
customFields: {
$set: customFields?.edges
? customFields?.edges.map(normalizeCustomFieldsValue)
: [],
},
brand: {
$apply: (brand: any) => (brand?.entityId ? brand?.entityId : null),
},
@ -63,6 +111,10 @@ export function normalizeProduct(productNode: any): Product {
$set: {
value: prices?.price.value,
currencyCode: prices?.price.currencyCode,
...(prices?.salePrice?.value && { salePrice: prices?.salePrice.value }),
...(prices?.retailPrice?.value && {
retailPrice: prices?.retailPrice.value,
}),
},
},
$unset: ['entityId'],

View File

@ -35,11 +35,14 @@ export type ProductVariant = {
image?: ProductImage
}
export type ProductMetafield = {
export type ProductCustomField = {
key: string
name: string
value: string
description?: string
type?: string
htmlValue?: string
description?: string
}
export type Product = {
@ -56,7 +59,7 @@ export type Product = {
options: ProductOption[]
vendor?: string
seo?: SEO
metafields?: ProductMetafield[]
customFields?: ProductCustomField[]
}
export type SearchProductsBody = {
@ -108,5 +111,5 @@ export type GetAllProductsOperation<T extends ProductTypes = ProductTypes> = {
export type GetProductOperation<T extends ProductTypes = ProductTypes> = {
data: { product?: T['product'] }
variables: { slug?: string; path?: string; withMetafields?: boolean }
variables: { slug?: string; path?: string; withCustomFields?: boolean }
}

View File

@ -51,6 +51,7 @@
"dependencies": {
"@vercel/commerce": "^0.0.1",
"@vercel/fetch": "^6.1.1",
"humanize-string": "^3.0.0",
"lodash.debounce": "^4.0.8"
},
"peerDependencies": {

View File

@ -1283,6 +1283,8 @@ export enum CheckoutErrorCode {
GiftCardUnusable = 'GIFT_CARD_UNUSABLE',
/** The input value should be greater than or equal to the minimum value allowed. */
GreaterThanOrEqualTo = 'GREATER_THAN_OR_EQUAL_TO',
/** Higher value discount applied. */
HigherValueDiscountApplied = 'HIGHER_VALUE_DISCOUNT_APPLIED',
/** The input value is invalid. */
Invalid = 'INVALID',
/** Cannot specify country and presentment currency code. */
@ -6867,47 +6869,6 @@ export type CustomerAccessTokenFragment = {
expiresAt: any
}
type Media_ExternalVideo_Fragment = {
__typename?: 'ExternalVideo'
mediaContentType: MediaContentType
alt?: string | null
previewImage?: { __typename?: 'Image'; url: any } | null
}
type Media_MediaImage_Fragment = {
__typename?: 'MediaImage'
id: string
mediaContentType: MediaContentType
alt?: string | null
image?: {
__typename?: 'Image'
url: any
width?: number | null
height?: number | null
} | null
previewImage?: { __typename?: 'Image'; url: any } | null
}
type Media_Model3d_Fragment = {
__typename?: 'Model3d'
mediaContentType: MediaContentType
alt?: string | null
previewImage?: { __typename?: 'Image'; url: any } | null
}
type Media_Video_Fragment = {
__typename?: 'Video'
mediaContentType: MediaContentType
alt?: string | null
previewImage?: { __typename?: 'Image'; url: any } | null
}
export type MediaFragment =
| Media_ExternalVideo_Fragment
| Media_MediaImage_Fragment
| Media_Model3d_Fragment
| Media_Video_Fragment
export type ProductCardFragment = {
__typename?: 'Product'
id: string
@ -7849,7 +7810,7 @@ export type GetPageQuery = {
export type GetProductBySlugQueryVariables = Exact<{
slug: Scalars['String']
withMetafields?: InputMaybe<Scalars['Boolean']>
withCustomFields?: InputMaybe<Scalars['Boolean']>
}>
export type GetProductBySlugQuery = {

View File

@ -2175,6 +2175,11 @@ enum CheckoutErrorCode {
"""
GREATER_THAN_OR_EQUAL_TO
"""
Higher value discount applied.
"""
HIGHER_VALUE_DISCOUNT_APPLIED
"""
The input value is invalid.
"""

View File

@ -2,13 +2,17 @@ import type {
OperationContext,
OperationOptions,
} from '@vercel/commerce/api/operations'
import { GetAllProductsOperation } from '../../types/product'
import type { GetAllProductsOperation } from '../../types/product'
import {
GetAllProductsQuery,
GetAllProductsQueryVariables,
Product as ShopifyProduct,
} from '../../../schema'
import type { ShopifyConfig, Provider } from '..'
import { normalizeProduct, getAllProductsQuery } from '../../utils'
export default function getAllProductsOperation({

View File

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

View File

@ -3,6 +3,6 @@
"features": {
"wishlist": false,
"customerAuth": true,
"metafields": true
"customFields": true
}
}

View File

@ -42,10 +42,10 @@ export type MetaieldTypes =
export type MetafieldType = LiteralUnion<MetaieldTypes>
export type ProductMetafield = Core.ProductMetafield & {
export type ProductCustomField = Core.ProductCustomField & {
type?: MetafieldType
}
export type Product = Core.Product & {
metafields?: ProductMetafield[]
metafields?: ProductCustomField[]
}

View File

@ -1,36 +0,0 @@
export const mediaFragment = /* GraphQL */ `
fragment Media on Media {
mediaContentType
alt
previewImage {
url
}
... on MediaImage {
id
image {
url
width
height
}
}
# ... on Video {
# id
# sources {
# mimeType
# url
# }
# }
# ... on Model3d {
# id
# sources {
# mimeType
# url
# }
# }
# ... on ExternalVideo {
# id
# embedUrl
# host
# }
}
`

View File

@ -1,5 +1,18 @@
import Cookies, { CookieAttributes } from 'js-cookie'
import { SearchProductsBody } from '../types/product'
import type {
CartLineInput,
CollectionEdge,
CartCreateMutation,
CartDetailsFragment,
CartCreateMutationVariables,
GetAllProductVendorsQuery,
GetAllProductVendorsQueryVariables,
} from '../../schema'
import type { Category } from '../types/site'
import type { MetafieldType, SearchProductsBody } from '../types/product'
import type { FetcherOptions } from '@vercel/commerce/utils/types'
import {
SHOPIFY_CART_URL_COOKIE,
@ -8,6 +21,109 @@ import {
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
} from '../const'
import { ShopifyConfig } from '../api'
import { normalizeCategory } from './normalize'
import { throwUserErrors } from './throw-user-errors'
import { cartCreateMutation } from './mutations/cart-mutations'
import { getAllProductVendors, getSiteCollectionsQuery } from './queries'
export const cartCreate = async (
fetch: <T = any, B = Body>(options: FetcherOptions<B>) => Promise<T>,
lines?: Array<CartLineInput> | CartLineInput
): Promise<CartDetailsFragment | null | undefined> => {
const { cartCreate } = await fetch<
CartCreateMutation,
CartCreateMutationVariables
>({
query: cartCreateMutation,
variables: {
input: {
lines,
},
},
})
const cart = cartCreate?.cart
throwUserErrors(cartCreate?.userErrors)
if (cart?.id) {
const options = {
expires: SHOPIFY_COOKIE_EXPIRE,
}
Cookies.set(SHOPIFY_CART_ID_COOKIE, cart.id, options)
}
setCartUrlCookie(cart?.checkoutUrl)
return cart
}
export const getCategories = async ({
fetch,
locale,
}: ShopifyConfig): Promise<Category[]> => {
const { data } = await fetch(
getSiteCollectionsQuery,
{
variables: {
first: 250,
},
},
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
return (
data.collections?.edges?.map(({ node }: CollectionEdge) =>
normalizeCategory(node)
) ?? []
)
}
export type Brand = {
entityId: string
name: string
path: string
}
export type BrandEdge = {
node: Brand
}
export type Brands = BrandEdge[]
export const getBrands = async (
config: ShopifyConfig
): Promise<BrandEdge[]> => {
const { data } = await config.fetch<
GetAllProductVendorsQuery,
GetAllProductVendorsQueryVariables
>(getAllProductVendors, {
variables: {
first: 250,
},
})
let vendorsStrings = data.products.edges.map(({ node: { vendor } }) => vendor)
return [...new Set(vendorsStrings)].map((v) => {
const id = v.replace(/\s+/g, '-').toLowerCase()
return {
node: {
entityId: id,
name: v,
path: `brands/${id}`,
},
}
})
}
export const setCartUrlCookie = (cartUrl: string) => {
if (cartUrl) {
const oldCookie = Cookies.get(SHOPIFY_CART_URL_COOKIE)
@ -27,34 +143,28 @@ export const getSortVariables = (
sort?: string,
isCategory: boolean = false
) => {
let output = {}
switch (sort) {
case 'price-asc':
output = {
return {
sortKey: 'PRICE',
reverse: false,
}
break
case 'price-desc':
output = {
return {
sortKey: 'PRICE',
reverse: true,
}
break
case 'trending-desc':
output = {
return {
sortKey: 'BEST_SELLING',
reverse: false,
}
break
case 'latest-desc':
output = {
return {
sortKey: isCategory ? 'CREATED' : 'CREATED_AT',
reverse: true,
}
break
}
return output
}
export const getSearchVariables = ({
@ -102,3 +212,92 @@ export const setCustomerToken = (
)
}
}
export const parseJson = (value: string): any => {
try {
return JSON.parse(value)
} catch (e) {
return value
}
}
const unitConversion: Record<string, string> = {
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',
}
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;' : 'No'
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">${value}</a>`
case 'multi_line_text_field':
return value
.split('\n')
.map((line) => `<p>${line}</p>`)
.join('')
case 'json':
case 'single_line_text_field':
case 'product_reference':
case 'page_reference':
case 'variant_reference':
case 'file_reference':
default:
return value
}
}

View File

@ -1,10 +1,11 @@
export { throwUserErrors } from './throw-user-errors'
export { handleFetchResponse } from './handle-fetch-response'
export { cartCreate } from './cart-create'
export { handleLogin, handleAutomaticLogin } from './handle-login'
export { handleAccountActivation } from './handle-account-activation'
export { getCategories } from './get-categories'
export { getBrands } from './get-brands'
export {
handleLogin,
handleAutomaticLogin,
handleAccountActivation,
} from './handle-login'
export * from './helpers'
export * from './queries'

View File

@ -1,9 +1,9 @@
import type { Page } from '../types/page'
import type { Product, ProductPrice } from '../types/product'
import type { MetafieldType, Product, ProductPrice } from '../types/product'
import type { Cart, LineItem } from '../types/cart'
import type { Category } from '../types/site'
import {
import type {
Product as ShopifyProduct,
SelectedOption,
ImageConnection,
@ -14,12 +14,15 @@ import {
PageEdge,
Collection,
CartDetailsFragment,
MetafieldConnection,
} from '../../schema'
import { colorMap } from './colors'
import humanizeString from 'humanize-string'
import { CommerceError } from '@vercel/commerce/utils/errors'
type MoneyProps = MoneyV2 & { retailPrice?: string }
import { colorMap } from './colors'
import { getMetafieldValue, parseJson } from './helpers'
type MoneyProps = MoneyV2 & { retailPrice?: string | number }
const money = (money: MoneyProps): ProductPrice => {
const { amount, currencyCode, retailPrice } = money || { currencyCode: 'USD' }
@ -101,7 +104,8 @@ const normalizeProductVariants = (variants: ProductVariantConnection) => {
)
}
export function normalizeProduct({
export function normalizeProduct(
{
id,
title: name,
vendor,
@ -113,8 +117,11 @@ export function normalizeProduct({
options,
metafields,
...rest
}: ShopifyProduct): Product {
}: ShopifyProduct,
locale?: string
): Product {
const variant = variants?.nodes?.[0]
return {
id,
name,
@ -132,7 +139,14 @@ 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: metafields?.nodes?.map((m) => m) || [],
customFields:
metafields?.nodes?.map(({ key, type, value }) => ({
key,
name: humanizeString(key),
type,
value,
htmlValue: normalizeMetafieldValue(type, value, locale),
})) || [],
...(description && { description }),
...(descriptionHtml && { descriptionHtml }),
...rest,
@ -212,3 +226,18 @@ 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(' &#8226; ')
: value
}
return getMetafieldValue(type, value, locale)
}

View File

@ -1,5 +1,5 @@
export const getProductQuery = /* GraphQL */ `
query getProductBySlug($slug: String!, $withMetafields: Boolean = false) {
query getProductBySlug($slug: String!, $withCustomFields: Boolean = false) {
productByHandle(handle: $slug) {
id
handle
@ -46,7 +46,7 @@ export const getProductQuery = /* GraphQL */ `
}
}
}
metafields(first: 25) @include(if: $withMetafields) {
metafields(first: 25) @include(if: $withCustomFields) {
nodes {
key
value

View File

@ -0,0 +1,30 @@
import type { ProductCustomField } from '@framework/types/product'
const ProductCustomFields = ({ fields }: { fields: ProductCustomField[] }) => {
return (
<ul className="flex flex-col space-y-2 divide-y divide-dashed">
{fields.map((m) => (
<li
className="flex space-x-2 justify-start items-start text-sm pt-2"
key={m.key}
>
<span className="font-bold capitalize whitespace-nowrap">
{m.name}
</span>
:
{m.htmlValue ? (
<div
dangerouslySetInnerHTML={{
__html: m.htmlValue,
}}
/>
) : (
<span>{m.value}</span>
)}
</li>
))}
</ul>
)
}
export default ProductCustomFields

View File

@ -0,0 +1 @@
export { default } from './ProductCustomFields'

View File

@ -5,6 +5,7 @@ import { ProductOptions } from '@components/product'
import { Button, Text, Rating, Collapse, useUI } from '@components/ui'
import { useProduct } from '../context'
import ProductCustomFields from '../ProductCustomFields'
interface ProductSidebarProps {
className?: string
}
@ -59,23 +60,6 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ className }) => {
)}
</div>
<div className="mt-6">
{process.env.COMMERCE_METAFIELDS_ENABLED &&
product.metafields &&
product.metafields?.length > 0 && (
<Collapse title="Metafields">
<ul className="flex flex-col space-y-2 divide-y divide-dashed">
{product.metafields.map((m) => (
<li
className="flex space-x-2 justify-start items-center text-sm pt-2"
key={m.key}
>
<span className="font-bold capitalize">{m.key}</span>:
<span>{m.value}</span>
</li>
))}
</ul>
</Collapse>
)}
<Collapse title="Care">
This is a limited edition production run. Printing starts when the
drop ends.
@ -85,6 +69,13 @@ const ProductSidebar: FC<ProductSidebarProps> = ({ className }) => {
drop ends. Reminder: Bad Boys For Life. Shipping may take 10+ days due
to COVID-19.
</Collapse>
{process.env.COMMERCE_CUSTOMFIELDS_ENABLED &&
product.customFields &&
product.customFields.length > 0 && (
<Collapse title="Technical Details">
<ProductCustomFields fields={product.customFields} />
</Collapse>
)}
</div>
</div>
)

View File

@ -20,7 +20,7 @@ export async function getStaticProps({
const productPromise = commerce.getProduct({
variables: {
slug: params!.slug,
withMetafields: Boolean(process.env.COMMERCE_METAFIELDS_ENABLED),
withCustomFields: Boolean(process.env.COMMERCE_CUSTOMFIELDS_ENABLED),
},
config,
preview,

View File

@ -2853,6 +2853,11 @@ decamelize@^1.2.0:
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
decamelize@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-6.0.0.tgz#8cad4d916fde5c41a264a43d0ecc56fe3d31749e"
integrity sha512-Fv96DCsdOgB6mdGl67MT5JaTNKRzrzill5OH5s8bjYJXVlcXyPYGyPsUkWyGV5p1TXI5esYIYMMeDJL0hEIwaA==
decode-uri-component@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
@ -3986,6 +3991,13 @@ humanize-ms@^1.2.1:
dependencies:
ms "^2.0.0"
humanize-string@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/humanize-string/-/humanize-string-3.0.0.tgz#4ea0ef1daf1d23fd8d8c7864adf0117f74939455"
integrity sha512-jhWD2GAZRMELz0IEIfqpEdi0M4CMQF1GpJpBYIopFN6wT+78STiujfQTKcKqZzOJgUkIgJSo2xFeHdsg922JZQ==
dependencies:
decamelize "^6.0.0"
husky@^7.0.4:
version "7.0.4"
resolved "https://registry.yarnpkg.com/husky/-/husky-7.0.4.tgz#242048245dc49c8fb1bf0cc7cfb98dd722531535"