'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 } } } interface SquarePayments { card(options?: object): Promise } interface SquareCard { attach(selector: string): Promise tokenize(): Promise<{ status: string token?: string errors?: Array<{ message: string }> }> destroy(): Promise } // ───────────────────────────────────────────────────────────────────────────── 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(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((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 (

Online payment is not configured.{' '} Contact us {' '} to complete your booking.

) } return (
{/* Accepted card types */}
{['Visa', 'Mastercard', 'Amex', 'Discover'].map((brand) => ( {brand} ))}
{/* Square mounts the iframe card form here */}
{!cardReady && !error && (

Loading secure payment form…

)} {error && (

{error}

If this keeps happening, you can{' '} email us your order details {' '}and we'll send you an invoice.

)}

256-bit SSL · Powered by Square · Your card info never touches our servers

) }