From 8930c8d5d585d4d6d35344821cf440af5ebabe25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:59:46 +0000 Subject: [PATCH 1/2] fix(shop): send /merch to /shop and show added cart lines The leftover /merch landing still linked Cotton Bureau and Sticker Mule and claimed items were sold at cost. Redirect it to the Shopify storefront. After add-to-cart, seed an optimistic cart when none exists and open the drawer only once that line is in cache so shoppers never see an empty drawer on a successful add. --- src/components/shop/CartDrawer.tsx | 57 ++++++++--- src/components/shop/ProductDrawer.tsx | 28 +----- src/components/shop/ProductImage.tsx | 1 + src/hooks/useCart.ts | 121 +++++++--------------- src/routes/merch.tsx | 139 +------------------------- src/routes/shop.products.$handle.tsx | 52 +++++----- src/utils/cart-optimistic.ts | 111 ++++++++++++++++++++ src/utils/shopify-queries.ts | 33 ++++++ tests/cart-optimistic.test.ts | 113 +++++++++++++++++++++ tests/merch-route.test.ts | 20 ++++ tests/shopify-variant.test.ts | 20 +++- 11 files changed, 414 insertions(+), 281 deletions(-) create mode 100644 src/utils/cart-optimistic.ts create mode 100644 tests/cart-optimistic.test.ts create mode 100644 tests/merch-route.test.ts diff --git a/src/components/shop/CartDrawer.tsx b/src/components/shop/CartDrawer.tsx index 2d6791170..1d1edb9e8 100644 --- a/src/components/shop/CartDrawer.tsx +++ b/src/components/shop/CartDrawer.tsx @@ -1,4 +1,5 @@ import { Link } from '@tanstack/react-router' +import { useIsMutating } from '@tanstack/react-query' import { MinusIcon, PlusIcon, @@ -6,7 +7,12 @@ import { TrashIcon, } from '@phosphor-icons/react' import { twMerge } from 'tailwind-merge' -import { useCart, useRemoveCartLine, useUpdateCartLine } from '~/hooks/useCart' +import { + CART_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' @@ -29,7 +35,9 @@ type CartDrawerProps = { */ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) { const { cart, totalQuantity } = useCart() + const mutating = useIsMutating({ mutationKey: CART_MUTATION_KEY }) const hasLines = !!cart && cart.lines.nodes.length > 0 + const awaitingCart = !hasLines && mutating > 0 return ( @@ -70,6 +78,8 @@ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) { onOpenChange(false)} /> + ) : awaitingCart ? ( + ) : ( onOpenChange(false)} /> )} @@ -78,6 +88,15 @@ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) { ) } +function CartPending() { + return ( +
+ +

Adding to cart…

+
+ ) +} + function CartEmpty({ onClose }: { onClose: () => void }) { return (
@@ -120,19 +139,31 @@ function CartFooter({

Shipping and taxes calculated at checkout.

- - Checkout - - → + {cart.checkoutUrl ? ( + + Checkout + + → + + + ) : ( + + Checkout - + )} , - selected: Record, -): 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, - selected: Record, -): ProductDetailVariant | undefined { - return variants.find((v) => - v.selectedOptions.every((o) => selected[o.name] === o.value), - ) -} - type ProductDrawerProps = { productHandle: string | null initialProduct?: ProductDetail | null @@ -352,7 +330,6 @@ function ProductPanel({ } const addToCart = useAddToCart() - const openCartDrawer = useCartDrawerStore((s) => s.openDrawer) React.useEffect(() => { if (!showAdded) return @@ -601,7 +578,6 @@ function ProductPanel({ onClick={() => { if (!selectedVariant) return setShowAdded(true) - openCartDrawer() addToCart.mutate({ variantId: selectedVariant.id, quantity, diff --git a/src/components/shop/ProductImage.tsx b/src/components/shop/ProductImage.tsx index ba2a87f33..77697d418 100644 --- a/src/components/shop/ProductImage.tsx +++ b/src/components/shop/ProductImage.tsx @@ -52,6 +52,7 @@ export function ProductImage({ return ( ) { } } +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({ + 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(CART_QUERY_KEY) + if (cartHasLines(cached)) return cached ?? null + return null + }, staleTime: 30_000, }) @@ -78,24 +100,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 @@ -115,65 +119,15 @@ export function useAddToCart() { onMutate: async (input) => { trackMutationStart() - const quantity = input.quantity ?? 1 await qc.cancelQueries({ queryKey: CART_QUERY_KEY }) const previous = qc.getQueryData(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(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(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 } }, @@ -186,6 +140,7 @@ export function useAddToCart() { // totals) with the real server response. onSuccess: (cart) => { qc.setQueryData(CART_QUERY_KEY, cart) + openDrawerIfCartHasLines(cart) }, onSettled: () => settleWhenIdle(qc), @@ -210,7 +165,7 @@ export function useUpdateCartLine() { : line, ) const nextQty = nextLines.reduce((sum, line) => sum + line.quantity, 0) - qc.setQueryData(CART_QUERY_KEY, { + qc.setQueryData(CART_QUERY_KEY, { ...previous, totalQuantity: nextQty, lines: { ...previous.lines, nodes: nextLines }, @@ -243,7 +198,7 @@ export function useRemoveCartLine() { (line) => line.id !== input.lineId, ) const nextQty = nextLines.reduce((sum, line) => sum + line.quantity, 0) - qc.setQueryData(CART_QUERY_KEY, { + qc.setQueryData(CART_QUERY_KEY, { ...previous, totalQuantity: nextQty, lines: { ...previous.lines, nodes: nextLines }, @@ -288,7 +243,7 @@ export function useRemoveDiscountCode() { await qc.cancelQueries({ queryKey: CART_QUERY_KEY }) const previous = qc.getQueryData(CART_QUERY_KEY) if (previous) { - qc.setQueryData(CART_QUERY_KEY, { + qc.setQueryData(CART_QUERY_KEY, { ...previous, discountCodes: [], }) diff --git a/src/routes/merch.tsx b/src/routes/merch.tsx index d47460ec4..6e5a5a0e5 100644 --- a/src/routes/merch.tsx +++ b/src/routes/merch.tsx @@ -1,138 +1,7 @@ -import { createFileRoute } from '@tanstack/react-router' -import * as React from 'react' -import { seo } from '~/utils/seo' -import { - TShirtIcon, - ShoppingBagIcon, - DeviceMobileIcon, - TagIcon, - CircleIcon, -} from '@phosphor-icons/react' -import { twMerge } from 'tailwind-merge' -import { BaseballCapIcon } from '~/components/icons/BaseballCapIcon' +import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/merch')({ - component: RouteComp, - head: () => ({ - meta: seo({ - title: 'TanStack Merch', - description: - 'Official TanStack merchandise including apparel and stickers.', - }), - }), -}) - -const merchItems = [ - { - name: 'Apparel', - description: - 'T-shirts, sweatshirts, hoodies, onesies, hats, totes, and phone cases featuring TanStack designs', - icons: [ - { Icon: TShirtIcon, label: 'T-shirts' }, - { Icon: BaseballCapIcon, label: 'Hats' }, - { Icon: ShoppingBagIcon, label: 'Totes' }, - { Icon: DeviceMobileIcon, label: 'Phone cases' }, - ], - href: 'https://cottonbureau.com/people/tanstack', - iconColor: 'text-blue-500', - borderColor: 'border-blue-500/50 hover:border-blue-500/70', - hoverShadow: 'hover:shadow-blue-500/20', - }, - { - name: 'Stickers & Buttons', - description: - 'High-quality vinyl stickers and small buttons for your laptop, water bottle, and more', - icons: [ - { Icon: TagIcon, label: 'Stickers' }, - { Icon: CircleIcon, label: 'Buttons' }, - ], - href: 'https://www.stickermule.com/tanstack', - iconColor: 'text-purple-500', - borderColor: 'border-purple-500/50 hover:border-purple-500/70', - hoverShadow: 'hover:shadow-purple-500/20', + beforeLoad: () => { + throw redirect({ to: '/shop', statusCode: 308 }) }, -] - -function RouteComp() { - return ( -
-
-
-

TanStack Merch

-

- Show your support for TanStack with official merchandise. All items - are sold at cost (supplier price + shipping) with no markup. We do - not pre-stock inventory and make no profit from merchandise sales. -

-
- -
- {merchItems.map((item, i) => ( - -
-
- {item.icons.map(({ Icon, label }, iconIdx) => ( -
- -
- ))} -
-

{item.name}

-

- {item.description} -

-
- - Shop {item.name} - - - - -
-
-
- ))} -
-
-
- ) -} +}) diff --git a/src/routes/shop.products.$handle.tsx b/src/routes/shop.products.$handle.tsx index 78d77da5f..33a781e6c 100644 --- a/src/routes/shop.products.$handle.tsx +++ b/src/routes/shop.products.$handle.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import { Link, createFileRoute, notFound } from '@tanstack/react-router' import { twMerge } from 'tailwind-merge' import { ProductImage } from '~/components/shop/ProductImage' -import { useCartDrawerStore } from '~/components/shop/cartDrawerStore' import { ShopNote } from '~/components/shop/ShopNote' import { ShopSpecs } from '~/components/shop/ShopSpecs' import { @@ -19,6 +18,8 @@ import { import { useAddToCart } from '~/hooks/useCart' import { getProduct } from '~/utils/shop.functions' import { + findExactVariant, + findMatchingVariant, hasAvailableVariant, type ProductDetail, type ProductDetailVariant, @@ -85,29 +86,36 @@ export function ProductPage({ ) const [quantity, setQuantity] = React.useState(1) - const selectedVariant = findMatchingVariant(variants, selected) - const variantImage = selectedVariant?.image ?? null - const initialImageIndex = React.useMemo(() => { + const selectedVariant = findExactVariant(variants, selected) + const variantForImage = findMatchingVariant(variants, selected) + const variantImage = variantForImage?.image ?? null + const matchingImageIndex = React.useMemo(() => { if (!variantImage) return 0 const i = product.images.nodes.findIndex( (img) => img.url === variantImage.url, ) return i }, [variantImage, product.images.nodes]) - const [activeImageIndex, setActiveImageIndex] = - React.useState(initialImageIndex) + const [activeImageIndex, setActiveImageIndex] = React.useState(() => + matchingImageIndex >= 0 ? matchingImageIndex : 0, + ) React.useEffect(() => { - setActiveImageIndex(initialImageIndex) - }, [initialImageIndex, selectedVariant?.id]) + if (matchingImageIndex >= 0) setActiveImageIndex(matchingImageIndex) + }, [matchingImageIndex, variantForImage?.id]) const heroImage = + (matchingImageIndex < 0 ? variantImage : null) ?? product.images.nodes[activeImageIndex] ?? variantImage ?? product.images.nodes[0] ?? null - const displayPrice = selectedVariant?.price ?? variants[0]?.price ?? null + const displayPrice = + selectedVariant?.price ?? + variantForImage?.price ?? + variants[0]?.price ?? + null const inStock = selectedVariant ? selectedVariant.availableForSale : variants.some((variant) => variant.availableForSale) @@ -237,6 +245,7 @@ function ProductGallery({
{option.name} - - {selected[option.name]} - + {selected[option.name] ? ( + + {selected[option.name]} + + ) : !isEnabled && optionIndex > 0 ? ( + + Pick a{' '} + {selectableOptions[optionIndex - 1]?.name.toLowerCase()}{' '} + first + + ) : null} {shouldUseSelect ? ( @@ -417,7 +434,6 @@ function QuantityAdd({ product: ProductDetail }) { const addToCart = useAddToCart() - const openDrawer = useCartDrawerStore((s) => s.openDrawer) const [showAdded, setShowAdded] = React.useState(false) const disabled = @@ -456,7 +472,6 @@ function QuantityAdd({ onClick={() => { if (!variant) return setShowAdded(true) - openDrawer() addToCart.mutate({ variantId: variant.id, quantity, @@ -508,15 +523,6 @@ function ProductDescription({ html }: { html: string }) { ) } -function findMatchingVariant( - variants: Array, - selected: Record, -): ProductDetailVariant | undefined { - return variants.find((v) => - v.selectedOptions.every((opt) => selected[opt.name] === opt.value), - ) -} - function ProductJsonLd({ product, selectedVariant, diff --git a/src/utils/cart-optimistic.ts b/src/utils/cart-optimistic.ts new file mode 100644 index 000000000..b9adba902 --- /dev/null +++ b/src/utils/cart-optimistic.ts @@ -0,0 +1,111 @@ +import type { + CartDetail, + CartLineDetail, + CartLineMerchandise, +} from '~/utils/shopify-queries' + +export type AddToCartLineSnapshot = { + productTitle: string + productHandle: string + variantTitle: string + price: CartLineMerchandise['price'] + image: CartLineMerchandise['image'] + selectedOptions: CartLineMerchandise['selectedOptions'] +} + +function buildOptimisticLine( + variantId: string, + quantity: number, + snap: AddToCartLineSnapshot, +): CartLineDetail { + const lineTotal = String(Number(snap.price.amount) * quantity) + return { + id: `optimistic-${variantId}`, + quantity, + merchandise: { + id: 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, + }, + }, + } +} + +function emptyOptimisticCart(line: CartLineDetail): CartDetail { + const money = line.cost.totalAmount + return { + id: 'optimistic-cart', + checkoutUrl: '', + totalQuantity: line.quantity, + cost: { + totalAmount: money, + subtotalAmount: money, + totalTaxAmount: null, + }, + lines: { nodes: [line] }, + discountCodes: [], + } +} + +/** + * Apply an add-to-cart to a cached cart. Seeds a cart when none exists so + * the drawer can render the new line before Shopify responds. + */ +export function applyOptimisticAddToCart( + previous: CartDetail | null | undefined, + input: { + variantId: string + quantity: number + line?: AddToCartLineSnapshot + }, +): CartDetail | null { + const { variantId, quantity, line: snap } = input + + if (!snap) { + if (!previous) return previous ?? null + return { + ...previous, + totalQuantity: (previous.totalQuantity ?? 0) + quantity, + } + } + + const newLine = buildOptimisticLine(variantId, quantity, snap) + if (!previous) return emptyOptimisticCart(newLine) + + const existingIdx = previous.lines.nodes.findIndex( + (existing) => existing.merchandise.id === variantId, + ) + const nextLines = + existingIdx >= 0 + ? previous.lines.nodes.map((existing, index) => + index === existingIdx + ? { ...existing, quantity: existing.quantity + quantity } + : existing, + ) + : [newLine, ...previous.lines.nodes] + + return { + ...previous, + totalQuantity: nextLines.reduce( + (sum, existing) => sum + existing.quantity, + 0, + ), + lines: { ...previous.lines, nodes: nextLines }, + } +} + +export function cartHasLines(cart: CartDetail | null | undefined) { + return !!cart && cart.lines.nodes.length > 0 +} diff --git a/src/utils/shopify-queries.ts b/src/utils/shopify-queries.ts index b7a4b4359..d3e005caf 100644 --- a/src/utils/shopify-queries.ts +++ b/src/utils/shopify-queries.ts @@ -271,6 +271,39 @@ export function hasAvailableVariant( ) } +/** + * First variant that matches every chosen option. Unchosen options (missing + * or empty string) are wildcards, so a color pick can resolve an image + * before size is chosen. + */ +export function findMatchingVariant< + TVariant extends Pick, +>( + variants: Array, + selected: Record, +): TVariant | undefined { + return variants.find((variant) => + variant.selectedOptions.every( + (option) => + !selected[option.name] || selected[option.name] === option.value, + ), + ) +} + +/** Variant whose options all equal the current selection. No wildcards. */ +export function findExactVariant< + TVariant extends Pick, +>( + variants: Array, + selected: Record, +): TVariant | undefined { + return variants.find((variant) => + variant.selectedOptions.every( + (option) => selected[option.name] === option.value, + ), + ) +} + export type ProductDetail = Pick< Product, 'id' | 'handle' | 'title' | 'descriptionHtml' diff --git a/tests/cart-optimistic.test.ts b/tests/cart-optimistic.test.ts new file mode 100644 index 000000000..4bf0ff735 --- /dev/null +++ b/tests/cart-optimistic.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { applyOptimisticAddToCart } from '../src/utils/cart-optimistic' +import type { CartDetail } from '../src/utils/shopify-queries' + +const line = { + productTitle: 'Classic Hoodie', + productHandle: 'classic-hoodie', + variantTitle: 'Black / L', + price: { amount: '48.00', currencyCode: 'USD' }, + image: { + url: 'https://cdn.shopify.com/hoodie.jpg', + altText: 'Classic Hoodie', + width: 800, + height: 800, + }, + selectedOptions: [ + { name: 'Color', value: 'Black' }, + { name: 'Size', value: 'L' }, + ], +} + +test('first add seeds a cart line when no cart exists', () => { + const next = applyOptimisticAddToCart(null, { + variantId: 'gid://shopify/ProductVariant/1', + quantity: 1, + line, + }) + + assert.ok(next) + assert.equal(next.totalQuantity, 1) + assert.equal(next.lines.nodes.length, 1) + assert.equal(next.lines.nodes[0]?.merchandise.product.title, 'Classic Hoodie') + assert.equal( + next.lines.nodes[0]?.merchandise.id, + 'gid://shopify/ProductVariant/1', + ) +}) + +test('adding the same variant increments quantity', () => { + const first = applyOptimisticAddToCart(null, { + variantId: 'gid://shopify/ProductVariant/1', + quantity: 1, + line, + }) + const next = applyOptimisticAddToCart(first, { + variantId: 'gid://shopify/ProductVariant/1', + quantity: 2, + line, + }) + + assert.ok(next) + assert.equal(next.lines.nodes.length, 1) + assert.equal(next.lines.nodes[0]?.quantity, 3) + assert.equal(next.totalQuantity, 3) +}) + +test('adding a different variant prepends a new line', () => { + const existing: CartDetail = { + id: 'gid://shopify/Cart/1', + checkoutUrl: 'https://checkout.example/cart', + totalQuantity: 1, + cost: { + totalAmount: { amount: '28.00', currencyCode: 'USD' }, + subtotalAmount: { amount: '28.00', currencyCode: 'USD' }, + totalTaxAmount: null, + }, + lines: { + nodes: [ + { + id: 'gid://shopify/CartLine/tee', + quantity: 1, + merchandise: { + id: 'gid://shopify/ProductVariant/tee', + title: 'White / M', + availableForSale: true, + selectedOptions: [ + { name: 'Color', value: 'White' }, + { name: 'Size', value: 'M' }, + ], + price: { amount: '28.00', currencyCode: 'USD' }, + image: null, + product: { handle: 'classic-tee', title: 'Classic Tee' }, + }, + cost: { + totalAmount: { amount: '28.00', currencyCode: 'USD' }, + }, + }, + ], + }, + discountCodes: [], + } + + const next = applyOptimisticAddToCart(existing, { + variantId: 'gid://shopify/ProductVariant/1', + quantity: 1, + line, + }) + + assert.ok(next) + assert.equal(next.lines.nodes.length, 2) + assert.equal(next.lines.nodes[0]?.merchandise.product.title, 'Classic Hoodie') + assert.equal(next.totalQuantity, 2) + assert.equal(next.checkoutUrl, existing.checkoutUrl) +}) + +test('without a line snapshot, an empty cache stays empty', () => { + const next = applyOptimisticAddToCart(null, { + variantId: 'gid://shopify/ProductVariant/1', + quantity: 1, + }) + assert.equal(next, null) +}) diff --git a/tests/merch-route.test.ts b/tests/merch-route.test.ts new file mode 100644 index 000000000..c7f74b3af --- /dev/null +++ b/tests/merch-route.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +const source = readFileSync( + new URL('../src/routes/merch.tsx', import.meta.url), + 'utf8', +) + +test('/merch permanently redirects to the Shopify shop', () => { + assert.match(source, /redirect\(\{\s*to:\s*'\/shop'/) + assert.match(source, /statusCode:\s*308/) +}) + +test('/merch no longer sends shoppers to Cotton Bureau or Sticker Mule', () => { + assert.equal(/cottonbureau/i.test(source), false) + assert.equal(/stickermule/i.test(source), false) + assert.equal(/sold at cost/i.test(source), false) + assert.equal(/no profit/i.test(source), false) +}) diff --git a/tests/shopify-variant.test.ts b/tests/shopify-variant.test.ts index a94bae49a..12fb9114c 100644 --- a/tests/shopify-variant.test.ts +++ b/tests/shopify-variant.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { hasAvailableVariant } from '../src/utils/shopify-queries' +import { + findExactVariant, + findMatchingVariant, + hasAvailableVariant, +} from '../src/utils/shopify-queries' const variants = [ { @@ -44,3 +48,17 @@ test('complete selections only match the selected variant', () => { test('partial selections are unavailable when all matches are sold out', () => { assert.equal(hasAvailableVariant(variants, { Color: 'Blue' }), false) }) + +test('wildcard matching resolves a variant from color alone', () => { + const match = findMatchingVariant(variants, { Color: 'Black' }) + assert.equal(match?.selectedOptions[0]?.value, 'Black') +}) + +test('exact matching requires every option to be chosen', () => { + assert.equal(findExactVariant(variants, { Color: 'Black' }), undefined) + assert.equal( + findExactVariant(variants, { Color: 'Black', Size: 'Large' }) + ?.selectedOptions[1]?.value, + 'Large', + ) +}) From 8d5fc26480ece3fa1093a00bbd053f17763e7067 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 15:02:56 +0000 Subject: [PATCH 2/2] fix(shop): keep add-to-cart disabled while pending Disable add controls for the full isPending interval so a second click cannot create a competing cart. Isolate add mutations from the drawer pending state, recalculate optimistic line/cart money in minor units, and clear checkoutUrl until Shopify confirms. --- src/components/shop/CartDrawer.tsx | 4 +- src/components/shop/ProductDrawer.tsx | 2 +- src/hooks/useCart.ts | 15 ++++-- src/routes/shop.products.$handle.tsx | 3 +- src/utils/cart-optimistic.ts | 68 +++++++++++++++++++++--- tests/add-to-cart-pending.test.ts | 37 +++++++++++++ tests/cart-optimistic.test.ts | 76 ++++++++++++++++++++++++++- 7 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 tests/add-to-cart-pending.test.ts diff --git a/src/components/shop/CartDrawer.tsx b/src/components/shop/CartDrawer.tsx index 1d1edb9e8..641183bf9 100644 --- a/src/components/shop/CartDrawer.tsx +++ b/src/components/shop/CartDrawer.tsx @@ -8,7 +8,7 @@ import { } from '@phosphor-icons/react' import { twMerge } from 'tailwind-merge' import { - CART_MUTATION_KEY, + CART_ADD_MUTATION_KEY, useCart, useRemoveCartLine, useUpdateCartLine, @@ -35,7 +35,7 @@ type CartDrawerProps = { */ export function CartDrawer({ open, onOpenChange }: CartDrawerProps) { const { cart, totalQuantity } = useCart() - const mutating = useIsMutating({ mutationKey: CART_MUTATION_KEY }) + const mutating = useIsMutating({ mutationKey: CART_ADD_MUTATION_KEY }) const hasLines = !!cart && cart.lines.nodes.length > 0 const awaitingCart = !hasLines && mutating > 0 diff --git a/src/components/shop/ProductDrawer.tsx b/src/components/shop/ProductDrawer.tsx index 2d7218309..9d88c7c0c 100644 --- a/src/components/shop/ProductDrawer.tsx +++ b/src/components/shop/ProductDrawer.tsx @@ -573,7 +573,7 @@ function ProductPanel({ disabled={ !isComplete || !selectedVariant?.availableForSale || - (addToCart.isPending && !showAdded) + addToCart.isPending } onClick={() => { if (!selectedVariant) return diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts index 25ede1c7d..c80fac40a 100644 --- a/src/hooks/useCart.ts +++ b/src/hooks/useCart.ts @@ -25,13 +25,18 @@ import type { CartDetail } 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 - * in flight before triggering a background refetch, and by the cart - * drawer to avoid flashing the empty state during a successful add. + * 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. */ 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()` * because its exact semantics at `onSettled` time (does it still count the @@ -111,7 +116,7 @@ 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 }, diff --git a/src/routes/shop.products.$handle.tsx b/src/routes/shop.products.$handle.tsx index 33a781e6c..3709c0b2f 100644 --- a/src/routes/shop.products.$handle.tsx +++ b/src/routes/shop.products.$handle.tsx @@ -436,8 +436,7 @@ function QuantityAdd({ const addToCart = useAddToCart() const [showAdded, setShowAdded] = React.useState(false) - const disabled = - !variant || !variant.availableForSale || (addToCart.isPending && !showAdded) + const disabled = !variant || !variant.availableForSale || addToCart.isPending const price = variant?.price const label = showAdded diff --git a/src/utils/cart-optimistic.ts b/src/utils/cart-optimistic.ts index b9adba902..728a832a8 100644 --- a/src/utils/cart-optimistic.ts +++ b/src/utils/cart-optimistic.ts @@ -13,12 +13,42 @@ export type AddToCartLineSnapshot = { selectedOptions: CartLineMerchandise['selectedOptions'] } +function moneyMinorUnits(amount: string) { + const dot = amount.indexOf('.') + if (dot === -1) return { units: BigInt(amount), scale: 0 } + const fraction = amount.slice(dot + 1) + return { + units: BigInt(amount.slice(0, dot) + fraction), + scale: fraction.length, + } +} + +function formatMinorUnits(units: bigint, scale: number) { + if (scale === 0) return String(units) + const digits = units.toString().padStart(scale + 1, '0') + const splitAt = digits.length - scale + return `${digits.slice(0, splitAt)}.${digits.slice(splitAt)}` +} + +function multiplyMoney(amount: string, quantity: number) { + const { units, scale } = moneyMinorUnits(amount) + return formatMinorUnits(units * BigInt(quantity), scale) +} + +function addMoney(left: string, right: string) { + const a = moneyMinorUnits(left) + const b = moneyMinorUnits(right) + const scale = Math.max(a.scale, b.scale) + const leftUnits = a.units * 10n ** BigInt(scale - a.scale) + const rightUnits = b.units * 10n ** BigInt(scale - b.scale) + return formatMinorUnits(leftUnits + rightUnits, scale) +} + function buildOptimisticLine( variantId: string, quantity: number, snap: AddToCartLineSnapshot, ): CartLineDetail { - const lineTotal = String(Number(snap.price.amount) * quantity) return { id: `optimistic-${variantId}`, quantity, @@ -36,7 +66,7 @@ function buildOptimisticLine( }, cost: { totalAmount: { - amount: lineTotal, + amount: multiplyMoney(snap.price.amount, quantity), currencyCode: snap.price.currencyCode, }, }, @@ -77,6 +107,7 @@ export function applyOptimisticAddToCart( if (!previous) return previous ?? null return { ...previous, + checkoutUrl: '', totalQuantity: (previous.totalQuantity ?? 0) + quantity, } } @@ -89,19 +120,42 @@ export function applyOptimisticAddToCart( ) const nextLines = existingIdx >= 0 - ? previous.lines.nodes.map((existing, index) => - index === existingIdx - ? { ...existing, quantity: existing.quantity + quantity } - : existing, - ) + ? previous.lines.nodes.map((existing, index) => { + if (index !== existingIdx) return existing + const nextQty = existing.quantity + quantity + return { + ...existing, + quantity: nextQty, + cost: { + totalAmount: { + amount: multiplyMoney( + existing.merchandise.price.amount, + nextQty, + ), + currencyCode: existing.cost.totalAmount.currencyCode, + }, + }, + } + }) : [newLine, ...previous.lines.nodes] + const currencyCode = previous.cost.totalAmount.currencyCode + const summed = nextLines.reduce( + (sum, existing) => addMoney(sum, existing.cost.totalAmount.amount), + '0', + ) return { ...previous, + checkoutUrl: '', totalQuantity: nextLines.reduce( (sum, existing) => sum + existing.quantity, 0, ), + cost: { + ...previous.cost, + totalAmount: { amount: summed, currencyCode }, + subtotalAmount: { amount: summed, currencyCode }, + }, lines: { ...previous.lines, nodes: nextLines }, } } diff --git a/tests/add-to-cart-pending.test.ts b/tests/add-to-cart-pending.test.ts new file mode 100644 index 000000000..32b6b6874 --- /dev/null +++ b/tests/add-to-cart-pending.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +const productPage = readFileSync( + new URL('../src/routes/shop.products.$handle.tsx', import.meta.url), + 'utf8', +) +const productDrawer = readFileSync( + new URL('../src/components/shop/ProductDrawer.tsx', import.meta.url), + 'utf8', +) +const cartDrawer = readFileSync( + new URL('../src/components/shop/CartDrawer.tsx', import.meta.url), + 'utf8', +) +const useCart = readFileSync( + new URL('../src/hooks/useCart.ts', import.meta.url), + 'utf8', +) + +test('add controls stay disabled for the full addToCart.isPending interval', () => { + for (const source of [productPage, productDrawer]) { + assert.equal(source.includes('isPending && !showAdded'), false) + assert.match(source, /addToCart\.isPending/) + } +}) + +test('cart drawer pending state tracks add-to-cart mutations only', () => { + assert.match(useCart, /CART_ADD_MUTATION_KEY = \['shopify', 'cart', 'add'\]/) + assert.match(useCart, /mutationKey: CART_ADD_MUTATION_KEY/) + assert.match( + cartDrawer, + /useIsMutating\(\{ mutationKey: CART_ADD_MUTATION_KEY \}\)/, + ) + assert.equal(cartDrawer.includes('CART_MUTATION_KEY'), false) +}) diff --git a/tests/cart-optimistic.test.ts b/tests/cart-optimistic.test.ts index 4bf0ff735..10e38621e 100644 --- a/tests/cart-optimistic.test.ts +++ b/tests/cart-optimistic.test.ts @@ -35,6 +35,11 @@ test('first add seeds a cart line when no cart exists', () => { next.lines.nodes[0]?.merchandise.id, 'gid://shopify/ProductVariant/1', ) + assert.equal(next.checkoutUrl, '') + assert.equal(next.lines.nodes[0]?.cost.totalAmount.amount, '48.00') + assert.equal(next.cost.totalAmount.amount, '48.00') + assert.equal(next.cost.subtotalAmount.amount, '48.00') + assert.equal(next.cost.totalTaxAmount, null) }) test('adding the same variant increments quantity', () => { @@ -53,6 +58,10 @@ test('adding the same variant increments quantity', () => { assert.equal(next.lines.nodes.length, 1) assert.equal(next.lines.nodes[0]?.quantity, 3) assert.equal(next.totalQuantity, 3) + assert.equal(next.checkoutUrl, '') + assert.equal(next.lines.nodes[0]?.cost.totalAmount.amount, '144.00') + assert.equal(next.cost.totalAmount.amount, '144.00') + assert.equal(next.cost.subtotalAmount.amount, '144.00') }) test('adding a different variant prepends a new line', () => { @@ -101,7 +110,26 @@ test('adding a different variant prepends a new line', () => { assert.equal(next.lines.nodes.length, 2) assert.equal(next.lines.nodes[0]?.merchandise.product.title, 'Classic Hoodie') assert.equal(next.totalQuantity, 2) - assert.equal(next.checkoutUrl, existing.checkoutUrl) + assert.equal(next.checkoutUrl, '') + assert.equal(next.cost.totalAmount.amount, '76.00') + assert.equal(next.cost.subtotalAmount.amount, '76.00') +}) + +test('line and cart totals stay decimal-safe', () => { + const cheap = { + ...line, + price: { amount: '19.99', currencyCode: 'USD' }, + } + const next = applyOptimisticAddToCart(null, { + variantId: 'gid://shopify/ProductVariant/cheap', + quantity: 2, + line: cheap, + }) + + assert.ok(next) + assert.equal(next.lines.nodes[0]?.cost.totalAmount.amount, '39.98') + assert.equal(next.cost.totalAmount.amount, '39.98') + assert.equal(next.cost.subtotalAmount.amount, '39.98') }) test('without a line snapshot, an empty cache stays empty', () => { @@ -111,3 +139,49 @@ test('without a line snapshot, an empty cache stays empty', () => { }) assert.equal(next, null) }) + +test('without a line snapshot, checkoutUrl is cleared on an existing cart', () => { + const existing: CartDetail = { + id: 'gid://shopify/Cart/1', + checkoutUrl: 'https://checkout.example/cart', + totalQuantity: 1, + cost: { + totalAmount: { amount: '28.00', currencyCode: 'USD' }, + subtotalAmount: { amount: '28.00', currencyCode: 'USD' }, + totalTaxAmount: null, + }, + lines: { + nodes: [ + { + id: 'gid://shopify/CartLine/tee', + quantity: 1, + merchandise: { + id: 'gid://shopify/ProductVariant/tee', + title: 'White / M', + availableForSale: true, + selectedOptions: [ + { name: 'Color', value: 'White' }, + { name: 'Size', value: 'M' }, + ], + price: { amount: '28.00', currencyCode: 'USD' }, + image: null, + product: { handle: 'classic-tee', title: 'Classic Tee' }, + }, + cost: { + totalAmount: { amount: '28.00', currencyCode: 'USD' }, + }, + }, + ], + }, + discountCodes: [], + } + + const next = applyOptimisticAddToCart(existing, { + variantId: 'gid://shopify/ProductVariant/1', + quantity: 1, + }) + + assert.ok(next) + assert.equal(next.checkoutUrl, '') + assert.equal(next.totalQuantity, 2) +})