diff --git a/src/components/shop/ProductCard.tsx b/src/components/shop/ProductCard.tsx index b9899bd0..dec10234 100644 --- a/src/components/shop/ProductCard.tsx +++ b/src/components/shop/ProductCard.tsx @@ -150,7 +150,7 @@ export function ProductCard({ className=" group flex flex-col min-w-[340px] max-w-[400px] w-full rounded-xl border border-transparent bg-transparent - hover:bg-shop-bg-2 hover:border-shop-line-2 + hover:bg-[#EFEFE3] dark:hover:bg-shop-bg-2 hover:border-shop-line-2 transition-[border-color,background-color] duration-200 px-[22px] pt-7 pb-5 " diff --git a/src/components/shop/ProductDrawer.tsx b/src/components/shop/ProductDrawer.tsx index 4a8e2470..054c7b6d 100644 --- a/src/components/shop/ProductDrawer.tsx +++ b/src/components/shop/ProductDrawer.tsx @@ -145,18 +145,37 @@ export function ProductDrawer({ const isOpen = !!productHandle // Keep the last-known handle alive through the exit animation so the drawer - // slides out with content visible (not empty). Cleared after 400ms — just - // past the 380ms transition — so the body unmounts cleanly after exit. + // slides out with content visible (not empty). Uses the derived-state pattern + // so displayHandle is updated synchronously on open (no empty-frame flash). const [displayHandle, setDisplayHandle] = React.useState(null) + const [prevProductHandle, setPrevProductHandle] = React.useState< + string | null + >(null) + if (productHandle !== prevProductHandle) { + setPrevProductHandle(productHandle) + if (productHandle) setDisplayHandle(productHandle) + } + + // Clear displayHandle after exit animation completes React.useEffect(() => { - if (productHandle) { - setDisplayHandle(productHandle) - } else { + if (!productHandle) { const t = setTimeout(() => setDisplayHandle(null), 400) return () => clearTimeout(t) } }, [productHandle]) + // Pre-fetch product data so the drawer only animates open once content is ready, + // preventing the skeleton flash. Same query key as DrawerBody so cache is shared. + const { data: prefetchedProduct } = useQuery({ + queryKey: ['shopify', 'product', displayHandle ?? ''], + queryFn: () => getProduct({ data: { handle: displayHandle! } }), + enabled: !!displayHandle, + staleTime: 5 * 60 * 1000, + }) + + // Delay the open animation until data is in cache. Close animates immediately. + const isAnimatedOpen = isOpen && !!prefetchedProduct + const effectiveWidth = width const navigateStep = React.useCallback( @@ -222,11 +241,11 @@ export function ProductDrawer({ + + {open ? ( +
+ {options.map((opt) => { + const isSelected = opt.value === value + const isFocused = opt.value === focused + return ( + + ) + })} +
+ ) : null} + + ) +} diff --git a/src/components/shop/ui/Tab.tsx b/src/components/shop/ui/Tab.tsx index fc54165c..bc0f6dd8 100644 --- a/src/components/shop/ui/Tab.tsx +++ b/src/components/shop/ui/Tab.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { twMerge } from 'tailwind-merge' type Props = React.ButtonHTMLAttributes & { isActive?: boolean @@ -11,18 +12,18 @@ export const ShopTab = React.forwardRef( { isActive, count: _count, children, className, ...rest }, ref, ) { - const base = - 'inline-flex items-center px-4 py-2.5 rounded-xl font-shop-mono text-shop-ui transition-colors cursor-pointer' - const state = isActive - ? 'border-2 border-shop-muted font-medium text-shop-text' - : 'border border-shop-line-2 font-normal text-shop-text-2 hover:text-shop-text hover:border-shop-muted' - return ( diff --git a/src/routes/shop.index.tsx b/src/routes/shop.index.tsx index 30ed18dd..77c872ab 100644 --- a/src/routes/shop.index.tsx +++ b/src/routes/shop.index.tsx @@ -124,97 +124,109 @@ function ShopIndex() { : allProducts.filter((p) => p.productType?.toLowerCase() === activeType) return ( -
-
- - Built in public,
- worn in production. - - } - lede="Official TanStack apparel, accessories, and stickers. Limited runs, ethically produced, shipped worldwide. Rep the libraries that ship your code every day." - /> +
+ {/* Hero */} +
+
+ + Built in public,
+ worn in production. + + } + lede="Official TanStack apparel, accessories, and stickers. Limited runs, ethically produced, shipped worldwide. Rep the libraries that ship your code every day." + /> +
-
- - navigate({ - to: '/shop', - search: (prev) => ({ ...prev, type: undefined }), - }) - } - > - All - - {typeOptions.map((opt) => ( + {/* Sticky filter + sort bar */} +
+
navigate({ to: '/shop', - search: (prev) => ({ ...prev, type: opt.key }), + search: (prev) => ({ ...prev, type: undefined }), }) } > - {opt.display} + All - ))} - { - const nextId = e.target.value as ValidSortId - navigate({ - to: '/shop', - search: (prev) => ({ - ...prev, - sort: nextId === 'BEST_SELLING' ? undefined : nextId, - }), - }) - }} - > - {SORT_OPTIONS.map((opt) => ( - + {typeOptions.map((opt) => ( + + navigate({ + to: '/shop', + search: (prev) => ({ ...prev, type: opt.key }), + }) + } + > + {opt.display} + ))} - + { + const nextId = e.target.value as ValidSortId + navigate({ + to: '/shop', + search: (prev) => ({ + ...prev, + sort: nextId === 'BEST_SELLING' ? undefined : nextId, + }), + }) + }} + > + {SORT_OPTIONS.map((opt) => ( + + ))} + +
- {products.length === 0 ? ( -
- No products yet. Check back soon! -
- ) : ( - <> -
- {products.map((product, i) => ( - - ))} -
- {hasNextPage ? ( -
- loadMore.mutate()} - disabled={loadMore.isPending} - > - {loadMore.isPending ? 'Loading…' : 'Load more'} - -
- ) : null} - - )} + {/* Product grid */} +
+ {products.length === 0 ? ( +
+ No products yet. Check back soon! +
+ ) : ( + <> +
+ {products.map((product, i) => ( + + ))} +
+ {hasNextPage ? ( +
+ loadMore.mutate()} + disabled={loadMore.isPending} + > + {loadMore.isPending ? 'Loading…' : 'Load more'} + +
+ ) : null} + + )} +