basementstudio / commerce-toolkit

Ship better storefronts πŸ›

Home Page:commerce-toolkit-nextjs-shopify.vercel.app

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

BSMNT Commerce Toolkit

commerce-toolkit

Welcome to the BSMNT Commerce Toolkit: packages to help you ship better storefronts, faster, and with more confidence.

This toolkit has helped usβ€”basement.studioβ€”ship reliable storefronts that could handle crazy amounts of traffic. Some of them include: shopmrbeast.com, karljacobs.co, shopmrballen.com, and ranboo.fashion.


πŸ’‘ If you're looking for an example with Next.js + Shopify, check out our example here.


This repository currently holds three packages:

  1. @bsmnt/storefront-hooks: React Hooks to manage storefront client-side state.

    • βœ… Manage the whole cart lifecycle with the help of @tanstack/react-query and localStorage
    • βœ… Easily manage your cart mutations (like adding stuff into it)
    • βœ… An opinionated, but powerful, way to structure storefront hooks
  2. @bsmnt/sdk-gen: a CLI that generates a type-safe, graphql SDK.

    • βœ… Easily connect to any GraphQL API
    • βœ… Generated TypeScript types from your queries
    • βœ… Lighter than avarage, as it doesn't depend on graphql for production
  3. @bsmnt/drop: Helpers for managing a countdown. Generally used to create hype around a merch drop.

These play really well together, but can also be used separately. Let's see how they work!


@bsmnt/storefront-hooks

yarn add @bsmnt/storefront-hooks @tanstack/react-query

This package exports:

  • createStorefrontHooks: function that creates the hooks needed to interact with the cart.
import { createStorefrontHooks } from '@bsmnt/storefront-hooks'

export const hooks = createStorefrontHooks({
  cartCookieKey: '', // to save cart id in cookie
  fetchers: {}, // hooks will use these internally
  mutators: {}, // hooks will use these internally
  createCartIfNotFound: false, // defaults to false. if true, will create a cart if none is found
  queryClientConfig: {} // internal query client config
})

Take a look at some examples:

Simple example, with localStorage
import { createStorefrontHooks } from '@bsmnt/storefront-hooks'

type LineItem = {
  merchandiseId: string
  quantity: number
}

type Cart = {
  id: string
  lines: LineItem[]
}

export const {
  QueryClientProvider,
  useCartQuery,
  useAddLineItemsToCartMutation,
  useOptimisticCartUpdate,
  useRemoveLineItemsFromCartMutation,
  useUpdateLineItemsInCartMutation
} = createStorefrontHooks<Cart>({
  cartCookieKey: 'example-nextjs-localstorage',
  fetchers: {
    fetchCart: (cartId: string) => {
      const cartFromLocalStorage = localStorage.getItem(cartId)

      if (!cartFromLocalStorage) throw new Error('Cart not found')

      const cart: Cart = JSON.parse(cartFromLocalStorage)

      return cart
    }
  },
  mutators: {
    addLineItemsToCart: (cartId, lines) => {
      const cartFromLocalStorage = localStorage.getItem(cartId)

      if (!cartFromLocalStorage) throw new Error('Cart not found')

      const cart: Cart = JSON.parse(cartFromLocalStorage)
      // Add line if not exists, update quantity if exists
      const updatedCart = lines.reduce((cart, line) => {
        const lineIndex = cart.lines.findIndex(
          (cartLine) => cartLine.merchandiseId === line.merchandiseId
        )

        if (lineIndex === -1) {
          cart.lines.push(line)
        } else {
          cart.lines[lineIndex]!.quantity += line.quantity
        }

        return cart
      }, cart)

      localStorage.setItem(cartId, JSON.stringify(updatedCart))

      return {
        data: updatedCart
      }
    },
    createCart: () => {
      const cart: Cart = { id: 'cart', lines: [] }
      localStorage.setItem(cart.id, JSON.stringify(cart))

      return { data: cart }
    },
    createCartWithLines: (lines) => {
      const cart = { id: 'cart', lines }
      localStorage.setItem(cart.id, JSON.stringify(cart))

      return { data: cart }
    },
    removeLineItemsFromCart: (cartId, lineIds) => {
      const cartFromLocalStorage = localStorage.getItem(cartId)

      if (!cartFromLocalStorage) throw new Error('Cart not found')

      const cart: Cart = JSON.parse(cartFromLocalStorage)
      cart.lines = cart.lines.filter(
        (line) => !lineIds.includes(line.merchandiseId)
      )
      localStorage.setItem(cart.id, JSON.stringify(cart))

      return {
        data: cart
      }
    },
    updateLineItemsInCart: (cartId, lines) => {
      const cartFromLocalStorage = localStorage.getItem(cartId)

      if (!cartFromLocalStorage) throw new Error('Cart not found')

      const cart: Cart = JSON.parse(cartFromLocalStorage)
      cart.lines = lines
      localStorage.setItem(cart.id, JSON.stringify(cart))

      return {
        data: cart
      }
    }
  },
  logging: {
    onError(type, error) {
      console.info({ type, error })
    },
    onSuccess(type, data) {
      console.info({ type, data })
    }
  }
})
Complete example, with @bsmnt/sdk-gen
# Given the following file tree:
.
└── storefront/
    β”œβ”€β”€ sdk-gen/
    β”‚   └── sdk.ts # generated with @bsmnt/sdk-gen
    └── hooks.ts # <- we'll work here

