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 e7fec9ea72
commit dbd3589add
3 changed files with 173 additions and 79 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,9 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef import { useEffect, useRef, useState } from 'react'
import { fmt } from '@/lib/format' import { fmt } from '@/lib/format'
// ── Minimal Square Web Payments SDK types ───────────────────────────────────── // ── Square Web Payments SDK types ─────────────────────────────────────────────
declare global { declare global {
interface Window { interface Window {
Square?: { 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 { interface SquarePayments {
card(options?: object): Promise<SquareCard> card(options?: object): Promise<SquareCard>
googlePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
applePay(paymentRequest: SquarePaymentRequest): Promise<SquareWalletMethod>
paymentRequest(options: PaymentRequestOptions): SquarePaymentRequest
} }
interface SquareCard { interface SquareCard {
attach(selector: string): Promise<void> attach(selector: string): Promise<void>
tokenize(): Promise<{ tokenize(): Promise<TokenResult>
status: string
token?: string
errors?: Array<{ message: string }>
}>
destroy(): Promise<void> 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 { 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 appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID ?? ''
const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? '' const locationId = process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID ?? ''
const [sdkReady, setSdkReady] = useState(false) const [sdkReady, setSdkReady] = useState(false)
const [cardReady, setCardReady] = useState(false) const [cardReady, setCardReady] = useState(false)
const [submitting, setSubmitting] = useState(false) const [googlePayReady, setGooglePayReady] = useState(false)
const [error, setError] = useState('') const [applePayReady, setApplePayReady] = useState(false)
const cardRef = useRef<SquareCard | null>(null) 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) // 1 — Load Square SDK script (idempotent)
useEffect(() => { useEffect(() => {
@ -90,7 +110,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
// Clear any previous error when the booking slot changes // Clear any previous error when the booking slot changes
useEffect(() => { setError('') }, [payload.deliverySlotISO, payload.pickupSlotISO]) 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(() => { useEffect(() => {
if (!active || !sdkReady || !window.Square || !appId || !locationId) return if (!active || !sdkReady || !window.Square || !appId || !locationId) return
if (cardRef.current) return // already initialised if (cardRef.current) return // already initialised
@ -98,7 +118,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
;(async () => { ;(async () => {
try { 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) => await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => 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 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') await card.attach('#sq-card')
if (mounted) { if (mounted) { cardRef.current = card; setCardReady(true) }
cardRef.current = card else { card.destroy().catch(() => {}); return }
setCardReady(true)
} else { // Google Pay (available in Chrome and most modern browsers)
card.destroy().catch(() => {}) 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) { } catch (e) {
console.error('[PaymentForm] init:', e) console.error('[PaymentForm] init:', e)
if (mounted) setError('Could not load payment form — please refresh and try again.') 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?.destroy().catch(() => {})
cardRef.current = null cardRef.current = null
setCardReady(false) setCardReady(false)
googlePayRef.current?.destroy().catch(() => {})
googlePayRef.current = null
setGooglePayReady(false)
applePayRef.current?.destroy().catch(() => {})
applePayRef.current = null
setApplePayReady(false)
} }
}, [active, sdkReady, appId, locationId]) }, [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 if (!cardRef.current || submitting) return
setSubmitting(true) setSubmitting(true)
setError('') setError('')
try { try {
const tokenResult = await cardRef.current.tokenize() const tokenResult = await cardRef.current.tokenize()
if (tokenResult.status !== 'OK' || !tokenResult.token) { if (tokenResult.status !== 'OK' || !tokenResult.token) {
setError( setError(
tokenResult.errors?.map((e) => e.message).join(' ') ?? tokenResult.errors?.map((e) => e.message).join(' ') ??
@ -146,48 +229,29 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
) )
return return
} }
await submitToken(tokenResult.token)
} finally {
setSubmitting(false)
}
}
const checkoutBody = JSON.stringify({ ...payload, sourceId: tokenResult.token }) const handleWalletPay = async (walletRef: { current: SquareWalletMethod | null }) => {
if (!walletRef.current || submitting) return
// Attempt the checkout request. On a network-level failure (fetch throws), setSubmitting(true)
// wait 2 seconds and retry once automatically — the idempotency key ensures setError('')
// no double charge and will return success if the first attempt already try {
// captured payment but the response was lost. const tokenResult = await walletRef.current.tokenize()
const attemptCheckout = () => fetch('/api/checkout', { if (tokenResult.status !== 'OK' || !tokenResult.token) {
method: 'POST', // 'Cancel' means the user dismissed the native sheet — not an error
headers: { 'Content-Type': 'application/json' }, if (tokenResult.status !== 'Cancel') {
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.
setError( setError(
'Connection issue — your payment may have already been processed. ' + tokenResult.errors?.map((e) => e.message).join(' ') ??
'Please tap "Place Order" again to confirm, or contact us if this persists.' '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 return
} }
await submitToken(tokenResult.token)
onSuccess(data.orderId as string, data.shortRef as string)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -205,8 +269,34 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
) )
} }
const hasWallet = googlePayReady || applePayReady
return ( return (
<div> <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 */} {/* Accepted card types */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => ( {['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => (
@ -246,7 +336,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
<button <button
className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`} className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`}
disabled={!cardReady || submitting} disabled={!cardReady || submitting}
onClick={handlePay} onClick={handleCardPay}
style={{ marginTop: '1rem' }} style={{ marginTop: '1rem' }}
> >
Place Order · {fmt(payload.grandTotal)} Place Order · {fmt(payload.grandTotal)}

View File

@ -91,9 +91,10 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
const items = objects const items = objects
.filter((o) => o.type === 'ITEM') .filter((o) => o.type === 'ITEM')
.filter((o) => .filter((o) =>
onlineCategoryId // If an "online" category exists in Square, only show items tagged with it.
? (o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId) // If the category doesn't exist in this account, show all items.
: false !onlineCategoryId ||
(o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
) )
.map((item) => { .map((item) => {
const data = item.itemData! const data = item.itemData!
@ -143,34 +144,36 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
.filter((ml): ml is ModifierList => ml !== null) .filter((ml): ml is ModifierList => ml !== null)
const itemCategories: { id?: string }[] = data.categories ?? [] const itemCategories: { id?: string }[] = data.categories ?? []
const hasCategory = (id: string | undefined) =>
!!id && itemCategories.some((c) => c.id === id)
// Derive display category from the first Square category that isn't 'online' or 'latex' // Derive display categories from all Square categories that aren't 'online' or 'latex'
const skipIds = new Set([onlineCategoryId, latexCategoryId].filter(Boolean) as string[]) const skipIds = new Set([onlineCategoryId, latexCategoryId].filter(Boolean) as string[])
const displayCatName = itemCategories const displayCatNames = itemCategories
.filter((c) => c.id && !skipIds.has(c.id)) .filter((c) => c.id && !skipIds.has(c.id))
.map((c) => categoryNameMap.get(c.id!)) .map((c) => categoryNameMap.get(c.id!))
.find(Boolean) ?? 'Other' .filter((n): n is string => !!n)
const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') const primaryCatName = displayCatNames[0] ?? 'Other'
const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const categorySlug = toSlug(primaryCatName)
const categorySlugs = displayCatNames.length ? displayCatNames.map(toSlug) : [categorySlug]
const categoryLbls = displayCatNames.length ? displayCatNames : [primaryCatName]
return { return {
id: item.id!, id: item.id!,
name: data.name ?? 'Unnamed item', name: data.name ?? 'Unnamed item',
description: data.description ?? '', description: data.description ?? '',
category: categorySlug, category: categorySlug,
categoryLabel: displayCatName, categoryLabel: primaryCatName,
categories: [categorySlug], categories: categorySlugs,
categoryLabels: [displayCatName], categoryLabels: categoryLbls,
price: priceAmount ? Number(priceAmount) : null, price: priceAmount ? Number(priceAmount) : null,
imageUrl, imageUrl,
imageUrls, imageUrls,
featured: false, featured: false,
tags: [], tags: [],
modifiers, modifiers,
showColors: hasLatexColors, showColors: hasLatexColors,
colorMin: 1, colorMin: 1,
colorMax: null, colorMax: null,
chromeSurchargePerColor: 0, chromeSurchargePerColor: 0,
variations, variations,
} }