This commit is contained in:
cond0r 2022-07-22 19:57:02 +03:00
parent 79d320501d
commit 3bcf9d1d53
11 changed files with 347 additions and 333 deletions

View File

@ -1,3 +1,6 @@
import Cookies from 'js-cookie'
import type { CookieAttributes } from 'js-cookie'
import type { FetcherOptions } from '@vercel/commerce/utils/types' import type { FetcherOptions } from '@vercel/commerce/utils/types'
import type { import type {
@ -7,7 +10,6 @@ import type {
CustomerAccessTokenCreateInput, CustomerAccessTokenCreateInput,
} from '../../schema' } from '../../schema'
import { setCustomerToken } from './helpers'
import { throwUserErrors } from './throw-user-errors' import { throwUserErrors } from './throw-user-errors'
import { import {
@ -15,6 +17,27 @@ import {
customerAccessTokenCreateMutation, customerAccessTokenCreateMutation,
} from './mutations' } from './mutations'
import { SHOPIFY_COOKIE_EXPIRE, SHOPIFY_CUSTOMER_TOKEN_COOKIE } from '../const'
export const getCustomerToken = () => Cookies.get(SHOPIFY_CUSTOMER_TOKEN_COOKIE)
export const setCustomerToken = (
token: string | null,
options?: CookieAttributes
) => {
if (!token) {
Cookies.remove(SHOPIFY_CUSTOMER_TOKEN_COOKIE)
} else {
Cookies.set(
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
token,
options ?? {
expires: SHOPIFY_COOKIE_EXPIRE,
}
)
}
}
export const handleLogin = (data: any) => { export const handleLogin = (data: any) => {
const response = data.customerAccessTokenCreate const response = data.customerAccessTokenCreate
throwUserErrors(response?.customerUserErrors) throwUserErrors(response?.customerUserErrors)

View File

@ -0,0 +1,65 @@
import type { FetcherOptions } from '@vercel/commerce/utils/types'
import type {
CartLineInput,
CartCreateMutation,
CartDetailsFragment,
CartCreateMutationVariables,
} from '../../schema'
import Cookies from 'js-cookie'
import { throwUserErrors } from './throw-user-errors'
import { cartCreateMutation } from './mutations/cart-mutations'
import {
SHOPIFY_CART_ID_COOKIE,
SHOPIFY_CART_URL_COOKIE,
SHOPIFY_COOKIE_EXPIRE,
} from '../const'
export const setCartUrlCookie = (cartUrl: string) => {
if (cartUrl) {
const oldCookie = Cookies.get(SHOPIFY_CART_URL_COOKIE)
if (oldCookie !== cartUrl) {
Cookies.set(SHOPIFY_CART_URL_COOKIE, cartUrl, {
expires: SHOPIFY_COOKIE_EXPIRE,
})
}
}
}
export const getCartId = (id?: string) => {
return id || Cookies.get(SHOPIFY_CART_ID_COOKIE)
}
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
}

View File

@ -0,0 +1,132 @@
import type {
CollectionEdge,
GetAllProductVendorsQuery,
GetAllProductVendorsQueryVariables,
} from '../../schema'
import type { Category } from '../types/site'
import type { SearchProductsBody } from '../types/product'
import { ShopifyConfig } from '../api'
import { normalizeCategory } from './normalize'
import { getAllProductVendors, getSiteCollectionsQuery } from './queries'
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 getSortVariables = (
sort?: string,
isCategory: boolean = false
) => {
switch (sort) {
case 'price-asc':
return {
sortKey: 'PRICE',
reverse: false,
}
case 'price-desc':
return {
sortKey: 'PRICE',
reverse: true,
}
case 'trending-desc':
return {
sortKey: 'BEST_SELLING',
reverse: false,
}
case 'latest-desc':
return {
sortKey: isCategory ? 'CREATED' : 'CREATED_AT',
reverse: true,
}
}
}
export const getSearchVariables = ({
brandId,
search,
categoryId,
sort,
locale,
}: SearchProductsBody) => {
let query = ''
if (search) {
query += `product_type:${search} OR title:${search} OR tag:${search} `
}
if (brandId) {
query += `${search ? 'AND ' : ''}vendor:${brandId}`
}
return {
categoryId,
query,
...getSortVariables(sort, !!categoryId),
...(locale && {
locale,
}),
}
}

View File

@ -1,303 +0,0 @@
import Cookies, { CookieAttributes } from 'js-cookie'
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,
SHOPIFY_COOKIE_EXPIRE,
SHOPIFY_CART_ID_COOKIE,
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)
if (oldCookie !== cartUrl) {
Cookies.set(SHOPIFY_CART_URL_COOKIE, cartUrl, {
expires: SHOPIFY_COOKIE_EXPIRE,
})
}
}
}
export const getCartId = (id?: string) => {
return id || Cookies.get(SHOPIFY_CART_ID_COOKIE)
}
export const getSortVariables = (
sort?: string,
isCategory: boolean = false
) => {
switch (sort) {
case 'price-asc':
return {
sortKey: 'PRICE',
reverse: false,
}
case 'price-desc':
return {
sortKey: 'PRICE',
reverse: true,
}
case 'trending-desc':
return {
sortKey: 'BEST_SELLING',
reverse: false,
}
case 'latest-desc':
return {
sortKey: isCategory ? 'CREATED' : 'CREATED_AT',
reverse: true,
}
}
}
export const getSearchVariables = ({
brandId,
search,
categoryId,
sort,
locale,
}: SearchProductsBody) => {
let query = ''
if (search) {
query += `product_type:${search} OR title:${search} OR tag:${search} `
}
if (brandId) {
query += `${search ? 'AND ' : ''}vendor:${brandId}`
}
return {
categoryId,
query,
...getSortVariables(sort, !!categoryId),
...(locale && {
locale,
}),
}
}
export const getCustomerToken = () => Cookies.get(SHOPIFY_CUSTOMER_TOKEN_COOKIE)
export const setCustomerToken = (
token: string | null,
options?: CookieAttributes
) => {
if (!token) {
Cookies.remove(SHOPIFY_CUSTOMER_TOKEN_COOKIE)
} else {
Cookies.set(
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
token,
options ?? {
expires: SHOPIFY_COOKIE_EXPIRE,
}
)
}
}
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

