BSMNT 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.
This repository currently holds three packages:
-
@bsmnt/storefront-hooks
: React Hooks to manage storefront client-side state.✅ Manage the whole cart lifecycle with the help of@tanstack/react-query
andlocalStorage
✅ Easily manage your cart mutations (like adding stuff into it)✅ An opinionated, but powerful, way to structure storefront hooks
-
@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 ongraphql
for production
-
@bsmnt/drop
: Helpers for managing a countdown. Generally used to create hype around a merch drop.✅ Create your countdown in just a couple of minutes✅ Reveal your site only when the drop is ready to go (see this example from one of our drops)
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 theCountdownStore
useCountdownStore
: Hook that consumes theCountdownProvider
context and returns theCountdownStore
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
Authors
- Santiago Moran (@morangsantiago) – basement.studio
- Julian Benegas (@julianbenegas8) – basement.studio