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:
parent
e7fec9ea72
commit
dbd3589add
File diff suppressed because one or more lines are too long
@ -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<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 {
|
||||
@ -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<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(() => {
|
||||
@ -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<void>((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 (
|
||||
<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) => (
|
||||
@ -246,7 +336,7 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
<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)}
|
||||
|
||||
@ -91,9 +91,10 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
|
||||
const items = objects
|
||||
.filter((o) => o.type === 'ITEM')
|
||||
.filter((o) =>
|
||||
onlineCategoryId
|
||||
? (o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
|
||||
: false
|
||||
// If an "online" category exists in Square, only show items tagged with it.
|
||||
// If the category doesn't exist in this account, show all items.
|
||||
!onlineCategoryId ||
|
||||
(o.itemData?.categories ?? []).some((c: { id?: string }) => c.id === onlineCategoryId)
|
||||
)
|
||||
.map((item) => {
|
||||
const data = item.itemData!
|
||||
@ -143,34 +144,36 @@ export async function getSquareCatalog(): Promise<CatalogItem[]> {
|
||||
.filter((ml): ml is ModifierList => ml !== null)
|
||||
|
||||
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 displayCatName = itemCategories
|
||||
const displayCatNames = itemCategories
|
||||
.filter((c) => c.id && !skipIds.has(c.id))
|
||||
.map((c) => categoryNameMap.get(c.id!))
|
||||
.find(Boolean) ?? 'Other'
|
||||
const categorySlug = displayCatName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
.filter((n): n is string => !!n)
|
||||
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 {
|
||||
id: item.id!,
|
||||
name: data.name ?? 'Unnamed item',
|
||||
description: data.description ?? '',
|
||||
category: categorySlug,
|
||||
categoryLabel: displayCatName,
|
||||
categories: [categorySlug],
|
||||
categoryLabels: [displayCatName],
|
||||
price: priceAmount ? Number(priceAmount) : null,
|
||||
categoryLabel: primaryCatName,
|
||||
categories: categorySlugs,
|
||||
categoryLabels: categoryLbls,
|
||||
price: priceAmount ? Number(priceAmount) : null,
|
||||
imageUrl,
|
||||
imageUrls,
|
||||
featured: false,
|
||||
tags: [],
|
||||
featured: false,
|
||||
tags: [],
|
||||
modifiers,
|
||||
showColors: hasLatexColors,
|
||||
colorMin: 1,
|
||||
colorMax: null,
|
||||
showColors: hasLatexColors,
|
||||
colorMin: 1,
|
||||
colorMax: null,
|
||||
chromeSurchargePerColor: 0,
|
||||
variations,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user