@ -5,9 +5,12 @@ export {
handleLogin, handleLogin,
handleAutomaticLogin, handleAutomaticLogin,
handleAccountActivation, handleAccountActivation,
} from './handle-login' } from './auth'
export * from './helpers' export * from './auth'
export * from './cart'
export * from './metafields'
export * from './collections'
export * from './queries' export * from './queries'
export * from './mutations' export * from './mutations'
export * from './normalize' export * from './normalize'

View File

@ -0,0 +1,90 @@
import { MetafieldType } from '../types/product'
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

@ -20,7 +20,7 @@ import humanizeString from 'humanize-string'
import { CommerceError } from '@vercel/commerce/utils/errors' import { CommerceError } from '@vercel/commerce/utils/errors'
import { colorMap } from './colors' import { colorMap } from './colors'
import { getMetafieldValue, parseJson } from './helpers' import { getMetafieldValue, parseJson } from './metafields'
type MoneyProps = MoneyV2 & { retailPrice?: string | number } type MoneyProps = MoneyV2 & { retailPrice?: string | number }

View File

@ -5,6 +5,6 @@
"wishlist": false, "wishlist": false,
"customerAuth": false, "customerAuth": false,
"customCheckout": false, "customCheckout": false,
"metafields": false "customFields": false
} }
} }

View File

