feat: copy woocommerce from shopify

This commit is contained in:
Reza Babaei
2021-08-08 02:05:48 +03:00
committed by Reza Babaei
parent 45e6946b94
commit 61f2914697
96 changed files with 18047 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
COMMERCE_PROVIDER=shopify
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=

View File

@@ -0,0 +1,135 @@
## Shopify Provider
**Demo:** https://shopify.demo.vercel.store/
Before getting starter, a [Shopify](https://www.shopify.com/) account and store is required before using the provider.
Next, copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git):
```bash
cp framework/shopify/.env.template .env.local
```
Then, set the environment variables in `.env.local` to match the ones from your store.
## Contribute
Our commitment to Open Source can be found [here](https://vercel.com/oss).
If you find an issue with the provider or want a new feature, feel free to open a PR or [create a new issue](https://github.com/vercel/commerce/issues).
## Modifications
These modifications are temporarily until contributions are made to remove them.
### Adding item to Cart
```js
// components/product/ProductView/ProductView.tsx
const ProductView: FC<Props> = ({ product }) => {
const addToCart = async () => {
setLoading(true)
try {
await addItem({
productId: product.id,
variantId: variant ? variant.id : product.variants[0].id,
})
openSidebar()
setLoading(false)
} catch (err) {
setLoading(false)
}
}
}
```
### Proceed to Checkout
```js
// components/cart/CartSidebarView/CartSidebarView.tsx
import { useCommerce } from '@framework'
const CartSidebarView: FC = () => {
const { checkout } = useCommerce()
return (
<Button href={checkout.webUrl} Component="a" width="100%">
Proceed to Checkout
</Button>
)
}
```
## APIs
Collections of APIs to fetch data from a Shopify store.
The data is fetched using the [Shopify JavaScript Buy SDK](https://github.com/Shopify/js-buy-sdk#readme). Read the [Shopify Storefront API reference](https://shopify.dev/docs/storefront-api/reference) for more information.
### getProduct
Get a single product by its `handle`.
```js
import getProduct from '@framework/product/get-product'
import { getConfig } from '@framework/api'
const config = getConfig()
const product = await getProduct({
variables: { slug },
config,
})
```
### getAllProducts
```js
import getAllProducts from '@framework/product/get-all-products'
import { getConfig } from '@framework/api'
const config = getConfig()
const { products } = await getAllProducts({
variables: { first: 12 },
config,
})
```
### getAllCollections
```js
import getAllCollections from '@framework/product/get-all-collections'
import { getConfig } from '@framework/api'
const config = getConfig()
const collections = await getAllCollections({
config,
})
```
### getAllPages
```js
import getAllPages from '@framework/common/get-all-pages'
import { getConfig } from '@framework/api'
const config = getConfig()
const pages = await getAllPages({
variables: { first: 12 },
config,
})
```
## Code generation
This provider makes use of GraphQL code generation. The [schema.graphql](./schema.graphql) and [schema.d.ts](./schema.d.ts) files contain the generated types & schema introspection results.
When developing the provider, changes to any GraphQL operations should be followed by re-generation of the types and schema files:
From the project root dir, run:
```sh
yarn generate:shopify
```

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import {
SHOPIFY_CHECKOUT_ID_COOKIE,
SHOPIFY_CHECKOUT_URL_COOKIE,
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
} from '../../../const'
import associateCustomerWithCheckoutMutation from '../../../utils/mutations/associate-customer-with-checkout'
import type { CheckoutEndpoint } from '.'
const checkout: CheckoutEndpoint['handlers']['checkout'] = async ({
req,
res,
config,
}) => {
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 checkout

View File

@@ -0,0 +1,18 @@
import { GetAPISchema, createEndpoint } from '@commerce/api'
import checkoutEndpoint from '@commerce/api/endpoints/checkout'
import type { CheckoutSchema } from '../../../types/checkout'
import type { ShopifyAPI } from '../..'
import checkout from './checkout'
export type CheckoutAPI = GetAPISchema<ShopifyAPI, CheckoutSchema>
export type CheckoutEndpoint = CheckoutAPI['endpoint']
export const handlers: CheckoutEndpoint['handlers'] = { checkout }
const checkoutApi = createEndpoint<CheckoutAPI>({
handler: checkoutEndpoint,
handlers,
})
export default checkoutApi

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
import {
CommerceAPI,
CommerceAPIConfig,
getCommerceApi as commerceApi,
} from '@commerce/api'
import {
API_URL,
API_TOKEN,
SHOPIFY_CUSTOMER_TOKEN_COOKIE,
SHOPIFY_CHECKOUT_ID_COOKIE,
} from '../const'
import fetchGraphqlApi from './utils/fetch-graphql-api'
import * as operations from './operations'
if (!API_URL) {
throw new Error(
`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 NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN is missing and it's required to access your store`
)
}
export interface ShopifyConfig extends CommerceAPIConfig {}
const ONE_DAY = 60 * 60 * 24
const config: ShopifyConfig = {
commerceUrl: API_URL,
apiToken: API_TOKEN,
customerCookie: SHOPIFY_CUSTOMER_TOKEN_COOKIE,
cartCookie: SHOPIFY_CHECKOUT_ID_COOKIE,
cartCookieMaxAge: ONE_DAY * 30,
fetch: fetchGraphqlApi,
}
export const provider = {
config,
operations,
}
export type Provider = typeof provider
export type ShopifyAPI<P extends Provider = Provider> = CommerceAPI<P>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): ShopifyAPI<P> {
return commerceApi(customProvider)
}

View File

@@ -0,0 +1,69 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import {
GetAllPagesQuery,
GetAllPagesQueryVariables,
PageEdge,
} from '../../schema'
import { normalizePages } from '../../utils'
import type { ShopifyConfig, Provider } from '..'
import type { GetAllPagesOperation, Page } from '../../types/page'
import getAllPagesQuery from '../../utils/queries/get-all-pages-query'
export default function getAllPagesOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllPages<T extends GetAllPagesOperation>(opts?: {
config?: Partial<ShopifyConfig>
preview?: boolean
}): Promise<T['data']>
async function getAllPages<T extends GetAllPagesOperation>(
opts: {
config?: Partial<ShopifyConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getAllPages<T extends GetAllPagesOperation>({
query = getAllPagesQuery,
config,
variables,
}: {
url?: string
config?: Partial<ShopifyConfig>
variables?: GetAllPagesQueryVariables
preview?: boolean
query?: string
} = {}): Promise<T['data']> {
const { fetch, locale, locales = ['en-US', 'es'] } = commerce.getConfig(
config
)
const { data } = await fetch<GetAllPagesQuery, GetAllPagesQueryVariables>(
query,
{
variables,
},
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
return {
pages: locales.reduce<Page[]>(
(arr, locale) =>
arr.concat(normalizePages(data.pages.edges as PageEdge[], locale)),
[]
),
}
}
return getAllPages
}

View File

@@ -0,0 +1,55 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import { GetAllProductPathsOperation } from '../../types/product'
import {
GetAllProductPathsQuery,
GetAllProductPathsQueryVariables,
ProductEdge,
} from '../../schema'
import type { ShopifyConfig, Provider } from '..'
import { getAllProductsQuery } from '../../utils'
export default function getAllProductPathsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProductPaths<
T extends GetAllProductPathsOperation
>(opts?: {
variables?: T['variables']
config?: ShopifyConfig
}): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>(
opts: {
variables?: T['variables']
config?: ShopifyConfig
} & OperationOptions
): Promise<T['data']>
async function getAllProductPaths<T extends GetAllProductPathsOperation>({
query = getAllProductsQuery,
config,
variables,
}: {
query?: string
config?: ShopifyConfig
variables?: T['variables']
} = {}): Promise<T['data']> {
config = commerce.getConfig(config)
const { data } = await config.fetch<
GetAllProductPathsQuery,
GetAllProductPathsQueryVariables
>(query, { variables })
return {
products: data.products.edges.map(({ node: { handle } }) => ({
path: `/${handle}`,
})),
}
}
return getAllProductPaths
}

View File

@@ -0,0 +1,67 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import { GetAllProductsOperation } from '../../types/product'
import {
GetAllProductsQuery,
GetAllProductsQueryVariables,
Product as ShopifyProduct,
} from '../../schema'
import type { ShopifyConfig, Provider } from '..'
import getAllProductsQuery from '../../utils/queries/get-all-products-query'
import { normalizeProduct } from '../../utils'
export default function getAllProductsOperation({
commerce,
}: OperationContext<Provider>) {
async function getAllProducts<T extends GetAllProductsOperation>(opts?: {
variables?: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
}): Promise<T['data']>
async function getAllProducts<T extends GetAllProductsOperation>(
opts: {
variables?: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getAllProducts<T extends GetAllProductsOperation>({
query = getAllProductsQuery,
variables,
config,
}: {
query?: string
variables?: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
} = {}): Promise<T['data']> {
const { fetch, locale } = commerce.getConfig(config)
const { data } = await fetch<
GetAllProductsQuery,
GetAllProductsQueryVariables
>(
query,
{ variables },
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
return {
products: data.products.edges.map(({ node }) =>
normalizeProduct(node as ShopifyProduct)
),
}
}
return getAllProducts
}

View File

@@ -0,0 +1,64 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import { normalizePage } from '../../utils'
import type { ShopifyConfig, Provider } from '..'
import {
GetPageQuery,
GetPageQueryVariables,
Page as ShopifyPage,
} from '../../schema'
import { GetPageOperation } from '../../types/page'
import getPageQuery from '../../utils/queries/get-page-query'
export default function getPageOperation({
commerce,
}: OperationContext<Provider>) {
async function getPage<T extends GetPageOperation>(opts: {
variables: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
}): Promise<T['data']>
async function getPage<T extends GetPageOperation>(
opts: {
variables: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getPage<T extends GetPageOperation>({
query = getPageQuery,
variables,
config,
}: {
query?: string
variables: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
}): Promise<T['data']> {
const { fetch, locale } = commerce.getConfig(config)
const {
data: { node: page },
} = await fetch<GetPageQuery, GetPageQueryVariables>(
query,
{
variables,
},
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
return page ? { page: normalizePage(page as ShopifyPage, locale) } : {}
}
return getPage
}

View File

@@ -0,0 +1,63 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import { GetProductOperation } from '../../types/product'
import { normalizeProduct, getProductQuery } from '../../utils'
import type { ShopifyConfig, Provider } from '..'
import { GetProductBySlugQuery, Product as ShopifyProduct } from '../../schema'
export default function getProductOperation({
commerce,
}: OperationContext<Provider>) {
async function getProduct<T extends GetProductOperation>(opts: {
variables: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
}): Promise<T['data']>
async function getProduct<T extends GetProductOperation>(
opts: {
variables: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getProduct<T extends GetProductOperation>({
query = getProductQuery,
variables,
config: cfg,
}: {
query?: string
variables: T['variables']
config?: Partial<ShopifyConfig>
preview?: boolean
}): Promise<T['data']> {
const { fetch, locale } = commerce.getConfig(cfg)
const {
data: { productByHandle },
} = await fetch<GetProductBySlugQuery>(
query,
{
variables,
},
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
return {
...(productByHandle && {
product: normalizeProduct(productByHandle as ShopifyProduct),
}),
}
}
return getProduct
}

View File

@@ -0,0 +1,62 @@
import type {
OperationContext,
OperationOptions,
} from '@commerce/api/operations'
import { GetSiteInfoQueryVariables } from '../../schema'
import type { ShopifyConfig, Provider } from '..'
import { GetSiteInfoOperation } from '../../types/site'
import { getCategories, getBrands, getSiteInfoQuery } from '../../utils'
export default function getSiteInfoOperation({
commerce,
}: OperationContext<Provider>) {
async function getSiteInfo<T extends GetSiteInfoOperation>(opts?: {
config?: Partial<ShopifyConfig>
preview?: boolean
}): Promise<T['data']>
async function getSiteInfo<T extends GetSiteInfoOperation>(
opts: {
config?: Partial<ShopifyConfig>
preview?: boolean
} & OperationOptions
): Promise<T['data']>
async function getSiteInfo<T extends GetSiteInfoOperation>({
query = getSiteInfoQuery,
config,
variables,
}: {
query?: string
config?: Partial<ShopifyConfig>
preview?: boolean
variables?: GetSiteInfoQueryVariables
} = {}): Promise<T['data']> {
const cfg = commerce.getConfig(config)
const categoriesPromise = getCategories(cfg)
const brandsPromise = getBrands(cfg)
/*
const { fetch, locale } = cfg
const { data } = await fetch<GetSiteInfoQuery, GetSiteInfoQueryVariables>(
query,
{ variables },
{
...(locale && {
headers: {
'Accept-Language': locale,
},
}),
}
)
*/
return {
categories: await categoriesPromise,
brands: await brandsPromise,
}
}
return getSiteInfo
}

View File

@@ -0,0 +1,7 @@
export { default as getAllPages } from './get-all-pages'
export { default as getPage } from './get-page'
export { default as getAllProducts } from './get-all-products'
export { default as getAllProductPaths } from './get-all-product-paths'
export { default as getProduct } from './get-product'
export { default as getSiteInfo } from './get-site-info'
export { default as login } from './login'

View File

@@ -0,0 +1,48 @@
import type { ServerResponse } from 'http'
import type { OperationContext } from '@commerce/api/operations'
import type { LoginOperation } from '../../types/login'
import type { ShopifyConfig, Provider } from '..'
import {
customerAccessTokenCreateMutation,
setCustomerToken,
throwUserErrors,
} from '../../utils'
import { CustomerAccessTokenCreateMutation } from '../../schema'
export default function loginOperation({
commerce,
}: OperationContext<Provider>) {
async function login<T extends LoginOperation>({
query = customerAccessTokenCreateMutation,
variables,
config,
}: {
query?: string
variables: T['variables']
res: ServerResponse
config?: ShopifyConfig
}): Promise<T['data']> {
config = commerce.getConfig(config)
const {
data: { customerAccessTokenCreate },
} = await config.fetch<CustomerAccessTokenCreateMutation>(query, {
variables,
})
throwUserErrors(customerAccessTokenCreate?.customerUserErrors)
const customerAccessToken = customerAccessTokenCreate?.customerAccessToken
const accessToken = customerAccessToken?.accessToken
if (accessToken) {
setCustomerToken(accessToken)
}
return {
result: customerAccessToken?.accessToken,
}
}
return login
}

View File

@@ -0,0 +1,45 @@
import type { GraphQLFetcher } from '@commerce/api'
import fetch from './fetch'
import { API_URL, API_TOKEN } from '../../const'
import { getError } from '../../utils/handle-fetch-response'
const fetchGraphqlApi: GraphQLFetcher = async (
query: string,
{ variables } = {},
fetchOptions
) => {
try {
const res = await fetch(API_URL, {
...fetchOptions,
method: 'POST',
headers: {
'X-Shopify-Storefront-Access-Token': API_TOKEN!,
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
const { data, errors, status } = await res.json()
if (errors) {
throw getError(errors, status)
}
return { data, res }
} catch (err) {
throw getError(
[
{
message: `${err} \n Most likely related to an unexpected output. e.g the store might be protected with password or not available.`,
},
],
500
)
}
}
export default fetchGraphqlApi

View File

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

View File

@@ -0,0 +1,62 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import type { LoginHook } from '../types/login'
import useCustomer from '../customer/use-customer'
import {
setCustomerToken,
throwUserErrors,
customerAccessTokenCreateMutation,
} from '../utils'
import { Mutation, MutationCustomerAccessTokenCreateArgs } from '../schema'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<LoginHook> = {
fetchOptions: {
query: customerAccessTokenCreateMutation,
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message:
'An email and password are required to login',
})
}
const { customerAccessTokenCreate } = await fetch<
Mutation,
MutationCustomerAccessTokenCreateArgs
>({
...options,
variables: {
input: { email, password },
},
})
throwUserErrors(customerAccessTokenCreate?.customerUserErrors)
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

@@ -0,0 +1,37 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import useLogout, { UseLogout } from '@commerce/auth/use-logout'
import type { LogoutHook } from '../types/logout'
import useCustomer from '../customer/use-customer'
import customerAccessTokenDeleteMutation from '../utils/mutations/customer-access-token-delete'
import { getCustomerToken, setCustomerToken } from '../utils/customer-token'
export default useLogout as UseLogout<typeof handler>
export const handler: MutationHook<LogoutHook> = {
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]
)
},
}

View File

@@ -0,0 +1,65 @@
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 type { SignupHook } from '../types/signup'
import useCustomer from '../customer/use-customer'
import { Mutation, MutationCustomerCreateArgs } from '../schema'
import {
handleAutomaticLogin,
throwUserErrors,
customerCreateMutation,
} from '../utils'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<SignupHook> = {
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 { customerCreate } = await fetch<
Mutation,
MutationCustomerCreateArgs
>({
...options,
variables: {
input: {
firstName,
lastName,
email,
password,
},
},
})
throwUserErrors(customerCreate?.customerUserErrors)
await handleAutomaticLogin(fetch, { email, password })
return null
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
},
}

View File

@@ -0,0 +1,4 @@
export { default as useCart } from './use-cart'
export { default as useAddItem } from './use-add-item'
export { default as useUpdateItem } from './use-update-item'
export { default as useRemoveItem } from './use-remove-item'

View File

@@ -0,0 +1,61 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useAddItem, { UseAddItem } from '@commerce/cart/use-add-item'
import type { AddItemHook } from '../types/cart'
import useCart from './use-cart'
import {
checkoutLineItemAddMutation,
getCheckoutId,
checkoutToCart,
} from '../utils'
import { Mutation, MutationCheckoutLineItemsAddArgs } from '../schema'
export default useAddItem as UseAddItem<typeof handler>
export const handler: MutationHook<AddItemHook> = {
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]
)
},
}

View File

@@ -0,0 +1,57 @@
import { useMemo } from 'react'
import useCommerceCart, { UseCart } from '@commerce/cart/use-cart'
import { SWRHook } from '@commerce/utils/types'
import { checkoutCreate, checkoutToCart } from '../utils'
import getCheckoutQuery from '../utils/queries/get-checkout-query'
import { GetCartHook } from '../types/cart'
import {
GetCheckoutQuery,
GetCheckoutQueryVariables,
CheckoutDetailsFragment,
} from '../schema'
export default useCommerceCart as UseCart<typeof handler>
export const handler: SWRHook<GetCartHook> = {
fetchOptions: {
query: getCheckoutQuery,
},
async fetcher({ input: { cartId: checkoutId }, options, fetch }) {
let checkout
if (checkoutId) {
const data = await fetch({
...options,
variables: {
checkoutId: 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
},
enumerable: true,
},
}),
[response]
)
},
}

View File

@@ -0,0 +1,67 @@
import { useCallback } from 'react'
import type {
MutationHookContext,
HookFetcherContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors'
import useRemoveItem, { UseRemoveItem } from '@commerce/cart/use-remove-item'
import type { Cart, LineItem, RemoveItemHook } from '../types/cart'
import useCart from './use-cart'
export type RemoveItemFn<T = any> = T extends LineItem
? (input?: RemoveItemActionInput<T>) => Promise<Cart | null | undefined>
: (input: RemoveItemActionInput<T>) => Promise<Cart | null>
export type RemoveItemActionInput<T = any> = T extends LineItem
? Partial<RemoveItemHook['actionInput']>
: RemoveItemHook['actionInput']
export default useRemoveItem as UseRemoveItem<typeof handler>
import {
checkoutLineItemRemoveMutation,
getCheckoutId,
checkoutToCart,
} from '../utils'
import { Mutation, MutationCheckoutLineItemsRemoveArgs } from '../schema'
export const handler = {
fetchOptions: {
query: checkoutLineItemRemoveMutation,
},
async fetcher({
input: { itemId },
options,
fetch,
}: HookFetcherContext<RemoveItemHook>) {
const data = await fetch<Mutation, MutationCheckoutLineItemsRemoveArgs>({
...options,
variables: { checkoutId: getCheckoutId(), lineItemIds: [itemId] },
})
return checkoutToCart(data.checkoutLineItemsRemove)
},
useHook: ({ fetch }: MutationHookContext<RemoveItemHook>) => <
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])
},
}

View File

@@ -0,0 +1,105 @@
import { useCallback } from 'react'
import debounce from 'lodash.debounce'
import type {
HookFetcherContext,
MutationHookContext,
} from '@commerce/utils/types'
import { ValidationError } from '@commerce/utils/errors'
import useUpdateItem, { UseUpdateItem } from '@commerce/cart/use-update-item'
import useCart from './use-cart'
import { handler as removeItemHandler } from './use-remove-item'
import type { UpdateItemHook, LineItem } from '../types/cart'
import {
getCheckoutId,
checkoutLineItemUpdateMutation,
checkoutToCart,
} from '../utils'
import { Mutation, MutationCheckoutLineItemsUpdateArgs } from '../schema'
export type UpdateItemActionInput<T = any> = T extends LineItem
? Partial<UpdateItemHook['actionInput']>
: UpdateItemHook['actionInput']
export default useUpdateItem as UseUpdateItem<typeof handler>
export const handler = {
fetchOptions: {
query: checkoutLineItemUpdateMutation,
},
async fetcher({
input: { itemId, item },
options,
fetch,
}: HookFetcherContext<UpdateItemHook>) {
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 checkoutToCart(checkoutLineItemsUpdate)
},
useHook: ({ fetch }: MutationHookContext<UpdateItemHook>) => <
T extends LineItem | undefined = undefined
>(
ctx: {
item?: T
wait?: number
} = {}
) => {
const { item } = ctx
const { mutate } = useCart() as any
return useCallback(
debounce(async (input: UpdateItemActionInput<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]
)
},
}

View File

@@ -0,0 +1,32 @@
{
"schema": {
"https://${NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN}/api/2021-07/graphql.json": {
"headers": {
"X-Shopify-Storefront-Access-Token": "${NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN}"
}
}
},
"documents": [
{
"./framework/shopify/**/*.{ts,tsx}": {
"noRequire": true
}
}
],
"generates": {
"./framework/shopify/schema.d.ts": {
"plugins": ["typescript", "typescript-operations"],
"config": {
"scalars": {
"ID": "string"
}
}
},
"./framework/shopify/schema.graphql": {
"plugins": ["schema-ast"]
}
},
"hooks": {
"afterAllFileWrite": ["prettier --write"]
}
}

View File

@@ -0,0 +1,6 @@
{
"provider": "shopify",
"features": {
"wishlist": false
}
}

View File

@@ -0,0 +1,13 @@
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 SHOPIFY_COOKIE_EXPIRE = 30
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 @@
export { default as useCustomer } from './use-customer'

View File

@@ -0,0 +1,32 @@
import useCustomer, { UseCustomer } from '@commerce/customer/use-customer'
import type { CustomerHook } from '../types/customer'
import { SWRHook } from '@commerce/utils/types'
import { getCustomerQuery, getCustomerToken } from '../utils'
import { GetCustomerQuery, GetCustomerQueryVariables } from '../schema'
export default useCustomer as UseCustomer<typeof handler>
export const handler: SWRHook<CustomerHook> = {
fetchOptions: {
query: getCustomerQuery,
},
async fetcher({ options, fetch }) {
const customerAccessToken = getCustomerToken()
if (customerAccessToken) {
const data = await fetch<GetCustomerQuery, GetCustomerQueryVariables>({
...options,
variables: { customerAccessToken: getCustomerToken() },
})
return data.customer
}
return null
},
useHook: ({ useData }) => (input) => {
return useData({
swrOptions: {
revalidateOnFocus: false,
...input?.swrOptions,
},
})
},
}

View File

@@ -0,0 +1,27 @@
import { Fetcher } from '@commerce/utils/types'
import { API_TOKEN, API_URL } from './const'
import { handleFetchResponse } from './utils'
const fetcher: Fetcher = async ({
url = API_URL,
method = 'POST',
variables,
query,
}) => {
const { locale, ...vars } = variables ?? {}
return handleFetchResponse(
await fetch(url, {
method,
body: JSON.stringify({ query, variables: vars }),
headers: {
'X-Shopify-Storefront-Access-Token': API_TOKEN!,
'Content-Type': 'application/json',
...(locale && {
'Accept-Language': locale,
}),
},
})
)
}
export default fetcher

View File

@@ -0,0 +1,40 @@
import * as React from 'react'
import { ReactNode } from 'react'
import {
CommerceConfig,
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
import { shopifyProvider } from './provider'
import type { ShopifyProvider } from './provider'
import { SHOPIFY_CHECKOUT_ID_COOKIE } from './const'
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
export function CommerceProvider({ children, ...config }: ShopifyProps) {
return (
<CoreCommerceProvider
provider={shopifyProvider}
config={{ ...shopifyConfig, ...config }}
>
{children}
</CoreCommerceProvider>
)
}
export const useCommerce = () => useCoreCommerce<ShopifyProvider>()

View File

@@ -0,0 +1,8 @@
const commerce = require('./commerce.config.json')
module.exports = {
commerce,
images: {
domains: ['cdn.shopify.com'],
},
}

View File

@@ -0,0 +1,2 @@
export * from '@commerce/product/use-price'
export { default } from '@commerce/product/use-price'

View File

@@ -0,0 +1,89 @@
import { SWRHook } from '@commerce/utils/types'
import useSearch, { UseSearch } from '@commerce/product/use-search'
import {
CollectionEdge,
GetAllProductsQuery,
GetProductsFromCollectionQueryVariables,
Product as ShopifyProduct,
ProductEdge,
} from '../schema'
import {
getAllProductsQuery,
getCollectionProductsQuery,
getSearchVariables,
normalizeProduct,
} from '../utils'
import type { SearchProductsHook } from '../types/product'
export type SearchProductsInput = {
search?: string
categoryId?: number
brandId?: number
sort?: string
locale?: string
}
export default useSearch as UseSearch<typeof handler>
export const handler: SWRHook<SearchProductsHook> = {
fetchOptions: {
query: getAllProductsQuery,
},
async fetcher({ input, options, fetch }) {
const { categoryId, brandId } = input
const method = options?.method
const variables = getSearchVariables(input)
let products
// change the query to getCollectionProductsQuery when categoryId is set
if (categoryId) {
const data = await fetch<
CollectionEdge,
GetProductsFromCollectionQueryVariables
>({
query: getCollectionProductsQuery,
method,
variables,
})
// filter on client when brandId & categoryId are set since is not available on collection product query
products = brandId
? data.node?.products?.edges?.filter(
({ node: { vendor } }: ProductEdge) =>
vendor.replace(/\s+/g, '-').toLowerCase() === brandId
)
: data.node?.products?.edges
} else {
const data = await fetch<GetAllProductsQuery>({
query: options.query,
method,
variables,
})
products = data.products?.edges
}
return {
products: products?.map(({ node }) =>
normalizeProduct(node as ShopifyProduct)
),
found: !!products?.length,
}
},
useHook: ({ useData }) => (input = {}) => {
return useData({
input: [
['search', input.search],
['categoryId', input.categoryId],
['brandId', input.brandId],
['sort', input.sort],
['locale', input.locale],
],
swrOptions: {
revalidateOnFocus: false,
...input.swrOptions,
},
})
},
}

View File

@@ -0,0 +1,27 @@
import { SHOPIFY_CHECKOUT_ID_COOKIE } 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 useCustomer } from './customer/use-customer'
import { handler as useSearch } from './product/use-search'
import { handler as useLogin } from './auth/use-login'
import { handler as useLogout } from './auth/use-logout'
import { handler as useSignup } from './auth/use-signup'
import fetcher from './fetcher'
export const shopifyProvider = {
locale: 'en-us',
cartCookie: SHOPIFY_CHECKOUT_ID_COOKIE,
fetcher,
cart: { useCart, useAddItem, useUpdateItem, useRemoveItem },
customer: { useCustomer },
products: { useSearch },
auth: { useLogin, useLogout, useSignup },
}
export type ShopifyProvider = typeof shopifyProvider

5586
framework/woocommerce/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

@@ -0,0 +1,32 @@
import * as Core from '@commerce/types/cart'
export * from '@commerce/types/cart'
export type ShopifyCart = {}
/**
* Extend core cart types
*/
export type Cart = Core.Cart & {
lineItems: Core.LineItem[]
url?: string
}
export type CartTypes = Core.CartTypes
export type CartHooks = Core.CartHooks<CartTypes>
export type GetCartHook = CartHooks['getCart']
export type AddItemHook = CartHooks['addItem']
export type UpdateItemHook = CartHooks['updateItem']
export type RemoveItemHook = CartHooks['removeItem']
export type CartSchema = Core.CartSchema<CartTypes>
export type CartHandlers = Core.CartHandlers<CartTypes>
export type GetCartHandler = CartHandlers['getCart']
export type AddItemHandler = CartHandlers['addItem']
export type UpdateItemHandler = CartHandlers['updateItem']
export type RemoveItemHandler = CartHandlers['removeItem']

View File

@@ -0,0 +1 @@
export * from '@commerce/types/checkout'

View File

@@ -0,0 +1 @@
export * from '@commerce/types/common'

View File

@@ -0,0 +1,5 @@
import * as Core from '@commerce/types/customer'
export * from '@commerce/types/customer'
export type CustomerSchema = Core.CustomerSchema

View File

@@ -0,0 +1,25 @@
import * as Cart from './cart'
import * as Checkout from './checkout'
import * as Common from './common'
import * as Customer from './customer'
import * as Login from './login'
import * as Logout from './logout'
import * as Page from './page'
import * as Product from './product'
import * as Signup from './signup'
import * as Site from './site'
import * as Wishlist from './wishlist'
export type {
Cart,
Checkout,
Common,
Customer,
Login,
Logout,
Page,
Product,
Signup,
Site,
Wishlist,
}

View File

@@ -0,0 +1,8 @@
import * as Core from '@commerce/types/login'
import type { CustomerAccessTokenCreateInput } from '../schema'
export * from '@commerce/types/login'
export type LoginOperation = Core.LoginOperation & {
variables: CustomerAccessTokenCreateInput
}

View File

@@ -0,0 +1 @@
export * from '@commerce/types/logout'

View File

@@ -0,0 +1,11 @@
import * as Core from '@commerce/types/page'
export * from '@commerce/types/page'
export type Page = Core.Page
export type PageTypes = {
page: Page
}
export type GetAllPagesOperation = Core.GetAllPagesOperation<PageTypes>
export type GetPageOperation = Core.GetPageOperation<PageTypes>

View File

@@ -0,0 +1 @@
export * from '@commerce/types/product'

View File

@@ -0,0 +1 @@
export * from '@commerce/types/signup'

View File

@@ -0,0 +1 @@
export * from '@commerce/types/site'

View File

@@ -0,0 +1 @@
export * from '@commerce/types/wishlist'

View File

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

View File

@@ -0,0 +1,42 @@
import type { Cart } from '../types/cart'
import { CommerceError } from '@commerce/utils/errors'
import {
CheckoutLineItemsAddPayload,
CheckoutLineItemsRemovePayload,
CheckoutLineItemsUpdatePayload,
CheckoutCreatePayload,
CheckoutUserError,
Checkout,
Maybe,
} from '../schema'
import { normalizeCart } from './normalize'
import throwUserErrors from './throw-user-errors'
export type CheckoutQuery = {
checkout: Checkout
checkoutUserErrors?: Array<CheckoutUserError>
}
export type CheckoutPayload =
| CheckoutLineItemsAddPayload
| CheckoutLineItemsUpdatePayload
| CheckoutLineItemsRemovePayload
| CheckoutCreatePayload
| CheckoutQuery
const checkoutToCart = (checkoutPayload?: Maybe<CheckoutPayload>): Cart => {
const checkout = checkoutPayload?.checkout
throwUserErrors(checkoutPayload?.checkoutUserErrors)
if (!checkout) {
throw new CommerceError({
message: 'Missing checkout object from response',
})
}
return normalizeCart(checkout)
}
export default checkoutToCart

View File

@@ -0,0 +1,21 @@
import Cookies, { CookieAttributes } from 'js-cookie'
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,
}
)
}
}

View File

@@ -0,0 +1,44 @@
import {
GetAllProductVendorsQuery,
GetAllProductVendorsQueryVariables,
} from '../schema'
import { ShopifyConfig } from '../api'
import getAllProductVendors from './queries/get-all-product-vendors-query'
export type Brand = {
entityId: string
name: string
path: string
}
export type BrandEdge = {
node: Brand
}
export type Brands = BrandEdge[]
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 default getBrands

View File

@@ -0,0 +1,34 @@
import type { Category } from '../types/site'
import { ShopifyConfig } from '../api'
import { CollectionEdge } from '../schema'
import { normalizeCategory } from './normalize'
import getSiteCollectionsQuery from './queries/get-all-collections-query'
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 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,31 @@
import getSortVariables from './get-sort-variables'
import { SearchProductsBody } from '../types/product'
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 default getSearchVariables

View File

@@ -0,0 +1,32 @@
const getSortVariables = (sort?: string, isCategory: boolean = false) => {
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: isCategory ? 'CREATED' : 'CREATED_AT',
reverse: true,
}
break
}
return output
}
export default getSortVariables

