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 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-28 16:53:55 -04:00
parent 53a2ca03e7
commit 92ab3a5633
2 changed files with 154 additions and 66 deletions

View File

@ -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<SquareCard>
googlePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
applePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
paymentRequest(options: PaymentRequestOptions): SquarePaymentRequest
}
interface SquareCard {
attach(selector: string): Promise<void>
tokenize(): Promise<{
status: string
token?: string
errors?: Array<{ message: string }>
}>
tokenize(): Promise<TokenResult>
destroy(): Promise<void>
}
interface SquareWalletMethod {
attach(selector: string): Promise<void>
tokenize(): Promise<TokenResult>
destroy(): Promise<void>
}
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<SquareCard | null>(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<SquareCard | null>(null)
const googlePayRef = useRef<SquareWalletMethod | null>(null)
const applePayRef = useRef<SquareWalletMethod | null>(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<void>((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 (
<div>
{/* Wallet button containers always in the DOM so the SDK can attach to them;
hidden via display:none until the respective method initialises */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: hasWallet ? '1rem' : '0' }}>
<div
id="sq-google-pay"
onClick={() => handleWalletPay(googlePayRef)}
style={{ display: googlePayReady ? 'block' : 'none', cursor: 'pointer' }}
/>
<div
id="sq-apple-pay"
onClick={() => handleWalletPay(applePayRef)}
style={{ display: applePayReady ? 'block' : 'none', cursor: 'pointer' }}
/>
</div>
{/* Divider between wallet buttons and card form */}
{hasWallet && (
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem' }}>
<div style={{ flex: 1, height: '1px', background: '#e0e0e0' }} />
<span style={{ fontSize: '0.75rem', color: '#999', whiteSpace: 'nowrap' }}>or pay with card</span>
<div style={{ flex: 1, height: '1px', background: '#e0e0e0' }} />
</div>
)}
{/* Accepted card types */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => (
@ -292,7 +379,7 @@ export default function PaymentForm({ payload, onSuccess, onError, active }: Pro
<button
className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`}
disabled={!cardReady || submitting}
onClick={handlePay}
onClick={handleCardPay}
style={{ marginTop: '1rem' }}
>
Place Order · {fmt(payload.grandTotal)}

File diff suppressed because one or more lines are too long