chris e8240e383a fix: increase Square card form height to show postal code field
89px only fit two rows; postal code (third row) was clipped by the container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:24:23 -04:00

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { BASE } from '@/lib/basepath'
import { useEffect, useRef, useState } from 'react' // useRef kept for cardRef
import { fmt } from '@/lib/format'
// ── Minimal Square Web Payments SDK types ─────────────────────────────────────
declare global {
interface Window {
Square?: {
payments(appId: string, locationId: string): Promise<SquarePayments>
}
}
}
interface SquarePayments {
card(options?: object): Promise<SquareCard>
}
interface SquareCard {
attach(selector: string): Promise<void>
tokenize(): Promise<{
status: string
token?: string
errors?: Array<{ message: string }>
}>
destroy(): Promise<void>
}
// ─────────────────────────────────────────────────────────────────────────────
export interface CheckoutPayload {
lineItems: Array<{
name: string
quantity: number
priceCents: number
catalogItemId?: string
colors?: string[]
note?: string
modifiers?: Array<{ catalogObjectId: string; name: string }>
}>
selectedColors: string[]
deliverySlotISO?: string
driveMinutes?: number
deliveryAddress?: string
deliveryTier?: string
deliveryNotes?: string
deliveryCents?: number
pickupSlotISO?: string
customerFirstName: string
customerLastName: string
customerEmail: string
customerPhone: string
grandTotal: number // cents — shown on the button
idempotencyKey?: string // stable per checkout attempt — prevents duplicate orders on retry
}
interface Props {
payload: CheckoutPayload
onSuccess: (orderId: string, shortRef: string) => void
onError?: (message: string, status: number) => void
active: boolean // true only when the payment step is visible
}
const SDK_URL =
process.env.NEXT_PUBLIC_SQUARE_ENVIRONMENT === 'production'
? 'https://web.squarecdn.com/v1/square.js'
: 'https://sandbox.web.squarecdn.com/v1/square.js'
function buildMailtoLink(payload: CheckoutPayload): string {
const { fmt: fmtCents } = { fmt: (c: number) => `$${(c / 100).toFixed(2)}` }
const items = payload.lineItems
.map((li) => `${li.quantity}× ${li.name}${fmtCents(li.priceCents * li.quantity)}${li.note ? ` (${li.note})` : ''}`)
.join('\n')
const fulfillment = payload.deliverySlotISO
? `Delivery: ${new Date(payload.deliverySlotISO).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} to ${payload.deliveryAddress ?? ''}`
: payload.pickupSlotISO
? `Pickup: ${new Date(payload.pickupSlotISO).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })}`
: 'Fulfillment: not set'
const body = [
`Hi Beach Party Balloons,`,
``,
`I tried to place an order online but ran into a payment error. Could you please send me an invoice?`,
``,
`Name: ${payload.customerFirstName} ${payload.customerLastName}`,
`Email: ${payload.customerEmail}`,
`Phone: ${payload.customerPhone}`,
``,
`Order:`,
items,
``,
fulfillment,
`Total: ${fmtCents(payload.grandTotal)}`,
].join('\n')
const addr = atob('aW5mb0BiZWFjaHBhcnR5YmFsbG9vbnMuY29t')
return `mailto:${addr}?subject=${encodeURIComponent('Online Order — Invoice Request')}&body=${encodeURIComponent(body)}`
}
export default function PaymentForm({ payload, onSuccess, onError, 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)
// 1 — Load Square SDK script (idempotent)
useEffect(() => {
if (window.Square) { setSdkReady(true); return }
const existing = document.querySelector(`script[src="${SDK_URL}"]`)
if (existing) {
existing.addEventListener('load', () => setSdkReady(true))
return
}
const script = document.createElement('script')
script.src = SDK_URL
script.onload = () => setSdkReady(true)
script.onerror = () => setError('Failed to load payment processor. Please refresh and try again.')
document.head.appendChild(script)
}, [])
// 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
useEffect(() => {
if (!active || !sdkReady || !window.Square || !appId || !locationId) return
if (cardRef.current) return // already initialised
let mounted = true
;(async () => {
try {
// Double-rAF: wait for the browser to finish painting so #sq-card is in the DOM
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
if (!mounted) return
if (!document.getElementById('sq-card')) {
if (mounted) setError('Could not load payment form — please refresh and try again.')
return
}
const payments = await window.Square!.payments(appId, locationId)
const card = await payments.card()
await card.attach('#sq-card')
if (mounted) {
cardRef.current = card
setCardReady(true)
} else {
card.destroy().catch(() => {})
}
} catch (e) {
console.error('[PaymentForm] init:', e)
if (mounted) setError('Could not load payment form — please refresh and try again.')
}
})()
return () => {
mounted = false
cardRef.current?.destroy().catch(() => {})
cardRef.current = null
setCardReady(false)
}
}, [active, sdkReady, appId, locationId])
const handlePay = 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
}
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.
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)
} finally {
setSubmitting(false)
}
}
if (!appId || !locationId) {
return (
<p style={{ fontSize: '0.85rem', color: '#cc3333' }}>
Online payment is not configured.{' '}
<a href="https://beachpartyballoons.com/contact/" style={{ color: '#cc3333', textDecoration: 'underline' }}>
Contact us
</a>{' '}
to complete your booking.
</p>
)
}
return (
<div>
{/* Accepted card types */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => (
<span key={brand} style={{
fontSize: '0.68rem', fontWeight: 'bold', letterSpacing: '0.02em',
padding: '2px 7px', borderRadius: '4px',
border: '1px solid #d0d0d0', color: '#555', background: '#fafafa',
}}>{brand}</span>
))}
</div>
{/* Square mounts the iframe card form here */}
<div
id="sq-card"
style={{
minHeight: '160px',
opacity: cardReady ? 1 : 0.5,
transition: 'opacity 0.25s',
}}
/>
{!cardReady && !error && (
<p style={{ fontSize: '0.78rem', color: '#888', marginTop: '0.5rem' }}>
Loading secure payment form
</p>
)}
{error && (
<div style={{ marginTop: '0.6rem' }}>
<p style={{ fontSize: '0.78rem', color: '#cc3333', lineHeight: 1.45 }}>
{error}
</p>
<p style={{ fontSize: '0.75rem', color: '#555', marginTop: '0.5rem', lineHeight: 1.5 }}>
If this keeps happening, you can{' '}
<a
href={buildMailtoLink(payload)}
style={{ color: '#0d6e75', textDecoration: 'underline' }}
>
email us your order details
</a>
{' '}and we&apos;ll send you an invoice.
</p>
</div>
)}
<button
className={`button is-info is-fullwidth${submitting ? ' is-loading' : ''}`}
disabled={!cardReady || submitting}
onClick={handlePay}
style={{ marginTop: '1rem' }}
>
Place Order · {fmt(payload.grandTotal)}
</button>
<p style={{ fontSize: '0.72rem', color: '#888', textAlign: 'center', marginTop: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
256-bit SSL · Powered by Square · Your card info never touches our servers
</p>
</div>
)
}