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'
|
'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,17 +11,32 @@ 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>
|
||||||
|
destroy(): Promise<void>
|
||||||
|
}
|
||||||
|
interface SquareWalletMethod {
|
||||||
|
attach(selector: string): Promise<void>
|
||||||
|
tokenize(): Promise<TokenResult>
|
||||||
|
destroy(): Promise<void>
|
||||||
|
}
|
||||||
|
interface TokenResult {
|
||||||
status: string
|
status: string
|
||||||
token?: string
|
token?: string
|
||||||
errors?: Array<{ message: string }>
|
errors?: Array<{ message: string }>
|
||||||
}>
|
|
||||||
destroy(): Promise<void>
|
|
||||||
}
|
}
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -68,9 +83,14 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
|
|
||||||
const [sdkReady, setSdkReady] = useState(false)
|
const [sdkReady, setSdkReady] = useState(false)
|
||||||
const [cardReady, setCardReady] = useState(false)
|
const [cardReady, setCardReady] = useState(false)
|
||||||
|
const [googlePayReady, setGooglePayReady] = useState(false)
|
||||||
|
const [applePayReady, setApplePayReady] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const cardRef = useRef<SquareCard | null>(null)
|
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)
|
||||||
|
|
||||||
|
// Card (always available)
|
||||||
const card = await payments.card()
|
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,31 +173,18 @@ 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
|
||||||
if (!cardRef.current || submitting) return
|
const submitToken = async (token: string) => {
|
||||||
setSubmitting(true)
|
const checkoutBody = JSON.stringify({ ...payload, sourceId: token })
|
||||||
setError('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tokenResult = await cardRef.current.tokenize()
|
|
||||||
|
|
||||||
if (tokenResult.status !== 'OK' || !tokenResult.token) {
|
|
||||||
setError(
|
|
||||||
tokenResult.errors?.map((e) => e.message).join(' ') ??
|
|
||||||
'Could not read card. Please check your details and try again.'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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', {
|
const attemptCheckout = () => fetch('/api/checkout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -163,14 +195,10 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
try {
|
try {
|
||||||
res = await attemptCheckout()
|
res = await attemptCheckout()
|
||||||
} catch {
|
} catch {
|
||||||
// Network failure on first attempt — pause and retry once
|
|
||||||
await new Promise((r) => setTimeout(r, 2000))
|
await new Promise((r) => setTimeout(r, 2000))
|
||||||
try {
|
try {
|
||||||
res = await attemptCheckout()
|
res = await attemptCheckout()
|
||||||
} catch {
|
} 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. ' +
|
'Connection issue — your payment may have already been processed. ' +
|
||||||
'Please tap "Place Order" again to confirm, or contact us if this persists.'
|
'Please tap "Place Order" again to confirm, or contact us if this persists.'
|
||||||
@ -180,14 +208,50 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (!res.ok || !data.success) {
|
if (!res.ok || !data.success) {
|
||||||
setError(data.error ?? 'Checkout failed — please try again or contact us.')
|
setError(data.error ?? 'Checkout failed — please try again or contact us.')
|
||||||
if (res.status !== 409) console.error('[checkout] response:', data)
|
if (res.status !== 409) console.error('[checkout] response:', data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess(data.orderId as string, data.shortRef as string)
|
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(' ') ??
|
||||||
|
'Could not read card. Please check your details and try again.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await submitToken(tokenResult.token)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
tokenResult.errors?.map((e) => e.message).join(' ') ??
|
||||||
|
'Payment was not completed. Please try again.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await submitToken(tokenResult.token)
|
||||||
} 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)}
|
||||||
|
|||||||
@ -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,25 +144,27 @@ 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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user