From 9642888c8070ce91063f6b91fd622524b55abc7c Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 15 Apr 2026 15:20:26 -0600 Subject: [PATCH] feat(shop): collections, search, policy pages, PDP gallery + qty, cart drawer, discount codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the shop up to parity with a standard Shopify storefront: **New routes** - /shop/collections/$handle: collection listings with sort + cursor pagination, SSR, SEO meta, breadcrumbs - /shop/pages/$handle: renders Shopify admin-managed pages (shipping, returns, privacy, terms, about, etc.). Sidebar now links to the four standard policies out of the box. - /shop/search: Storefront API search with cursor pagination, URL-driven query param, accessible search form **Listing enhancements** - Sort dropdown on /shop and /shop/collections/* wired to URL search params (SSR-correct, shareable, back/forward friendly) - Load More cursor-based pagination on all listings (first page always comes from the loader; subsequent pages accumulate client-side) - Responsive images via a shared ProductImage primitive: srcset with 1x / 1.5x / 2x / 3x widths bounded by the image's intrinsic width, correct sizes hint, lazy-loaded past the fold, eager for the first row **PDP** - Image gallery with thumbnails; click swaps the active image, selected variant auto-focuses its own image - Quantity stepper integrated into Add to cart - Variant availability awareness: option buttons for combinations that don't resolve to an available variant get a line-through + dimmed treatment (clicks still allowed to surface the sold-out state) - JSON-LD schema.org Product block for rich search results (single offer or AggregateOffer based on variant count) - Per-product OG image via Shopify CDN transforms (1200x630 jpg, center crop) so social shares get the product photo, not the generic card - Breadcrumbs: Shop > **Cart** - Discount code form in the summary panel (applyDiscountCode / removeDiscountCode server fns with valibot validation + invalid-code detection, since Shopify silently drops unknown codes) - Slide-in cart drawer (Radix Dialog) controlled by a zustand store so any component can trigger it. Add to cart on the PDP now opens the drawer on success for instant feedback without losing the PDP scroll - Navbar cart button switched from Link to drawer trigger; visibility rules unchanged (always on /shop/*, site-wide when cart has items) - Cart page gains breadcrumbs + discount code UI **Shared components** - src/components/shop/ProductCard (with compare-at-price strike-through and "from $X" when variants span a price range) - src/components/shop/ProductImage (responsive srcset via Shopify CDN transforms, pure HTML — no hydrogen-react Provider needed) - src/components/shop/Breadcrumbs (accessible nav with aria-current) - src/components/shop/CartDrawer - src/components/shop/cartDrawerStore (zustand) **Queries** - Shared ProductCard GraphQL fragment reused across products list, collection products, and search results - New: COLLECTION_QUERY, PAGE_QUERY, SEARCH_QUERY, CART_DISCOUNT_CODES_UPDATE_MUTATION - Cart fragment now includes discountCodes - Products query now takes cursor + sort args **Sidebar** - Adds Search link, Info section (Shipping/Returns/Privacy/Terms) - Sidebar renders Shopify Collections live via the parent /shop loader Nothing here requires new env vars. Policy pages render whatever Shopify's admin has configured under Online Store > Pages with matching handles; a missing page falls through to the existing 404. --- src/components/NavbarCartButton.tsx | 14 +- src/components/shop/Breadcrumbs.tsx | 52 ++++ src/components/shop/CartDrawer.tsx | 240 ++++++++++++++++ src/components/shop/ProductCard.tsx | 64 +++++ src/components/shop/ProductImage.tsx | 66 +++++ src/components/shop/ShopLayout.tsx | 40 +++ src/components/shop/cartDrawerStore.ts | 19 ++ src/hooks/useCart.ts | 44 +++ src/routeTree.gen.ts | 63 +++++ src/routes/shop.cart.tsx | 98 ++++++- src/routes/shop.collections.$handle.tsx | 187 ++++++++++++ src/routes/shop.index.tsx | 188 ++++++++---- src/routes/shop.pages.$handle.tsx | 48 ++++ src/routes/shop.products.$handle.tsx | 361 ++++++++++++++++++++---- src/routes/shop.search.tsx | 153 ++++++++++ src/utils/shop.functions.ts | 192 ++++++++++++- src/utils/shopify-queries.ts | 300 ++++++++++++++++++-- 17 files changed, 1986 insertions(+), 143 deletions(-) create mode 100644 src/components/shop/Breadcrumbs.tsx create mode 100644 src/components/shop/CartDrawer.tsx create mode 100644 src/components/shop/ProductCard.tsx create mode 100644 src/components/shop/ProductImage.tsx create mode 100644 src/components/shop/cartDrawerStore.ts create mode 100644 src/routes/shop.collections.$handle.tsx create mode 100644 src/routes/shop.pages.$handle.tsx create mode 100644 src/routes/shop.search.tsx diff --git a/src/components/NavbarCartButton.tsx b/src/components/NavbarCartButton.tsx index 9dfd9de5..3ca096ca 100644 --- a/src/components/NavbarCartButton.tsx +++ b/src/components/NavbarCartButton.tsx @@ -1,7 +1,8 @@ -import { Link, useLocation } from '@tanstack/react-router' +import { useLocation } from '@tanstack/react-router' import { ShoppingCart } from 'lucide-react' import { twMerge } from 'tailwind-merge' import { useCart } from '~/hooks/useCart' +import { useCartDrawerStore } from '~/components/shop/cartDrawerStore' /** * Cart button in the main Navbar. @@ -10,17 +11,22 @@ import { useCart } from '~/hooks/useCart' * • Always visible on /shop/* routes (even at zero items) * • Site-wide when the cart has at least one item * • Hidden elsewhere when the cart is empty + * + * Click opens the global CartDrawer — no navigation, so the user keeps + * their place in the docs or blog while reviewing their cart. */ export function NavbarCartButton() { const { pathname } = useLocation() const { totalQuantity } = useCart() + const openDrawer = useCartDrawerStore((s) => s.openDrawer) const onShopRoute = pathname === '/shop' || pathname.startsWith('/shop/') if (!onShopRoute && totalQuantity === 0) return null return ( - 0 ? `Cart (${totalQuantity} items)` : 'Cart'} className={twMerge( 'relative flex items-center justify-center', @@ -41,6 +47,6 @@ export function NavbarCartButton() { {totalQuantity > 99 ? '99+' : totalQuantity} ) : null} - + ) } diff --git a/src/components/shop/Breadcrumbs.tsx b/src/components/shop/Breadcrumbs.tsx new file mode 100644 index 00000000..d27218fd --- /dev/null +++ b/src/components/shop/Breadcrumbs.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { Link } from '@tanstack/react-router' +import { ChevronRight } from 'lucide-react' + +export type Crumb = { + label: string + href?: string +} + +/** + * Accessible breadcrumb trail. The last crumb renders as plain text + * (current location); intermediate crumbs link back. + */ +export function Breadcrumbs({ crumbs }: { crumbs: Array }) { + return ( + + ) +} diff --git a/src/components/shop/CartDrawer.tsx b/src/components/shop/CartDrawer.tsx new file mode 100644 index 00000000..588b965d --- /dev/null +++ b/src/components/shop/CartDrawer.tsx @@ -0,0 +1,240 @@ +import * as React from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { Link } from '@tanstack/react-router' +import { Minus, Plus, ShoppingCart, Trash2, X } from 'lucide-react' +import { twMerge } from 'tailwind-merge' +import { useCart, useRemoveCartLine, useUpdateCartLine } from '~/hooks/useCart' +import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' +import type { CartLineDetail } from '~/utils/shopify-queries' + +type CartDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * Slide-in cart drawer. Shares state with /shop/cart through the same + * useCart React Query key, so adds in the drawer mirror the full page + * and vice-versa. Pinned to the right on desktop; full-width slide-up on + * mobile would be nice later, but a right-anchored sheet is the standard + * Shopify-theme pattern and works on phones too. + */ +export function CartDrawer({ open, onOpenChange }: CartDrawerProps) { + const { cart, totalQuantity } = useCart() + const hasLines = !!cart && cart.lines.nodes.length > 0 + + return ( + + + + +
+ + Cart{totalQuantity > 0 ? ` (${totalQuantity})` : ''} + + + + +
+ + {hasLines ? ( + <> +
    + {cart.lines.nodes.map((line) => ( + onOpenChange(false)} + /> + ))} +
+ onOpenChange(false)} /> + + ) : ( + onOpenChange(false)} /> + )} +
+
+
+ ) +} + +function DrawerEmpty({ onClose }: { onClose: () => void }) { + return ( +
+ +

Your cart is empty.

+ + Shop all products + +
+ ) +} + +function DrawerFooter({ + cart, + onClose, +}: { + cart: NonNullable['cart']> + onClose: () => void +}) { + const subtotal = cart.cost.subtotalAmount + return ( +
+
+ Subtotal + + {formatMoney(subtotal.amount, subtotal.currencyCode)} + +
+

+ Shipping and taxes calculated at checkout. +

+ + Checkout + + + View cart + +
+ ) +} + +function DrawerCartLine({ + line, + onClose, +}: { + line: CartLineDetail + onClose: () => void +}) { + const update = useUpdateCartLine() + const remove = useRemoveCartLine() + const { merchandise } = line + const options = merchandise.selectedOptions + .filter((o) => o.name.toLowerCase() !== 'title') + .map((o) => `${o.name}: ${o.value}`) + .join(' · ') + + const isBusy = update.isPending || remove.isPending + + return ( +
  • + +
    + {merchandise.image ? ( + {merchandise.image.altText + ) : null} +
    + +
    + + {merchandise.product.title} + + {options ? ( +

    + {options} +

    + ) : null} +
    +
    + + {line.quantity} + +
    +
    + + {formatMoney( + line.cost.totalAmount.amount, + line.cost.totalAmount.currencyCode, + )} + + +
    +
    +
    +
  • + ) +} diff --git a/src/components/shop/ProductCard.tsx b/src/components/shop/ProductCard.tsx new file mode 100644 index 00000000..b12b6001 --- /dev/null +++ b/src/components/shop/ProductCard.tsx @@ -0,0 +1,64 @@ +import { Link } from '@tanstack/react-router' +import { twMerge } from 'tailwind-merge' +import { ProductImage } from './ProductImage' +import { formatMoney } from '~/utils/shopify-format' +import type { ProductListItem } from '~/utils/shopify-queries' + +type ProductCardProps = { + product: ProductListItem + sizes?: string + loading?: 'eager' | 'lazy' +} + +/** + * Grid tile for a product. Handles compare-at-price strike-through and + * the "from $X" treatment when a product has variants at different prices. + */ +export function ProductCard({ + product, + sizes, + loading = 'lazy', +}: ProductCardProps) { + const { minVariantPrice, maxVariantPrice } = product.priceRange + const compareAt = product.compareAtPriceRange?.minVariantPrice + const isRange = minVariantPrice.amount !== maxVariantPrice.amount + const isDiscounted = + compareAt && Number(compareAt.amount) > Number(minVariantPrice.amount) + + return ( + +
    + +
    +
    +

    {product.title}

    +

    + + {isRange ? 'From ' : ''} + {formatMoney(minVariantPrice.amount, minVariantPrice.currencyCode)} + + {isDiscounted ? ( + + {formatMoney(compareAt.amount, compareAt.currencyCode)} + + ) : null} +

    +
    + + ) +} diff --git a/src/components/shop/ProductImage.tsx b/src/components/shop/ProductImage.tsx new file mode 100644 index 00000000..ba2a87f3 --- /dev/null +++ b/src/components/shop/ProductImage.tsx @@ -0,0 +1,66 @@ +import { twMerge } from 'tailwind-merge' +import { shopifyImageUrl } from '~/utils/shopify-format' + +type ShopifyImageFields = { + url: string + altText?: string | null + width?: number | null + height?: number | null +} + +type ProductImageProps = { + image: ShopifyImageFields | null | undefined + alt: string + /** Intrinsic width rendered for layout. The srcset still covers multiple DPRs. */ + width: number + /** + * Viewport widths this image occupies, for correct `sizes` hinting. + * Default assumes 1–4 column grid. Override for hero/PDP placements. + */ + sizes?: string + loading?: 'eager' | 'lazy' + className?: string +} + +/** + * Responsive Shopify CDN image. Emits a srcset with DPR-aware widths so + * retina gets sharper pixels and mobile doesn't download desktop-sized + * assets. Shopify's CDN serves WebP on-the-fly via `?format=webp`. + */ +export function ProductImage({ + image, + alt, + width, + sizes = '(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 100vw', + loading = 'lazy', + className, +}: ProductImageProps) { + if (!image) return null + const aspect = image.width && image.height ? image.width / image.height : 1 + const height = Math.round(width / aspect) + + // Provide 1x, 1.5x, 2x, 3x variants bounded by the image's intrinsic width. + const multipliers = [1, 1.5, 2, 3] + const srcset = multipliers + .map((m) => Math.round(width * m)) + .filter((w, i, arr) => w <= (image.width ?? w) && arr.indexOf(w) === i) + .map( + (w) => + `${shopifyImageUrl(image.url, { width: w, format: 'webp' })} ${w}w`, + ) + .join(', ') + + return ( + {image.altText + ) +} diff --git a/src/components/shop/ShopLayout.tsx b/src/components/shop/ShopLayout.tsx index c7727b14..e34aaaf9 100644 --- a/src/components/shop/ShopLayout.tsx +++ b/src/components/shop/ShopLayout.tsx @@ -3,8 +3,10 @@ import { Link, useLocation } from '@tanstack/react-router' import { ChevronLeft, ChevronRight, + FileText, Menu, Package, + Search, ShoppingBag, ShoppingCart, X, @@ -12,12 +14,21 @@ import { import { twMerge } from 'tailwind-merge' import { useLocalStorage } from '~/utils/useLocalStorage' import type { CollectionListItem } from '~/utils/shopify-queries' +import { CartDrawer } from './CartDrawer' +import { useCartDrawerStore } from './cartDrawerStore' type ShopLayoutProps = { collections: Array children: React.ReactNode } +const POLICY_PAGES = [ + { handle: 'shipping-policy', label: 'Shipping' }, + { handle: 'refund-policy', label: 'Returns' }, + { handle: 'privacy-policy', label: 'Privacy' }, + { handle: 'terms-of-service', label: 'Terms' }, +] as const + /** * /shop layout: persistent left sidebar on md+, slide-in drawer on mobile. * Collapse state persists to localStorage. When collapsed, hovering the @@ -139,10 +150,18 @@ export function ShopLayout({ collections, children }: ShopLayoutProps) { > {children} + + ) } +function GlobalCartDrawer() { + const open = useCartDrawerStore((s) => s.open) + const setOpen = useCartDrawerStore((s) => s.setOpen) + return +} + function ShopSidebarNav({ collections, showLabels, @@ -163,6 +182,13 @@ function ShopSidebarNav({ onNavigate={onNavigate} exact /> + ) : null} + + + {POLICY_PAGES.map((page) => ( + + ))} + ) } diff --git a/src/components/shop/cartDrawerStore.ts b/src/components/shop/cartDrawerStore.ts new file mode 100644 index 00000000..7de88be0 --- /dev/null +++ b/src/components/shop/cartDrawerStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand' + +/** + * Tiny store that controls the global cart drawer's open state. Kept in a + * module rather than React context so any component — the navbar button, + * the "Add to cart" button on the PDP, etc. — can trigger it without + * threading props. + */ +export const useCartDrawerStore = create<{ + open: boolean + setOpen: (open: boolean) => void + openDrawer: () => void + closeDrawer: () => void +}>((set) => ({ + open: false, + setOpen: (open) => set({ open }), + openDrawer: () => set({ open: true }), + closeDrawer: () => set({ open: false }), +})) diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts index 4c71497c..bbdb8bcd 100644 --- a/src/hooks/useCart.ts +++ b/src/hooks/useCart.ts @@ -1,8 +1,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { addToCart, + applyDiscountCode, getCart, removeCartLine, + removeDiscountCode, updateCartLine, } from '~/utils/shop.functions' import type { CartDetail } from '~/utils/shopify-queries' @@ -156,3 +158,45 @@ export function useRemoveCartLine() { }, }) } + +export function useApplyDiscountCode() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (input: { code: string }) => + applyDiscountCode({ data: { code: input.code } }), + onSuccess: (cart) => { + qc.setQueryData(CART_QUERY_KEY, cart) + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: CART_QUERY_KEY }) + }, + }) +} + +export function useRemoveDiscountCode() { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => removeDiscountCode(), + onMutate: async () => { + await qc.cancelQueries({ queryKey: CART_QUERY_KEY }) + const previous = qc.getQueryData(CART_QUERY_KEY) + if (previous) { + qc.setQueryData(CART_QUERY_KEY, { + ...previous, + discountCodes: [], + }) + } + return { previous } + }, + onError: (_err, _input, ctx) => { + if (ctx?.previous !== undefined) + qc.setQueryData(CART_QUERY_KEY, ctx.previous) + }, + onSuccess: (cart) => { + qc.setQueryData(CART_QUERY_KEY, cart) + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: CART_QUERY_KEY }) + }, + }) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index be592601..a94f1f24 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -51,6 +51,7 @@ import { Route as AccountIndexRouteImport } from './routes/account/index' import { Route as LibraryIdIndexRouteImport } from './routes/$libraryId/index' import { Route as ShowcaseSubmitRouteImport } from './routes/showcase/submit' import { Route as ShowcaseIdRouteImport } from './routes/showcase/$id' +import { Route as ShopSearchRouteImport } from './routes/shop.search' import { Route as ShopCartRouteImport } from './routes/shop.cart' import { Route as PartnersPartnerRouteImport } from './routes/partners.$partner' import { Route as OauthTokenRouteImport } from './routes/oauth/token' @@ -102,6 +103,8 @@ import { Route as LibraryIdVersionIndexRouteImport } from './routes/$libraryId/$ import { Route as StatsNpmPackagesRouteImport } from './routes/stats/npm/$packages' import { Route as ShowcaseEditIdRouteImport } from './routes/showcase/edit.$id' import { Route as ShopProductsHandleRouteImport } from './routes/shop.products.$handle' +import { Route as ShopPagesHandleRouteImport } from './routes/shop.pages.$handle' +import { Route as ShopCollectionsHandleRouteImport } from './routes/shop.collections.$handle' import { Route as IntentRegistryPackageNameRouteImport } from './routes/intent/registry/$packageName' import { Route as AuthProviderStartRouteImport } from './routes/auth/$provider/start' import { Route as ApiMcpSplatRouteImport } from './routes/api/mcp/$' @@ -356,6 +359,11 @@ const ShowcaseIdRoute = ShowcaseIdRouteImport.update({ path: '/showcase/$id', getParentRoute: () => rootRouteImport, } as any) +const ShopSearchRoute = ShopSearchRouteImport.update({ + id: '/search', + path: '/search', + getParentRoute: () => ShopRoute, +} as any) const ShopCartRoute = ShopCartRouteImport.update({ id: '/cart', path: '/cart', @@ -612,6 +620,16 @@ const ShopProductsHandleRoute = ShopProductsHandleRouteImport.update({ path: '/products/$handle', getParentRoute: () => ShopRoute, } as any) +const ShopPagesHandleRoute = ShopPagesHandleRouteImport.update({ + id: '/pages/$handle', + path: '/pages/$handle', + getParentRoute: () => ShopRoute, +} as any) +const ShopCollectionsHandleRoute = ShopCollectionsHandleRouteImport.update({ + id: '/collections/$handle', + path: '/collections/$handle', + getParentRoute: () => ShopRoute, +} as any) const IntentRegistryPackageNameRoute = IntentRegistryPackageNameRouteImport.update({ id: '/intent/registry/$packageName', @@ -906,6 +924,7 @@ export interface FileRoutesByFullPath { '/oauth/token': typeof OauthTokenRoute '/partners/$partner': typeof PartnersPartnerRoute '/shop/cart': typeof ShopCartRoute + '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute '/$libraryId/': typeof LibraryIdIndexRoute @@ -941,6 +960,8 @@ export interface FileRoutesByFullPath { '/api/mcp/$': typeof ApiMcpSplatRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren + '/shop/collections/$handle': typeof ShopCollectionsHandleRoute + '/shop/pages/$handle': typeof ShopPagesHandleRoute '/shop/products/$handle': typeof ShopProductsHandleRoute '/showcase/edit/$id': typeof ShowcaseEditIdRoute '/stats/npm/$packages': typeof StatsNpmPackagesRoute @@ -1036,6 +1057,7 @@ export interface FileRoutesByTo { '/oauth/token': typeof OauthTokenRoute '/partners/$partner': typeof PartnersPartnerRoute '/shop/cart': typeof ShopCartRoute + '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute '/$libraryId': typeof LibraryIdIndexRoute @@ -1069,6 +1091,8 @@ export interface FileRoutesByTo { '/api/github/webhook': typeof ApiGithubWebhookRoute '/api/mcp/$': typeof ApiMcpSplatRoute '/auth/$provider/start': typeof AuthProviderStartRoute + '/shop/collections/$handle': typeof ShopCollectionsHandleRoute + '/shop/pages/$handle': typeof ShopPagesHandleRoute '/shop/products/$handle': typeof ShopProductsHandleRoute '/showcase/edit/$id': typeof ShowcaseEditIdRoute '/stats/npm/$packages': typeof StatsNpmPackagesRoute @@ -1173,6 +1197,7 @@ export interface FileRoutesById { '/oauth/token': typeof OauthTokenRoute '/partners/$partner': typeof PartnersPartnerRoute '/shop/cart': typeof ShopCartRoute + '/shop/search': typeof ShopSearchRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute '/$libraryId/': typeof LibraryIdIndexRoute @@ -1208,6 +1233,8 @@ export interface FileRoutesById { '/api/mcp/$': typeof ApiMcpSplatRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren + '/shop/collections/$handle': typeof ShopCollectionsHandleRoute + '/shop/pages/$handle': typeof ShopPagesHandleRoute '/shop/products/$handle': typeof ShopProductsHandleRoute '/showcase/edit/$id': typeof ShowcaseEditIdRoute '/stats/npm/$packages': typeof StatsNpmPackagesRoute @@ -1313,6 +1340,7 @@ export interface FileRouteTypes { | '/oauth/token' | '/partners/$partner' | '/shop/cart' + | '/shop/search' | '/showcase/$id' | '/showcase/submit' | '/$libraryId/' @@ -1348,6 +1376,8 @@ export interface FileRouteTypes { | '/api/mcp/$' | '/auth/$provider/start' | '/intent/registry/$packageName' + | '/shop/collections/$handle' + | '/shop/pages/$handle' | '/shop/products/$handle' | '/showcase/edit/$id' | '/stats/npm/$packages' @@ -1443,6 +1473,7 @@ export interface FileRouteTypes { | '/oauth/token' | '/partners/$partner' | '/shop/cart' + | '/shop/search' | '/showcase/$id' | '/showcase/submit' | '/$libraryId' @@ -1476,6 +1507,8 @@ export interface FileRouteTypes { | '/api/github/webhook' | '/api/mcp/$' | '/auth/$provider/start' + | '/shop/collections/$handle' + | '/shop/pages/$handle' | '/shop/products/$handle' | '/showcase/edit/$id' | '/stats/npm/$packages' @@ -1579,6 +1612,7 @@ export interface FileRouteTypes { | '/oauth/token' | '/partners/$partner' | '/shop/cart' + | '/shop/search' | '/showcase/$id' | '/showcase/submit' | '/$libraryId/' @@ -1614,6 +1648,8 @@ export interface FileRouteTypes { | '/api/mcp/$' | '/auth/$provider/start' | '/intent/registry/$packageName' + | '/shop/collections/$handle' + | '/shop/pages/$handle' | '/shop/products/$handle' | '/showcase/edit/$id' | '/stats/npm/$packages' @@ -2049,6 +2085,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShowcaseIdRouteImport parentRoute: typeof rootRouteImport } + '/shop/search': { + id: '/shop/search' + path: '/search' + fullPath: '/shop/search' + preLoaderRoute: typeof ShopSearchRouteImport + parentRoute: typeof ShopRoute + } '/shop/cart': { id: '/shop/cart' path: '/cart' @@ -2406,6 +2449,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShopProductsHandleRouteImport parentRoute: typeof ShopRoute } + '/shop/pages/$handle': { + id: '/shop/pages/$handle' + path: '/pages/$handle' + fullPath: '/shop/pages/$handle' + preLoaderRoute: typeof ShopPagesHandleRouteImport + parentRoute: typeof ShopRoute + } + '/shop/collections/$handle': { + id: '/shop/collections/$handle' + path: '/collections/$handle' + fullPath: '/shop/collections/$handle' + preLoaderRoute: typeof ShopCollectionsHandleRouteImport + parentRoute: typeof ShopRoute + } '/intent/registry/$packageName': { id: '/intent/registry/$packageName' path: '/intent/registry/$packageName' @@ -2887,13 +2944,19 @@ const PartnersRouteWithChildren = PartnersRoute._addFileChildren( interface ShopRouteChildren { ShopCartRoute: typeof ShopCartRoute + ShopSearchRoute: typeof ShopSearchRoute ShopIndexRoute: typeof ShopIndexRoute + ShopCollectionsHandleRoute: typeof ShopCollectionsHandleRoute + ShopPagesHandleRoute: typeof ShopPagesHandleRoute ShopProductsHandleRoute: typeof ShopProductsHandleRoute } const ShopRouteChildren: ShopRouteChildren = { ShopCartRoute: ShopCartRoute, + ShopSearchRoute: ShopSearchRoute, ShopIndexRoute: ShopIndexRoute, + ShopCollectionsHandleRoute: ShopCollectionsHandleRoute, + ShopPagesHandleRoute: ShopPagesHandleRoute, ShopProductsHandleRoute: ShopProductsHandleRoute, } diff --git a/src/routes/shop.cart.tsx b/src/routes/shop.cart.tsx index 17c4308a..7f0cc5d6 100644 --- a/src/routes/shop.cart.tsx +++ b/src/routes/shop.cart.tsx @@ -1,9 +1,17 @@ +import * as React from 'react' import { Link, createFileRoute } from '@tanstack/react-router' -import { Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react' +import { Minus, Plus, ShoppingCart, Trash2, X } from 'lucide-react' import { twMerge } from 'tailwind-merge' -import { useCart, useRemoveCartLine, useUpdateCartLine } from '~/hooks/useCart' +import { Breadcrumbs } from '~/components/shop/Breadcrumbs' +import { + useApplyDiscountCode, + useCart, + useRemoveCartLine, + useRemoveDiscountCode, + useUpdateCartLine, +} from '~/hooks/useCart' import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' -import type { CartLineDetail } from '~/utils/shopify-queries' +import type { CartDetail, CartLineDetail } from '~/utils/shopify-queries' export const Route = createFileRoute('/shop/cart')({ component: CartPage, @@ -22,6 +30,9 @@ function CartPage() { return (
    +

    Cart

    @@ -38,6 +49,9 @@ function CartPage() {