Skip to content

basementstudio/commerce-toolkit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

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