From 92ab3a5633411cef3894af4409874ed63e03b4b3 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 28 May 2026 16:53:55 -0400 Subject: [PATCH] Add Google Pay and Apple Pay support; fix catalog category filter - PaymentForm now initialises Google Pay and Apple Pay via Square's Web Payments SDK alongside the existing card form; wallet buttons appear above the card with an "or pay with card" divider when available - Apple Pay domain verification file added to public/.well-known/ - square.ts: fix online-category filter to show all items when the category doesn't exist; support multi-category display per item Co-Authored-By: Claude Sonnet 4.6 --- estore/src/components/PaymentForm.tsx | 219 ++++++++++++------ ...le-developer-merchantid-domain-association | 1 + 2 files changed, 154 insertions(+), 66 deletions(-) create mode 100644 public/.well-known/apple-developer-merchantid-domain-association diff --git a/estore/src/components/PaymentForm.tsx b/estore/src/components/PaymentForm.tsx index 7309161..6fb236c 100644 --- a/estore/src/components/PaymentForm.tsx +++ b/estore/src/components/PaymentForm.tsx @@ -1,10 +1,10 @@ 'use client' import { BASE } from '@/lib/basepath' -import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef +import { useEffect, useRef, useState } from 'react' import { fmt } from '@/lib/format' -// ── Minimal Square Web Payments SDK types ───────────────────────────────────── +// ── Square Web Payments SDK types ───────────────────────────────────────────── declare global { interface Window { Square?: { @@ -12,18 +12,33 @@ declare global { } } } +interface PaymentRequestOptions { + countryCode: string + currencyCode: string + total: { label: string; amount: string } +} +interface SquarePaymentRequest {} // opaque handle passed to wallet methods interface SquarePayments { card(options?: object): Promise + googlePay(paymentRequest: SquarePaymentRequest): Promise + applePay(paymentRequest: SquarePaymentRequest): Promise + paymentRequest(options: PaymentRequestOptions): SquarePaymentRequest } interface SquareCard { attach(selector: string): Promise - tokenize(): Promise<{ - status: string - token?: string - errors?: Array<{ message: string }> - }> + tokenize(): Promise destroy(): Promise } +interface SquareWalletMethod { + attach(selector: string): Promise + tokenize(): Promise + destroy(): Promise +} +interface TokenResult { + status: string + token?: string + errors?: Array<{ message: string }> +} // ───────────────────────────────────────────────────────────────────────────── export interface CheckoutPayload { @@ -98,11 +113,16 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? '' const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? '' - const [sdkReady, setSdkReady] = useState(false) - const [cardReady, setCardReady] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState('') - const cardRef = useRef(null) + const [sdkReady, setSdkReady] = useState(false) + const [cardReady, setCardReady] = useState(false) + const [googlePayReady, setGooglePayReady] = useState(false) + const [applePayReady, setApplePayReady] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + + const cardRef = useRef(null) + const googlePayRef = useRef(null) + const applePayRef = useRef(null) // 1 — Load Square SDK script (idempotent) useEffect(() => { @@ -122,7 +142,7 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro // Clear any previous error when the booking slot changes useEffect(() => { setError('') }, [payload.deliverySlotISO, payload.pickupSlotISO]) - // 2 — Initialise card form once SDK is ready and the step is visible + // 2 — Initialise payment methods once SDK is ready and the step is visible useEffect(() => { if (!active || !sdkReady || !window.Square || !appId || !locationId) return if (cardRef.current) return // already initialised @@ -130,7 +150,7 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro ;(async () => { try { - // Double-rAF: wait for the browser to finish painting so #sq-card is in the DOM + // Double-rAF: wait for the browser to finish painting so all containers are in the DOM await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())) ) @@ -141,14 +161,39 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro } const payments = await window.Square!.payments(appId, locationId) - const card = await payments.card() + + // Card (always available) + const card = await payments.card() await card.attach('#sq-card') - if (mounted) { - cardRef.current = card - setCardReady(true) - } else { - card.destroy().catch(() => {}) - } + if (mounted) { cardRef.current = card; setCardReady(true) } + else { card.destroy().catch(() => {}); return } + + // Google Pay (available in Chrome and most modern browsers) + try { + const req = payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { label: 'Beach Party Balloons', amount: (payload.grandTotal / 100).toFixed(2) }, + }) + const gp = await payments.googlePay(req) + await gp.attach('#sq-google-pay') + if (mounted) { googlePayRef.current = gp; setGooglePayReady(true) } + else gp.destroy().catch(() => {}) + } catch { /* not available in this browser or environment */ } + + // Apple Pay (Safari on Apple devices only, requires registered domain) + try { + const req = payments.paymentRequest({ + countryCode: 'US', + currencyCode: 'USD', + total: { label: 'Beach Party Balloons', amount: (payload.grandTotal / 100).toFixed(2) }, + }) + const ap = await payments.applePay(req) + await ap.attach('#sq-apple-pay') + if (mounted) { applePayRef.current = ap; setApplePayReady(true) } + else ap.destroy().catch(() => {}) + } catch { /* not available (non-Safari / domain not yet registered with Apple) */ } + } catch (e) { console.error('[PaymentForm] init:', e) if (mounted) setError('Could not load payment form — please refresh and try again.') @@ -160,17 +205,57 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro cardRef.current?.destroy().catch(() => {}) cardRef.current = null setCardReady(false) + googlePayRef.current?.destroy().catch(() => {}) + googlePayRef.current = null + setGooglePayReady(false) + applePayRef.current?.destroy().catch(() => {}) + applePayRef.current = null + setApplePayReady(false) } }, [active, sdkReady, appId, locationId]) - const handlePay = async () => { + // Shared: POST token to checkout API with one auto-retry on network failure + const submitToken = async (token: string) => { + const checkoutBody = JSON.stringify({ ...payload, sourceId: token }) + const attemptCheckout = () => fetch(BASE + '/api/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: checkoutBody, + }) + + let res: Response + try { + res = await attemptCheckout() + } catch { + await new Promise((r) => setTimeout(r, 2000)) + try { + res = await attemptCheckout() + } catch { + setError( + 'Connection issue — your payment may have already been processed. ' + + 'Please tap "Place Order" again to confirm, or contact us if this persists.' + ) + return + } + } + + const data = await res.json() + if (!res.ok || !data.success) { + const msg = data.error ?? 'Checkout failed — please try again or contact us.' + if (res.status !== 409) console.error('[checkout] response:', data) + if (res.status === 400 && onError) { onError(msg, res.status); return } + setError(msg) + return + } + onSuccess(data.orderId as string, data.shortRef as string) + } + + const handleCardPay = async () => { if (!cardRef.current || submitting) return setSubmitting(true) setError('') - try { const tokenResult = await cardRef.current.tokenize() - if (tokenResult.status !== 'OK' || !tokenResult.token) { setError( tokenResult.errors?.map((e) => e.message).join(' ') ?? @@ -178,53 +263,29 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro ) return } + await submitToken(tokenResult.token) + } finally { + setSubmitting(false) + } + } - const checkoutBody = JSON.stringify({ ...payload, sourceId: tokenResult.token }) - - // Attempt the checkout request. On a network-level failure (fetch throws), - // wait 2 seconds and retry once automatically — the idempotency key ensures - // no double charge and will return success if the first attempt already - // captured payment but the response was lost. - const attemptCheckout = () => fetch(BASE + '/api/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: checkoutBody, - }) - - let res: Response - try { - res = await attemptCheckout() - } catch { - // Network failure on first attempt — pause and retry once - await new Promise((r) => setTimeout(r, 2000)) - try { - res = await attemptCheckout() - } catch { - // Both attempts failed — payment may or may not have gone through. - // The idempotency key is preserved in localStorage, so clicking - // "Place Order" again will safely resolve either way. + const handleWalletPay = async (walletRef: { current: SquareWalletMethod | null }) => { + if (!walletRef.current || submitting) return + setSubmitting(true) + setError('') + try { + const tokenResult = await walletRef.current.tokenize() + if (tokenResult.status !== 'OK' || !tokenResult.token) { + // 'Cancel' means the user dismissed the native sheet — not an error + if (tokenResult.status !== 'Cancel') { setError( - 'Connection issue — your payment may have already been processed. ' + - 'Please tap "Place Order" again to confirm, or contact us if this persists.' + tokenResult.errors?.map((e) => e.message).join(' ') ?? + 'Payment was not completed. Please try again.' ) - return } - } - - const data = await res.json() - - if (!res.ok || !data.success) { - const msg = data.error ?? 'Checkout failed — please try again or contact us.' - if (res.status !== 409) console.error('[checkout] response:', data) - if (res.status === 400 && onError) { - onError(msg, res.status) - return - } - setError(msg) return } - - onSuccess(data.orderId as string, data.shortRef as string) + await submitToken(tokenResult.token) } finally { setSubmitting(false) } @@ -242,8 +303,34 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro ) } + const hasWallet = googlePayReady || applePayReady + return (
+ {/* Wallet button containers — always in the DOM so the SDK can attach to them; + hidden via display:none until the respective method initialises */} +
+
handleWalletPay(googlePayRef)} + style={{ display: googlePayReady ? 'block' : 'none', cursor: 'pointer' }} + /> +
handleWalletPay(applePayRef)} + style={{ display: applePayReady ? 'block' : 'none', cursor: 'pointer' }} + /> +
+ + {/* Divider between wallet buttons and card form */} + {hasWallet && ( +
+
+ or pay with card +
+
+ )} + {/* Accepted card types */}
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => ( @@ -292,7 +379,7 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro