From dbd3589add3ad89685b00a8ac0cc8fcd9d758ff5 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 --- ...le-developer-merchantid-domain-association | 1 + src/components/PaymentForm.tsx | 212 +++++++++++++----- src/lib/square.ts | 39 ++-- 3 files changed, 173 insertions(+), 79 deletions(-) create mode 100644 public/.well-known/apple-developer-merchantid-domain-association diff --git a/public/.well-known/apple-developer-merchantid-domain-association b/public/.well-known/apple-developer-merchantid-domain-association new file mode 100644 index 0000000..bdf7f54 --- /dev/null +++ b/public/.well-known/apple-developer-merchantid-domain-association @@ -0,0 +1 @@ +7B227073704964223A2242383642463746383933373735353242343346373441324434304635313141343141334233383342463146384542463741443644463733303342413638363031222C2276657273696F6E223A312C22637265617465644F6E223A313731353230333837363638312C227369676E6174757265223A2233303830303630393261383634383836663730643031303730326130383033303830303230313031333130643330306230363039363038363438303136353033303430323031333038303036303932613836343838366637306430313037303130303030613038303330383230336533333038323033383861303033303230313032303230383136363334633862306533303537313733303061303630383261383634386365336430343033303233303761333132653330326330363033353530343033306332353431373037303663363532303431373037303663363936333631373436393666366532303439366537343635363737323631373436393666366532303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330316531373064333233343330333433323339333133373334333733323337356131373064333233393330333433323338333133373334333733323336356133303566333132353330323330363033353530343033306331633635363336333264373336643730326436323732366636623635373232643733363936373665356635353433333432643530353234663434333131343330313230363033353530343062306330623639346635333230353337393733373436353664373333313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030346332313537376564656264366337623232313866363864643730393061313231386463376230626436663263323833643834363039356439346166346135343131623833343230656438313166333430376538333333316631633534633366376562333232306436626164356434656666343932383938393365376330663133613338323032313133303832303230643330306330363033353531643133303130316666303430323330303033303166303630333535316432333034313833303136383031343233663234396334346639336534656632376536633466363238366333666132626266643265346233303435303630383262303630313035303530373031303130343339333033373330333530363038326230363031303530353037333030313836323936383734373437303361326632663666363337333730326536313730373036633635326536333666366432663666363337333730333033343264363137303730366336353631363936333631333333303332333038323031316430363033353531643230303438323031313433303832303131303330383230313063303630393261383634383836663736333634303530313330383166653330383163333036303832623036303130353035303730323032333038316236306338316233353236353663363936313665363336353230366636653230373436383639373332303633363537323734363936363639363336313734363532303632373932303631366537393230373036313732373437393230363137333733373536643635373332303631363336333635373037343631366536333635323036663636323037343638363532303734363836353665323036313730373036633639363336313632366336353230373337343631366536343631373236343230373436353732366437333230363136653634323036333666366536343639373436393666366537333230366636363230373537333635326332303633363537323734363936363639363336313734363532303730366636633639363337393230363136653634323036333635373237343639363636393633363137343639366636653230373037323631363337343639363336353230373337343631373436353664363536653734373332653330333630363038326230363031303530353037303230313136326136383734373437303361326632663737373737373265363137303730366336353265363336663664326636333635373237343639363636393633363137343635363137353734363836663732363937343739326633303334303630333535316431663034326433303262333032396130323761303235383632333638373437343730336132663266363337323663326536313730373036633635326536333666366432663631373037303663363536313639363336313333326536333732366333303164303630333535316430653034313630343134393435376462366664353734383138363839383937363266376535373835303765373962353832343330306530363033353531643066303130316666303430343033303230373830333030663036303932613836343838366637363336343036316430343032303530303330306130363038326138363438636533643034303330323033343930303330343630323231303063366630323363623236313462623330333838386131363239383365316139336631303536663530666137386364623962613463613234316363313465323565303232313030626533636430646664313632343766363439343437353338306539643434633232386131303839306133613164633732346238623463623838383938313862633330383230326565333038323032373561303033303230313032303230383439366432666266336139386461393733303061303630383261383634386365336430343033303233303637333131623330313930363033353530343033306331323431373037303663363532303532366636663734323034333431323032643230343733333331323633303234303630333535303430623063316434313730373036633635323034333635373237343639363636393633363137343639366636653230343137353734363836663732363937343739333131333330313130363033353530343061306330613431373037303663363532303439366536333265333130623330303930363033353530343036313330323535353333303165313730643331333433303335333033363332333333343336333333303561313730643332333933303335333033363332333333343336333333303561333037613331326533303263303630333535303430333063323534313730373036633635323034313730373036633639363336313734363936663665323034393665373436353637373236313734363936663665323034333431323032643230343733333331323633303234303630333535303430623063316434313730373036633635323034333635373237343639363636393633363137343639366636653230343137353734363836663732363937343739333131333330313130363033353530343061306330613431373037303663363532303439366536333265333130623330303930363033353530343036313330323535353333303539333031333036303732613836343863653364303230313036303832613836343863653364303330313037303334323030303466303137313138343139643736343835643531613565323538313037373665383830613265666465376261653464653038646663346239336531333335366435363635623335616532326430393737363064323234653762626130386664373631376365383863623736626236363730626563386538323938346666353434356133383166373330383166343330343630363038326230363031303530353037303130313034336133303338333033363036303832623036303130353035303733303031383632613638373437343730336132663266366636333733373032653631373037303663363532653633366636643266366636333733373033303334326436313730373036633635373236663666373436333631363733333330316430363033353531643065303431363034313432336632343963343466393365346566323765366334663632383663336661326262666432653462333030663036303335353164313330313031666630343035333030333031303166663330316630363033353531643233303431383330313638303134626262306465613135383333383839616134386139396465626562646562616664616362323461623330333730363033353531643166303433303330326533303263613032616130323838363236363837343734373033613266326636333732366332653631373037303663363532653633366636643266363137303730366336353732366636663734363336313637333332653633373236633330306530363033353531643066303130316666303430343033303230313036333031303036306132613836343838366637363336343036303230653034303230353030333030613036303832613836343863653364303430333032303336373030333036343032333033616366373238333531313639396231383666623335633335366361363262666634313765646439306637353464613238656265663139633831356534326237383966383938663739623539396639386435343130643866396465396332666530323330333232646435343432316230613330353737366335646633333833623930363766643137376332633231366439363466633637323639383231323666353466383761376431623939636239623039383932313631303639393066303939323164303030303331383230313839333038323031383530323031303133303831383633303761333132653330326330363033353530343033306332353431373037303663363532303431373037303663363936333631373436393666366532303439366537343635363737323631373436393666366532303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333032303831363633346338623065333035373137333030623036303936303836343830313635303330343032303161303831393333303138303630393261383634383836663730643031303930333331306230363039326138363438383666373064303130373031333031633036303932613836343838366637306430313039303533313066313730643332333433303335333033383332333133333331333133363561333032383036303932613836343838366637306430313039333433313162333031393330306230363039363038363438303136353033303430323031613130613036303832613836343863653364303430333032333032663036303932613836343838366637306430313039303433313232303432303964626161326334646561343634393836646630393363646264373236636162343735383065393333633433363339633234303164373162306266363466636133303061303630383261383634386365336430343033303230343438333034363032323130303866356264303330376230613734333836313063393266353561363438316462653038376534653534646235336362613232613436323562323666363934326230323231303062643136303436636264626634346339613563373432376337343963316236626435666361653534396337396130323034346564353630363634653235313363303030303030303030303030227D \ No newline at end of file diff --git a/src/components/PaymentForm.tsx b/src/components/PaymentForm.tsx index 4eb72ee..771a56d 100644 --- a/src/components/PaymentForm.tsx +++ b/src/components/PaymentForm.tsx @@ -1,9 +1,9 @@ 'use client' -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?: { @@ -11,18 +11,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 { @@ -66,11 +81,16 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { 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(() => { @@ -90,7 +110,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { // 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 @@ -98,7 +118,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { ;(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())) ) @@ -109,14 +129,39 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { } 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.') @@ -128,17 +173,55 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { 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('/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) { + setError(data.error ?? 'Checkout failed — please try again or contact us.') + if (res.status !== 409) console.error('[checkout] response:', data) + 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(' ') ?? @@ -146,48 +229,29 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { ) 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('/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) { - setError(data.error ?? 'Checkout failed — please try again or contact us.') - if (res.status !== 409) console.error('[checkout] response:', data) return } - - onSuccess(data.orderId as string, data.shortRef as string) + await submitToken(tokenResult.token) } finally { setSubmitting(false) } @@ -205,8 +269,34 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) { ) } + 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) => ( @@ -246,7 +336,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {