89px only fit two rows; postal code (third row) was clipped by the container. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
'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'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>
|
||
)
|
||
}
|