Redesign cart and collections, fix product pagination

- Cart drawer: single-column layout matching the site — no bag icon,
  no dividers, square images, tighter type, discount code form,
  estimated total, and a Go to Checkout button
- Cart: per-line loading state so one row's update no longer disables
  every other row or the checkout button
- Discounts: cartDiscountCodesUpdate mutation plus discountCodes on the
  cart fragment, wired to an applyDiscountCode store action
- Products: fix "load more" returning the same first page — the query
  now takes an `after` cursor and getProductsPage exposes pageInfo
- Collections: cards match the product cards (no border, radius, blurb,
  or CTA row) at 4-up on desktop and 2-up on mobile; section headers
  match the products section
- Shop Pay: cart permalink with payment=shop_pay instead of the hosted
  shop-js element
- Buttons converted to the shadcn Button component throughout
- PDP: swipeable image carousel with dots on mobile, sticky info column,
  colour swatches never fall back to variant photos
- Rename the store from Stride to Shop; larger header wordmark

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
Rami Bitar
2026-08-01 12:05:57 -04:00
co-authored by Claude Opus 5
parent 69f7435d6e
commit e04f1e0405
20 changed files with 546 additions and 466 deletions
+50
View File
@@ -7,6 +7,7 @@ import {
ADD_CART_LINES_MUTATION,
UPDATE_CART_LINES_MUTATION,
REMOVE_CART_LINES_MUTATION,
UPDATE_CART_DISCOUNT_CODES_MUTATION,
GET_CART_QUERY,
} from '@/graphql/cart';
import { useEffect } from 'react';
@@ -59,10 +60,16 @@ interface CartLine {
};
}
export interface CartDiscountCode {
code: string;
applicable: boolean;
}
export interface Cart {
id: string;
checkoutUrl: string;
totalQuantity: number;
discountCodes?: CartDiscountCode[];
cost: {
subtotalAmount: {
amount: string;
@@ -147,6 +154,24 @@ async function removeCartLinesApi(
return response.data.cartLinesRemove.cart;
}
async function updateCartDiscountCodesApi(
cartId: string,
discountCodes: string[]
): Promise<Cart> {
const response = await shopifyFetch({
query: UPDATE_CART_DISCOUNT_CODES_MUTATION,
variables: { cartId, discountCodes },
});
if (response.data.cartDiscountCodesUpdate.userErrors.length > 0) {
throw new Error(
response.data.cartDiscountCodesUpdate.userErrors[0].message
);
}
return response.data.cartDiscountCodesUpdate.cart;
}
async function getCartApi(cartId: string): Promise<Cart | null> {
const response = await shopifyFetch({
query: GET_CART_QUERY,
@@ -186,6 +211,7 @@ interface CartState {
addItem: (variantId: string, quantity?: number) => Promise<Cart>;
removeItem: (lineId: string) => Promise<Cart>;
updateItemQuantity: (lineId: string, quantity: number) => Promise<Cart>;
applyDiscountCode: (code: string) => Promise<Cart>;
refreshCart: () => Promise<void>;
}
@@ -312,6 +338,29 @@ export const useCartStore = create<CartState>((set, get) => ({
}
},
// Apply a discount code. Shopify accepts unknown codes and reports them back
// as `applicable: false`, so callers should check the returned cart.
applyDiscountCode: async (code: string) => {
const { cartId } = get();
if (!cartId) throw new Error('No cart exists');
try {
set({ loading: true, error: null });
const trimmed = code.trim();
const updatedCart = await updateCartDiscountCodesApi(
cartId,
trimmed ? [trimmed] : []
);
set({ cart: updatedCart, loading: false });
return updatedCart;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to apply discount code';
set({ error: errorMessage, loading: false });
throw err;
}
},
// Refresh cart from Shopify
refreshCart: async () => {
const storedCartId = localStorage.getItem(CART_ID_KEY);
@@ -374,6 +423,7 @@ export function useShopifyCart() {
addItem: store.addItem,
removeItem: store.removeItem,
updateItemQuantity: store.updateItemQuantity,
applyDiscountCode: store.applyDiscountCode,
refreshCart: store.refreshCart,
};
}
+27 -4
View File
@@ -81,11 +81,19 @@ export interface Product {
interface UseProductsOptions {
first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null;
query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
reverse?: boolean;
}
export interface ProductsPage {
products: Product[];
hasNextPage: boolean;
endCursor: string | null;
}
interface UseProductsReturn {
products: Product[];
loading: boolean;
@@ -94,18 +102,33 @@ interface UseProductsReturn {
}
// Fetch multiple products
export async function getProducts({
export async function getProducts(
options: UseProductsOptions = {}
): Promise<Product[]> {
const { products } = await getProductsPage(options);
return products;
}
// Same fetch, but keeps the cursor so callers can page through the catalogue.
export async function getProductsPage({
first = 20,
after = null,
query = '',
sortKey = 'BEST_SELLING',
reverse = false,
}: UseProductsOptions = {}): Promise<Product[]> {
}: UseProductsOptions = {}): Promise<ProductsPage> {
const response = await shopifyFetch({
query: GET_PRODUCTS_QUERY,
variables: { first, query, sortKey, reverse },
variables: { first, after, query, sortKey, reverse },
});
return response.data.products.edges.map((edge: { node: Product }) => edge.node);
const { edges, pageInfo } = response.data.products;
return {
products: edges.map((edge: { node: Product }) => edge.node),
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
// Fetch a single product by handle