Template
- 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
247 lines
5.9 KiB
TypeScript
247 lines
5.9 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { shopifyFetch } from '@/services/shopify/client';
|
|
import {
|
|
GET_PRODUCTS_QUERY,
|
|
GET_PRODUCT_QUERY,
|
|
QUERY_PRODUCT_RECOMMENDATIONS,
|
|
} from '@/graphql/products';
|
|
|
|
interface ProductImage {
|
|
url: string;
|
|
altText?: string;
|
|
}
|
|
|
|
interface ProductPrice {
|
|
amount: string;
|
|
currencyCode: string;
|
|
}
|
|
|
|
interface ProductVariant {
|
|
id: string;
|
|
title: string;
|
|
price: ProductPrice;
|
|
availableForSale: boolean;
|
|
selectedOptions: Array<{
|
|
name: string;
|
|
value: string;
|
|
}>;
|
|
image?: ProductImage;
|
|
}
|
|
|
|
export interface ProductOptionValue {
|
|
id: string;
|
|
name: string;
|
|
swatch?: {
|
|
color?: string | null;
|
|
image?: {
|
|
previewImage?: {
|
|
url: string;
|
|
} | null;
|
|
} | null;
|
|
} | null;
|
|
firstSelectableVariant?: {
|
|
id: string;
|
|
image?: ProductImage | null;
|
|
} | null;
|
|
}
|
|
|
|
export interface ProductOption {
|
|
id: string;
|
|
name: string;
|
|
values: string[];
|
|
optionValues?: ProductOptionValue[];
|
|
}
|
|
|
|
export interface Product {
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
descriptionHtml?: string;
|
|
handle: string;
|
|
images: {
|
|
edges: Array<{
|
|
node: ProductImage;
|
|
}>;
|
|
};
|
|
priceRange: {
|
|
minVariantPrice: ProductPrice;
|
|
};
|
|
compareAtPriceRange?: {
|
|
minVariantPrice: ProductPrice;
|
|
};
|
|
variants: {
|
|
edges: Array<{
|
|
node: ProductVariant;
|
|
}>;
|
|
};
|
|
options: ProductOption[];
|
|
}
|
|
|
|
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;
|
|
error: string | null;
|
|
refetch: () => Promise<void>;
|
|
}
|
|
|
|
// Fetch multiple products
|
|
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<ProductsPage> {
|
|
const response = await shopifyFetch({
|
|
query: GET_PRODUCTS_QUERY,
|
|
variables: { first, after, query, sortKey, reverse },
|
|
});
|
|
|
|
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
|
|
export async function getProduct(handle: string): Promise<Product | null> {
|
|
const response = await shopifyFetch({
|
|
query: GET_PRODUCT_QUERY,
|
|
variables: { handle },
|
|
});
|
|
|
|
return response.data.product;
|
|
}
|
|
|
|
// Fetch product recommendations
|
|
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
|
const response = await shopifyFetch({
|
|
query: QUERY_PRODUCT_RECOMMENDATIONS,
|
|
variables: { productId },
|
|
});
|
|
|
|
return response.data.productRecommendations || [];
|
|
}
|
|
|
|
// Hook for fetching multiple products
|
|
export function useProducts(options: UseProductsOptions = {}): UseProductsReturn {
|
|
const [products, setProducts] = useState<Product[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchProducts = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const data = await getProducts(options);
|
|
setProducts(data);
|
|
} catch (err) {
|
|
console.error('Error fetching products:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to load products');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [options.first, options.query, options.sortKey, options.reverse]);
|
|
|
|
useEffect(() => {
|
|
fetchProducts();
|
|
}, [fetchProducts]);
|
|
|
|
return { products, loading, error, refetch: fetchProducts };
|
|
}
|
|
|
|
// Hook for fetching a single product
|
|
export function useProduct(handle: string | null) {
|
|
const [product, setProduct] = useState<Product | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchProduct = useCallback(async () => {
|
|
if (!handle) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const data = await getProduct(handle);
|
|
setProduct(data);
|
|
if (!data) {
|
|
setError('Product not found');
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching product:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to load product');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [handle]);
|
|
|
|
useEffect(() => {
|
|
fetchProduct();
|
|
}, [fetchProduct]);
|
|
|
|
return { product, loading, error, refetch: fetchProduct };
|
|
}
|
|
|
|
// Hook for fetching product recommendations
|
|
export function useProductRecommendations(productId: string | null) {
|
|
const [recommendations, setRecommendations] = useState<Product[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchRecommendations = useCallback(async () => {
|
|
if (!productId) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const data = await getProductRecommendations(productId);
|
|
setRecommendations(data);
|
|
} catch (err) {
|
|
console.error('Error fetching recommendations:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to load recommendations');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [productId]);
|
|
|
|
useEffect(() => {
|
|
fetchRecommendations();
|
|
}, [fetchRecommendations]);
|
|
|
|
return { recommendations, loading, error, refetch: fetchRecommendations };
|
|
}
|