This example depends on @bsmnt/sdk-gen.

// ./storefront/hooks.ts

import { createStorefrontHooks } from '@bsmnt/storefront-hooks'
import { storefront } from '../sdk-gen/sdk'
import type {
  CartGenqlSelection,
  CartUserErrorGenqlSelection,
  FieldsSelection,
  Cart as GenqlCart
} from '../sdk-gen/generated'

const cartFragment = {
  id: true,
  checkoutUrl: true,
  createdAt: true,
  cost: { subtotalAmount: { amount: true, currencyCode: true } }
} satisfies CartGenqlSelection

export type Cart = FieldsSelection<GenqlCart, typeof cartFragment>

const userErrorFragment = {
  message: true,
  code: true,
  field: true
} satisfies CartUserErrorGenqlSelection

export const {
  QueryClientProvider,
  useCartQuery,
  useAddLineItemsToCartMutation,
  useOptimisticCartUpdate,
  useRemoveLineItemsFromCartMutation,
  useUpdateLineItemsInCartMutation
} = createStorefrontHooks({
  cartCookieKey: 'example-nextjs-shopify',
  fetchers: {
    fetchCart: async (cartId) => {
      const { cart } = await storefront.query({
        cart: {
          __args: { id: cartId },
          ...cartFragment
        }
      })

      if (cart === undefined) throw new Error('Request failed')
      return cart
    }
  },
  mutators: {
    addLineItemsToCart: async (cartId, lines) => {
      const { cartLinesAdd } = await storefront.mutation({
        cartLinesAdd: {
          __args: {
            cartId,
            lines
          },
          cart: cartFragment,
          userErrors: userErrorFragment
        }
      })

      return {
        data: cartLinesAdd?.cart,
        userErrors: cartLinesAdd?.userErrors
      }
    },
    createCart: async () => {
      const { cartCreate } = await storefront.mutation({
        cartCreate: {
          cart: cartFragment,
          userErrors: userErrorFragment
        }
      })
      return {
        data: cartCreate?.cart,
        userErrors: cartCreate?.userErrors
      }
    },
    // TODO we could use the same mutation as createCart?
    createCartWithLines: async (lines) => {
      const { cartCreate } = await storefront.mutation({
        cartCreate: {
          __args: { input: { lines } },
          cart: cartFragment,
          userErrors: userErrorFragment
        }
      })
      return {
        data: cartCreate?.cart,
        userErrors: cartCreate?.userErrors
      }
    },
    removeLineItemsFromCart: async (cartId, lineIds) => {
      const { cartLinesRemove } = await storefront.mutation({
        cartLinesRemove: {
          __args: { cartId, lineIds },
          cart: cartFragment,
          userErrors: userErrorFragment
        }
      })
      return {
        data: cartLinesRemove?.cart,
        userErrors: cartLinesRemove?.userErrors
      }
    },
    updateLineItemsInCart: async (cartId, lines) => {
      const { cartLinesUpdate } = await storefront.mutation({
        cartLinesUpdate: {
          __args: {
            cartId,
            lines: lines.map((l) => ({
              id: l.merchandiseId,
              quantity: l.quantity,
              attributes: l.attributes
            }))
          },
          cart: cartFragment,
          userErrors: userErrorFragment
        }
      })
      return {
        data: cartLinesUpdate?.cart,
        userErrors: cartLinesUpdate?.userErrors
      }
    }
  },
  createCartIfNotFound: true
})

@bsmnt/sdk-gen

yarn add @bsmnt/sdk-gen --dev

This package installs a CLI with a single command: generate. Running it will hit your GraphQL endpoint and generate TypeScript types from your queries and mutations. It's powered by Genql, so be sure to check out their docs.

# By default, you can have a file tree like the following:
.
└── sdk-gen/
    └── config.js
// ./sdk-gen/config.js

/**
 * @type {import("@bsmnt/sdk-gen").Config}
 */
module.exports = {
  endpoint: '',
  headers: {}
}

And then you can run the generator:

yarn sdk-gen

This will look inside ./sdk-gen/ for a config.js file, and for all your .{graphql,gql} files under that directory.

If you want to use a custom directory (and not the default, which is ./sdk-gen/), you can use the --dir argument.