View File

@@ -0,0 +1,30 @@
import { FetcherOptions } from '@commerce/utils/types'
import throwUserErrors from './throw-user-errors'
import {
MutationCustomerActivateArgs,
MutationCustomerActivateByUrlArgs,
} from '../schema'
import { Mutation } from '../schema'
import { customerActivateByUrlMutation } from './mutations'
const handleAccountActivation = async (
fetch: <T = any, B = Body>(options: FetcherOptions<B>) => Promise<T>,
input: MutationCustomerActivateByUrlArgs
) => {
try {
const { customerActivateByUrl } = await fetch<
Mutation,
MutationCustomerActivateArgs
>({
query: customerActivateByUrlMutation,
variables: {
input,
},
})
throwUserErrors(customerActivateByUrl?.customerUserErrors)
} catch (error) {}
}
export default handleAccountActivation

View File

@@ -0,0 +1,27 @@
import { FetcherError } from '@commerce/utils/errors'
export function getError(errors: any[] | null, 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,36 @@
import { FetcherOptions } from '@commerce/utils/types'
import { CustomerAccessTokenCreateInput } from '../schema'
import { setCustomerToken } from './customer-token'
import { customerAccessTokenCreateMutation } from './mutations'
import throwUserErrors from './throw-user-errors'
const handleLogin = (data: any) => {
const response = data.customerAccessTokenCreate
throwUserErrors(response?.customerUserErrors)
const customerAccessToken = response?.customerAccessToken
const accessToken = customerAccessToken?.accessToken
if (accessToken) {
setCustomerToken(accessToken)
}
return customerAccessToken
}
export const handleAutomaticLogin = async (
fetch: <T = any, B = Body>(options: FetcherOptions<B>) => Promise<T>,
input: CustomerAccessTokenCreateInput
) => {
try {
const loginData = await fetch({
query: customerAccessTokenCreateMutation,
variables: {
input,
},
})
handleLogin(loginData)
} catch (error) {}
}
export default handleLogin

View File

@@ -0,0 +1,15 @@
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 getBrands } from './get-brands'
export { default as getCategories } from './get-categories'
export { default as getCheckoutId } from './get-checkout-id'
export { default as checkoutCreate } from './checkout-create'
export { default as checkoutToCart } from './checkout-to-cart'
export { default as handleLogin, handleAutomaticLogin } from './handle-login'
export { default as handleAccountActivation } from './handle-account-activation'
export { default as throwUserErrors } from './throw-user-errors'
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,19 @@
import { checkoutDetailsFragment } from '../queries/get-checkout-query'
const checkoutCreateMutation = /* GraphQL */ `
mutation checkoutCreate($input: CheckoutCreateInput = {}) {
checkoutCreate(input: $input) {
checkoutUserErrors {
code
field
message
}
checkout {
...checkoutDetails
}
}
}
${checkoutDetailsFragment}
`
export default checkoutCreateMutation

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import { checkoutDetailsFragment } from '../queries/get-checkout-query'
const checkoutLineItemUpdateMutation = /* GraphQL */ `
mutation checkoutLineItemUpdate(
$checkoutId: ID!
$lineItems: [CheckoutLineItemUpdateInput!]!
) {
checkoutLineItemsUpdate(checkoutId: $checkoutId, lineItems: $lineItems) {
checkoutUserErrors {
code
field
message
}
checkout {
...checkoutDetails
}
}
}
${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,19 @@
const customerActivateByUrlMutation = /* GraphQL */ `
mutation customerActivateByUrl($activationUrl: URL!, $password: String!) {
customerActivateByUrl(activationUrl: $activationUrl, password: $password) {
customer {
id
}
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`
export default customerActivateByUrlMutation

View File

@@ -0,0 +1,19 @@
const customerActivateMutation = /* GraphQL */ `
mutation customerActivate($id: ID!, $input: CustomerActivateInput!) {
customerActivate(id: $id, input: $input) {
customer {
id
}
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`
export default customerActivateMutation

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,9 @@
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'
export { default as customerActivateMutation } from './customer-activate'
export { default as customerActivateByUrlMutation } from './customer-activate-by-url'

View File

@@ -0,0 +1,197 @@
import type { Page } from '../types/page'
import type { Product } from '../types/product'
import type { Cart, LineItem } from '../types/cart'
import type { Category } from '../types/site'
import {
Product as ShopifyProduct,
Checkout,
CheckoutLineItemEdge,
SelectedOption,
ImageConnection,
ProductVariantConnection,
MoneyV2,
ProductOption,
Page as ShopifyPage,
PageEdge,
Collection,
} from '../schema'
import { colorMap } from '@lib/colors'
const money = ({ amount, currencyCode }: MoneyV2) => {
return {
value: +amount,
currencyCode,
}
}
const normalizeProductOption = ({
id,
name: displayName,
values,
}: ProductOption) => {
return {
__typename: 'MultipleChoiceOption',
id,
displayName: displayName.toLowerCase(),
values: values.map((value) => {
let output: any = {
label: value,
}
if (displayName.match(/colou?r/gi)) {
const mapedColor = colorMap[value.toLowerCase().replace(/ /g, '')]
if (mapedColor) {
output = {
...output,
hexColors: [mapedColor],
}
}
}
return output
}),
}
}
const normalizeProductImages = ({ edges }: ImageConnection) =>
edges?.map(({ node: { originalSrc: url, ...rest } }) => ({
url,
...rest,
}))
const normalizeProductVariants = ({ edges }: ProductVariantConnection) => {
return edges?.map(
({
node: {
id,
selectedOptions,
sku,
title,
priceV2,
compareAtPriceV2,
requiresShipping,
availableForSale,
},
}) => {
return {
id,
name: title,
sku: sku ?? id,
price: +priceV2.amount,
listPrice: +compareAtPriceV2?.amount,
requiresShipping,
availableForSale,
options: selectedOptions.map(({ name, value }: SelectedOption) => {
const options = normalizeProductOption({
id,
name,
values: [value],
})
return options
}),
}
}
)
}
export function normalizeProduct({
id,
title: name,
vendor,
images,
variants,
description,
descriptionHtml,
handle,
priceRange,
options,
metafields,
...rest
}: ShopifyProduct): Product {
return {
id,
name,
vendor,
path: `/${handle}`,
slug: handle?.replace(/^\/+|\/+$/g, ''),
price: money(priceRange?.minVariantPrice),
images: normalizeProductImages(images),
variants: variants ? normalizeProductVariants(variants) : [],
options: options
? options
.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))
: [],
...(description && { description }),
...(descriptionHtml && { descriptionHtml }),
...rest,
}
}
export function normalizeCart(checkout: Checkout): Cart {
return {
id: checkout.id,
url: checkout.webUrl,
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 || '/product-img-placeholder.svg',
},
requiresShipping: variant?.requiresShipping ?? false,
price: variant?.priceV2?.amount,
listPrice: variant?.compareAtPriceV2?.amount,
},
path: String(variant?.product?.handle),
discounts: [],
options: variant?.title == 'Default Title' ? [] : variant?.selectedOptions,
}
}
export const normalizePage = (
{ title: name, handle, ...page }: ShopifyPage,
locale: string = 'en-US'
): Page => ({
...page,
url: `/${locale}/${handle}`,
name,
})
export const normalizePages = (edges: PageEdge[], locale?: string): Page[] =>
edges?.map((edge) => normalizePage(edge.node, locale))
export const normalizeCategory = ({
title: name,
handle,
id,
}: Collection): Category => ({
id,
name,
slug: handle,
path: `/${handle}`,
})

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,57 @@
export const productConnectionFragment = /* GraphQL */ `
fragment productConnection on ProductConnection {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
id
title
vendor
handle
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
) {
products(
first: $first
sortKey: $sortKey
reverse: $reverse
query: $query
) {
...productConnection
}
}
${productConnectionFragment}
`
export default getAllProductsQuery

View File

@@ -0,0 +1,70 @@
export const checkoutDetailsFragment = /* GraphQL */ `
fragment checkoutDetails on Checkout {
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
selectedOptions {
name
value
}
image {
originalSrc
altText
width
height
}
priceV2 {
amount
currencyCode
}
compareAtPriceV2 {
amount
currencyCode
}
product {
handle
}
}
quantity
}
}
}
}
`
const getCheckoutQuery = /* GraphQL */ `
query getCheckout($checkoutId: ID!) {
node(id: $checkoutId) {
...checkoutDetails
}
}
${checkoutDetailsFragment}
`
export default getCheckoutQuery

View File

@@ -0,0 +1,21 @@
import { productConnectionFragment } from './get-all-products-query'
const getCollectionProductsQuery = /* GraphQL */ `
query getProductsFromCollection(
$categoryId: ID!
$first: Int = 250
$sortKey: ProductCollectionSortKeys = RELEVANCE
$reverse: Boolean = false
) {
node(id: $categoryId) {
id
... on Collection {
products(first: $first, sortKey: $sortKey, reverse: $reverse) {
...productConnection
}
}
}
}
${productConnectionFragment}
`
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,14 @@
export const getPageQuery = /* GraphQL */ `
query getPage($id: ID!) {
node(id: $id) {
id
... on Page {
title
handle
body
bodySummary
}
}
}
`
export default getPageQuery

View File

@@ -0,0 +1,72 @@
const getProductQuery = /* GraphQL */ `
query getProductBySlug($slug: String!) {
productByHandle(handle: $slug) {
id
handle
availableForSale
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
sku
availableForSale
requiresShipping
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,8 @@
const getSiteInfoQuery = /* GraphQL */ `
query getSiteInfo {
shop {
name
}
}
`
export default getSiteInfoQuery

View File

@@ -0,0 +1,11 @@
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'
export { default as getSiteInfoQuery } from './get-site-info-query'

View File

@@ -0,0 +1,38 @@
import { ValidationError } from '@commerce/utils/errors'
import {
CheckoutErrorCode,
CheckoutUserError,
CustomerErrorCode,
CustomerUserError,
} from '../schema'
export type UserErrors = Array<CheckoutUserError | CustomerUserError>
export type UserErrorCode =
| CustomerErrorCode
| CheckoutErrorCode
| null
| undefined
const getCustomMessage = (code: UserErrorCode, message: string) => {
switch (code) {
case 'UNIDENTIFIED_CUSTOMER':
message = 'Cannot find an account that matches the provided credentials'
break
}
return message
}
export const throwUserErrors = (errors?: UserErrors) => {
if (errors && errors.length) {
throw new ValidationError({
errors: errors.map(({ code, message }) => ({
code: code ?? 'validation_error',
message: getCustomMessage(code, message),
})),
})
}
}
export default throwUserErrors

View File

@@ -0,0 +1,13 @@
import { useCallback } from 'react'
export function emptyHook() {
const useEmptyHook = async (options = {}) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
}
return useEmptyHook
}
export default emptyHook

View File

@@ -0,0 +1,17 @@
import { useCallback } from 'react'
type Options = {
includeProducts?: boolean
}
export function emptyHook(options?: Options) {
const useEmptyHook = async ({ id }: { id: string | number }) => {
return useCallback(async function () {
return Promise.resolve()
}, [])
}
return useEmptyHook
}
export default emptyHook

View File

@@ -0,0 +1,46 @@
// TODO: replace this hook and other wishlist hooks with a handler, or remove them if
// Shopify doesn't have a wishlist
import { HookFetcher } from '@commerce/utils/types'
import { Product } from '../schema'
const defaultOpts = {}
export type Wishlist = {
items: [
{
product_id: number
variant_id: number
id: number
product: Product
}
]
}
export interface UseWishlistOptions {
includeProducts?: boolean
}
export interface UseWishlistInput extends UseWishlistOptions {
customerId?: number
}
export const fetcher: HookFetcher<Wishlist | null, UseWishlistInput> = () => {
return null
}
export function extendHook(
customFetcher: typeof fetcher,
// swrOptions?: SwrOptions<Wishlist | null, UseWishlistInput>
swrOptions?: any
) {
const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => {
return { data: null }
}
useWishlist.extend = extendHook
return useWishlist
}
export default extendHook(fetcher)