Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 44 additions & 13 deletions src/components/shop/CartDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { Link } from '@tanstack/react-router'
import { useIsMutating } from '@tanstack/react-query'
import {
MinusIcon,
PlusIcon,
ShoppingCartIcon,
TrashIcon,
} from '@phosphor-icons/react'
import { twMerge } from 'tailwind-merge'
import { useCart, useRemoveCartLine, useUpdateCartLine } from '~/hooks/useCart'
import {
CART_ADD_MUTATION_KEY,
useCart,
useRemoveCartLine,
useUpdateCartLine,
} from '~/hooks/useCart'
import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format'
import type { CartLineDetail } from '~/utils/shopify-queries'
import { ShopLabel, ShopMono } from './ui'
Expand All @@ -29,7 +35,9 @@ type CartDrawerProps = {
*/
export function CartDrawer({ open, onOpenChange }: CartDrawerProps) {
const { cart, totalQuantity } = useCart()
const mutating = useIsMutating({ mutationKey: CART_ADD_MUTATION_KEY })
const hasLines = !!cart && cart.lines.nodes.length > 0
const awaitingCart = !hasLines && mutating > 0

return (
<Drawer open={open} onOpenChange={onOpenChange}>
Expand Down Expand Up @@ -70,6 +78,8 @@ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) {
</DrawerBody>
<CartFooter cart={cart} onClose={() => onOpenChange(false)} />
</>
) : awaitingCart ? (
<CartPending />
) : (
<CartEmpty onClose={() => onOpenChange(false)} />
)}
Expand All @@ -78,6 +88,15 @@ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) {
)
}

function CartPending() {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-8 text-center text-shop-text-2">
<ShoppingCartIcon className="w-10 h-10 text-shop-muted" />
<p>Adding to cart…</p>
</div>
)
}