yarn sdk-gen --dir ./my-custom/directory

After running the generator, you should get the following result:

.
└── sdk-gen/
    β”œβ”€β”€ config.js
    β”œβ”€β”€ documents.gql
    β”œβ”€β”€ generated/              # <- generated
    β”‚   β”œβ”€β”€ index.ts
    β”‚   └── graphql.schema.json
    └── sdk.ts                  # <- generated

Inside sdk.ts, you'll have the bsmntSdk being exported:

import config from './config'
import { createSdk } from './generated'

export const bsmntSdk = createSdk(config)

And that's all. You should be able to use that to hit your GraphQL API in a type safe manner.

An added benefit is that this sdk doesn't depend on graphql. Many GraphQL Clients require it as a peer dependency (e.g graphql-request), which adds important KBs to the bundle.

↳ For a standard way to use this with the Shopify Storefront API, take a look at our example With Next.js + Shopify.


@bsmnt/drop

yarn add @bsmnt/drop

This package exports:

  • CountdownProvider: Context Provider for the CountdownStore
  • useCountdownStore: Hook that consumes the CountdownProvider context and returns the CountdownStore
  • zeroPad: utility to pad a number with zeroes

To use, just wrap the CountdownProvider wherever you want to add your countdown. For example with Next.js:

// _app.tsx
import type { AppProps } from 'next/app'
import { CountdownProvider } from '@bsmnt/drop'
import { Countdown } from '../components/countdown'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <CountdownProvider
      endDate={Date.now() + 1000 * 5} // set this to 5 seconds from now just to test
      countdownChildren={<Countdown />}
      exitDelay={1000} // optional, just to give some time to animate the countdown before finally unmounting it
      startDate={Date.now()} // optional, just if you need some kind of progress UI
    >
      <Component {...pageProps} />
    </CountdownProvider>
  )
}

And then your Countdown may look something like:

import { useCountdownStore } from '@bsmnt/drop'

export const Countdown = () => {
  const humanTimeRemaining = useCountdownStore()(
    (state) => state.humanTimeRemaining // keep in mind this is zustand, so you can slice this store
  )

  return (
    <div>
      <h1>Countdown</h1>
      <ul>
        <li>Days: {humanTimeRemaining.days}</li>
        <li>Hours: {humanTimeRemaining.hours}</li>
        <li>Minutes: {humanTimeRemaining.minutes}</li>
        <li>Seconds: {humanTimeRemaining.seconds}</li>
      </ul>
    </div>
  )
}
Important note regarding SSR

If you render humanTimeRemaining.seconds, there's a high chance that your server will render something different than your client, as that value will change each second.

In most cases, you can safely suppressHydrationWarning (see issue #21 for more info):

import { useCountdownStore } from '@bsmnt/drop'

export const Countdown = () => {
  const humanTimeRemaining = useCountdownStore()(
    (state) => state.humanTimeRemaining // keep in mind this is zustand, so you can slice this store
  )

  return (
    <div>
      <h1>Countdown</h1>
      <ul>
        <li suppressHydrationWarning>Days: {humanTimeRemaining.days}</li>
        <li suppressHydrationWarning>Hours: {humanTimeRemaining.hours}</li>
        <li suppressHydrationWarning>Minutes: {humanTimeRemaining.minutes}</li>
        <li suppressHydrationWarning>Seconds: {humanTimeRemaining.seconds}</li>
      </ul>
    </div>
  )
}

If you don't want to take that risk, a safer option is waiting until your app is hydrated before rendering the real time remaining:

import { useEffect, useState } from 'react'
import { useCountdownStore } from '@bsmnt/drop'

const Countdown = () => {
  const humanTimeRemaining = useCountdownStore()(
    (state) => state.humanTimeRemaining // keep in mind this is zustand, so you can slice this store
  )

  const [hasRenderedOnce, setHasRenderedOnce] = useState(false)

  useEffect(() => {
    setHasRenderedOnce(true)
  }, [])

  return (
    <div>
      <h1>Countdown</h1>
      <ul>
        <li>Days: {humanTimeRemaining.days}</li>
        <li>Hours: {humanTimeRemaining.hours}</li>
        <li>Minutes: {hasRenderedOnce ? humanTimeRemaining.minutes : '59'}</li>
        <li>Seconds: {hasRenderedOnce ? humanTimeRemaining.seconds : '59'}</li>
      </ul>
    </div>
  )
}

Examples

Some examples to get you started:



Contributing

Pull requests are welcome. Issues are welcome. For major changes, please open an issue first to discuss what you would like to change.

License

MIT

Authors

About

Ship better storefronts πŸ›

commerce-toolkit-nextjs-shopify.vercel.app

License:MIT License


Languages

Language:TypeScript 99.3%Language:JavaScript 0.7%