410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useRef } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
|
import type { CartItem, CustomerInfo, OrderResult } from "@/lib/square-types"
|
|
|
|
interface PaymentFormProps {
|
|
items: CartItem[]
|
|
onPaymentSuccess: (result: OrderResult) => void
|
|
onPaymentError: (error: string) => void
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
Square: any
|
|
}
|
|
}
|
|
|
|
export default function SquarePaymentForm({ items, onPaymentSuccess, onPaymentError }: PaymentFormProps) {
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [squareLoaded, setSquareLoaded] = useState(false)
|
|
const [loadingError, setLoadingError] = useState<string>("")
|
|
const [customerInfo, setCustomerInfo] = useState<CustomerInfo>({
|
|
name: "",
|
|
email: "",
|
|
phone: "",
|
|
address: {
|
|
street: "",
|
|
city: "",
|
|
state: "",
|
|
zipCode: "",
|
|
country: "US"
|
|
},
|
|
})
|
|
|
|
const paymentsRef = useRef<any>(null)
|
|
const cardRef = useRef<any>(null)
|
|
|
|
const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
|
|
|
useEffect(() => {
|
|
loadSquareSDK()
|
|
return () => {
|
|
// Cleanup on unmount
|
|
if (cardRef.current) {
|
|
try {
|
|
cardRef.current.destroy()
|
|
} catch (e) {
|
|
console.warn("Error destroying card:", e)
|
|
}
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const loadSquareSDK = async () => {
|
|
try {
|
|
setLoadingError("")
|
|
|
|
// Check if Square is already loaded
|
|
if (window.Square) {
|
|
await initializeSquare()
|
|
return
|
|
}
|
|
|
|
// Validate required environment variables
|
|
if (!process.env.NEXT_PUBLIC_SQUARE_APPLICATION_ID) {
|
|
throw new Error("Square Application ID not configured")
|
|
}
|
|
|
|
if (!process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID) {
|
|
throw new Error("Square Location ID not configured")
|
|
}
|
|
|
|
// Load Square Web Payments SDK
|
|
const script = document.createElement("script")
|
|
script.src = process.env.SQUARE_ENVIRONMENT === "production"
|
|
? "https://web.squarecdn.com/v1/square.js"
|
|
: "https://sandbox.web.squarecdn.com/v1/square.js"
|
|
script.async = true
|
|
|
|
script.onload = async () => {
|
|
try {
|
|
await initializeSquare()
|
|
} catch (error) {
|
|
setLoadingError(`Square initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
script.onerror = () => {
|
|
setLoadingError("Failed to load Square payment system")
|
|
}
|
|
|
|
document.head.appendChild(script)
|
|
|
|
} catch (error) {
|
|
setLoadingError(error instanceof Error ? error.message : "Failed to load payment system")
|
|
}
|
|
}
|
|
|
|
const initializeSquare = async () => {
|
|
try {
|
|
console.log("Initializing Square Web Payments SDK...")
|
|
|
|
// Initialize Square Payments
|
|
paymentsRef.current = window.Square.payments(
|
|
process.env.NEXT_PUBLIC_SQUARE_APPLICATION_ID,
|
|
process.env.NEXT_PUBLIC_SQUARE_LOCATION_ID
|
|
)
|
|
|
|
// Create and attach card payment method
|
|
cardRef.current = await paymentsRef.current.card({
|
|
style: {
|
|
input: {
|
|
fontSize: '16px',
|
|
fontFamily: 'Arial, sans-serif',
|
|
color: '#373F4A',
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: '6px',
|
|
borderColor: '#E0E2E5',
|
|
borderWidth: '1px',
|
|
padding: '12px'
|
|
},
|
|
'.input-container': {
|
|
borderRadius: '6px',
|
|
borderColor: '#E0E2E5',
|
|
borderWidth: '1px'
|
|
},
|
|
'.input-container.is-focus': {
|
|
borderColor: '#4A90E2',
|
|
boxShadow: '0 0 0 1px #4A90E2'
|
|
},
|
|
'.input-container.is-error': {
|
|
borderColor: '#E02F2F'
|
|
},
|
|
'.message-text': {
|
|
color: '#E02F2F',
|
|
fontSize: '14px'
|
|
}
|
|
}
|
|
})
|
|
|
|
await cardRef.current.attach('#card-container')
|
|
setSquareLoaded(true)
|
|
console.log("Square Web Payments SDK initialized successfully")
|
|
|
|
} catch (error) {
|
|
console.error("Square initialization error:", error)
|
|
setLoadingError(`Payment system initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
|
}
|
|
}
|
|
|
|
const handleCustomerInfoChange = (field: string, value: string) => {
|
|
if (field.includes(".")) {
|
|
const [parent, child] = field.split(".")
|
|
setCustomerInfo((prev) => ({
|
|
...prev,
|
|
[parent]: {
|
|
...prev[parent as keyof CustomerInfo],
|
|
[child]: value,
|
|
},
|
|
}))
|
|
} else {
|
|
setCustomerInfo((prev) => ({
|
|
...prev,
|
|
[field]: value,
|
|
}))
|
|
}
|
|
}
|
|
|
|
const validateForm = (): string | null => {
|
|
if (!customerInfo.name.trim()) return "Name is required"
|
|
if (!customerInfo.email.trim()) return "Email is required"
|
|
if (!/\S+@\S+\.\S+/.test(customerInfo.email)) return "Valid email is required"
|
|
return null
|
|
}
|
|
|
|
const handlePayment = async () => {
|
|
if (!squareLoaded || !cardRef.current) {
|
|
onPaymentError("Payment system not ready")
|
|
return
|
|
}
|
|
|
|
const validationError = validateForm()
|
|
if (validationError) {
|
|
onPaymentError(validationError)
|
|
return
|
|
}
|
|
|
|
setIsLoading(true)
|
|
|
|
try {
|
|
console.log("Starting payment process...")
|
|
|
|
// Step 1: Create order
|
|
console.log("Creating order...")
|
|
const orderResponse = await fetch("/api/square/orders/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ items, customerInfo }),
|
|
})
|
|
|
|
if (!orderResponse.ok) {
|
|
const errorText = await orderResponse.text()
|
|
throw new Error(`Order creation failed: ${errorText}`)
|
|
}
|
|
|
|
const orderResult = await orderResponse.json()
|
|
if (!orderResult.success) {
|
|
throw new Error(orderResult.error || "Failed to create order")
|
|
}
|
|
|
|
console.log("Order created:", orderResult.order.id)
|
|
|
|
// Step 2: Tokenize the card
|
|
console.log("Tokenizing card...")
|
|
const tokenResult = await cardRef.current.tokenize()
|
|
|
|
if (tokenResult.status !== "OK") {
|
|
const errorMessage = tokenResult.errors?.[0]?.detail || "Card tokenization failed"
|
|
throw new Error(errorMessage)
|
|
}
|
|
|
|
console.log("Card tokenized successfully")
|
|
|
|
// Step 3: Process payment
|
|
console.log("Processing payment...")
|
|
const paymentResponse = await fetch("/api/square/payments/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
sourceId: tokenResult.token,
|
|
orderId: orderResult.order.id,
|
|
amount: totalAmount,
|
|
currency: "USD",
|
|
customerInfo,
|
|
items
|
|
}),
|
|
})
|
|
|
|
if (!paymentResponse.ok) {
|
|
const errorText = await paymentResponse.text()
|
|
throw new Error(`Payment processing failed: ${errorText}`)
|
|
}
|
|
|
|
const paymentResult = await paymentResponse.json()
|
|
if (!paymentResult.success) {
|
|
throw new Error(paymentResult.error || "Payment failed")
|
|
}
|
|
|
|
console.log("Payment processed successfully:", paymentResult.payment.id)
|
|
|
|
// Success!
|
|
onPaymentSuccess({
|
|
orderId: orderResult.order.id,
|
|
paymentId: paymentResult.payment.id,
|
|
status: paymentResult.payment.status,
|
|
totalAmount: totalAmount,
|
|
receiptUrl: paymentResult.payment.receiptUrl
|
|
})
|
|
|
|
} catch (error) {
|
|
console.error("Payment error:", error)
|
|
onPaymentError(error instanceof Error ? error.message : "Payment failed")
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
|
{/* Loading Error */}
|
|
{loadingError && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{loadingError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Order Summary */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Order Summary</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{items.map((item) => (
|
|
<div key={`${item.id}-${item.variationId}`} className="flex justify-between items-center">
|
|
<div>
|
|
<h4 className="font-medium">{item.name}</h4>
|
|
<p className="text-sm text-gray-600">Quantity: {item.quantity}</p>
|
|
{item.description && <p className="text-sm text-gray-500">{item.description}</p>}
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-medium">${(item.price * item.quantity).toFixed(2)}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="border-t pt-4">
|
|
<div className="flex justify-between items-center font-bold text-lg">
|
|
<span>Total:</span>
|
|
<span>${totalAmount.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Customer Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Customer Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input
|
|
placeholder="Full Name *"
|
|
value={customerInfo.name}
|
|
onChange={(e) => handleCustomerInfoChange("name", e.target.value)}
|
|
required
|
|
/>
|
|
<Input
|
|
type="email"
|
|
placeholder="Email Address *"
|
|
value={customerInfo.email}
|
|
onChange={(e) => handleCustomerInfoChange("email", e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<Input
|
|
type="tel"
|
|
placeholder="Phone Number"
|
|
value={customerInfo.phone}
|
|
onChange={(e) => handleCustomerInfoChange("phone", e.target.value)}
|
|
/>
|
|
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium">Shipping Address</h4>
|
|
<Input
|
|
placeholder="Street Address"
|
|
value={customerInfo.address?.street || ""}
|
|
onChange={(e) => handleCustomerInfoChange("address.street", e.target.value)}
|
|
/>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Input
|
|
placeholder="City"
|
|
value={customerInfo.address?.city || ""}
|
|
onChange={(e) => handleCustomerInfoChange("address.city", e.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="State"
|
|
value={customerInfo.address?.state || ""}
|
|
onChange={(e) => handleCustomerInfoChange("address.state", e.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="ZIP Code"
|
|
value={customerInfo.address?.zipCode || ""}
|
|
onChange={(e) => handleCustomerInfoChange("address.zipCode", e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Payment Form */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Payment Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!squareLoaded && !loadingError && (
|
|
<div className="text-center py-4">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-yellow-400 mx-auto mb-2"></div>
|
|
<p className="text-sm text-gray-600">Loading secure payment form...</p>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
id="card-container"
|
|
className={`mb-4 border rounded-lg min-h-[120px] ${!squareLoaded ? 'opacity-50' : ''}`}
|
|
style={{ minHeight: '120px' }}
|
|
>
|
|
{/* Square card form will be inserted here */}
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handlePayment}
|
|
disabled={isLoading || !squareLoaded || !!loadingError}
|
|
className="w-full bg-yellow-400 text-black hover:bg-yellow-300 disabled:opacity-50"
|
|
>
|
|
{isLoading ? (
|
|
<div className="flex items-center">
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-black mr-2"></div>
|
|
Processing Payment...
|
|
</div>
|
|
) : (
|
|
`Pay $${totalAmount.toFixed(2)}`
|
|
)}
|
|
</Button>
|
|
|
|
<p className="text-xs text-gray-500 mt-2 text-center">
|
|
Secure payment powered by Square • PCI DSS Compliant
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|