feat: add comprehensive demo application with custom payment UI

- Custom test payment UI with modern Tailwind CSS design
- Payment method selection (iDEAL, Credit Card, PayPal, Apple Pay, Bank Transfer)
- Test scenario selection (6 scenarios: success, delayed, cancelled, declined, expired, pending)
- Real-time payment status polling
- Success and failure result pages with payment details
- Interactive demo homepage at root path
- Sample data seeding (customers, invoices)
- Customers collection with auto-sync to invoices
- Comprehensive documentation (README.md, DEMO_GUIDE.md)
- Proper cursor styles for all interactive elements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-08 14:03:28 +01:00
parent fa22900db5
commit 3508418698
18 changed files with 2505 additions and 459 deletions

View File

@@ -0,0 +1,56 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export async function POST(request: Request) {
try {
const payload = await getPayload({
config: configPromise,
})
const body = await request.json()
const { amount, currency, description } = body
if (!amount || !currency) {
return Response.json(
{ success: false, error: 'Amount and currency are required' },
{ status: 400 }
)
}
// Create a payment using the test provider
const payment = await payload.create({
collection: 'payments',
data: {
provider: 'test',
amount,
currency,
description: description || 'Demo payment',
status: 'pending',
metadata: {
source: 'demo-ui',
createdAt: new Date().toISOString(),
},
},
})
return Response.json({
success: true,
payment: {
id: payment.providerId, // Use the test provider ID for the UI
paymentId: payment.id,
amount: payment.amount,
currency: payment.currency,
description: payment.description,
},
})
} catch (error) {
console.error('Failed to create payment:', error)
return Response.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create payment',
},
{ status: 500 }
)
}
}

1
dev/app/globals.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

19
dev/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Billing Plugin Demo - PayloadCMS',
description: 'Demo application for @xtr-dev/payload-billing plugin',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

183
dev/app/page.tsx Normal file
View File

