'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 | null = null; function loadShopJs(): Promise { if (shopJsPromise) return shopJsPromise; shopJsPromise = new Promise((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 = ({ variants, disabled = false, className, width = '100%', borderRadius = '8px', fallback = null, }) => { const containerRef = useRef(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 (
); }; export default ShopPayButton;