Template
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:
co-authored by
Claude Opus 5
parent
69f7435d6e
commit
e04f1e0405
@@ -1,14 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client';
|
||||
|
||||
const SHOP_JS_URL =
|
||||
'https://cdn.shopify.com/shopifycloud/shop-js/modules/v2/loader.pay-button.esm.js';
|
||||
const TAG_NAME = 'shop-pay-button';
|
||||
// The hosted element renders at ~43px; reserve it so nothing shifts while shop-js loads.
|
||||
const MIN_HEIGHT = '43px';
|
||||
const LOAD_TIMEOUT_MS = 10000;
|
||||
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
|
||||
import { Loader } from '@/app/components/ui/loader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface ShopPayVariant {
|
||||
id: string;
|
||||
@@ -18,43 +14,13 @@ export interface ShopPayVariant {
|
||||
interface ShopPayButtonProps {
|
||||
variants: ShopPayVariant[];
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
width?: string;
|
||||
borderRadius?: string;
|
||||
fallback?: React.ReactNode;
|
||||
/** Used when no permalink can be built (missing domain or unusable IDs). */
|
||||
onFallbackClick?: () => void;
|
||||
}
|
||||
|
||||
let shopJsPromise: Promise<void> | null = null;
|
||||
|
||||
function loadShopJs(): Promise<void> {
|
||||
if (shopJsPromise) return shopJsPromise;
|
||||
|
||||
shopJsPromise = new Promise<void>((resolve, reject) => {
|
||||
if (!document.querySelector(`script[src="${SHOP_JS_URL}"]`)) {
|
||||
const script = document.createElement('script');
|
||||
script.src = SHOP_JS_URL;
|
||||
script.type = 'module';
|
||||
script.addEventListener('error', () =>
|
||||
reject(new Error('Failed to load the Shop Pay script.'))
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// Element registration, not script load, is the real readiness signal.
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for the Shop Pay button.')),
|
||||
LOAD_TIMEOUT_MS
|
||||
);
|
||||
customElements.whenDefined(TAG_NAME).then(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}, reject);
|
||||
});
|
||||
|
||||
return shopJsPromise;
|
||||
}
|
||||
|
||||
// Storefront API IDs arrive as GIDs; the web component wants the bare numeric ID.
|
||||
// Cart permalinks need the bare numeric ID; the Storefront API returns GIDs.
|
||||
function toNumericVariantId(id: string): string | null {
|
||||
const trimmed = id.trim();
|
||||
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
|
||||
@@ -62,18 +28,6 @@ function toNumericVariantId(id: string): string | null {
|
||||
return /^\d+$/.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function toVariantsAttribute(variants: ShopPayVariant[]): string | null {
|
||||
if (variants.length === 0) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const { id, quantity = 1 } of variants) {
|
||||
const numericId = toNumericVariantId(id);
|
||||
if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null;
|
||||
parts.push(`${numericId}:${quantity}`);
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
function toStoreUrl(domain?: string): string | null {
|
||||
if (!domain) return null;
|
||||
try {
|
||||
@@ -84,66 +38,62 @@ function toStoreUrl(domain?: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// https://{shop}/cart/{variantId}:{qty},{variantId}:{qty}?payment=shop_pay
|
||||
// Loads the cart and drops the buyer straight into the Shop Pay checkout.
|
||||
export function buildShopPayUrl(variants: ShopPayVariant[]): string | null {
|
||||
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
|
||||
if (!storeUrl || variants.length === 0) return null;
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const { id, quantity = 1 } of variants) {
|
||||
const numericId = toNumericVariantId(id);
|
||||
if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null;
|
||||
lines.push(`${numericId}:${quantity}`);
|
||||
}
|
||||
|
||||
return `${storeUrl}/cart/${lines.join(',')}?payment=shop_pay`;
|
||||
}
|
||||
|
||||
const BUTTON_CLASSES =
|
||||
'flex h-11 w-full cursor-pointer items-center justify-center rounded-md bg-shop px-4 text-white transition-colors hover:bg-shop/85 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
const ShopPayButton: React.FC<ShopPayButtonProps> = ({
|
||||
variants,
|
||||
disabled = false,
|
||||
className,
|
||||
width = '100%',
|
||||
borderRadius = '8px',
|
||||
fallback = null,
|
||||
loading = false,
|
||||
className = '',
|
||||
onFallbackClick,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(
|
||||
'loading'
|
||||
const shopPayUrl = buildShopPayUrl(variants);
|
||||
const contents = (
|
||||
<>
|
||||
<span className="sr-only">Buy with</span>
|
||||
{loading ? <Loader size={16} /> : <ShopPayLogo />}
|
||||
</>
|
||||
);
|
||||
|
||||
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
|
||||
const variantsAttribute = toVariantsAttribute(variants);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeUrl || !variantsAttribute) return;
|
||||
|
||||
let cancelled = false;
|
||||
loadShopJs().then(
|
||||
() => !cancelled && setStatus('ready'),
|
||||
() => !cancelled && setStatus('error')
|
||||
// An anchor keeps the checkout URL visible, openable in a new tab, and
|
||||
// navigable without JS; the button only stands in when there's no URL.
|
||||
if (shopPayUrl && !disabled) {
|
||||
return (
|
||||
<a
|
||||
href={shopPayUrl}
|
||||
className={`${BUTTON_CLASSES} ${className}`.trim()}
|
||||
>
|
||||
{contents}
|
||||
</a>
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [storeUrl, variantsAttribute]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (status !== 'ready' || !container || !storeUrl || !variantsAttribute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.createElement(TAG_NAME);
|
||||
button.setAttribute('store-url', storeUrl);
|
||||
button.setAttribute('variants', variantsAttribute);
|
||||
button.setAttribute('channel', 'headless');
|
||||
if (disabled) button.setAttribute('disabled', '');
|
||||
button.style.setProperty('--shop-pay-button-width', width);
|
||||
button.style.setProperty('--shop-pay-button-border-radius', borderRadius);
|
||||
container.appendChild(button);
|
||||
|
||||
return () => {
|
||||
button.remove();
|
||||
};
|
||||
}, [status, storeUrl, variantsAttribute, disabled, width, borderRadius]);
|
||||
|
||||
if (!storeUrl || !variantsAttribute || status === 'error') {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ minHeight: MIN_HEIGHT }}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onFallbackClick}
|
||||
disabled={disabled || (!shopPayUrl && !onFallbackClick)}
|
||||
className={`${BUTTON_CLASSES} ${className}`.trim()}
|
||||
>
|
||||
{contents}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user