mirror of
https://github.com/xtr-dev/payload-billing.git
synced 2025-12-10 02:43:24 +00:00
feat: add automatic payment/invoice status sync and invoice view page
Core Plugin Enhancements: - Add afterChange hook to payments collection to auto-update linked invoice status to 'paid' when payment succeeds - Add afterChange hook to invoices collection for bidirectional payment-invoice relationship management - Add invoice status sync when manually marked as paid - Update plugin config types to support collection extension options Demo Application Features: - Add professional invoice view page with print-friendly layout (/invoice/[id]) - Add custom message field to payment creation form - Add invoice API endpoint to fetch complete invoice data with customer info - Add payment API endpoint to retrieve payment with invoice relationship - Update payment success page with "View Invoice" button - Implement beforeChange hook to copy custom message from payment metadata to invoice - Remove customer collection dependency - use direct customerInfo fields instead Documentation: - Update README with automatic status synchronization section - Add collection extension examples to demo README - Document new features: bidirectional relationships, status sync, invoice view Technical Improvements: - Fix total calculation in invoice API (use 'amount' field instead of 'total') - Add proper TypeScript types with CollectionSlug casting - Implement Next.js 15 async params pattern in API routes - Add customer name/email/company fields to payment creation form 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,10 @@ export async function POST(request: Request) {
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const { amount, currency, description } = body
|
||||
const { amount, currency, description, message, customerName, customerEmail, customerCompany } = body
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Received payment request:', { amount, currency, customerName, customerEmail, customerCompany })
|
||||
|
||||
if (!amount || !currency) {
|
||||
return Response.json(
|
||||
@@ -17,7 +20,16 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Create a payment using the test provider
|
||||
if (!customerName || !customerEmail) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Missing customer info:', { customerName, customerEmail })
|
||||
return Response.json(
|
||||
{ success: false, error: 'Customer name and email are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create a payment first using the test provider
|
||||
const payment = await payload.create({
|
||||
collection: 'payments',
|
||||
data: {
|
||||
@@ -29,10 +41,42 @@ export async function POST(request: Request) {
|
||||
metadata: {
|
||||
source: 'demo-ui',
|
||||
createdAt: new Date().toISOString(),
|
||||
customMessage: message, // Store the custom message in metadata
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create an invoice linked to the payment
|
||||
// The invoice's afterChange hook will automatically link the payment back to the invoice
|
||||
const invoice = await payload.create({
|
||||
collection: 'invoices',
|
||||
data: {
|
||||
payment: payment.id, // Link to the payment
|
||||
customerInfo: {
|
||||
name: customerName,
|
||||
email: customerEmail,
|
||||
company: customerCompany,
|
||||
},
|
||||
billingAddress: {
|
||||
line1: '123 Demo Street',
|
||||
city: 'Demo City',
|
||||
state: 'DC',
|
||||
postalCode: '12345',
|
||||
country: 'US',
|
||||
},
|
||||
currency,
|
||||
items: [
|
||||
{
|
||||
description: description || 'Demo payment',
|
||||
quantity: 1,
|
||||
unitAmount: amount,
|
||||
},
|
||||
],
|
||||
taxAmount: 0,
|
||||
status: 'open',
|
||||
},
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
payment: {
|
||||
@@ -41,6 +85,7 @@ export async function POST(request: Request) {
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
description: payment.description,
|
||||
invoiceId: invoice.id,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
116
dev/app/api/demo/invoice/[id]/route.ts
Normal file
116
dev/app/api/demo/invoice/[id]/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
})
|
||||
|
||||
const { id: invoiceId } = await params
|
||||
|
||||
if (!invoiceId) {
|
||||
return Response.json(
|
||||
{ success: false, error: 'Invoice ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the invoice
|
||||
const invoice = await payload.findByID({
|
||||
collection: 'invoices',
|
||||
id: invoiceId,
|
||||
})
|
||||
|
||||
if (!invoice) {
|
||||
return Response.json(
|
||||
{ success: false, error: 'Invoice not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get customer info - either from relationship or direct fields
|
||||
let customerInfo = null
|
||||
|
||||
if (invoice.customer) {
|
||||
// Try to fetch from customer relationship
|
||||
try {
|
||||
const customerData = await payload.findByID({
|
||||
collection: 'customers',
|
||||
id: typeof invoice.customer === 'object' ? invoice.customer.id : invoice.customer,
|
||||
})
|
||||
customerInfo = {
|
||||
name: customerData.name,
|
||||
email: customerData.email,
|
||||
phone: customerData.phone,
|
||||
company: customerData.company,
|
||||
taxId: customerData.taxId,
|
||||
billingAddress: customerData.address,
|
||||
}
|
||||
} catch (error) {
|
||||
// Customer not found or collection doesn't exist
|
||||
console.error('Failed to fetch customer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to direct customerInfo fields if no customer relationship
|
||||
if (!customerInfo && invoice.customerInfo) {
|
||||
customerInfo = {
|
||||
name: invoice.customerInfo.name,
|
||||
email: invoice.customerInfo.email,
|
||||
phone: invoice.customerInfo.phone,
|
||||
company: invoice.customerInfo.company,
|
||||
taxId: invoice.customerInfo.taxId,
|
||||
billingAddress: invoice.billingAddress,
|
||||
}
|
||||
}
|
||||
|
||||
// Default customer if neither is available
|
||||
if (!customerInfo) {
|
||||
customerInfo = {
|
||||
name: 'Unknown Customer',
|
||||
email: 'unknown@example.com',
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate subtotal from items (or use stored subtotal)
|
||||
const subtotal = invoice.subtotal || invoice.items?.reduce((sum: number, item: any) => {
|
||||
return sum + (item.unitAmount * item.quantity)
|
||||
}, 0) || 0
|
||||
|
||||
const taxAmount = invoice.taxAmount || 0
|
||||
const total = invoice.amount || (subtotal + taxAmount)
|
||||
|
||||
// Prepare the response
|
||||
const invoiceData = {
|
||||
id: invoice.id,
|
||||
invoiceNumber: invoice.number || invoice.invoiceNumber,
|
||||
customer: customerInfo,
|
||||
currency: invoice.currency,
|
||||
items: invoice.items || [],
|
||||
subtotal,
|
||||
taxAmount,
|
||||
total,
|
||||
status: invoice.status,
|
||||
customMessage: invoice.customMessage,
|
||||
issuedAt: invoice.issuedAt,
|
||||
dueDate: invoice.dueDate,
|
||||
createdAt: invoice.createdAt,
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
invoice: invoiceData,
|
||||
})
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch invoice:', error)
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch invoice',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
63
dev/app/api/demo/payment/[id]/route.ts
Normal file
63
dev/app/api/demo/payment/[id]/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
})
|
||||
|
||||
const { id: paymentProviderId } = await params
|
||||
|
||||
if (!paymentProviderId) {
|
||||
return Response.json(
|
||||
{ success: false, error: 'Payment ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find payment by providerId (the test provider uses this format)
|
||||
const payments = await payload.find({
|
||||
collection: 'payments',
|
||||
where: {
|
||||
providerId: {
|
||||
equals: paymentProviderId,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (!payments.docs.length) {
|
||||
return Response.json(
|
||||
{ success: false, error: 'Payment not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const payment = payments.docs[0]
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
payment: {
|
||||
id: payment.id,
|
||||
providerId: payment.providerId,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
status: payment.status,
|
||||
description: payment.description,
|
||||
invoice: payment.invoice,
|
||||
metadata: payment.metadata,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch payment:', error)
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch payment',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
317
dev/app/invoice/[id]/page.tsx
Normal file
317
dev/app/invoice/[id]/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface InvoiceItem {
|
||||
description: string
|
||||
quantity: number
|
||||
unitAmount: number
|
||||
id?: string
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
company?: string
|
||||
taxId?: string
|
||||
billingAddress?: {
|
||||
line1: string
|
||||
line2?: string
|
||||
city: string
|
||||
state?: string
|
||||
postalCode: string
|
||||
country: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string
|
||||
invoiceNumber: string
|
||||
customer: Customer
|
||||
currency: string
|
||||
items: InvoiceItem[]
|
||||
subtotal: number
|
||||
taxAmount?: number
|
||||
total: number
|
||||
status: string
|
||||
customMessage?: string
|
||||
issuedAt?: string
|
||||
dueDate?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function InvoiceViewPage() {
|
||||
const params = useParams()
|
||||
const invoiceId = params.id as string
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
}, [invoiceId])
|
||||
|
||||
const fetchInvoice = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/demo/invoice/${invoiceId}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setInvoice(data.invoice)
|
||||
} else {
|
||||
setError(data.error || 'Failed to load invoice')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
<div className="text-slate-600 text-lg">Loading invoice...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !invoice) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
<div className="max-w-2xl w-full bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-red-600 text-5xl mb-4">⚠️</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">Invoice Not Found</h1>
|
||||
<p className="text-slate-600 mb-6">{error || 'The requested invoice could not be found.'}</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Back to Demo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return `${invoice.currency.toUpperCase()} ${(amount / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 py-8 print:bg-white print:py-0">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Print Button - Hidden when printing */}
|
||||
<div className="mb-6 flex justify-end print:hidden">
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
|
||||
/>
|
||||
</svg>
|
||||
Print Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Invoice Container */}
|
||||
<div className="bg-white rounded-lg shadow-lg print:shadow-none print:rounded-none">
|
||||
<div className="p-8 md:p-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8 pb-8 border-b-2 border-slate-200">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-slate-800 mb-2">INVOICE</h1>
|
||||
<p className="text-slate-600">Invoice #{invoice.invoiceNumber}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-blue-600 mb-1">
|
||||
@xtr-dev/payload-billing
|
||||
</div>
|
||||
<p className="text-slate-600 text-sm">Test Provider Demo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Bill To */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase mb-3">Bill To</h2>
|
||||
<div className="text-slate-800">
|
||||
<p className="font-semibold text-lg">{invoice.customer.name}</p>
|
||||
{invoice.customer.company && (
|
||||
<p className="text-slate-600">{invoice.customer.company}</p>
|
||||
)}
|
||||
<p className="text-slate-600">{invoice.customer.email}</p>
|
||||
{invoice.customer.phone && (
|
||||
<p className="text-slate-600">{invoice.customer.phone}</p>
|
||||
)}
|
||||
{invoice.customer.billingAddress && (
|
||||
<div className="mt-2 text-slate-600">
|
||||
<p>{invoice.customer.billingAddress.line1}</p>
|
||||
{invoice.customer.billingAddress.line2 && (
|
||||
<p>{invoice.customer.billingAddress.line2}</p>
|
||||
)}
|
||||
<p>
|
||||
{invoice.customer.billingAddress.city}
|
||||
{invoice.customer.billingAddress.state && `, ${invoice.customer.billingAddress.state}`} {invoice.customer.billingAddress.postalCode}
|
||||
</p>
|
||||
<p>{invoice.customer.billingAddress.country}</p>
|
||||
</div>
|
||||
)}
|
||||
{invoice.customer.taxId && (
|
||||
<p className="mt-2 text-slate-600">Tax ID: {invoice.customer.taxId}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Details */}
|
||||
<div className="text-right md:text-left">
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase mb-3">Invoice Details</h2>
|
||||
<div className="space-y-2 text-slate-800">
|
||||
<div className="flex justify-between md:justify-start md:gap-4">
|
||||
<span className="text-slate-600">Status:</span>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
invoice.status === 'paid'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: invoice.status === 'open'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: invoice.status === 'void'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-slate-100 text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{invoice.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between md:justify-start md:gap-4">
|
||||
<span className="text-slate-600">Issued:</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(invoice.issuedAt || invoice.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.dueDate && (
|
||||
<div className="flex justify-between md:justify-start md:gap-4">
|
||||
<span className="text-slate-600">Due:</span>
|
||||
<span className="font-medium">{formatDate(invoice.dueDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
{invoice.customMessage && (
|
||||
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h3 className="text-sm font-semibold text-blue-900 uppercase mb-2">Message</h3>
|
||||
<p className="text-blue-800 whitespace-pre-wrap">{invoice.customMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line Items Table */}
|
||||
<div className="mb-8">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-slate-300">
|
||||
<th className="text-left py-3 text-slate-700 font-semibold">Description</th>
|
||||
<th className="text-right py-3 text-slate-700 font-semibold w-24">Qty</th>
|
||||
<th className="text-right py-3 text-slate-700 font-semibold w-32">Unit Price</th>
|
||||
<th className="text-right py-3 text-slate-700 font-semibold w-32">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.items.map((item, index) => (
|
||||
<tr key={item.id || index} className="border-b border-slate-200">
|
||||
<td className="py-4 text-slate-800">{item.description}</td>
|
||||
<td className="py-4 text-right text-slate-800">{item.quantity}</td>
|
||||
<td className="py-4 text-right text-slate-800">
|
||||
{formatCurrency(item.unitAmount)}
|
||||
</td>
|
||||
<td className="py-4 text-right text-slate-800 font-medium">
|
||||
{formatCurrency(item.unitAmount * item.quantity)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="flex justify-end mb-8">
|
||||
<div className="w-full md:w-80">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 text-slate-700">
|
||||
<span>Subtotal:</span>
|
||||
<span className="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||
</div>
|
||||
{invoice.taxAmount !== undefined && invoice.taxAmount > 0 && (
|
||||
<div className="flex justify-between py-2 text-slate-700">
|
||||
<span>Tax:</span>
|
||||
<span className="font-medium">{formatCurrency(invoice.taxAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between py-3 border-t-2 border-slate-300 text-lg font-bold text-slate-900">
|
||||
<span>Total:</span>
|
||||
<span>{formatCurrency(invoice.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-8 border-t border-slate-200 text-center text-slate-500 text-sm">
|
||||
<p>Thank you for your business!</p>
|
||||
<p className="mt-2">
|
||||
This is a demo invoice generated by @xtr-dev/payload-billing plugin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back Button - Hidden when printing */}
|
||||
<div className="mt-6 text-center print:hidden">
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block text-blue-600 hover:text-blue-700 font-semibold transition-colors"
|
||||
>
|
||||
← Back to Demo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print Styles */}
|
||||
<style jsx global>{`
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
@page {
|
||||
margin: 1cm;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
dev/app/page.tsx
102
dev/app/page.tsx
@@ -7,20 +7,39 @@ export default function HomePage() {
|
||||
const [paymentId, setPaymentId] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [customerName, setCustomerName] = useState<string>('Demo Customer')
|
||||
const [customerEmail, setCustomerEmail] = useState<string>('demo@example.com')
|
||||
const [customerCompany, setCustomerCompany] = useState<string>('Demo Company')
|
||||
const [message, setMessage] = useState<string>('')
|
||||
|
||||
const createDemoPayment = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
// Validate required fields
|
||||
if (!customerName || !customerEmail) {
|
||||
setError('Customer name and email are required')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
amount: 2500,
|
||||
currency: 'USD',
|
||||
description: 'Demo payment from custom UI',
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerCompany: customerCompany || undefined,
|
||||
message: message || undefined,
|
||||
}
|
||||
|
||||
console.log('Sending payment request:', requestBody)
|
||||
|
||||
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',
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@@ -64,17 +83,80 @@ export default function HomePage() {
|
||||
</h3>
|
||||
|
||||
{!paymentId ? (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="customerName" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Customer Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="customerName"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customerEmail" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Customer Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="customerEmail"
|
||||
value={customerEmail}
|
||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customerCompany" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Company Name (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="customerCompany"
|
||||
value={customerCompany}
|
||||
onChange={(e) => setCustomerCompany(e.target.value)}
|
||||
placeholder="Acme Corporation"
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Custom Message (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
rows={3}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Enter a message to include in the invoice..."
|
||||
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
This message will be added to the invoice using collection extension options
|
||||
</p>
|
||||
</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"
|
||||
className="w-full 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">
|
||||
<div className="p-4 bg-red-50 border border-red-200 text-red-800 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -102,6 +184,10 @@ export default function HomePage() {
|
||||
onClick={() => {
|
||||
setPaymentId('')
|
||||
setError('')
|
||||
setCustomerName('Demo Customer')
|
||||
setCustomerEmail('demo@example.com')
|
||||
setCustomerCompany('Demo Company')
|
||||
setMessage('')
|
||||
}}
|
||||
className="bg-slate-200 text-slate-700 px-6 py-3 rounded-lg font-semibold hover:bg-slate-300 transition-all cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -2,13 +2,31 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
|
||||
function PaymentSuccessContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const paymentId = searchParams.get('paymentId')
|
||||
const amount = searchParams.get('amount')
|
||||
const currency = searchParams.get('currency')
|
||||
const [invoiceId, setInvoiceId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch the payment to get the invoice ID
|
||||
if (paymentId) {
|
||||
fetch(`/api/demo/payment/${paymentId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success && data.payment?.invoice) {
|
||||
const invId = typeof data.payment.invoice === 'object' ? data.payment.invoice.id : data.payment.invoice
|
||||
setInvoiceId(invId)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch payment invoice:', err)
|
||||
})
|
||||
}
|
||||
}, [paymentId])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-600 to-emerald-700 flex items-center justify-center p-4">
|
||||
@@ -76,6 +94,35 @@ function PaymentSuccessContent() {
|
||||
<h3 className="font-semibold text-slate-800 text-lg">What's Next?</h3>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{invoiceId && (
|
||||
<Link
|
||||
href={`/invoice/${invoiceId}`}
|
||||
className="flex items-center justify-between p-4 border-2 border-green-500 bg-green-50 rounded-lg hover:bg-green-100 transition-all group cursor-pointer"
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-green-800 group-hover:text-green-900">
|
||||
📄 View Invoice
|
||||
</div>
|
||||
<div className="text-sm text-green-700">
|
||||
See your invoice with custom message
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-5 h-5 text-green-600 group-hover:text-green-800"
|
||||
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="/"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user