diff --git a/src/components/shop/CartDrawer.tsx b/src/components/shop/CartDrawer.tsx index 2d6791170..641183bf9 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_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' @@ -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 ( @@ -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 @@ -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, 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 +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 @@ -107,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 }, @@ -115,65 +124,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 +145,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 +170,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 +203,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 +248,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..3709c0b2f 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,11 +434,9 @@ function QuantityAdd({ product: ProductDetail }) { const addToCart = useAddToCart() - const openDrawer = useCartDrawerStore((s) => s.openDrawer) 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 @@ -456,7 +471,6 @@ function QuantityAdd({ onClick={() => { if (!variant) return setShowAdded(true) - openDrawer() addToCart.mutate({ variantId: variant.id, quantity, @@ -508,15 +522,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..728a832a8 --- /dev/null +++ b/src/utils/cart-optimistic.ts @@ -0,0 +1,165 @@ +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 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 { + 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: multiplyMoney(snap.price.amount, quantity), + 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, + checkoutUrl: '', + 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) => { + 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 }, + } +} + +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/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 new file mode 100644 index 000000000..10e38621e --- /dev/null +++ b/tests/cart-optimistic.test.ts @@ -0,0 +1,187 @@ +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', + ) + 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', () => { + 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) + 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', () => { + 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, '') + 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', () => { + const next = applyOptimisticAddToCart(null, { + variantId: 'gid://shopify/ProductVariant/1', + quantity: 1, + }) + 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) +}) 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', + ) +})