4
0
forked from crowetic/commerce

Shopify Provider Updates (#209)

* Implement Shopify Provider

* Update README.md

* Update README.md

* normalizations & missing files

* Update index.ts

* fixes

* Update normalize.ts

* fix: cart error on first load

* shopify checkout redirect & api handler

* Update get-checkout-id.ts

* Fix: color option

* Update normalize.ts

* changes

* Update next.config.js

* start customer auth & signup

* Update config.ts

* Login, Sign Up, Log Out, and checkout & customer association

* Automatic login after sign-up

* Update handle-login.ts

* changes

* Revert "Merge branch 'agnostic' of https://github.com/vercel/commerce into agnostic"

This reverts commit 23c8ed7c2d48d30e74ad94216f9910650fadf30c, reversing
changes made to bf50965a39ef0b1b956461ebe62070809fbe1d63.

* change readme

* Revert "Merge branch 'master' of https://github.com/vercel/commerce into agnostic"

This reverts commit bf50965a39ef0b1b956461ebe62070809fbe1d63, reversing
changes made to 0dad4ddedbf0bff2d0b5800ca469fda0073889ea.

* Revert "Revert "Merge branch 'agnostic' of https://github.com/vercel/commerce into agnostic""

This reverts commit c9a43f1bce0572d0eff41f3af893be8bdb00bedd.

* align with upstream changes

* query all products for vendors & paths, improve search

* Update use-search.tsx

* fix cart after upstream changes

* fixes after upstream changes

* Moved handler to each hook

* Added initial version of useAddItem

* Updated types

* Update use-add-item.tsx

* Moved auth & cart hooks + several fixes

* Updated cart item, fixed deprecations

* Update next.config.js

* Aligned with upstream changes

* Updates

* Update next.config.js
This commit is contained in:
cond0r 2021-02-25 23:05:47 +02:00 committed by GitHub
parent 3b386e3d55
commit 7334924694
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 16565 additions and 624 deletions

View File

@ -8,6 +8,7 @@ import useCart from '@framework/cart/use-cart'
import usePrice from '@framework/product/use-price'
import CartItem from '../CartItem'
import s from './CartSidebarView.module.css'
import { LineItem } from '@commerce/types'
const CartSidebarView: FC = () => {
const { closeSidebar } = useUI()
@ -91,7 +92,7 @@ const CartSidebarView: FC = () => {
My Cart
</h2>
<ul className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accents-3 border-t border-accents-3">
{data!.lineItems.map((item) => (
{data!.lineItems.map((item: any) => (
<CartItem
key={item.id}
item={item}

View File

@ -25,6 +25,9 @@ const Navbar: FC = () => (
<Link href="/search?q=accessories">
<a className={s.link}>Accessories</a>
</Link>
<Link href="/search?q=shoes">
<a className={s.link}>Shoes</a>
</Link>
</nav>
</div>

View File

@ -1 +1,46 @@
export default function () {}
import isAllowedMethod from '../utils/is-allowed-method'
import createApiHandler, {
ShopifyApiHandler,
} from '../utils/create-api-handler'
import {
SHOPIFY_CHECKOUT_ID_COOKIE,
SHOPIFY_CHECKOUT_URL_COOKIE,
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
} from '@framework/const'
import { getConfig } from '..'
import associateCustomerWithCheckoutMutation from '@framework/utils/mutations/associate-customer-with-checkout'
const METHODS = ['GET']
const checkoutApi: ShopifyApiHandler<any> = async (req, res, config) => {
if (!isAllowedMethod(req, res, METHODS)) return
config = getConfig()
const { cookies } = req
const checkoutUrl = cookies[SHOPIFY_CHECKOUT_URL_COOKIE]
const customerCookie = cookies[SHOPIFY_CUSTOMER_TOKEN_COOKIE]
if (customerCookie) {
try {
await config.fetch(associateCustomerWithCheckoutMutation, {
variables: {
checkoutId: cookies[SHOPIFY_CHECKOUT_ID_COOKIE],
customerAccessToken: cookies[SHOPIFY_CUSTOMER_TOKEN_COOKIE],
},
})
} catch (error) {
console.error(error)
}
}
if (checkoutUrl) {
res.redirect(checkoutUrl)
} else {
res.redirect('/cart')
}
}
export default createApiHandler(checkoutApi, {}, {})

View File

@ -0,0 +1 @@
export default function () {}

View File

@ -1,24 +1,29 @@
import type { CommerceAPIConfig } from '@commerce/api'
import fetchGraphqlApi from './utils/fetch-graphql-api'
export interface ShopifyConfig extends CommerceAPIConfig {}
const API_URL = process.env.SHOPIFY_STORE_DOMAIN
const API_TOKEN = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN
import {
API_URL,
API_TOKEN,
SHOPIFY_CHECKOUT_ID_COOKIE,
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
} from '@framework/const'
if (!API_URL) {
console.log(process.env)
throw new Error(
`The environment variable SHOPIFY_STORE_DOMAIN is missing and it's required to access your store`
`The environment variable NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN is missing and it's required to access your store`
)
}
if (!API_TOKEN) {
throw new Error(
`The environment variable SHOPIFY_STOREFRONT_ACCESS_TOKEN is missing and it's required to access your store`
`The environment variable NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN is missing and it's required to access your store`
)
}
import fetchGraphqlApi from './utils/fetch-graphql-api'
export interface ShopifyConfig extends CommerceAPIConfig {}
export class Config {
private config: ShopifyConfig
@ -40,11 +45,11 @@ export class Config {
const config = new Config({
commerceUrl: API_URL,
apiToken: API_TOKEN,
// TODO
// @ts-ignore
apiToken: API_TOKEN!,
cartCookie: SHOPIFY_CHECKOUT_ID_COOKIE,
cartCookieMaxAge: 60 * 60 * 24 * 30,
fetch: fetchGraphqlApi,
customerCookie: 'SHOP_TOKEN',
customerCookie: SHOPIFY_CUSTOMER_TOKEN_COOKIE,
})
export function getConfig(userConfig?: Partial<ShopifyConfig>) {

View File

@ -0,0 +1,58 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
import { ShopifyConfig, getConfig } from '..'
export type ShopifyApiHandler<
T = any,
H extends ShopifyHandlers = {},
Options extends {} = {}
> = (
req: NextApiRequest,
res: NextApiResponse<ShopifyApiResponse<T>>,
config: ShopifyConfig,
handlers: H,
// Custom configs that may be used by a particular handler
options: Options
) => void | Promise<void>
export type ShopifyHandler<T = any, Body = null> = (options: {
req: NextApiRequest
res: NextApiResponse<ShopifyApiResponse<T>>
config: ShopifyConfig
body: Body
}) => void | Promise<void>
export type ShopifyHandlers<T = any> = {
[k: string]: ShopifyHandler<T, any>
}
export type ShopifyApiResponse<T> = {
data: T | null
errors?: { message: string; code?: string }[]
}
export default function createApiHandler<
T = any,
H extends ShopifyHandlers = {},
Options extends {} = {}
>(
handler: ShopifyApiHandler<T, H, Options>,
handlers: H,
defaultOptions: Options
) {
return function getApiHandler({
config,
operations,
options,
}: {
config?: ShopifyConfig
operations?: Partial<H>
options?: Options extends {} ? Partial<Options> : never
} = {}): NextApiHandler {
const ops = { ...operations, ...handlers }
const opts = { ...defaultOptions, ...options }
return function apiHandler(req, res) {
return handler(req, res, getConfig(config), ops, opts)
}
}
}

View File

@ -0,0 +1,41 @@
import { ProductEdge } from '@framework/schema'
import { ShopifyConfig } from '..'
const fetchAllProducts = async ({
config,
query,
variables,
acc = [],
cursor,
}: {
config: ShopifyConfig
query: string
acc?: ProductEdge[]
variables?: any
cursor?: string
}): Promise<ProductEdge[]> => {
const { data } = await config.fetch(query, {
variables: { ...variables, cursor },
})
const edges: ProductEdge[] = data.products?.edges ?? []
const hasNextPage = data.products?.pageInfo?.hasNextPage
acc = acc.concat(edges)
if (hasNextPage) {
const cursor = edges.pop()?.cursor
if (cursor) {
return fetchAllProducts({
config,
query,
variables,
acc,
cursor,
})
}
}
return acc
}
export default fetchAllProducts

View File

@ -1,33 +1,19 @@
import { CommerceAPIFetchOptions } from '@commerce/api'
import { FetcherError } from '@commerce/utils/errors'
import { getConfig } from '../index'
import type { GraphQLFetcher } from '@commerce/api'
import fetch from './fetch'
export interface GraphQLFetcherResult<Data = any> {
data: Data
res: Response
}
export type GraphQLFetcher<
Data extends GraphQLFetcherResult = GraphQLFetcherResult,
Variables = any
> = (
query: string,
queryData?: CommerceAPIFetchOptions<Variables>,
fetchOptions?: RequestInit
) => Promise<Data>
import { API_URL, API_TOKEN } from '../../const'
import { getError } from '@framework/utils/handle-fetch-response'
const fetchGraphqlApi: GraphQLFetcher = async (
query: string,
{ variables } = {},
fetchOptions
) => {
const config = getConfig()
const url = `https://${config.commerceUrl}/api/2021-01/graphql.json`
const res = await fetch(url, {
const res = await fetch(API_URL, {
...fetchOptions,
method: 'POST',
headers: {
'X-Shopify-Storefront-Access-Token': config.apiToken,
'X-Shopify-Storefront-Access-Token': API_TOKEN!,
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
@ -37,15 +23,12 @@ const fetchGraphqlApi: GraphQLFetcher = async (
}),
})
const json = await res.json()
if (json.errors) {
throw new FetcherError({
errors: json.errors ?? [{ message: 'Failed to fetch Shopify API' }],
status: res.status,
})
const { data, errors, status } = await res.json()
if (errors) {
throw getError(errors, status)
}
return { data: json.data, res }
return { data, res }
}
export default fetchGraphqlApi

View File

@ -0,0 +1,2 @@
import zeitFetch from '@vercel/fetch'
export default zeitFetch()

View File

@ -0,0 +1,28 @@
import type { NextApiRequest, NextApiResponse } from 'next'
export default function isAllowedMethod(
req: NextApiRequest,
res: NextApiResponse,
allowedMethods: string[]
) {
const methods = allowedMethods.includes('OPTIONS')
? allowedMethods
: [...allowedMethods, 'OPTIONS']
if (!req.method || !methods.includes(req.method)) {
res.status(405)
res.setHeader('Allow', methods.join(', '))
res.end()
return false
}
if (req.method === 'OPTIONS') {
res.status(200)
res.setHeader('Allow', methods.join(', '))
res.setHeader('Content-Length', '0')
res.end()
return false
}
return true
}

View File

@ -1,13 +1,76 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError, ValidationError } from '@commerce/utils/errors'
import useCustomer from '../customer/use-customer'
import createCustomerAccessTokenMutation from '../utils/mutations/customer-access-token-create'
import {
CustomerAccessTokenCreateInput,
CustomerUserError,
Mutation,
MutationCheckoutCreateArgs,
} from '@framework/schema'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import { setCustomerToken } from '@framework/utils'
export function emptyHook() {
const useEmptyHook = async (options = {}) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
export default useLogin as UseLogin<typeof handler>
const getErrorMessage = ({ code, message }: CustomerUserError) => {
switch (code) {
case 'UNIDENTIFIED_CUSTOMER':
message = 'Cannot find an account that matches the provided credentials'
break
}
return useEmptyHook
return message
}
export default emptyHook
export const handler: MutationHook<null, {}, CustomerAccessTokenCreateInput> = {
fetchOptions: {
query: createCustomerAccessTokenMutation,
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to login',
})
}
const { customerAccessTokenCreate } = await fetch<
Mutation,
MutationCheckoutCreateArgs
>({
...options,
variables: {
input: { email, password },
},
})
const errors = customerAccessTokenCreate?.customerUserErrors
if (errors && errors.length) {
throw new ValidationError({
message: getErrorMessage(errors[0]),
})
}
const customerAccessToken = customerAccessTokenCreate?.customerAccessToken
const accessToken = customerAccessToken?.accessToken
if (accessToken) {
setCustomerToken(accessToken)
}
return null
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

@ -1,13 +1,39 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import useLogout, { UseLogout } from '@commerce/auth/use-logout'
import useCustomer from '../customer/use-customer'
import customerAccessTokenDeleteMutation from '@framework/utils/mutations/customer-access-token-delete'
import {
getCustomerToken,
setCustomerToken,
} from '@framework/utils/customer-token'
export function emptyHook() {
const useEmptyHook = async (options = {}) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
}
export default useLogout as UseLogout<typeof handler>
return useEmptyHook
export const handler: MutationHook<null> = {
fetchOptions: {
query: customerAccessTokenDeleteMutation,
},
async fetcher({ options, fetch }) {
await fetch({
...options,
variables: {
customerAccessToken: getCustomerToken(),
},
})
setCustomerToken(null)
return null
},
useHook: ({ fetch }) => () => {
const { mutate } = useCustomer()
return useCallback(
async function logout() {
const data = await fetch()
await mutate(null, false)
return data
},
[fetch, mutate]
)
},
}
export default emptyHook

View File

@ -1,13 +1,74 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import useCustomer from '../customer/use-customer'
import { CustomerCreateInput } from '@framework/schema'
export function emptyHook() {
const useEmptyHook = async (options = {}) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
}
import {
customerCreateMutation,
customerAccessTokenCreateMutation,
} from '@framework/utils/mutations'
import handleLogin from '@framework/utils/handle-login'
return useEmptyHook
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<
null,
{},
CustomerCreateInput,
CustomerCreateInput
> = {
fetchOptions: {
query: customerCreateMutation,
},
async fetcher({
input: { firstName, lastName, email, password },
options,
fetch,
}) {
if (!(firstName && lastName && email && password)) {
throw new CommerceError({
message:
'A first name, last name, email and password are required to signup',
})
}
const data = await fetch({
...options,
variables: {
input: {
firstName,
lastName,
email,
password,
},
},
})
try {
const loginData = await fetch({
query: customerAccessTokenCreateMutation,
variables: {
input: {
email,
password,
},
},
})
handleLogin(loginData)
} catch (error) {}
return data
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}
export default emptyHook

View File

@ -1,5 +1,3 @@
export { default as useCart } from './use-cart'
export { default as useAddItem } from './use-add-item'
export { default as useRemoveItem } from './use-remove-item'
// export { default as useWishlistActions } from './use-cart-actions'
// export { default as useUpdateItem } from './use-cart-actions'

View File

@ -1,30 +1,57 @@
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import useCart from './use-cart'
import { Cart, CartItemBody } from '../types'
import { checkoutLineItemAddMutation, getCheckoutId } from '../utils'
import { checkoutToCart } from './utils'
import { Mutation, MutationCheckoutLineItemsAddArgs } from '../schema'
import { useCallback } from 'react'
import { LineItemToAdd } from 'shopify-buy'
import { useCommerce } from '../index'
type Options = {
productId: number
variantId: string | number
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<Cart, {}, CartItemBody> = {
fetchOptions: {
query: checkoutLineItemAddMutation,
},
async fetcher({ input: item, options, fetch }) {
if (
item.quantity &&
(!Number.isInteger(item.quantity) || item.quantity! < 1)
) {
throw new CommerceError({
message: 'The item quantity has to be a valid integer greater than 0',
})
}
const { checkoutLineItemsAdd } = await fetch<
Mutation,
MutationCheckoutLineItemsAddArgs
>({
...options,
variables: {
checkoutId: getCheckoutId(),
lineItems: [
{
variantId: item.variantId,
quantity: item.quantity ?? 1,
},
],
},
})
return checkoutToCart(checkoutLineItemsAdd)
},
useHook: ({ fetch }) => () => {
const { mutate } = useCart()
return useCallback(
async function addItem(input) {
const data = await fetch({ input })
await mutate(data, false)
return data
},
[fetch, mutate]
)
},
}
const useAddItem = () => {
const { checkout, client, updateCheckout } = useCommerce()
return useCallback(
async function addItem(options: Options) {
const lineItems: LineItemToAdd[] = [
{
variantId: `${options.variantId}`,
quantity: 1,
},
]
const cart = await client?.checkout.addLineItems(checkout.id, lineItems)
updateCheckout(cart)
return cart
},
[checkout, client]
)
}
export default useAddItem

View File

@ -1,47 +1,60 @@
import { useCommerce } from '../index'
import useCart, { UseCart, FetchCartInput } from '@commerce/cart/use-cart'
import type { Cart } from '../types'
import { useMemo } from 'react'
import type { ShopifyProvider } from '..'
// export default useCart as UseCart<typeof handler>
export default useCart as UseCart
import useCommerceCart, {
FetchCartInput,
UseCart,
} from '@commerce/cart/use-cart'
export const handler = () => {
const { checkout } = useCommerce()
const { lineItems, totalPriceV2 } = checkout || {}
import { Cart } from '@commerce/types'
import { SWRHook } from '@commerce/utils/types'
import { checkoutCreate, checkoutToCart } from './utils'
import getCheckoutQuery from '../utils/queries/get-checkout-query'
console.log(checkout)
export default useCommerceCart as UseCart<ShopifyProvider>
return {
data: {
subTotal: totalPriceV2?.amount || 0,
total: totalPriceV2?.amount || 0,
currency: {
code: '',
},
line_items:
lineItems?.map((item) => {
return [
{
id: item.id,
name: item.title,
quantity: item.quantity,
export const handler: SWRHook<
Cart | null,
{},
FetchCartInput,
{ isEmpty?: boolean }
> = {
fetchOptions: {
query: getCheckoutQuery,
},
async fetcher({ input: { cartId: checkoutId }, options, fetch }) {
let checkout
if (checkoutId) {
const data = await fetch({
...options,
variables: {
checkoutId,
},
})
checkout = data.node
}
if (checkout?.completedAt || !checkoutId) {
checkout = await checkoutCreate(fetch)
}
return checkoutToCart({ checkout })
},
useHook: ({ useData }) => (input) => {
const response = useData({
swrOptions: { revalidateOnFocus: false, ...input?.swrOptions },
})
return useMemo(
() =>
Object.create(response, {
isEmpty: {
get() {
return (response.data?.lineItems.length ?? 0) <= 0
},
]
}) || [],
items:
lineItems?.map((item) => {
return {
id: item.id,
name: item.title,
images: [{ url: '/jacket.png' }],
url: '/',
quantity: item.quantity,
productId: item.id,
variantId: item.id,
}
}) || [],
},
isEmpty: false,
isLoading: false,
}
enumerable: true,
},
}),
[response]
)
},
}

View File

@ -1,17 +1,75 @@
import { useCallback } from 'react'
import { useCommerce } from '../index'
const useRemoveItem = () => {
const { checkout, client, updateCheckout } = useCommerce()
import type {
MutationHookContext,
HookFetcherContext,
} from '@commerce/utils/types'
return useCallback(
async function removeItem({ id }: { id: string }) {
const cart = await client?.checkout.removeLineItems(checkout.id, [id])
updateCheckout(cart)
return cart
},
[checkout, client]
)
import { ValidationError } from '@commerce/utils/errors'
import useRemoveItem, {
RemoveItemInput as RemoveItemInputBase,
UseRemoveItem,
} from '@commerce/cart/use-remove-item'
import useCart from './use-cart'
import { checkoutLineItemRemoveMutation, getCheckoutId } from '@framework/utils'
import { checkoutToCart } from './utils'
import { Cart, LineItem } from '@framework/types'
import {
Mutation,
MutationCheckoutLineItemsRemoveArgs,
} from '@framework/schema'
import { RemoveCartItemBody } from '@commerce/types'
export type RemoveItemFn<T = any> = T extends LineItem
? (input?: RemoveItemInput<T>) => Promise<Cart | null>
: (input: RemoveItemInput<T>) => Promise<Cart | null>
export type RemoveItemInput<T = any> = T extends LineItem
? Partial<RemoveItemInputBase>
: RemoveItemInputBase
export default useRemoveItem as UseRemoveItem<typeof handler>
export const handler = {
fetchOptions: {
query: checkoutLineItemRemoveMutation,
},
async fetcher({
input: { itemId },
options,
fetch,
}: HookFetcherContext<RemoveCartItemBody>) {
const data = await fetch<Mutation, MutationCheckoutLineItemsRemoveArgs>({
...options,
variables: { checkoutId: getCheckoutId(), lineItemIds: [itemId] },
})
return checkoutToCart(data.checkoutLineItemsRemove)
},
useHook: ({
fetch,
}: MutationHookContext<Cart | null, RemoveCartItemBody>) => <
T extends LineItem | undefined = undefined
>(
ctx: { item?: T } = {}
) => {
const { item } = ctx
const { mutate } = useCart()
const removeItem: RemoveItemFn<LineItem> = async (input) => {
const itemId = input?.id ?? item?.id
if (!itemId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({ input: { itemId } })
await mutate(data, false)
return data
}
return useCallback(removeItem as RemoveItemFn<T>, [fetch, mutate])
},
}
export default useRemoveItem

View File

@ -1,24 +1,110 @@
import { useCallback } from 'react'
import { useCommerce } from '../index'
import debounce from 'lodash.debounce'
import type {
HookFetcherContext,
MutationHookContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors'
import useUpdateItem, {
UpdateItemInput as UpdateItemInputBase,
UseUpdateItem,
} from '@commerce/cart/use-update-item'
const useUpdateItem = (item: CartItem) => {
const { checkout, client, updateCheckout } = useCommerce()
import useCart from './use-cart'
import { handler as removeItemHandler } from './use-remove-item'
import type { Cart, LineItem, UpdateCartItemBody } from '../types'
import { checkoutToCart } from './utils'
import { getCheckoutId, checkoutLineItemUpdateMutation } from '../utils'
import {
Mutation,
MutationCheckoutLineItemsUpdateArgs,
} from '@framework/schema'
return useCallback(
async function updateItem({ quantity }: { quantity: number }) {
const lineItemsToUpdate = [{ id: item.id, quantity }]
export type UpdateItemInput<T = any> = T extends LineItem
? Partial<UpdateItemInputBase<LineItem>>
: UpdateItemInputBase<LineItem>
const cart = await client?.checkout.updateLineItems(
checkout.id,
lineItemsToUpdate
)
export default useUpdateItem as UseUpdateItem<typeof handler>
updateCheckout(cart)
export const handler = {
fetchOptions: {
query: checkoutLineItemUpdateMutation,
},
async fetcher({
input: { itemId, item },
options,
fetch,
}: HookFetcherContext<UpdateCartItemBody>) {
if (Number.isInteger(item.quantity)) {
// Also allow the update hook to remove an item if the quantity is lower than 1
if (item.quantity! < 1) {
return removeItemHandler.fetcher({
options: removeItemHandler.fetchOptions,
input: { itemId },
fetch,
})
}
} else if (item.quantity) {
throw new ValidationError({
message: 'The item quantity has to be a valid integer',
})
}
const { checkoutLineItemsUpdate } = await fetch<
Mutation,
MutationCheckoutLineItemsUpdateArgs
>({
...options,
variables: {
checkoutId: getCheckoutId(),
lineItems: [
{
id: itemId,
quantity: item.quantity,
},
],
},
})
return cart
},
[checkout, client]
)
return checkoutToCart(checkoutLineItemsUpdate)
},
useHook: ({
fetch,
}: MutationHookContext<Cart | null, UpdateCartItemBody>) => <
T extends LineItem | undefined = undefined
>(
ctx: {
item?: T
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart() as any
return useCallback(
debounce(async (input: UpdateItemInput<T>) => {
const itemId = input.id ?? item?.id
const productId = input.productId ?? item?.productId
const variantId = input.productId ?? item?.variantId
if (!itemId || !productId || !variantId) {
throw new ValidationError({
message: 'Invalid input used for this operation',
})
}
const data = await fetch({
input: {
item: {
productId,
variantId,
quantity: input.quantity,
},
itemId,
},
})
await mutate(data, false)
return data
}, ctx.wait ?? 500),
[fetch, mutate]
)
},
}
export default useUpdateItem

View File

@ -0,0 +1,25 @@
import {
SHOPIFY_CHECKOUT_ID_COOKIE,
SHOPIFY_CHECKOUT_URL_COOKIE,
} from '@framework/const'
import checkoutCreateMutation from '@framework/utils/mutations/checkout-create'
import Cookies from 'js-cookie'
export const checkoutCreate = async (fetch: any) => {
const data = await fetch({
query: checkoutCreateMutation,
})
const checkout = data.checkoutCreate?.checkout
const checkoutId = checkout?.id
if (checkoutId) {
Cookies.set(SHOPIFY_CHECKOUT_ID_COOKIE, checkoutId)
Cookies.set(SHOPIFY_CHECKOUT_URL_COOKIE, checkout?.webUrl)
}
return checkout
}
export default checkoutCreate

View File

@ -0,0 +1,42 @@
import { Cart } from '@commerce/types'
import { CommerceError, ValidationError } from '@commerce/utils/errors'
import {
CheckoutLineItemsAddPayload,
CheckoutLineItemsRemovePayload,
CheckoutLineItemsUpdatePayload,
Maybe,
} from '@framework/schema'
import { normalizeCart } from '@framework/utils'
export type CheckoutPayload =
| CheckoutLineItemsAddPayload
| CheckoutLineItemsUpdatePayload
| CheckoutLineItemsRemovePayload
const checkoutToCart = (checkoutPayload?: Maybe<CheckoutPayload>): Cart => {
if (!checkoutPayload) {
throw new CommerceError({
message: 'Invalid response from Shopify',
})
}
const checkout = checkoutPayload?.checkout
const userErrors = checkoutPayload?.userErrors
if (userErrors && userErrors.length) {
throw new ValidationError({
message: userErrors[0].message,
})
}
if (!checkout) {
throw new CommerceError({
message: 'Invalid response from Shopify',
})
}
return normalizeCart(checkout)
}
export default checkoutToCart

View File

@ -0,0 +1,30 @@
import { HookFetcherFn } from '@commerce/utils/types'
import { Cart } from '@commerce/types'
import { checkoutCreate, checkoutToCart } from '.'
import { FetchCartInput } from '@commerce/cart/use-cart'
const fetcher: HookFetcherFn<Cart | null, FetchCartInput> = async ({
options,
input: { cartId: checkoutId },
fetch,
}) => {
let checkout
if (checkoutId) {
const data = await fetch({
...options,
variables: {
checkoutId,
},
})
checkout = data.node
}
if (checkout?.completedAt || !checkoutId) {
checkout = await checkoutCreate(fetch)
}
return checkoutToCart({ checkout })
}
export default fetcher

View File

@ -0,0 +1,2 @@
export { default as checkoutToCart } from './checkout-to-cart'
export { default as checkoutCreate } from './checkout-create'

View File

@ -1,53 +1,39 @@
import { getConfig, ShopifyConfig } from '../api'
import { Page as PageType, PageEdge } from '../types'
export type Page = PageType
export const getAllPagesQuery = /* GraphQL */ `
query($first: Int!) {
pages(first: $first) {
edges {
node {
id
title
handle
body
bodySummary
url
}
}
}
}
`
import { PageEdge } from '../schema'
import { getAllPagesQuery } from '../utils/queries'
type Variables = {
first?: number
}
type Options = {
variables?: Variables
config: ShopifyConfig
preview?: boolean
}
type ReturnType = {
pages: Page[]
}
const getAllPages = async (options?: Options): Promise<ReturnType> => {
let { config, variables = { first: 250 } } = options || {}
export type Page = {
id: string
name: string
url: string
sort_order?: number
body: string
}
const getAllPages = async (options?: {
variables?: Variables
config: ShopifyConfig
preview?: boolean
}): Promise<ReturnType> => {
let { config, variables = { first: 250 } } = options ?? {}
config = getConfig(config)
const { data } = await config.fetch(getAllPagesQuery, { variables })
const pages = data.pages.edges.map(({ node }: PageEdge) => {
return {
const pages = data.pages?.edges?.map(
({ node: { title: name, handle, ...node } }: PageEdge) => ({
...node,
name: node.handle,
url: `${config!.locale}/${node.handle}`,
}
})
url: `/${handle}`,
name,
})
)
return { pages }
}

View File

@ -0,0 +1,38 @@
import { getConfig, ShopifyConfig } from '../api'
import getPageQuery from '../utils/queries/get-page-query'
import { Page } from './get-all-pages'
type Variables = {
slug: string
}
type ReturnType = {
page: Page
}
const getPage = async (options: {
variables: Variables
config: ShopifyConfig
preview?: boolean
}): Promise<ReturnType> => {
let { config, variables } = options ?? {}
config = getConfig(config)
const { data } = await config.fetch(getPageQuery, {
variables,
})
const { pageByHandle: page } = data
return {
page: page
? {
...page,
name: page.title,
url: page?.handle,
}
: null,
}
}
export default getPage

View File

@ -1,29 +1,30 @@
import { ShopifyConfig } from '../index'
import getCategories, { Category } from '@framework/utils/get-categories'
import getVendors, { Brands } from '@framework/utils/get-vendors'
type Options = {
import { getConfig, ShopifyConfig } from '../api'
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: Category[]
brands: Brands
}
> = T
const getSiteInfo = async (options?: {
variables?: any
config: ShopifyConfig
preview?: boolean
}
}): Promise<GetSiteInfoResult> => {
let { config } = options ?? {}
config = getConfig(config)
const categories = await getCategories(config)
const brands = await getVendors(config)
const getSiteInfo = async (options: Options) => {
// TODO
return {
categories: [
{
path: '',
name: '',
entityId: 0,
},
],
brands: [
{
node: {
path: '',
name: '',
entityId: 0,
},
},
],
categories,
brands,
}
}

View File

@ -0,0 +1,11 @@
export const SHOPIFY_CHECKOUT_ID_COOKIE = 'shopify_checkoutId'
export const SHOPIFY_CHECKOUT_URL_COOKIE = 'shopify_checkoutUrl'
export const SHOPIFY_CUSTOMER_TOKEN_COOKIE = 'shopify_customerToken'
export const STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN
export const API_URL = `https://${STORE_DOMAIN}/api/2021-01/graphql.json`
export const API_TOKEN = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN

View File

@ -0,0 +1,24 @@
import { getConfig, ShopifyConfig } from '@framework/api'
import getCustomerIdQuery from '@framework/utils/queries/get-customer-id-query'
import Cookies from 'js-cookie'
async function getCustomerId({
customerToken: customerAccesToken,
config,
}: {
customerToken: string
config?: ShopifyConfig
}): Promise<number | undefined> {
config = getConfig(config)
const { data } = await config.fetch(getCustomerIdQuery, {
variables: {
customerAccesToken:
customerAccesToken || Cookies.get(config.customerCookie),
},
})
return data.customer?.id
}
export default getCustomerId

View File

@ -0,0 +1 @@
export { default as useCustomer } from './use-customer'

View File

@ -1,32 +1,27 @@
import type { HookFetcher } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data'
import useCommerceCustomer from '@commerce/customer/use-customer'
import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import { Customer } from '@commerce/types'
import { SWRHook } from '@commerce/utils/types'
import { getCustomerQuery, getCustomerToken } from '../utils'
import type { ShopifyProvider } from '..'
const defaultOpts = {}
export type Customer = {
entityId: number
firstName: string
lastName: string
email: string
export default useCustomer as UseCustomer<ShopifyProvider>
export const handler: SWRHook<Customer | null> = {
fetchOptions: {
query: getCustomerQuery,
},
async fetcher({ options, fetch }) {
const data = await fetch<any | null>({
...options,
variables: { customerAccessToken: getCustomerToken() },
})
return data.customer ?? null
},
useHook: ({ useData }) => (input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
}
export type CustomerData = {}
export const fetcher: HookFetcher<Customer | null> = async () => {
return null
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<Customer | null>
) {
const useCustomer = () => {
return { data: { firstName: null, lastName: null, email: null } }
}
useCustomer.extend = extendHook
return useCustomer
}
export default extendHook(fetcher)

View File

@ -1,51 +1,18 @@
import { FetcherError } from '@commerce/utils/errors'
import type { Fetcher } from '@commerce/utils/types'
import { Fetcher } from '@commerce/utils/types'
import { API_TOKEN, API_URL } from './const'
import { handleFetchResponse } from './utils'
async function getText(res: Response) {
try {
return (await res.text()) || res.statusText
} catch (error) {
return res.statusText
}
}
async function getError(res: Response) {
if (res.headers.get('Content-Type')?.includes('application/json')) {
const data = await res.json()
return new FetcherError({ errors: data.errors, status: res.status })
}
return new FetcherError({ message: await getText(res), status: res.status })
}
const fetcher: Fetcher = async ({
url,
query,
method = 'POST',
variables,
body: bodyObj,
}) => {
// const config = getConfig()
// url = `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2021-01/graphql.json`
const hasBody = Boolean(variables || bodyObj)
const body = hasBody
? JSON.stringify(variables ? { query, variables } : bodyObj)
: undefined
const headers = hasBody
? {
'X-Shopify-Storefront-Access-Token': config.apiToken,
const fetcher: Fetcher = async ({ method = 'POST', variables, query }) => {
return handleFetchResponse(
await fetch(API_URL, {
method,
body: JSON.stringify({ query, variables }),
headers: {
'X-Shopify-Storefront-Access-Token': API_TOKEN!,
'Content-Type': 'application/json',
}
: undefined
const res = await fetch(url!, { method, body, headers })
if (res.ok) {
const { data } = await res.json()
return data
}
throw await getError(res)
},
})
)
}
export default fetcher

View File

@ -1,109 +1,39 @@
import React, {
ReactNode,
createContext,
useContext,
useMemo,
useState,
useEffect,
} from 'react'
import Client from 'shopify-buy'
import { Shop, Cart, Client as ClientType } from './types'
import * as React from 'react'
import { ReactNode } from 'react'
import {
getCheckoutIdFromStorage,
setCheckoutIdInStorage,
} from './utils/storage'
import { getConfig } from '@framework/api'
CommerceConfig,
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
const Commerce = createContext<CommerceContextValue | {}>({})
import { shopifyProvider, ShopifyProvider } from './provider'
import { SHOPIFY_CHECKOUT_ID_COOKIE } from './const'
type CommerceProps = {
export { shopifyProvider }
export type { ShopifyProvider }
export const shopifyConfig: CommerceConfig = {
locale: 'en-us',
cartCookie: SHOPIFY_CHECKOUT_ID_COOKIE,
}
export type ShopifyConfig = Partial<CommerceConfig>
export type ShopifyProps = {
children?: ReactNode
locale: string
}
} & ShopifyConfig
type CommerceContextValue = {
client: ClientType
shop: Shop
checkout: Cart
updateCheckout: (cart: Cart | undefined) => void
currencyCode: string
locale: string
sessionToken: string
}
export function CommerceProvider({
children,
locale = 'en-US',
}: CommerceProps) {
const sessionToken = 'nextjs-commerce-shopify-token'
const config = getConfig()
const client = Client.buildClient({
storefrontAccessToken: config.apiToken,
domain: config.commerceUrl,
language: locale,
}) as ClientType
const [shop, setShop] = useState<Shop>()
const [checkout, setCheckout] = useState<Cart>()
const fetchShopify = async () => {
const shopInfo: Shop = await client.shop.fetchInfo()
let checkoutResource: Cart
const checkoutOptions = {
presentmentCurrencyCode:
/*config.currencyCode ||*/ shopInfo?.currencyCode,
}
let checkoutId = getCheckoutIdFromStorage(sessionToken)
// we could have a cart id stored in session storage
// user could be refreshing or navigating back and forth
if (checkoutId) {
checkoutResource = await client.checkout.fetch(checkoutId)
// could be expired order - we will create a new order
if (checkoutResource.completedAt) {
checkoutResource = await client.checkout.create(checkoutOptions)
}
} else {
checkoutResource = await client.checkout.create(checkoutOptions)
}
setCheckoutIdInStorage(sessionToken, checkoutResource.id)
setShop(shopInfo)
setCheckout(checkoutResource)
}
useEffect(() => {
fetchShopify()
}, [])
const updateCheckout = (newCheckout: Cart) => {
setCheckout(newCheckout)
}
// Because the config is an object, if the parent re-renders this provider
// will re-render every consumer unless we memoize the config
const cfg = useMemo(
() => ({
client,
checkout,
shop,
updateCheckout: updateCheckout,
currencyCode: /*config.currencyCode ||*/ checkout?.currencyCode,
locale,
sessionToken,
}),
[client]
export function CommerceProvider({ children, ...config }: ShopifyProps) {
return (
<CoreCommerceProvider
provider={shopifyProvider}
config={{ ...shopifyConfig, ...config }}
>
{children}
</CoreCommerceProvider>
)
return <Commerce.Provider value={cfg}>{children}</Commerce.Provider>
}
export function useCommerce<T extends CommerceContextValue>() {
return useContext(Commerce) as T
}
export const useCommerce = () => useCoreCommerce()

View File

@ -0,0 +1,29 @@
import { CollectionEdge } from '@framework/schema'
import { getConfig, ShopifyConfig } from '../api'
import getAllCollectionsQuery from '../utils/queries/get-all-collections-query'
const getAllCollections = async (options?: {
variables?: any
config: ShopifyConfig
preview?: boolean
}) => {
let { config, variables = { first: 250 } } = options ?? {}
config = getConfig(config)
const { data } = await config.fetch(getAllCollectionsQuery, { variables })
const edges = data.collections?.edges ?? []
const categories = edges.map(
({ node: { id: entityId, title: name, handle } }: CollectionEdge) => ({
entityId,
name,
path: `/${handle}`,
})
)
return {
categories,
}
}
export default getAllCollections

View File

@ -1,30 +1,41 @@
import Client from 'shopify-buy'
import { getConfig } from '../api'
import { Product } from '../types'
import toCommerceProducts from '../utils/to-commerce-products'
import { Product } from '@commerce/types'
import { getConfig, ShopifyConfig } from '../api'
import fetchAllProducts from '../api/utils/fetch-all-products'
import { ProductEdge } from '../schema'
import getAllProductsPathsQuery from '../utils/queries/get-all-products-paths-query'
type ReturnType = {
products: any[]
type ProductPath = {
path: string
}
const getAllProductPaths = async (): Promise<ReturnType> => {
const config = getConfig()
export type ProductPathNode = {
node: ProductPath
}
const client = Client.buildClient({
storefrontAccessToken: config.apiToken,
domain: config.commerceUrl,
type ReturnType = {
products: ProductPathNode[]
}
const getAllProductPaths = async (options?: {
variables?: any
config?: ShopifyConfig
preview?: boolean
}): Promise<ReturnType> => {
let { config, variables = { first: 250 } } = options ?? {}
config = getConfig(config)
const products = await fetchAllProducts({
config,
query: getAllProductsPathsQuery,
variables,
})
const res = (await client.product.fetchAll()) as Product[]
const products = toCommerceProducts(res)
return {
products: products.map((product) => {
return {
node: { ...product },
}
}),
products: products?.map(({ node: { handle } }: ProductEdge) => ({
node: {
path: `/${handle}`,
},
})),
}
}

View File

@ -1,36 +1,36 @@
import Client from 'shopify-buy'
import { ShopifyConfig } from '../api'
import { Product } from '../types'
import toCommerceProducts from '../utils/to-commerce-products'
export type ProductNode = Product
import { GraphQLFetcherResult } from '@commerce/api'
import { getConfig, ShopifyConfig } from '../api'
import { ProductEdge } from '../schema'
import { getAllProductsQuery } from '../utils/queries'
import { normalizeProduct } from '@framework/utils/normalize'
import { Product } from '@commerce/types'
type Variables = {
first?: number
field?: string
}
type Options = {
variables: Variables
config: ShopifyConfig
preview?: boolean
}
type ReturnType = {
products: any[]
products: Product[]
}
const getAllProducts = async (options: Options): Promise<ReturnType> => {
const { config } = options
const getAllProducts = async (options: {
variables?: Variables
config?: ShopifyConfig
preview?: boolean
}): Promise<ReturnType> => {
let { config, variables = { first: 250 } } = options ?? {}
config = getConfig(config)
const client = Client.buildClient({
storefrontAccessToken: config.apiToken,
domain: config.commerceUrl,
})
const { data }: GraphQLFetcherResult = await config.fetch(
getAllProductsQuery,
{ variables }
)
const res = (await client.product.fetchAll()) as Product[]
const products = toCommerceProducts(res)
const products =
data.products?.edges?.map(({ node: p }: ProductEdge) =>
normalizeProduct(p)
) ?? []
return {
products,

View File

@ -1,36 +1,31 @@
import Client from 'shopify-buy'
import { ShopifyConfig } from '../api'
import { Product } from '../types'
import toCommerceProducts from '../utils/to-commerce-products'
export type ProductNode = Product
import { GraphQLFetcherResult } from '@commerce/api'
import { getConfig, ShopifyConfig } from '../api'
import { normalizeProduct, getProductQuery } from '../utils'
type Variables = {
slug: string
}
type Options = {
variables: Variables
config: ShopifyConfig
preview?: boolean
}
type ReturnType = {
product: any
}
const getProduct = async (options: Options): Promise<ReturnType> => {
const { variables, config } = options
const getProduct = async (options: {
variables: Variables
config: ShopifyConfig
preview?: boolean
}): Promise<ReturnType> => {
let { config, variables } = options ?? {}
config = getConfig(config)
const client = Client.buildClient({
storefrontAccessToken: config.apiToken,
domain: config.commerceUrl,
const { data }: GraphQLFetcherResult = await config.fetch(getProductQuery, {
variables,
})
const res = (await client.product.fetchByHandle(variables.slug)) as Product
const { productByHandle: product } = data
return {
product: toCommerceProducts([res])[0],
product: product ? normalizeProduct(product) : null,
}
}

View File

@ -1,9 +1,17 @@
import type { HookFetcher } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data'
import useCommerceSearch from '@commerce/product/use-search'
import { ProductEdge } from '../types'
import { SWRHook } from '@commerce/utils/types'
import useSearch, { UseSearch } from '@commerce/product/use-search'
const defaultOpts = {}
import { ProductEdge } from '@framework/schema'
import {
getAllProductsQuery,
getSearchVariables,
normalizeProduct,
} from '@framework/utils'
import type { ShopifyProvider } from '..'
import { Product } from '@commerce/types'
export default useSearch as UseSearch<ShopifyProvider>
export type SearchProductsInput = {
search?: string
@ -13,29 +21,41 @@ export type SearchProductsInput = {
}
export type SearchProductsData = {
products: ProductEdge[]
products: Product[]
found: boolean
}
export const fetcher: HookFetcher<SearchProductsData, SearchProductsInput> = (
options,
{ search, categoryId, brandId, sort },
fetch
) => {
return { found: false, products: [] }
export const handler: SWRHook<
SearchProductsData,
SearchProductsInput,
SearchProductsInput
> = {
fetchOptions: {
query: getAllProductsQuery,
},
async fetcher({ input, options, fetch }) {
const resp = await fetch({
query: options?.query,
method: options?.method,
variables: getSearchVariables(input),
})
const edges = resp.products?.edges
return {
products: edges?.map(({ node: p }: ProductEdge) => normalizeProduct(p)),
found: !!edges?.length,
}
},
useHook: ({ useData }) => (input = {}) => {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}
export function extendHook(
customFetcher: typeof fetcher,
swrOptions?: SwrOptions<SearchProductsData, SearchProductsInput>
) {
const useSearch = (input: SearchProductsInput = {}) => {
return {}
}
useSearch.extend = extendHook
return useSearch
}
export default extendHook(fetcher)

View File

@ -1,12 +1,10 @@
import { SHOPIFY_CHECKOUT_ID_COOKIE, STORE_DOMAIN } from './const'
import { handler as useCart } from './cart/use-cart'
import { handler as useAddItem } from './cart/use-add-item'
import { handler as useUpdateItem } from './cart/use-update-item'
import { handler as useRemoveItem } from './cart/use-remove-item'
import { handler as useWishlist } from './wishlist/use-wishlist'
import { handler as useWishlistAddItem } from './wishlist/use-add-item'
import { handler as useWishlistRemoveItem } from './wishlist/use-remove-item'
import { handler as useCustomer } from './customer/use-customer'
import { handler as useSearch } from './product/use-search'
@ -18,17 +16,16 @@ import fetcher from './fetcher'
export const shopifyProvider = {
locale: 'en-us',
cartCookie: 'sp_cartId',
cartCookie: SHOPIFY_CHECKOUT_ID_COOKIE,
storeDomain: STORE_DOMAIN,
fetcher,
cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
wishlist: {
useWishlist,
useAddItem: useWishlistAddItem,
useRemoveItem: useWishlistRemoveItem,
},
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
features: {
wishlist: false,
},
}
export type ShopifyProvider = typeof shopifyProvider

4985
framework/shopify/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,130 +1,48 @@
import {
Product as BaseProduct,
ProductVariant as BaseProductVariant,
Cart as BaseCart,
CheckoutResource as BaseCheckoutResource,
AttributeInput,
Client as BaseClient,
Shop as BaseShop,
Image as BaseImage,
} from 'shopify-buy'
import * as Core from '@commerce/types'
import { CheckoutLineItem } from './schema'
export type SelectedOptions = {
export type ShopifyCheckout = {
id: string
name: string
value: string
webUrl: string
lineItems: CheckoutLineItem[]
}
export type PresentmentPrice = {
price: PriceV2
}
export type ProductVariant = BaseProductVariant & {
selectedOptions: Array<SelectedOptions>
presentmentPrices: Array<PresentmentPrice>
}
// TODO
export type ProductOptions = {
node: {
__typename: string
displayName: string
values: {
edges: [
{
node: {
label: string
id: string
}
}
]
}
}
}
// TODO
export type ProductEdge = {
node: Product
}
export type Product = BaseProduct & {
handle: string
name: string
path: string
entityId: number
descriptionHtml: string
prices: {
price: {
value: number
currencyCode: string
}
retailPrice: {
value: number
currencyCode: string
}
}
images: {
edges: [{ node: { urlOriginal: string; altText: string } }]
}
productOptions: ProductOptions
variants: Array<ProductVariant> & {
edges: [
{
node: {
productOptions: ProductOptions[]
entityId: number
}
}
]
}
}
export type PriceV2 = {
amount: number
currencyCode: string
}
export type Cart = BaseCart & {
webUrl?: string
currencyCode?: string
lineItemsSubtotalPrice?: PriceV2
totalPriceV2?: PriceV2
}
export type Shop = BaseShop & {
currencyCode?: string
}
export type Create = {
presentmentCurrencyCode?: string
}
export type CheckoutResource = BaseCheckoutResource & {
updateLineItems(
checkoutId: string | number,
lineItems: AttributeInput[]
): Promise<Cart>
create: (input: Create) => Promise<Cart>
}
export type Client = BaseClient & {
checkout: CheckoutResource
}
export type Page = {
export interface Cart extends Core.Cart {
id: string
title: string
name: string
handle: string
body: string
bodySummary: string
url: string
sort_order: number
lineItems: LineItem[]
}
export type PageEdge = {
node: Page
export interface LineItem extends Core.LineItem {
options: any[]
}
export type Image = BaseImage
/**
* Cart mutations
*/
export type OptionSelections = {
option_id: number
option_value: number | string
}
export type CartItemBody = Core.CartItemBody & {
productId: string // The product id is always required for BC
optionSelections?: OptionSelections
}
type X = Core.CartItemBody extends CartItemBody ? any : never
type Y = CartItemBody extends Core.CartItemBody ? any : never
export type GetCartHandlerBody = Core.GetCartHandlerBody
export type AddCartItemBody = Core.AddCartItemBody<CartItemBody>
export type AddCartItemHandlerBody = Core.AddCartItemHandlerBody<CartItemBody>
export type UpdateCartItemBody = Core.UpdateCartItemBody<CartItemBody>
export type UpdateCartItemHandlerBody = Core.UpdateCartItemHandlerBody<CartItemBody>
export type RemoveCartItemBody = Core.RemoveCartItemBody
export type RemoveCartItemHandlerBody = Core.RemoveCartItemHandlerBody

View File

@ -0,0 +1,12 @@
import Cookies from 'js-cookie'
import { SHOPIFY_CUSTOMER_TOKEN_COOKIE } from '../const'
export const getCustomerToken = () => Cookies.get(SHOPIFY_CUSTOMER_TOKEN_COOKIE)
export const setCustomerToken = (token: string | null, options?: any) => {
if (!token) {
Cookies.remove(SHOPIFY_CUSTOMER_TOKEN_COOKIE)
} else {
Cookies.set(SHOPIFY_CUSTOMER_TOKEN_COOKIE, token, options)
}
}

View File

@ -0,0 +1,29 @@
import { ShopifyConfig } from '@framework/api'
import { CollectionEdge } from '@framework/schema'
import getSiteCollectionsQuery from './queries/get-all-collections-query'
export type Category = {
endityId: string
name: string
path: string
}
const getCategories = async (config: ShopifyConfig): Promise<Category[]> => {
const { data } = await config.fetch(getSiteCollectionsQuery, {
variables: {
first: 250,
},
})
return (
data.collections?.edges?.map(
({ node: { title: name, handle } }: CollectionEdge) => ({
entityId: handle,
name,
path: `/${handle}`,
})
) ?? []
)
}
export default getCategories

View File

@ -0,0 +1,8 @@
import Cookies from 'js-cookie'
import { SHOPIFY_CHECKOUT_ID_COOKIE } from '../const'
const getCheckoutId = (id?: string) => {
return id ?? Cookies.get(SHOPIFY_CHECKOUT_ID_COOKIE)
}
export default getCheckoutId

View File

@ -0,0 +1,30 @@
import getSortVariables from './get-sort-variables'
import type { SearchProductsInput } from '@framework/product/use-search'
export const getSearchVariables = ({
categoryId,
brandId,
search,
sort,
}: SearchProductsInput) => {
let query = ''
if (search) {
query += `product_type:${search} OR title:${search} OR tag:${search}`
}
if (categoryId) {
query += `tag:${categoryId}`
}
if (brandId) {
query += `${categoryId ? ' AND ' : ''}vendor:${brandId}`
}
return {
query,
...getSortVariables(sort),
}
}
export default getSearchVariables

View File

@ -0,0 +1,32 @@
const getSortVariables = (sort?: string) => {
let output = {}
switch (sort) {
case 'price-asc':
output = {
sortKey: 'PRICE',
reverse: false,
}
break
case 'price-desc':
output = {
sortKey: 'PRICE',
reverse: true,
}
break
case 'trending-desc':
output = {
sortKey: 'BEST_SELLING',
reverse: false,
}
break
case 'latest-desc':
output = {
sortKey: 'CREATED_AT',
reverse: true,
}
break
}
return output
}
export default getSortVariables

View File

@ -0,0 +1,36 @@
import { ShopifyConfig } from '@framework/api'
import fetchAllProducts from '@framework/api/utils/fetch-all-products'
import getAllProductVendors from './queries/get-all-product-vendors-query'
export type BrandNode = {
name: string
path: string
}
export type BrandEdge = {
node: BrandNode
}
export type Brands = BrandEdge[]
const getVendors = async (config: ShopifyConfig): Promise<BrandEdge[]> => {
const vendors = await fetchAllProducts({
config,
query: getAllProductVendors,
variables: {
first: 250,
},
})
let vendorsStrings = vendors.map(({ node: { vendor } }) => vendor)
return [...new Set(vendorsStrings)].map((v) => ({
node: {
entityId: v,
name: v,
path: `brands/${v}`,
},
}))
}
export default getVendors

View File

@ -0,0 +1,27 @@
import { FetcherError } from '@commerce/utils/errors'
export function getError(errors: any[], status: number) {
errors = errors ?? [{ message: 'Failed to fetch Shopify API' }]
return new FetcherError({ errors, status })
}
export async function getAsyncError(res: Response) {
const data = await res.json()
return getError(data.errors, res.status)
}
const handleFetchResponse = async (res: Response) => {
if (res.ok) {
const { data, errors } = await res.json()
if (errors && errors.length) {
throw getError(errors, res.status)
}
return data
}
throw await getAsyncError(res)
}
export default handleFetchResponse

View File

@ -0,0 +1,39 @@
import { ValidationError } from '@commerce/utils/errors'
import { setCustomerToken } from './customer-token'
const getErrorMessage = ({
code,
message,
}: {
code: string
message: string
}) => {
switch (code) {
case 'UNIDENTIFIED_CUSTOMER':
message = 'Cannot find an account that matches the provided credentials'
break
}
return message
}
const handleLogin = (data: any) => {
const response = data.customerAccessTokenCreate
const errors = response?.customerUserErrors
if (errors && errors.length) {
throw new ValidationError({
message: getErrorMessage(errors[0]),
})
}
const customerAccessToken = response?.customerAccessToken
const accessToken = customerAccessToken?.accessToken
if (accessToken) {
setCustomerToken(accessToken)
}
return customerAccessToken
}
export default handleLogin

View File

@ -0,0 +1,10 @@
export { default as handleFetchResponse } from './handle-fetch-response'
export { default as getSearchVariables } from './get-search-variables'
export { default as getSortVariables } from './get-sort-variables'
export { default as getVendors } from './get-vendors'
export { default as getCategories } from './get-categories'
export { default as getCheckoutId } from './get-checkout-id'
export * from './queries'
export * from './mutations'
export * from './normalize'
export * from './customer-token'

View File

@ -0,0 +1,18 @@
const associateCustomerWithCheckoutMutation = /* GraphQl */ `
mutation associateCustomerWithCheckout($checkoutId: ID!, $customerAccessToken: String!) {
checkoutCustomerAssociateV2(checkoutId: $checkoutId, customerAccessToken: $customerAccessToken) {
checkout {
id
}
checkoutUserErrors {
code
field
message
}
customer {
id
}
}
}
`
export default associateCustomerWithCheckoutMutation

View File

@ -0,0 +1,16 @@
import { checkoutDetailsFragment } from '../queries/get-checkout-query'
const checkoutCreateMutation = /* GraphQL */ `
mutation {
checkoutCreate(input: {}) {
userErrors {
message
field
}
checkout {
${checkoutDetailsFragment}
}
}
}
`
export default checkoutCreateMutation

View File

@ -0,0 +1,16 @@
import { checkoutDetailsFragment } from '../queries/get-checkout-query'
const checkoutLineItemAddMutation = /* GraphQL */ `
mutation($checkoutId: ID!, $lineItems: [CheckoutLineItemInput!]!) {
checkoutLineItemsAdd(checkoutId: $checkoutId, lineItems: $lineItems) {
userErrors {
message
field
}
checkout {
${checkoutDetailsFragment}
}
}
}
`
export default checkoutLineItemAddMutation

View File

@ -0,0 +1,19 @@
import { checkoutDetailsFragment } from '../queries/get-checkout-query'
const checkoutLineItemRemoveMutation = /* GraphQL */ `
mutation($checkoutId: ID!, $lineItemIds: [ID!]!) {
checkoutLineItemsRemove(
checkoutId: $checkoutId
lineItemIds: $lineItemIds
) {
userErrors {
message
field
}
checkout {
${checkoutDetailsFragment}
}
}
}
`
export default checkoutLineItemRemoveMutation

View File

@ -0,0 +1,16 @@
import { checkoutDetailsFragment } from '../queries/get-checkout-query'
const checkoutLineItemUpdateMutation = /* GraphQL */ `
mutation($checkoutId: ID!, $lineItems: [CheckoutLineItemUpdateInput!]!) {
checkoutLineItemsUpdate(checkoutId: $checkoutId, lineItems: $lineItems) {
userErrors {
message
field
}
checkout {
${checkoutDetailsFragment}
}
}
}
`
export default checkoutLineItemUpdateMutation

View File

@ -0,0 +1,16 @@
const customerAccessTokenCreateMutation = /* GraphQL */ `
mutation customerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
customerAccessTokenCreate(input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`
export default customerAccessTokenCreateMutation

View File

@ -0,0 +1,14 @@
const customerAccessTokenDeleteMutation = /* GraphQL */ `
mutation customerAccessTokenDelete($customerAccessToken: String!) {
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
deletedAccessToken
deletedCustomerAccessTokenId
userErrors {
field
message
}
}
}
`
export default customerAccessTokenDeleteMutation

View File

@ -0,0 +1,15 @@
const customerCreateMutation = /* GraphQL */ `
mutation customerCreate($input: CustomerCreateInput!) {
customerCreate(input: $input) {
customerUserErrors {
code
field
message
}
customer {
id
}
}
}
`
export default customerCreateMutation

View File

@ -0,0 +1,7 @@
export { default as customerCreateMutation } from './customer-create'
export { default as checkoutCreateMutation } from './checkout-create'
export { default as checkoutLineItemAddMutation } from './checkout-line-item-add'
export { default as checkoutLineItemUpdateMutation } from './checkout-line-item-update'
export { default as checkoutLineItemRemoveMutation } from './checkout-line-item-remove'
export { default as customerAccessTokenCreateMutation } from './customer-access-token-create'
export { default as customerAccessTokenDeleteMutation } from './customer-access-token-delete'

View File

@ -0,0 +1,133 @@
import {
Product as ShopifyProduct,
Checkout,
CheckoutLineItemEdge,
SelectedOption,
ImageConnection,
ProductVariantConnection,
ProductOption,
MoneyV2,
} from '@framework/schema'
import type { Cart, LineItem } from '../types'
const money = ({ amount, currencyCode }: MoneyV2) => {
return {
value: +amount,
currencyCode,
}
}
const normalizeProductOption = ({
name: displayName,
values,
...rest
}: ProductOption) => {
return {
__typename: 'MultipleChoiceOption',
displayName,
values: values.map((value) => ({
label: value,
hexColors: displayName === 'Color' ? [value] : null,
})),
...rest,
}
}
const normalizeProductImages = ({ edges }: ImageConnection) =>
edges?.map(({ node: { originalSrc: url, ...rest } }) => ({
url,
...rest,
}))
const normalizeProductVariants = ({ edges }: ProductVariantConnection) => {
return edges?.map(({ node: { id, selectedOptions } }) => ({
id,
options: selectedOptions.map(({ name, value }: SelectedOption) =>
normalizeProductOption({
id,
name,
values: [value],
})
),
}))
}
export function normalizeProduct(productNode: ShopifyProduct): any {
const {
id,
title: name,
vendor,
images,
variants,
description,
handle,
priceRange,
options,
...rest
} = productNode
const product = {
id,
name,
vendor,
description,
path: `/${handle}`,
slug: handle?.replace(/^\/+|\/+$/g, ''),
price: money(priceRange?.minVariantPrice),
images: normalizeProductImages(images),
variants: variants ? normalizeProductVariants(variants) : [],
options: options ? options.map((o) => normalizeProductOption(o)) : [],
...rest,
}
return product
}
export function normalizeCart(checkout: Checkout): Cart {
return {
id: checkout.id,
customerId: '',
email: '',
createdAt: checkout.createdAt,
currency: {
code: checkout.totalPriceV2?.currencyCode,
},
taxesIncluded: checkout.taxesIncluded,
lineItems: checkout.lineItems?.edges.map(normalizeLineItem),
lineItemsSubtotalPrice: checkout.subtotalPriceV2?.amount,
subtotalPrice: checkout.subtotalPriceV2?.amount,
totalPrice: checkout.totalPriceV2?.amount,
discounts: [],
}
}
function normalizeLineItem({
node: { id, title, variant, quantity },
}: CheckoutLineItemEdge): LineItem {
return {
id,
variantId: String(variant?.id),
productId: String(variant?.id),
name: `${title}`,
quantity,
variant: {
id: String(variant?.id),
sku: variant?.sku ?? '',
name: variant?.title!,
image: {
url: variant?.image?.originalSrc,
},
requiresShipping: variant?.requiresShipping ?? false,
price: variant?.priceV2?.amount,
listPrice: variant?.compareAtPriceV2?.amount,
},
path: '',
discounts: [],
options: [
{
value: variant?.title,
},
],
}
}

View File

@ -0,0 +1,14 @@
const getSiteCollectionsQuery = /* GraphQL */ `
query getSiteCollections($first: Int!) {
collections(first: $first) {
edges {
node {
id
title
handle
}
}
}
}
`
export default getSiteCollectionsQuery

View File

@ -0,0 +1,14 @@
export const getAllPagesQuery = /* GraphQL */ `
query getAllPages($first: Int = 250) {
pages(first: $first) {
edges {
node {
id
title
handle
}
}
}
}
`
export default getAllPagesQuery

View File

@ -0,0 +1,17 @@
const getAllProductVendors = /* GraphQL */ `
query getAllProductVendors($first: Int = 250, $cursor: String) {
products(first: $first, after: $cursor) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
vendor
}
cursor
}
}
}
`
export default getAllProductVendors

View File

@ -0,0 +1,17 @@
const getAllProductsPathsQuery = /* GraphQL */ `
query getAllProductPaths($first: Int = 250, $cursor: String) {
products(first: $first, after: $cursor) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
handle
}
cursor
}
}
}
`
export default getAllProductsPathsQuery

View File

@ -0,0 +1,54 @@
export const productsFragment = `
products(
first: $first
sortKey: $sortKey
reverse: $reverse
query: $query
) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
id
title
vendor
handle
description
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
originalSrc
altText
width
height
}
}
}
}
}
}
`
const getAllProductsQuery = /* GraphQL */ `
query getAllProducts(
$first: Int = 250
$query: String = ""
$sortKey: ProductSortKeys = RELEVANCE
$reverse: Boolean = false
) {
${productsFragment}
}
`
export default getAllProductsQuery

View File

@ -0,0 +1,62 @@
export const checkoutDetailsFragment = `
id
webUrl
subtotalPriceV2{
amount
currencyCode
}
totalTaxV2 {
amount
currencyCode
}
totalPriceV2 {
amount
currencyCode
}
completedAt
createdAt
taxesIncluded
lineItems(first: 250) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
id
title
variant {
id
sku
title
image {
originalSrc
altText
width
height
}
priceV2{
amount
currencyCode
}
compareAtPriceV2{
amount
currencyCode
}
}
quantity
}
}
}
`
const getCheckoutQuery = /* GraphQL */ `
query($checkoutId: ID!) {
node(id: $checkoutId) {
... on Checkout {
${checkoutDetailsFragment}
}
}
}
`
export default getCheckoutQuery

View File

@ -0,0 +1,17 @@
import { productsFragment } from './get-all-products-query'
const getCollectionProductsQuery = /* GraphQL */ `
query getProductsFromCollection(
$categoryHandle: String!
$first: Int = 250
$query: String = ""
$sortKey: ProductSortKeys = RELEVANCE
$reverse: Boolean = false
) {
collectionByHandle(handle: $categoryHandle)
{
${productsFragment}
}
}
`
export default getCollectionProductsQuery

View File

@ -0,0 +1,8 @@
export const getCustomerQuery = /* GraphQL */ `
query getCustomerId($customerAccessToken: String!) {
customer(customerAccessToken: $customerAccessToken) {
id
}
}
`
export default getCustomerQuery

View File

@ -0,0 +1,16 @@
export const getCustomerQuery = /* GraphQL */ `
query getCustomer($customerAccessToken: String!) {
customer(customerAccessToken: $customerAccessToken) {
id
firstName
lastName
displayName
email
phone
tags
acceptsMarketing
createdAt
}
}
`
export default getCustomerQuery

View File

@ -0,0 +1,13 @@
export const getPageQuery = /* GraphQL */ `
query getPageBySlug($slug: String!) {
pageByHandle(handle: $slug) {
id
title
handle
body
bodySummary
url
}
}
`
export default getPageQuery

View File

@ -0,0 +1,68 @@
const getProductQuery = /* GraphQL */ `
query getProductBySlug($slug: String!) {
productByHandle(handle: $slug) {
id
handle
title
productType
vendor
description
descriptionHtml
options {
id
name
values
}
priceRange {
maxVariantPrice {
amount
currencyCode
}
minVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
id
title
selectedOptions {
name
value
}
priceV2 {
amount
currencyCode
}
compareAtPriceV2 {
amount
currencyCode
}
}
}
}
images(first: 250) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
originalSrc
altText
width
height
}
}
}
}
}
`
export default getProductQuery

View File

@ -0,0 +1,10 @@
export { default as getSiteCollectionsQuery } from './get-all-collections-query'
export { default as getProductQuery } from './get-product-query'
export { default as getAllProductsQuery } from './get-all-products-query'
export { default as getAllProductsPathtsQuery } from './get-all-products-paths-query'
export { default as getAllProductVendors } from './get-all-product-vendors-query'
export { default as getCollectionProductsQuery } from './get-collection-products-query'
export { default as getCheckoutQuery } from './get-checkout-query'
export { default as getAllPagesQuery } from './get-all-pages-query'
export { default as getPageQuery } from './get-page-query'
export { default as getCustomerQuery } from './get-customer-query'

View File

@ -1,7 +1,7 @@
import { HookFetcher } from '@commerce/utils/types'
import { SwrOptions } from '@commerce/utils/use-data'
import useCommerceWishlist from '@commerce/wishlist/use-wishlist'
import { Product } from '../types'
import { Product } from '../schema'
import useCustomer from '../customer/use-customer'
const defaultOpts = {}

View File

@ -1,7 +1,8 @@
const withCommerceConfig = require('./framework/commerce/with-config')
const commerce = { provider: 'bigcommerce' }
const commerce = { provider: 'shopify' }
const isBC = commerce.provider === 'bigcommerce'
const isShopify = commerce.provider === 'shopify'
module.exports = withCommerceConfig({
commerce,
@ -11,7 +12,7 @@ module.exports = withCommerceConfig({
},
rewrites() {
return [
isBC && {
(isBC || isShopify) && {
source: '/checkout',
destination: '/api/bigcommerce/checkout',
},

View File

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