Template
Product cards, header, footer, PDP, and typography reworked toward a leaner layout; adds shop policy pages backed by the Storefront API. - Type: switch to Geist Sans/Mono, regular-weight headings - Product cards: drop borders, rounded corners, and action buttons - Header: shorter bar, no bottom border, center-out hover underline, bag icon replacing the cart icon (drawer wording updated to match) - Footer: single line with policy links and social icons on bg-background - Policies: /policies/[handle] renders shop.privacyPolicy, termsOfService, refundPolicy, shippingPolicy, subscriptionPolicy (SSG, hourly revalidation) - PDP: image grid with mobile carousel + dots and click-to-zoom, sticky info column, colour swatches from option optionValues with a configurable name-to-colour fallback in config/swatches.ts, Shop-purple checkout button - Recommendations: left-aligned heading on bg-background - Ignore .env*.local and tsconfig.tsbuildinfo Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
151 lines
4.1 KiB
TypeScript
151 lines
4.1 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useRef, useState } 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;
|
|
|
|
export interface ShopPayVariant {
|
|
id: string;
|
|
quantity?: number;
|
|
}
|
|
|
|
interface ShopPayButtonProps {
|
|
variants: ShopPayVariant[];
|
|
disabled?: boolean;
|
|
className?: string;
|
|
width?: string;
|
|
borderRadius?: string;
|
|
fallback?: React.ReactNode;
|
|
}
|
|
|
|
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.
|
|
function toNumericVariantId(id: string): string | null {
|
|
const trimmed = id.trim();
|
|
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
|
|
if (gid) return gid[1];
|
|
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 {
|
|
return new URL(domain.startsWith('http') ? domain : `https://${domain}`)
|
|
.origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const ShopPayButton: React.FC<ShopPayButtonProps> = ({
|
|
variants,
|
|
disabled = false,
|
|
className,
|
|
width = '100%',
|
|
borderRadius = '8px',
|
|
fallback = null,
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(
|
|
'loading'
|
|
);
|
|
|
|
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')
|
|
);
|
|
|
|
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 }}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default ShopPayButton;
|