diff --git a/.env.template b/.env.template index 73a8a6e3b..7d8400baf 100644 --- a/.env.template +++ b/.env.template @@ -2,4 +2,6 @@ BIGCOMMERCE_STOREFRONT_API_URL= BIGCOMMERCE_STOREFRONT_API_TOKEN= BIGCOMMERCE_STORE_API_URL= BIGCOMMERCE_STORE_API_TOKEN= -BIGCOMMERCE_STORE_API_CLIENT_ID= \ No newline at end of file +BIGCOMMERCE_STORE_API_CLIENT_ID= +SHOPIFY_STORE_DOMAIN= +SHOPIFY_STOREFRONT_ACCESS_TOKEN= \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7d1d95638..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,4 +0,0 @@ -## Changelog - -- Select Variants Working -- Click on cart item title, closes the sidebar diff --git a/components/common/UserNav/UserNav.tsx b/components/common/UserNav/UserNav.tsx index 9d143a572..4d00970a9 100644 --- a/components/common/UserNav/UserNav.tsx +++ b/components/common/UserNav/UserNav.tsx @@ -4,11 +4,11 @@ import cn from 'classnames' import type { LineItem } from '@framework/types' import useCart from '@framework/cart/use-cart' import useCustomer from '@framework/customer/use-customer' +import { Avatar } from '@components/common' import { Heart, Bag } from '@components/icons' import { useUI } from '@components/ui/context' import DropdownMenu from './DropdownMenu' import s from './UserNav.module.css' -import { Avatar } from '@components/common' interface Props { className?: string diff --git a/components/product/ProductView/ProductView.tsx b/components/product/ProductView/ProductView.tsx index 2d31aaafc..c0fdd32d2 100644 --- a/components/product/ProductView/ProductView.tsx +++ b/components/product/ProductView/ProductView.tsx @@ -4,9 +4,8 @@ import { NextSeo } from 'next-seo' import { FC, useState } from 'react' import s from './ProductView.module.css' -import { useUI } from '@components/ui' import { Swatch, ProductSlider } from '@components/product' -import { Button, Container, Text } from '@components/ui' +import { Button, Container, Text, useUI } from '@components/ui' import type { Product } from '@commerce/types' import usePrice from '@framework/product/use-price' @@ -100,7 +99,6 @@ const ProductView: FC = ({ product }) => { -
{product.options?.map((opt) => ( diff --git a/components/ui/context.tsx b/components/ui/context.tsx index 013589941..13992a736 100644 --- a/components/ui/context.tsx +++ b/components/ui/context.tsx @@ -8,6 +8,7 @@ export interface State { displayToast: boolean modalView: string toastText: string + userAvatar: string } const initialState = { @@ -17,6 +18,7 @@ const initialState = { modalView: 'LOGIN_VIEW', displayToast: false, toastText: '', + userAvatar: '', } type Action = diff --git a/components/wishlist/WishlistButton/WishlistButton.tsx b/components/wishlist/WishlistButton/WishlistButton.tsx index e22215363..5a1f17e5c 100644 --- a/components/wishlist/WishlistButton/WishlistButton.tsx +++ b/components/wishlist/WishlistButton/WishlistButton.tsx @@ -1,11 +1,10 @@ import React, { FC, useState } from 'react' import cn from 'classnames' -import { Heart } from '@components/icons' import { useUI } from '@components/ui' import type { Product, ProductVariant } from '@commerce/types' -import useCustomer from '@framework/customer/use-customer' import useAddItem from '@framework/wishlist/use-add-item' +import useCustomer from '@framework/customer/use-customer' import useRemoveItem from '@framework/wishlist/use-remove-item' import useWishlist from '@framework/wishlist/use-wishlist' diff --git a/framework/shopify/README.md b/framework/shopify/README.md new file mode 100644 index 000000000..fc6a70ce3 --- /dev/null +++ b/framework/shopify/README.md @@ -0,0 +1,260 @@ +## Table of Contents + +- [Getting Started](#getting-started) + - [Modifications](#modifications) + - [Adding item to Cart](#adding-item-to-cart) + - [Proceed to Checkout](#proceed-to-checkout) +- [General Usage](#general-usage) + - [CommerceProvider](#commerceprovider) + - [useCommerce](#usecommerce) +- [Hooks](#hooks) + - [usePrice](#useprice) + - [useAddItem](#useadditem) + - [useRemoveItem](#useremoveitem) + - [useUpdateItem](#useupdateitem) +- [APIs](#apis) + - [getProduct](#getproduct) + - [getAllProducts](#getallproducts) + - [getAllCollections](#getallcollections) + - [getAllPages](#getallpages) + +# Shopify Storefront Data Hooks + +Collection of hooks and data fetching functions to integrate Shopify in a React application. Designed to work with [Next.js Commerce](https://demo.vercel.store/). + +## Getting Started + +1. Install dependencies: + +``` +yarn install shopify-buy +yarn install -D @types/shopify-buy +``` + +3. Environment variables need to be set: + +``` +SHOPIFY_STORE_DOMAIN= +SHOPIFY_STOREFRONT_ACCESS_TOKEN= +NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN= +NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN= +``` + +4. Point the framework to `shopify` by updating `tsconfig.json`: + +``` +"@framework/*": ["framework/shopify/*"], +"@framework": ["framework/shopify"] +``` + +### 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 = ({ 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 ( + + ) +} +``` + +## General Usage + +### CommerceProvider + +Provider component that creates the commerce context for children. + +```js +import { CommerceProvider } from '@framework' + +const App = ({ children }) => { + return {children} +} + +export default App +``` + +### useCommerce + +Returns the configs that are defined in the nearest `CommerceProvider`. Also provides access to Shopify's `checkout` and `shop`. + +```js +import { useCommerce } from 'nextjs-commerce-shopify' + +const { checkout, shop } = useCommerce() +``` + +- `checkout`: The information required to checkout items and pay ([Documentation](https://shopify.dev/docs/storefront-api/reference/checkouts/checkout)). +- `shop`: Represents a collection of the general settings and information about the shop ([Documentation](https://shopify.dev/docs/storefront-api/reference/online-store/shop/index)). + +## Hooks + +### usePrice + +Display the product variant price according to currency and locale. + +```js +import usePrice from '@framework/product/use-price' + +const { price } = usePrice({ + amount, +}) +``` + +Takes in either `amount` or `variant`: + +- `amount`: A price value for a particular item if the amount is known. +- `variant`: A shopify product variant. Price will be extracted from the variant. + +### useAddItem + +```js +import { useAddItem } from '@framework/cart' + +const AddToCartButton = ({ variantId, quantity }) => { + const addItem = useAddItem() + + const addToCart = async () => { + await addItem({ + variantId, + }) + } + + return +} +``` + +### useRemoveItem + +```js +import { useRemoveItem } from '@framework/cart' + +const RemoveButton = ({ item }) => { + const removeItem = useRemoveItem() + + const handleRemove = async () => { + await removeItem({ id: item.id }) + } + + return +} +``` + +### useUpdateItem + +```js +import { useUpdateItem } from '@framework/cart' + +const CartItem = ({ item }) => { + const [quantity, setQuantity] = useState(item.quantity) + const updateItem = useUpdateItem(item) + + const updateQuantity = async (e) => { + const val = e.target.value + await updateItem({ quantity: val }) + } + + return ( + + ) +} +``` + +## 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, +}) +``` diff --git a/framework/shopify/api/cart/index.ts b/framework/shopify/api/cart/index.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/framework/shopify/api/cart/index.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/framework/shopify/api/catalog/index.ts b/framework/shopify/api/catalog/index.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/framework/shopify/api/catalog/index.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/framework/shopify/api/catalog/products.ts b/framework/shopify/api/catalog/products.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/framework/shopify/api/catalog/products.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/framework/shopify/api/checkout/index.ts b/framework/shopify/api/checkout/index.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/framework/shopify/api/checkout/index.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/framework/shopify/api/customers/index.ts b/framework/shopify/api/customers/index.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/framework/shopify/api/customers/index.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/framework/shopify/api/customers/login.ts b/framework/shopify/api/customers/login.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/framework/shopify/api/customers/login.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/framework/shopify/api/customers/logout.ts b/framework/shopify/api/customers/logout.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/framework/shopify/api/customers/logout.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/framework/shopify/api/customers/signup.ts b/framework/shopify/api/customers/signup.ts new file mode 100644 index 000000000..ea9b101e1 --- /dev/null +++ b/framework/shopify/api/customers/signup.ts @@ -0,0 +1 @@ +export default function () {} diff --git a/framework/shopify/api/index.ts b/framework/shopify/api/index.ts new file mode 100644 index 000000000..dcb0fc2ba --- /dev/null +++ b/framework/shopify/api/index.ts @@ -0,0 +1,60 @@ +import type { CommerceAPIConfig } from '@commerce/api' +import fetchGraphqlApi from './utils/fetch-graphql-api' + +export interface ShopifyConfig extends CommerceAPIConfig {} + +// No I don't like this - will fix it later +const API_URL = + process.env.SHOPIFY_STORE_DOMAIN || + process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN +const API_TOKEN = + process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN || + process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN + +if (!API_URL) { + throw new Error( + `The environment variable 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` + ) +} + +export class Config { + private config: ShopifyConfig + + constructor(config: ShopifyConfig) { + this.config = config + } + + getConfig(userConfig: Partial = {}) { + return Object.entries(userConfig).reduce( + (cfg, [key, value]) => Object.assign(cfg, { [key]: value }), + { ...this.config } + ) + } + + setConfig(newConfig: Partial) { + Object.assign(this.config, newConfig) + } +} + +const config = new Config({ + commerceUrl: API_URL, + apiToken: API_TOKEN, + // TODO + // @ts-ignore + fetch: fetchGraphqlApi, + customerCookie: 'SHOP_TOKEN', +}) + +export function getConfig(userConfig?: Partial) { + return config.getConfig(userConfig) +} + +export function setConfig(newConfig: Partial) { + return config.setConfig(newConfig) +} diff --git a/framework/shopify/api/operations/get-all-collections.ts b/framework/shopify/api/operations/get-all-collections.ts new file mode 100644 index 000000000..9cf216a91 --- /dev/null +++ b/framework/shopify/api/operations/get-all-collections.ts @@ -0,0 +1,21 @@ +import Client from 'shopify-buy' +import { ShopifyConfig } from '../index' + +type Options = { + config: ShopifyConfig +} + +const getAllCollections = async (options: Options) => { + const { config } = options + + const client = Client.buildClient({ + storefrontAccessToken: config.apiToken, + domain: config.commerceUrl, + }) + + const res = await client.collection.fetchAllWithProducts() + + return JSON.parse(JSON.stringify(res)) +} + +export default getAllCollections diff --git a/framework/shopify/api/operations/get-page.ts b/framework/shopify/api/operations/get-page.ts new file mode 100644 index 000000000..11651e335 --- /dev/null +++ b/framework/shopify/api/operations/get-page.ts @@ -0,0 +1,27 @@ +import { ShopifyConfig, getConfig } from '..' +import type { Page } from '../../types' + +export type { Page } + +export type GetPageResult = T + +export type PageVariables = { + id: string +} + +async function getPage({ + url, + variables, + config, + preview, +}: { + url?: string + variables: PageVariables + config?: ShopifyConfig + preview?: boolean +}): Promise { + config = getConfig(config) + return {} +} + +export default getPage diff --git a/framework/shopify/api/utils/fetch-graphql-api.ts b/framework/shopify/api/utils/fetch-graphql-api.ts new file mode 100644 index 000000000..946242c93 --- /dev/null +++ b/framework/shopify/api/utils/fetch-graphql-api.ts @@ -0,0 +1,51 @@ +import { CommerceAPIFetchOptions } from '@commerce/api' +import { FetcherError } from '@commerce/utils/errors' +import { getConfig } from '../index' + +export interface GraphQLFetcherResult { + data: Data + res: Response +} +export type GraphQLFetcher< + Data extends GraphQLFetcherResult = GraphQLFetcherResult, + Variables = any +> = ( + query: string, + queryData?: CommerceAPIFetchOptions, + fetchOptions?: RequestInit +) => Promise + +const fetchGraphqlApi: GraphQLFetcher = async ( + query: string, + { variables } = {}, + fetchOptions +) => { + const config = getConfig() + const url = `https://${config.commerceUrl}/api/2020-10/graphql.json` + + const res = await fetch(url, { + ...fetchOptions, + method: 'POST', + headers: { + 'X-Shopify-Storefront-Access-Token': config.apiToken, + ...fetchOptions?.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + variables, + }), + }) + + const json = await res.json() + if (json.errors) { + throw new FetcherError({ + errors: json.errors ?? [{ message: 'Failed to fetch Shopify API' }], + status: res.status, + }) + } + + return { data: json.data, res } +} + +export default fetchGraphqlApi diff --git a/framework/shopify/api/wishlist/index.tsx b/framework/shopify/api/wishlist/index.tsx new file mode 100644 index 000000000..a72856673 --- /dev/null +++ b/framework/shopify/api/wishlist/index.tsx @@ -0,0 +1,2 @@ +export type WishlistItem = { product: any; id: number } +export default function () {} diff --git a/framework/shopify/auth/use-login.tsx b/framework/shopify/auth/use-login.tsx new file mode 100644 index 000000000..75f067c3a --- /dev/null +++ b/framework/shopify/auth/use-login.tsx @@ -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 diff --git a/framework/shopify/auth/use-logout.tsx b/framework/shopify/auth/use-logout.tsx new file mode 100644 index 000000000..75f067c3a --- /dev/null +++ b/framework/shopify/auth/use-logout.tsx @@ -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 diff --git a/framework/shopify/auth/use-signup.tsx b/framework/shopify/auth/use-signup.tsx new file mode 100644 index 000000000..75f067c3a --- /dev/null +++ b/framework/shopify/auth/use-signup.tsx @@ -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 diff --git a/framework/shopify/cart/index.ts b/framework/shopify/cart/index.ts new file mode 100644 index 000000000..e1c6ef823 --- /dev/null +++ b/framework/shopify/cart/index.ts @@ -0,0 +1,5 @@ +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' diff --git a/framework/shopify/cart/use-add-item.tsx b/framework/shopify/cart/use-add-item.tsx new file mode 100644 index 000000000..276d66e30 --- /dev/null +++ b/framework/shopify/cart/use-add-item.tsx @@ -0,0 +1,30 @@ +import { useCallback } from 'react' +import { LineItemToAdd } from 'shopify-buy' +import { useCommerce } from '../index' + +type Options = { + productId: number + variantId: string | number +} + +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 diff --git a/framework/shopify/cart/use-cart.tsx b/framework/shopify/cart/use-cart.tsx new file mode 100644 index 000000000..f067b520d --- /dev/null +++ b/framework/shopify/cart/use-cart.tsx @@ -0,0 +1,42 @@ +import { useCommerce } from '../index' + +export function emptyHook() { + const { checkout } = useCommerce() + const { lineItems, totalPriceV2 } = checkout || {} + + 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, + }, + ] + }) || [], + 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, + } +} + +export default emptyHook diff --git a/framework/shopify/cart/use-remove-item.tsx b/framework/shopify/cart/use-remove-item.tsx new file mode 100644 index 000000000..c0ce93bd5 --- /dev/null +++ b/framework/shopify/cart/use-remove-item.tsx @@ -0,0 +1,17 @@ +import { useCallback } from 'react' +import { useCommerce } from '../index' + +const useRemoveItem = () => { + const { checkout, client, updateCheckout } = useCommerce() + + return useCallback( + async function removeItem({ id }: { id: string }) { + const cart = await client?.checkout.removeLineItems(checkout.id, [id]) + updateCheckout(cart) + return cart + }, + [checkout, client] + ) +} + +export default useRemoveItem diff --git a/framework/shopify/cart/use-update-item.tsx b/framework/shopify/cart/use-update-item.tsx new file mode 100644 index 000000000..05118a65b --- /dev/null +++ b/framework/shopify/cart/use-update-item.tsx @@ -0,0 +1,24 @@ +import { useCallback } from 'react' +import { useCommerce } from '../index' + +const useUpdateItem = (item: CartItem) => { + const { checkout, client, updateCheckout } = useCommerce() + + return useCallback( + async function updateItem({ quantity }: { quantity: number }) { + const lineItemsToUpdate = [{ id: item.id, quantity }] + + const cart = await client?.checkout.updateLineItems( + checkout.id, + lineItemsToUpdate + ) + + updateCheckout(cart) + + return cart + }, + [checkout, client] + ) +} + +export default useUpdateItem diff --git a/framework/shopify/common/get-all-pages.ts b/framework/shopify/common/get-all-pages.ts new file mode 100644 index 000000000..02db3fdc3 --- /dev/null +++ b/framework/shopify/common/get-all-pages.ts @@ -0,0 +1,55 @@ +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 + } + } + } + } +` + +type Variables = { + first?: number +} + +type Options = { + variables?: Variables + config: ShopifyConfig + preview?: boolean +} + +type ReturnType = { + pages: Page[] +} + +const getAllPages = async (options?: Options): Promise => { + 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 { + ...node, + name: node.handle, + url: `${config!.locale}/${node.handle}`, + } + }) + + return { pages } +} + +export default getAllPages diff --git a/framework/shopify/common/get-site-info.ts b/framework/shopify/common/get-site-info.ts new file mode 100644 index 000000000..c08ae2b92 --- /dev/null +++ b/framework/shopify/common/get-site-info.ts @@ -0,0 +1,30 @@ +import { ShopifyConfig } from '../index' + +type Options = { + config: ShopifyConfig + preview?: boolean +} + +const getSiteInfo = async (options: Options) => { + // TODO + return { + categories: [ + { + path: '', + name: '', + entityId: 0, + }, + ], + brands: [ + { + node: { + path: '', + name: '', + entityId: 0, + }, + }, + ], + } +} + +export default getSiteInfo diff --git a/framework/shopify/customer/use-customer.tsx b/framework/shopify/customer/use-customer.tsx new file mode 100644 index 000000000..a909443ff --- /dev/null +++ b/framework/shopify/customer/use-customer.tsx @@ -0,0 +1,32 @@ +import type { HookFetcher } from '@commerce/utils/types' +import type { SwrOptions } from '@commerce/utils/use-data' +import useCommerceCustomer from '@commerce/use-customer' + +const defaultOpts = {} + +export type Customer = { + entityId: number + firstName: string + lastName: string + email: string +} +export type CustomerData = {} + +export const fetcher: HookFetcher = async () => { + return null +} + +export function extendHook( + customFetcher: typeof fetcher, + swrOptions?: SwrOptions +) { + const useCustomer = () => { + return { data: { firstName: null, lastName: null, email: null } } + } + + useCustomer.extend = extendHook + + return useCustomer +} + +export default extendHook(fetcher) diff --git a/framework/shopify/index.tsx b/framework/shopify/index.tsx new file mode 100644 index 000000000..5fd08e0d9 --- /dev/null +++ b/framework/shopify/index.tsx @@ -0,0 +1,109 @@ +import React, { + ReactNode, + createContext, + useContext, + useMemo, + useState, + useEffect, +} from 'react' +import Client from 'shopify-buy' +import { Shop, Cart, Client as ClientType } from './types' +import { + getCheckoutIdFromStorage, + setCheckoutIdInStorage, +} from './utils/storage' +import { getConfig } from '@framework/api' + +const Commerce = createContext({}) + +type CommerceProps = { + children?: ReactNode + locale: string +} + +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() + const [checkout, setCheckout] = useState() + + 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] + ) + + return {children} +} + +export function useCommerce() { + return useContext(Commerce) as T +} diff --git a/framework/shopify/product/get-all-product-paths.ts b/framework/shopify/product/get-all-product-paths.ts new file mode 100644 index 000000000..3d4f0ef7a --- /dev/null +++ b/framework/shopify/product/get-all-product-paths.ts @@ -0,0 +1,31 @@ +import Client from 'shopify-buy' +import { getConfig } from '../api' +import { Product } from '../types' +import toCommerceProducts from '../utils/to-commerce-products' + +type ReturnType = { + products: any[] +} + +const getAllProductPaths = async (): Promise => { + const config = getConfig() + + const client = Client.buildClient({ + storefrontAccessToken: config.apiToken, + domain: config.commerceUrl, + }) + + const res = (await client.product.fetchAll()) as Product[] + + const products = toCommerceProducts(res) + + return { + products: products.map((product) => { + return { + node: { ...product }, + } + }), + } +} + +export default getAllProductPaths diff --git a/framework/shopify/product/get-all-products.ts b/framework/shopify/product/get-all-products.ts new file mode 100644 index 000000000..6e4881e99 --- /dev/null +++ b/framework/shopify/product/get-all-products.ts @@ -0,0 +1,40 @@ +import Client from 'shopify-buy' +import { ShopifyConfig } from '../api' +import { Product } from '../types' +import toCommerceProducts from '../utils/to-commerce-products' + +export type ProductNode = Product + +type Variables = { + first?: number + field?: string +} + +type Options = { + variables: Variables + config: ShopifyConfig + preview?: boolean +} + +type ReturnType = { + products: any[] +} + +const getAllProducts = async (options: Options): Promise => { + const { config } = options + + const client = Client.buildClient({ + storefrontAccessToken: config.apiToken, + domain: config.commerceUrl, + }) + + const res = (await client.product.fetchAll()) as Product[] + + const products = toCommerceProducts(res) + + return { + products, + } +} + +export default getAllProducts diff --git a/framework/shopify/product/get-product.ts b/framework/shopify/product/get-product.ts new file mode 100644 index 000000000..f71aa0213 --- /dev/null +++ b/framework/shopify/product/get-product.ts @@ -0,0 +1,37 @@ +import Client from 'shopify-buy' +import { ShopifyConfig } from '../api' +import { Product } from '../types' +import toCommerceProducts from '../utils/to-commerce-products' + +export type ProductNode = Product + +type Variables = { + slug: string +} + +type Options = { + variables: Variables + config: ShopifyConfig + preview?: boolean +} + +type ReturnType = { + product: any +} + +const getProduct = async (options: Options): Promise => { + const { variables, config } = options + + const client = Client.buildClient({ + storefrontAccessToken: config.apiToken, + domain: config.commerceUrl, + }) + + const res = (await client.product.fetchByHandle(variables.slug)) as Product + + return { + product: toCommerceProducts([res])[0], + } +} + +export default getProduct diff --git a/framework/shopify/product/use-price.tsx b/framework/shopify/product/use-price.tsx new file mode 100644 index 000000000..a79940a76 --- /dev/null +++ b/framework/shopify/product/use-price.tsx @@ -0,0 +1,2 @@ +export * from '@commerce/use-price' +export { default } from '@commerce/use-price' diff --git a/framework/shopify/product/use-search.tsx b/framework/shopify/product/use-search.tsx new file mode 100644 index 000000000..a2c32c896 --- /dev/null +++ b/framework/shopify/product/use-search.tsx @@ -0,0 +1,41 @@ +import type { HookFetcher } from '@commerce/utils/types' +import type { SwrOptions } from '@commerce/utils/use-data' +import useCommerceSearch from '@commerce/products/use-search' +import { ProductEdge } from '../types' + +const defaultOpts = {} + +export type SearchProductsInput = { + search?: string + categoryId?: number + brandId?: number + sort?: string +} + +export type SearchProductsData = { + products: ProductEdge[] + found: boolean +} + +export const fetcher: HookFetcher = ( + options, + { search, categoryId, brandId, sort }, + fetch +) => { + return { found: false, products: [] } +} + +export function extendHook( + customFetcher: typeof fetcher, + swrOptions?: SwrOptions +) { + const useSearch = (input: SearchProductsInput = {}) => { + return {} + } + + useSearch.extend = extendHook + + return useSearch +} + +export default extendHook(fetcher) diff --git a/framework/shopify/types.ts b/framework/shopify/types.ts new file mode 100644 index 000000000..47bb94e62 --- /dev/null +++ b/framework/shopify/types.ts @@ -0,0 +1,130 @@ +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' + +export type SelectedOptions = { + id: string + name: string + value: string +} + +export type PresentmentPrice = { + price: PriceV2 +} + +export type ProductVariant = BaseProductVariant & { + selectedOptions: Array + presentmentPrices: Array +} + +// 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 & { + 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 + + create: (input: Create) => Promise +} + +export type Client = BaseClient & { + checkout: CheckoutResource +} + +export type Page = { + id: string + title: string + name: string + handle: string + body: string + bodySummary: string + url: string + sort_order: number +} + +export type PageEdge = { + node: Page +} + +export type Image = BaseImage diff --git a/framework/shopify/utils/storage.ts b/framework/shopify/utils/storage.ts new file mode 100644 index 000000000..d46dadb21 --- /dev/null +++ b/framework/shopify/utils/storage.ts @@ -0,0 +1,13 @@ +export const getCheckoutIdFromStorage = (token: string) => { + if (window && window.sessionStorage) { + return window.sessionStorage.getItem(token) + } + + return null +} + +export const setCheckoutIdInStorage = (token: string, id: string | number) => { + if (window && window.sessionStorage) { + return window.sessionStorage.setItem(token, id + '') + } +} diff --git a/framework/shopify/utils/to-commerce-products.ts b/framework/shopify/utils/to-commerce-products.ts new file mode 100644 index 000000000..c0b411eb6 --- /dev/null +++ b/framework/shopify/utils/to-commerce-products.ts @@ -0,0 +1,60 @@ +import { Product, Image } from '../types' + +export default function toCommerceProducts(products: Product[]) { + return products.map((product: Product) => { + return { + id: product.id, + entityId: product.id, + name: product.title, + slug: product.handle, + title: product.title, + vendor: product.vendor, + description: product.descriptionHtml, + path: `/${product.handle}`, + price: { + value: +product.variants[0].price, + currencyCode: 'USD', // TODO + }, + images: product.images.map((image: Image) => { + return { + url: image.src, + } + }), + variants: product.variants.map((variant) => { + return { + id: variant.id, + options: variant.selectedOptions.map((selectedOption) => { + return { + __typename: 'MultipleChoiceOption', + displayName: selectedOption.name, + values: [ + { + node: { + id: variant.id, + label: selectedOption.value, + }, + }, + ], + } + }), + } + }), + productOptions: product.options.map((option) => { + return { + __typename: 'MultipleChoiceOption', + displayName: option.name, + values: option.values.map((value) => { + return { + node: { + entityId: 1, + label: value.value, + hexColors: [value.value], + }, + } + }), + } + }), + options: [], + } + }) +} diff --git a/framework/shopify/wishlist/use-add-item.tsx b/framework/shopify/wishlist/use-add-item.tsx new file mode 100644 index 000000000..75f067c3a --- /dev/null +++ b/framework/shopify/wishlist/use-add-item.tsx @@ -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 diff --git a/framework/shopify/wishlist/use-remove-item.tsx b/framework/shopify/wishlist/use-remove-item.tsx new file mode 100644 index 000000000..a2d3a8a05 --- /dev/null +++ b/framework/shopify/wishlist/use-remove-item.tsx @@ -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 diff --git a/framework/shopify/wishlist/use-wishlist.tsx b/framework/shopify/wishlist/use-wishlist.tsx new file mode 100644 index 000000000..2aac16810 --- /dev/null +++ b/framework/shopify/wishlist/use-wishlist.tsx @@ -0,0 +1,45 @@ +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 useCustomer from '../customer/use-customer' + +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 = () => { + return null +} + +export function extendHook( + customFetcher: typeof fetcher, + swrOptions?: SwrOptions +) { + const useWishlist = ({ includeProducts }: UseWishlistOptions = {}) => { + return { data: null } + } + + useWishlist.extend = extendHook + + return useWishlist +} + +export default extendHook(fetcher) diff --git a/tsconfig.json b/tsconfig.json index 43dfd2a27..9e712fb18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,14 +16,14 @@ "jsx": "preserve", "paths": { "@lib/*": ["lib/*"], - "@assets/*": ["assets/*"], - "@config/*": ["config/*"], - "@components/*": ["components/*"], "@utils/*": ["utils/*"], - "@commerce/*": ["framework/commerce/*"], + "@config/*": ["config/*"], + "@assets/*": ["assets/*"], + "@components/*": ["components/*"], "@commerce": ["framework/commerce"], - "@framework/*": ["framework/bigcommerce/*"], - "@framework": ["framework/bigcommerce"] + "@commerce/*": ["framework/commerce/*"], + "@framework": ["framework/bigcommerce"], + "@framework/*": ["framework/bigcommerce/*"] } }, "include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],