function CartEmpty({ onClose }: { onClose: () => void }) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-8 text-center text-shop-text-2">
Expand Down Expand Up @@ -120,19 +139,31 @@ function CartFooter({
<p className="font-shop-mono text-xs text-shop-muted tracking-[0.06em]">
Shipping and taxes calculated at checkout.
</p>
<a
href={cart.checkoutUrl}
className="
w-full h-10 rounded-md bg-shop-accent text-shop-accent-ink
font-semibold text-shop-ui flex items-center justify-center gap-2
transition-[filter] hover:brightness-110 group
"
>
Checkout
<span className="transition-transform group-hover:translate-x-[3px]">
{cart.checkoutUrl ? (
<a
href={cart.checkoutUrl}
className="
w-full h-10 rounded-md bg-shop-accent text-shop-accent-ink
font-semibold text-shop-ui flex items-center justify-center gap-2
transition-[filter] hover:brightness-110 group
"
>
Checkout
<span className="transition-transform group-hover:translate-x-[3px]">
</span>
</a>
) : (
<span
className="
w-full h-10 rounded-md bg-shop-accent text-shop-accent-ink
font-semibold text-shop-ui flex items-center justify-center
opacity-50
"
>
Checkout
</span>
</a>
)}
<Link
to="/shop/cart"
onClick={onClose}
Expand Down
30 changes: 3 additions & 27 deletions src/components/shop/ProductDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { twMerge } from 'tailwind-merge'
import { getProduct } from '~/utils/shop.functions'
import {
findExactVariant,
findMatchingVariant,
hasAvailableVariant,
type ProductDetail,
type ProductDetailVariant,
} from '~/utils/shopify-queries'
import { formatMoney } from '~/utils/shopify-format'
import { resolveShopProductColor, shopColorContrast } from '~/utils/shop-color'
Expand All @@ -27,35 +28,12 @@ import {
DrawerDescription,
DrawerTitle,
} from '~/components/ds/ui'
import { useCartDrawerStore } from './cartDrawerStore'

const MAX_INLINE_OPTION_VALUES = 8

const ARROW_CLS =
'absolute top-1/2 z-[3] flex size-11 -translate-y-1/2 items-center justify-center rounded-full border border-shop-line bg-shop-bg/90 text-shop-text shadow-xl backdrop-blur-sm transition-[transform,background-color] duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)] hover:bg-shop-surface-hover active:scale-95 motion-reduce:transition-none'

function findMatchingVariant(
variants: Array<ProductDetailVariant>,
selected: Record<string, string>,
): ProductDetailVariant | undefined {
// Empty string means "not yet chosen" — treated as wildcard for availability checks
return variants.find((v) =>
v.selectedOptions.every((o) => {
const s = selected[o.name]
return !s || s === o.value
}),
)
}

function findExactVariant(
variants: Array<ProductDetailVariant>,
selected: Record<string, string>,
): ProductDetailVariant | undefined {
return variants.find((v) =>
v.selectedOptions.every((o) => selected[o.name] === o.value),
)
}

type ProductDrawerProps = {
productHandle: string | null
initialProduct?: ProductDetail | null
Expand Down Expand Up @@ -352,7 +330,6 @@ function ProductPanel({
}

const addToCart = useAddToCart()
const openCartDrawer = useCartDrawerStore((s) => s.openDrawer)

React.useEffect(() => {
if (!showAdded) return
Expand Down Expand Up @@ -596,12 +573,11 @@ function ProductPanel({
disabled={
!isComplete ||
!selectedVariant?.availableForSale ||
(addToCart.isPending && !showAdded)
addToCart.isPending
}
onClick={() => {
if (!selectedVariant) return
setShowAdded(true)
openCartDrawer()
addToCart.mutate({
variantId: selectedVariant.id,
quantity,
Expand Down
1 change: 1 addition & 0 deletions src/components/shop/ProductImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function ProductImage({

return (
<img
key={image.url}
src={shopifyImageUrl(image.url, { width, format: 'webp' })}
srcSet={srcset}
sizes={sizes}
Expand Down
130 changes: 45 additions & 85 deletions src/hooks/useCart.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCartDrawerStore } from '~/components/shop/cartDrawerStore'
import {
applyOptimisticAddToCart,
cartHasLines,
type AddToCartLineSnapshot,
} from '~/utils/cart-optimistic'
import {
addToCart,
applyDiscountCode,
Expand All @@ -7,7 +13,7 @@ import {
removeDiscountCode,
updateCartLine,
} from '~/utils/shop.functions'
import type { CartDetail, CartLineDetail } from '~/utils/shopify-queries'
import type { CartDetail } from '~/utils/shopify-queries'

/**
* Shared React Query key for the current user's cart.
Expand All @@ -19,11 +25,17 @@ import type { CartDetail, CartLineDetail } from '~/utils/shopify-queries'
export const CART_QUERY_KEY = ['shopify', 'cart'] as const

/**
* Mutation key shared across all cart-mutating hooks. Used by
* `settleWhenIdle` to determine whether other cart mutations are still
* Mutation key shared across cart-mutating hooks other than add-to-cart.
* Used by `settleWhenIdle` to determine whether other cart mutations are still
* in flight before triggering a background refetch.
*/
const CART_MUTATION_KEY = ['shopify', 'cart', 'mutate'] as const
export const CART_MUTATION_KEY = ['shopify', 'cart', 'mutate'] as const

/**
* Distinct from `CART_MUTATION_KEY` (and not a prefix of it) so
* `useIsMutating` in the cart drawer matches add-to-cart only.
*/
export const CART_ADD_MUTATION_KEY = ['shopify', 'cart', 'add'] as const

/**
* Explicit in-flight counter. We don't rely on `queryClient.isMutating()`
Expand Down Expand Up @@ -55,15 +67,30 @@ function settleWhenIdle(qc: ReturnType<typeof useQueryClient>) {
}
}

function openDrawerIfCartHasLines(cart: CartDetail | null | undefined) {
if (cartHasLines(cart)) useCartDrawerStore.getState().openDrawer()
}

/**
* Read the current cart. Data is loader-seeded on shop routes, so there is
* no hydration gap — components that call this render with real data on the
* first frame. On non-shop routes the hook falls back to fetching on mount.
*
* After cartCreate the httpOnly cookie may not have landed on the immediate
* refetch. If Shopify says there is no cart but we already have lines in
* cache (optimistic or the mutation result), keep those lines.
*/
export function useCart() {
const query = useQuery<CartDetail | null>({
const qc = useQueryClient()
const query = useQuery({
queryKey: CART_QUERY_KEY,
queryFn: () => getCart(),
queryFn: async () => {
const cart = await getCart()
if (cart) return cart
const cached = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
if (cartHasLines(cached)) return cached ?? null
return null
},
staleTime: 30_000,
})

Expand All @@ -78,24 +105,6 @@ export function useCart() {
}
}

/**
* Snapshot of the product/variant from the PDP, passed through to
* onMutate so a full optimistic cart line can be rendered instantly.
*/
type AddToCartLineSnapshot = {
productTitle: string
productHandle: string
variantTitle: string
price: { amount: string; currencyCode: string }
image: {
url: string
altText?: string | null
width?: number | null
height?: number | null
} | null
selectedOptions: Array<{ name: string; value: string }>
}

type AddToCartInput = {
variantId: string
quantity?: number
Expand All @@ -107,73 +116,23 @@ export function useAddToCart() {
const qc = useQueryClient()

return useMutation({
mutationKey: CART_MUTATION_KEY,
mutationKey: CART_ADD_MUTATION_KEY,
mutationFn: (input: AddToCartInput) =>
addToCart({
data: { variantId: input.variantId, quantity: input.quantity ?? 1 },
}),

onMutate: async (input) => {
trackMutationStart()
const quantity = input.quantity ?? 1
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)

if (previous && input.line) {
const snap = input.line

// Does this variant already have a line in the cart?
const existingIdx = previous.lines.nodes.findIndex(
(l) => l.merchandise.id === input.variantId,
)

let nextLines: CartDetail['lines']['nodes']
if (existingIdx >= 0) {
nextLines = previous.lines.nodes.map((l, i) =>
i === existingIdx ? { ...l, quantity: l.quantity + quantity } : l,
)
} else {
const lineTotal = String(Number(snap.price.amount) * quantity)
nextLines = [
{
id: `optimistic-${Date.now()}`,
quantity,
merchandise: {
id: input.variantId,
title: snap.variantTitle,
availableForSale: true,
selectedOptions: snap.selectedOptions,
price: snap.price,
image: snap.image,
product: {
handle: snap.productHandle,
title: snap.productTitle,
},
},
cost: {
totalAmount: {
amount: lineTotal,
currencyCode: snap.price.currencyCode,
},
},
} as CartLineDetail,
...previous.lines.nodes,
]
}

qc.setQueryData<CartDetail | null>(CART_QUERY_KEY, {
...previous,
totalQuantity: nextLines.reduce((s, l) => s + l.quantity, 0),
lines: { ...previous.lines, nodes: nextLines },
})
} else if (previous) {
// No snapshot — fall back to just bumping the count
qc.setQueryData<CartDetail | null>(CART_QUERY_KEY, {
...previous,
totalQuantity: (previous.totalQuantity ?? 0) + quantity,
})
}

const next = applyOptimisticAddToCart(previous, {
variantId: input.variantId,
quantity: input.quantity ?? 1,
line: input.line,
})
qc.setQueryData(CART_QUERY_KEY, next)
openDrawerIfCartHasLines(next)
return { previous }
},

Expand All @@ -186,6 +145,7 @@ export function useAddToCart() {
// totals) with the real server response.
onSuccess: (cart) => {
qc.setQueryData(CART_QUERY_KEY, cart)
openDrawerIfCartHasLines(cart)
},

onSettled: () => settleWhenIdle(qc),
Expand All @@ -210,7 +170,7 @@ export function useUpdateCartLine() {
: line,
)
const nextQty = nextLines.reduce((sum, line) => sum + line.quantity, 0)
qc.setQueryData<CartDetail | null>(CART_QUERY_KEY, {
qc.setQueryData(CART_QUERY_KEY, {
...previous,
totalQuantity: nextQty,
lines: { ...previous.lines, nodes: nextLines },
Expand Down Expand Up @@ -243,7 +203,7 @@ export function useRemoveCartLine() {
(line) => line.id !== input.lineId,
)
const nextQty = nextLines.reduce((sum, line) => sum + line.quantity, 0)
qc.setQueryData<CartDetail | null>(CART_QUERY_KEY, {
qc.setQueryData(CART_QUERY_KEY, {
...previous,
totalQuantity: nextQty,
lines: { ...previous.lines, nodes: nextLines },
Expand Down Expand Up @@ -288,7 +248,7 @@ export function useRemoveDiscountCode() {
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
if (previous) {
qc.setQueryData<CartDetail | null>(CART_QUERY_KEY, {
qc.setQueryData(CART_QUERY_KEY, {
...previous,
discountCodes: [],
})
Expand Down
Loading
Loading