@@ -0,0 +1,183 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
export default function HomePage() {
const [paymentId, setPaymentId] = useState<string>('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>('')
const createDemoPayment = async () => {
setLoading(true)
setError('')
try {
const response = await fetch('/api/demo/create-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 2500,
currency: 'USD',
description: 'Demo payment from custom UI',
}),
})
const data = await response.json()
if (data.success) {
setPaymentId(data.payment.id)
} else {
setError(data.error || 'Failed to create payment')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-700 p-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-xl shadow-2xl overflow-hidden">
<div className="bg-gradient-to-r from-blue-600 to-purple-600 p-8 text-white">
<h1 className="text-4xl font-bold mb-2">Billing Plugin Demo</h1>
<p className="text-blue-100">
Test the @xtr-dev/payload-billing plugin with the test provider
</p>
</div>
<div className="p-8">
<div className="mb-8">
<h2 className="text-2xl font-bold text-slate-800 mb-4">
🎮 Interactive Demo
</h2>
<p className="text-slate-600 mb-6">
This demo shows how to integrate the billing plugin into your application. Click
the button below to create a test payment and see the custom payment UI in action.
</p>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-800 mb-4">
Create Test Payment
</h3>
{!paymentId ? (
<div>
<button
onClick={createDemoPayment}
disabled={loading}
className="bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-3 rounded-lg font-semibold hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{loading ? 'Creating Payment...' : 'Create Demo Payment'}
</button>
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 text-red-800 rounded-lg">
{error}
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 text-green-800 font-semibold mb-2">
<span></span>
<span>Payment Created Successfully!</span>
</div>
<p className="text-sm text-green-700">
Payment ID: <code className="bg-green-100 px-2 py-1 rounded">{paymentId}</code>
</p>
</div>
<div className="flex gap-3">
<Link
href={`/test-payment/${paymentId}`}
className="bg-gradient-to-r from-green-600 to-green-700 text-white px-6 py-3 rounded-lg font-semibold hover:shadow-lg transition-all inline-block cursor-pointer"
>
Go to Payment Page
</Link>
<button
onClick={() => {
setPaymentId('')
setError('')
}}
className="bg-slate-200 text-slate-700 px-6 py-3 rounded-lg font-semibold hover:bg-slate-300 transition-all cursor-pointer"
>
Create Another
</button>
</div>
</div>
)}
</div>
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-slate-800 mb-4">
📚 Quick Links
</h2>
<div className="grid md:grid-cols-2 gap-4">
<Link
href="/admin/collections/payments"
className="p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer"
>
<div className="font-semibold text-slate-800 mb-1">💳 Payments</div>
<div className="text-sm text-slate-600">View all payment transactions</div>
</Link>
<Link
href="/admin/collections/invoices"
className="p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer"
>
<div className="font-semibold text-slate-800 mb-1">🧾 Invoices</div>
<div className="text-sm text-slate-600">Manage invoices and billing</div>
</Link>
<Link
href="/admin/collections/refunds"
className="p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer"
>
<div className="font-semibold text-slate-800 mb-1">🔄 Refunds</div>
<div className="text-sm text-slate-600">Process and track refunds</div>
</Link>
<Link
href="/admin/collections/customers"
className="p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer"
>
<div className="font-semibold text-slate-800 mb-1">👥 Customers</div>
<div className="text-sm text-slate-600">Manage customer information</div>
</Link>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h2 className="text-xl font-bold text-slate-800 mb-3">
💡 About This Demo
</h2>
<div className="space-y-3 text-slate-700">
<p>
This demo application showcases the <code className="bg-blue-100 px-2 py-1 rounded">@xtr-dev/payload-billing</code> plugin
for PayloadCMS 3.x with the following features:
</p>
<ul className="list-disc list-inside space-y-2 ml-4">
<li>Test payment provider with customizable scenarios</li>
<li>Custom payment UI page with modern design</li>
<li>Customer relationship management with auto-sync</li>
<li>Invoice generation with line items and tax calculation</li>
<li>Refund processing and tracking</li>
<li>Sample data seeding for quick testing</li>
</ul>
<p className="pt-2">
The test provider allows you to simulate different payment outcomes including
success, failure, cancellation, and more - perfect for development and testing!
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
'use client'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
function PaymentFailedContent() {
const searchParams = useSearchParams()
const paymentId = searchParams.get('paymentId')
const reason = searchParams.get('reason') || 'unknown'
const amount = searchParams.get('amount')
const currency = searchParams.get('currency')
const getReasonText = (reason: string) => {
switch (reason) {
case 'failed':
return 'Payment was declined'
case 'cancelled':
return 'Payment was cancelled'
case 'expired':
return 'Payment session expired'
default:
return 'Payment could not be completed'
}
}
const getReasonDescription = (reason: string) => {
switch (reason) {
case 'failed':
return 'The payment provider declined the transaction. This is a simulated failure for testing purposes.'
case 'cancelled':
return 'The payment was cancelled before completion. You can try again with a different test scenario.'
case 'expired':
return 'The payment session timed out. Please create a new payment to try again.'
default:
return 'An unexpected error occurred during payment processing.'
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-red-600 to-orange-700 flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-xl shadow-2xl overflow-hidden">
<div className="bg-gradient-to-r from-red-600 to-orange-600 p-8 text-white text-center">
<div className="mb-4">
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center mx-auto">
<svg
className="w-12 h-12 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</div>
<h1 className="text-4xl font-bold mb-2">Payment {reason.charAt(0).toUpperCase() + reason.slice(1)}</h1>
<p className="text-red-100 text-lg">
{getReasonText(reason)}
</p>
</div>
<div className="p-8">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-8">
<h2 className="font-semibold text-red-900 mb-3 text-lg">
What Happened?
</h2>
<p className="text-red-800 mb-4">
{getReasonDescription(reason)}
</p>
<div className="space-y-3 pt-4 border-t border-red-200">
{paymentId && (
<div className="flex justify-between items-center">
<span className="text-slate-600">Payment ID:</span>
<code className="bg-red-100 text-red-800 px-3 py-1 rounded font-mono text-sm">
{paymentId}
</code>
</div>
)}
{amount && currency && (
<div className="flex justify-between items-center">
<span className="text-slate-600">Amount:</span>
<span className="text-red-900 font-bold text-xl">
{currency.toUpperCase()} {(parseInt(amount) / 100).toFixed(2)}
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-slate-600">Status:</span>
<span className="bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold capitalize">
{reason}
</span>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-slate-800 text-lg">Try Again</h3>
<div className="grid gap-3">
<Link
href="/"
className="flex items-center justify-between p-4 border-2 border-red-300 bg-red-50 rounded-lg hover:border-red-500 hover:bg-red-100 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-red-800 group-hover:text-red-900">
🔄 Try Another Payment
</div>
<div className="text-sm text-red-700">
Create a new test payment with different scenario
</div>
</div>
<svg
className="w-5 h-5 text-red-500 group-hover:text-red-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
<Link
href="/admin/collections/payments"
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-slate-800 group-hover:text-blue-700">
💳 View Payment History
</div>
<div className="text-sm text-slate-600">
Check all payments in admin
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
</div>
</div>
<div className="mt-8 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<strong>💡 Testing Tip:</strong> This failure was simulated using the test provider.
Try selecting a different test scenario like "Instant Success" or "Delayed Success"
to see a successful payment flow.
</p>
</div>
</div>
</div>
</div>
)
}
export default function PaymentFailedPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-red-600 to-orange-700 flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
}>
<PaymentFailedContent />
</Suspense>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
function PaymentSuccessContent() {
const searchParams = useSearchParams()
const paymentId = searchParams.get('paymentId')
const amount = searchParams.get('amount')
const currency = searchParams.get('currency')
return (
<div className="min-h-screen bg-gradient-to-br from-green-600 to-emerald-700 flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-xl shadow-2xl overflow-hidden">
<div className="bg-gradient-to-r from-green-600 to-emerald-600 p-8 text-white text-center">
<div className="mb-4">
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center mx-auto">
<svg
className="w-12 h-12 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
<h1 className="text-4xl font-bold mb-2">Payment Successful!</h1>
<p className="text-green-100 text-lg">
Your test payment has been processed successfully
</p>
</div>
<div className="p-8">
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-8">
<h2 className="font-semibold text-green-900 mb-4 text-lg">
Payment Details
</h2>
<div className="space-y-3">
{paymentId && (
<div className="flex justify-between items-center">
<span className="text-slate-600">Payment ID:</span>
<code className="bg-green-100 text-green-800 px-3 py-1 rounded font-mono text-sm">
{paymentId}
</code>
</div>
)}
{amount && currency && (
<div className="flex justify-between items-center">
<span className="text-slate-600">Amount:</span>
<span className="text-green-900 font-bold text-xl">
{currency.toUpperCase()} {(parseInt(amount) / 100).toFixed(2)}
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-slate-600">Status:</span>
<span className="bg-green-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
Succeeded
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Provider:</span>
<span className="text-slate-900 font-medium">Test Provider</span>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-semibold text-slate-800 text-lg">What's Next?</h3>
<div className="grid gap-3">
<Link
href="/"
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-green-500 hover:bg-green-50 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-slate-800 group-hover:text-green-700">
🏠 Back to Demo
</div>
<div className="text-sm text-slate-600">
Try another test payment
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
<Link
href="/admin/collections/payments"
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-slate-800 group-hover:text-blue-700">
💳 View All Payments
</div>
<div className="text-sm text-slate-600">
Check payment history in admin
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
<Link
href="/admin/collections/invoices"
className="flex items-center justify-between p-4 border-2 border-slate-200 rounded-lg hover:border-purple-500 hover:bg-purple-50 transition-all group cursor-pointer"
>
<div>
<div className="font-semibold text-slate-800 group-hover:text-purple-700">
🧾 View Invoices
</div>
<div className="text-sm text-slate-600">
Check invoices in admin
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 group-hover:text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</Link>
</div>
</div>
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>💡 Demo Tip:</strong> This was a simulated payment using the test provider.
In production, you would integrate with real providers like Stripe or Mollie.
</p>
</div>
</div>
</div>
</div>
)
}
export default function PaymentSuccessPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-green-600 to-emerald-700 flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</div>
}>
<PaymentSuccessContent />
</Suspense>
)
}

View File

@@ -0,0 +1,291 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
interface PaymentMethod {
id: string
name: string
icon: string
}
interface Scenario {
id: string
name: string
description: string
outcome: string
delay?: number
}
interface TestProviderConfig {
enabled: boolean
scenarios: Scenario[]
methods: PaymentMethod[]
testModeIndicators: {
showWarningBanners: boolean
showTestBadges: boolean
consoleWarnings: boolean
}
defaultDelay: number
customUiRoute: string
}
interface PaymentSession {
id: string
amount: number
currency: string
description?: string
}
export default function TestPaymentPage() {
const params = useParams()
const router = useRouter()
const paymentId = params.id as string
const [config, setConfig] = useState<TestProviderConfig | null>(null)
const [session, setSession] = useState<PaymentSession | null>(null)
const [selectedMethod, setSelectedMethod] = useState<string | null>(null)
const [selectedScenario, setSelectedScenario] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [status, setStatus] = useState<{
type: 'idle' | 'processing' | 'success' | 'error'
message: string
}>({ type: 'idle', message: '' })
useEffect(() => {
// Load test provider config
fetch('/api/payload-billing/test/config')
.then((res) => res.json())
.then((data) => {
setConfig(data)
if (data.testModeIndicators?.consoleWarnings) {
console.warn('[Test Provider] 🧪 TEST MODE: This is a simulated payment interface')
}
})
.catch((err) => {
console.error('Failed to load test provider config:', err)
})
// Load payment session (mock data for demo)
setSession({
id: paymentId,
amount: 2500,
currency: 'USD',
description: 'Demo payment for testing the billing plugin',
})
}, [paymentId])
const handleProcessPayment = async () => {
if (!selectedMethod || !selectedScenario) return
setProcessing(true)
setStatus({ type: 'processing', message: 'Initiating payment...' })
try {
const response = await fetch('/api/payload-billing/test/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentId,
scenarioId: selectedScenario,
method: selectedMethod,
}),
})
const result = await response.json()
if (result.success) {
const scenario = config?.scenarios.find((s) => s.id === selectedScenario)
setStatus({
type: 'processing',
message: `Processing payment with ${scenario?.name}...`,
})
// Poll for status updates
setTimeout(() => pollStatus(), result.delay || 1000)
} else {
throw new Error(result.error || 'Failed to process payment')
}
} catch (error) {
setStatus({
type: 'error',
message: error instanceof Error ? error.message : 'An error occurred',
})
setProcessing(false)
}
}
const pollStatus = async () => {
try {
const response = await fetch(`/api/payload-billing/test/status/${paymentId}`)
const result = await response.json()
if (result.status === 'paid') {
setStatus({ type: 'success', message: '✅ Payment successful!' })
setTimeout(() => {
const params = new URLSearchParams({
paymentId: paymentId,
amount: session.amount.toString(),
currency: session.currency,
})
router.push(`/payment-success?${params.toString()}`)
}, 2000)
} else if (['failed', 'cancelled', 'expired'].includes(result.status)) {
setStatus({ type: 'error', message: `❌ Payment ${result.status}` })
setTimeout(() => {
const params = new URLSearchParams({
paymentId: paymentId,
amount: session.amount.toString(),
currency: session.currency,
reason: result.status,
})
router.push(`/payment-failed?${params.toString()}`)
}, 2000)
} else if (result.status === 'pending') {
setStatus({ type: 'processing', message: 'Payment is still pending...' })
setTimeout(() => pollStatus(), 2000)
}
} catch (error) {
console.error('[Test Provider] Failed to poll status:', error)
setStatus({ type: 'error', message: 'Failed to check payment status' })
setProcessing(false)
}
}
if (!config || !session) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 to-purple-700 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-2xl p-8">
<div className="animate-pulse">Loading...</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 to-purple-700 p-4 md:p-8">
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-xl shadow-2xl overflow-hidden">
{config.testModeIndicators.showWarningBanners && (
<div className="bg-gradient-to-r from-orange-500 to-red-500 text-white px-6 py-3 text-center font-semibold">
🧪 TEST MODE - This is a simulated payment for development purposes
</div>
)}
<div className="bg-slate-50 px-8 py-6 border-b border-slate-200">
<div className="flex items-center justify-between mb-2">
<h1 className="text-2xl font-bold text-slate-800">Test Payment Checkout</h1>
{config.testModeIndicators.showTestBadges && (
<span className="bg-slate-600 text-white px-3 py-1 rounded text-xs font-bold uppercase">
Test
</span>
)}
</div>
<div className="text-3xl font-bold text-green-600 mb-3">
{session.currency.toUpperCase()} {(session.amount / 100).toFixed(2)}
</div>
{session.description && (
<p className="text-slate-600 text-base">{session.description}</p>
)}
</div>
<div className="p-8">
{/* Payment Methods */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
💳 Select Payment Method
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{config.methods.map((method) => (
<button
key={method.id}
onClick={() => setSelectedMethod(method.id)}
disabled={processing}
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
selectedMethod === method.id
? 'border-blue-500 bg-blue-500 text-white shadow-lg'
: 'border-slate-200 bg-white text-slate-700 hover:border-blue-300 hover:bg-blue-50'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="text-2xl mb-2">{method.icon}</div>
<div className="text-sm font-medium">{method.name}</div>
</button>
))}
</div>
</div>
{/* Test Scenarios */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
🎭 Select Test Scenario
</h2>
<div className="space-y-3">
{config.scenarios.map((scenario) => (
<button
key={scenario.id}
onClick={() => setSelectedScenario(scenario.id)}
disabled={processing}
className={`w-full p-4 rounded-lg border-2 text-left transition-all cursor-pointer ${
selectedScenario === scenario.id
? 'border-green-500 bg-green-500 text-white shadow-lg'
: 'border-slate-200 bg-white text-slate-700 hover:border-green-300 hover:bg-green-50'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<div className="font-semibold mb-1">{scenario.name}</div>
<div className={`text-sm ${selectedScenario === scenario.id ? 'text-white/90' : 'text-slate-600'}`}>
{scenario.description}
</div>
</button>
))}
</div>
</div>
{/* Process Button */}
<button
onClick={handleProcessPayment}
disabled={!selectedMethod || !selectedScenario || processing}
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white font-semibold py-4 rounded-lg transition-all hover:shadow-lg hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none cursor-pointer"
>
{processing ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></span>
Processing...
</span>
) : (
'Process Test Payment'
)}
</button>
{/* Status Message */}
{status.type !== 'idle' && (
<div
className={`mt-6 p-4 rounded-lg text-center font-semibold ${
status.type === 'processing'
? 'bg-yellow-50 text-yellow-800 border border-yellow-200'
: status.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{status.type === 'processing' && (
<span className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-yellow-800 mr-2"></span>
)}
{status.message}
</div>
)}
</div>
</div>
{/* Info Card */}
<div className="mt-6 bg-white/10 backdrop-blur-sm text-white rounded-lg p-6">
<h3 className="font-semibold mb-2">💡 Demo Information</h3>
<p className="text-sm text-white/90">
This is a custom test payment UI for the @xtr-dev/payload-billing plugin. Select a
payment method and scenario to simulate different payment outcomes. The payment will be
processed according to the selected scenario.
</p>
</div>
</div>
</div>
)
}