@ -1,13 +1,14 @@
import cn from 'clsx' import cn from 'clsx'
import Image from 'next/image' import Image from 'next/image'
import { WishlistButton } from '@components/wishlist'
import ProductSidebar from '../ProductSidebar'
import ProductTag from '../ProductTag'
import { useProduct } from '../context' import { useProduct } from '../context'
import { WishlistButton } from '@components/wishlist'
import ProductTag from '../ProductTag'
import ProductSlider from '../ProductSlider'
import ProductSidebar from '../ProductSidebar'
import s from './ProductDetails.module.css' import s from './ProductDetails.module.css'
import ProductSlider from '../ProductSlider'
const ProductDetails = () => { const ProductDetails = () => {
const { product, variant, price } = useProduct() const { product, variant, price } = useProduct()

View File

@ -1,12 +1,14 @@
import s from './ProductView.module.css' import s from './ProductView.module.css'
import { FC } from 'react'
import type { FC } from 'react'
import type { Product } from '@commerce/types/product' import type { Product } from '@commerce/types/product'
import { ProductCard } from '@components/product'
import { Container, Text } from '@components/ui'
import { ProductProvider } from '../context'
import ProductDetails from '../ProductDetails/ProductDetails'
import { SEO } from '@components/common' import { SEO } from '@components/common'
import { ProductProvider } from '../context'
import { Container, Text } from '@components/ui'
import { ProductCard } from '@components/product'
import ProductDetails from '../ProductDetails/ProductDetails'
interface ProductViewProps { interface ProductViewProps {
product: Product product: Product

View File

@ -1,28 +1,29 @@
import { Product, ProductImage, ProductVariant } from '@commerce/types/product' import { useMemo, useState, useEffect, useContext, createContext } from 'react'
import { import type { FC, ReactNode, Dispatch, SetStateAction } from 'react'
getProductVariant,
selectDefaultOptionFromProduct,
SelectedOptions,
} from './helpers'
import React, { FC, useMemo, useState, ReactNode, useEffect } from 'react' import type {
Product,
ProductImage,
ProductVariant,
} from '@commerce/types/product'
import type { SelectedOptions } from './helpers'
import usePrice from '@framework/product/use-price' import usePrice from '@framework/product/use-price'
import { getProductVariant, selectDefaultOptionFromProduct } from './helpers'
export interface ProductContextValue { export interface ProductContextValue {
product: Product product: Product
imageIndex: number | null imageIndex: number | null
setImageIndex: React.Dispatch<React.SetStateAction<number | null>> setImageIndex: Dispatch<SetStateAction<number | null>>
price: string price: string
variant: ProductVariant variant: ProductVariant
selectedOptions: SelectedOptions selectedOptions: SelectedOptions
setSelectedOptions: React.Dispatch<React.SetStateAction<SelectedOptions>> setSelectedOptions: Dispatch<SetStateAction<SelectedOptions>>
} }
export const ProductContext = React.createContext<ProductContextValue | null>( export const ProductContext = createContext<ProductContextValue | null>(null)
null
)
ProductContext.displayName = 'ProductContext' ProductContext.displayName = 'ProductContext'
@ -55,11 +56,11 @@ export const ProductProvider: FC<ProductProviderProps> = ({
}) })
useEffect(() => { useEffect(() => {
const idx = product.images.findIndex((image: ProductImage) => { const index = product.images.findIndex((image: ProductImage) => {
return image.url === variant?.image?.url return image.url === variant?.image?.url
}) })
if (idx !== -1) { if (index !== -1) {
setImageIndex(idx) setImageIndex(index)
} }
}, [variant, product]) }, [variant, product])
@ -82,7 +83,7 @@ export const ProductProvider: FC<ProductProviderProps> = ({
} }
export const useProduct = () => { export const useProduct = () => {
const context = React.useContext(ProductContext) as ProductContextValue const context = useContext(ProductContext) as ProductContextValue
if (context === undefined) { if (context === undefined) {
throw new Error(`useProduct must be used within a ProductProvider`) throw new Error(`useProduct must be used within a ProductProvider`)
} }