[OQI-005] feat: Add invoice and subscription management components
- InvoiceList: Paginated invoice table with filters - InvoiceDetail: Full invoice modal with line items - PaymentMethodsList: Manage saved payment methods - SubscriptionUpgradeFlow: Plan upgrade/downgrade with preview Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c956ac0c0f
commit
c7626f841c
372
src/components/payments/InvoiceDetail.tsx
Normal file
372
src/components/payments/InvoiceDetail.tsx
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* InvoiceDetail Component
|
||||||
|
* Modal displaying full invoice details with line items
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
|
Mail,
|
||||||
|
Printer,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
CreditCard,
|
||||||
|
Building,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getInvoiceById, downloadInvoice } from '../../services/payment.service';
|
||||||
|
|
||||||
|
interface InvoiceLineItem {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceData {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
status: 'paid' | 'pending' | 'failed' | 'refunded' | 'void';
|
||||||
|
amount: number;
|
||||||
|
subtotal: number;
|
||||||
|
tax: number;
|
||||||
|
taxRate?: number;
|
||||||
|
discount?: number;
|
||||||
|
currency: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
paidAt?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
lineItems: InvoiceLineItem[];
|
||||||
|
billingDetails?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company?: string;
|
||||||
|
address?: {
|
||||||
|
line1: string;
|
||||||
|
line2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
paymentMethod?: {
|
||||||
|
type: string;
|
||||||
|
last4?: string;
|
||||||
|
brand?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceDetailProps {
|
||||||
|
invoiceId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onDownload?: (invoiceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InvoiceDetail: React.FC<InvoiceDetailProps> = ({
|
||||||
|
invoiceId,
|
||||||
|
onClose,
|
||||||
|
onDownload,
|
||||||
|
}) => {
|
||||||
|
const [invoice, setInvoice] = useState<InvoiceData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInvoice = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getInvoiceById(invoiceId);
|
||||||
|
setInvoice(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load invoice');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchInvoice();
|
||||||
|
}, [invoiceId]);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
if (onDownload) {
|
||||||
|
onDownload(invoiceId);
|
||||||
|
} else {
|
||||||
|
await downloadInvoice(invoiceId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed:', err);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
const invoiceUrl = `${window.location.origin}/invoices/${invoiceId}`;
|
||||||
|
await navigator.clipboard.writeText(invoiceUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: InvoiceData['status']) => {
|
||||||
|
const styles = {
|
||||||
|
paid: { bg: 'bg-green-500/20', text: 'text-green-400', icon: CheckCircle },
|
||||||
|
pending: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', icon: Clock },
|
||||||
|
failed: { bg: 'bg-red-500/20', text: 'text-red-400', icon: AlertCircle },
|
||||||
|
refunded: { bg: 'bg-blue-500/20', text: 'text-blue-400', icon: CheckCircle },
|
||||||
|
void: { bg: 'bg-gray-500/20', text: 'text-gray-400', icon: AlertCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = styles[status];
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium ${style.bg} ${style.text}`}>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="capitalize">{status}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||||
|
<div className="w-full max-w-2xl max-h-[90vh] overflow-hidden bg-gray-800 rounded-xl shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-600/20 rounded-lg">
|
||||||
|
<FileText className="w-5 h-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Invoice Details</h2>
|
||||||
|
{invoice && (
|
||||||
|
<p className="text-sm text-gray-400">{invoice.number}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="overflow-y-auto max-h-[calc(90vh-140px)] p-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : invoice ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status & Dates */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-gray-700">
|
||||||
|
{getStatusBadge(invoice.status)}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>Issued: {formatDate(invoice.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{invoice.paidAt && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
|
<span>Paid: {formatDate(invoice.paidAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Billing Details */}
|
||||||
|
{invoice.billingDetails && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Building className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-400">Bill To</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-white font-medium">{invoice.billingDetails.name}</p>
|
||||||
|
{invoice.billingDetails.company && (
|
||||||
|
<p className="text-gray-400 text-sm">{invoice.billingDetails.company}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-gray-400 text-sm">{invoice.billingDetails.email}</p>
|
||||||
|
{invoice.billingDetails.address && (
|
||||||
|
<p className="text-gray-500 text-sm mt-2">
|
||||||
|
{invoice.billingDetails.address.line1}
|
||||||
|
{invoice.billingDetails.address.line2 && <>, {invoice.billingDetails.address.line2}</>}
|
||||||
|
<br />
|
||||||
|
{invoice.billingDetails.address.city}, {invoice.billingDetails.address.state} {invoice.billingDetails.address.postalCode}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice.paymentMethod && (
|
||||||
|
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<CreditCard className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-400">Payment Method</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-white">
|
||||||
|
{invoice.paymentMethod.brand} •••• {invoice.paymentMethod.last4}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-3">Items</h3>
|
||||||
|
<div className="bg-gray-900/50 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Description</th>
|
||||||
|
<th className="text-center py-3 px-4 text-sm font-medium text-gray-400">Qty</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-400">Unit Price</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-400">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{invoice.lineItems.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="py-3 px-4 text-white">{item.description}</td>
|
||||||
|
<td className="py-3 px-4 text-center text-gray-400">{item.quantity}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-gray-400 font-mono">
|
||||||
|
{formatAmount(item.unitPrice, invoice.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-white font-mono">
|
||||||
|
{formatAmount(item.amount, invoice.currency)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="w-64 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Subtotal</span>
|
||||||
|
<span className="text-white font-mono">{formatAmount(invoice.subtotal, invoice.currency)}</span>
|
||||||
|
</div>
|
||||||
|
{invoice.discount && invoice.discount > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Discount</span>
|
||||||
|
<span className="text-green-400 font-mono">-{formatAmount(invoice.discount, invoice.currency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.tax > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">
|
||||||
|
Tax {invoice.taxRate && `(${invoice.taxRate}%)`}
|
||||||
|
</span>
|
||||||
|
<span className="text-white font-mono">{formatAmount(invoice.tax, invoice.currency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between pt-2 border-t border-gray-700">
|
||||||
|
<span className="text-white font-medium">Total</span>
|
||||||
|
<span className="text-white font-bold font-mono text-lg">
|
||||||
|
{formatAmount(invoice.amount, invoice.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
{invoice && (
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-gray-700 bg-gray-800/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 text-green-400" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
Copy Link
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Downloading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Download PDF
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InvoiceDetail;
|
||||||
361
src/components/payments/InvoiceList.tsx
Normal file
361
src/components/payments/InvoiceList.tsx
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* InvoiceList Component
|
||||||
|
* Display paginated invoice history with filtering and actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getInvoices, downloadInvoice } from '../../services/payment.service';
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
status: 'paid' | 'pending' | 'failed' | 'refunded' | 'void';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
paidAt?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
lineItems?: InvoiceLineItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceLineItem {
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceListProps {
|
||||||
|
onInvoiceClick?: (invoice: Invoice) => void;
|
||||||
|
onDownload?: (invoice: Invoice) => void;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
showFilters?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'paid' | 'pending' | 'failed';
|
||||||
|
|
||||||
|
const InvoiceList: React.FC<InvoiceListProps> = ({
|
||||||
|
onInvoiceClick,
|
||||||
|
onDownload,
|
||||||
|
itemsPerPage = 10,
|
||||||
|
showFilters = true,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchInvoices = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getInvoices({
|
||||||
|
page: currentPage,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setInvoices(result.invoices);
|
||||||
|
setTotalPages(Math.ceil(result.total / itemsPerPage));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load invoices');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInvoices();
|
||||||
|
}, [currentPage, statusFilter, itemsPerPage]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (currentPage === 1) {
|
||||||
|
fetchInvoices();
|
||||||
|
} else {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleDownload = async (invoice: Invoice) => {
|
||||||
|
setDownloadingId(invoice.id);
|
||||||
|
try {
|
||||||
|
if (onDownload) {
|
||||||
|
onDownload(invoice);
|
||||||
|
} else {
|
||||||
|
await downloadInvoice(invoice.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed:', err);
|
||||||
|
} finally {
|
||||||
|
setDownloadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: Invoice['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="w-4 h-4 text-yellow-400" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="w-4 h-4 text-red-400" />;
|
||||||
|
case 'refunded':
|
||||||
|
return <RefreshCw className="w-4 h-4 text-blue-400" />;
|
||||||
|
case 'void':
|
||||||
|
return <XCircle className="w-4 h-4 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: Invoice['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return 'text-green-400 bg-green-500/20';
|
||||||
|
case 'pending':
|
||||||
|
return 'text-yellow-400 bg-yellow-500/20';
|
||||||
|
case 'failed':
|
||||||
|
return 'text-red-400 bg-red-500/20';
|
||||||
|
case 'refunded':
|
||||||
|
return 'text-blue-400 bg-blue-500/20';
|
||||||
|
case 'void':
|
||||||
|
return 'text-gray-400 bg-gray-500/20';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && invoices.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-400 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchInvoices}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search invoices..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4 text-gray-500" />
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value as StatusFilter);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={fetchInvoices}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invoice Table */}
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-400">No invoices found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Invoice</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Date</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Status</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-400">Amount</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<tr
|
||||||
|
key={invoice.id}
|
||||||
|
className={`hover:bg-gray-800/50 transition-colors ${
|
||||||
|
onInvoiceClick ? 'cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onInvoiceClick?.(invoice)}
|
||||||
|
>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gray-800 rounded-lg">
|
||||||
|
<FileText className="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">{invoice.number}</p>
|
||||||
|
<p className="text-sm text-gray-500 truncate max-w-[200px]">
|
||||||
|
{invoice.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<p className="text-gray-300">{formatDate(invoice.createdAt)}</p>
|
||||||
|
{invoice.dueDate && invoice.status === 'pending' && (
|
||||||
|
<p className="text-xs text-gray-500">Due: {formatDate(invoice.dueDate)}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${getStatusColor(
|
||||||
|
invoice.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{getStatusIcon(invoice.status)}
|
||||||
|
<span className="capitalize">{invoice.status}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<span className="text-white font-medium font-mono">
|
||||||
|
{formatAmount(invoice.amount, invoice.currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{onInvoiceClick && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onInvoiceClick(invoice);
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDownload(invoice);
|
||||||
|
}}
|
||||||
|
disabled={downloadingId === invoice.id}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
title="Download PDF"
|
||||||
|
>
|
||||||
|
{downloadingId === invoice.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-700">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InvoiceList;
|
||||||
366
src/components/payments/PaymentMethodsList.tsx
Normal file
366
src/components/payments/PaymentMethodsList.tsx
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* PaymentMethodsList Component
|
||||||
|
* Display and manage saved payment methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Star,
|
||||||
|
MoreVertical,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
Shield,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
getPaymentMethods,
|
||||||
|
removePaymentMethod,
|
||||||
|
setDefaultPaymentMethod,
|
||||||
|
} from '../../services/payment.service';
|
||||||
|
|
||||||
|
export interface PaymentMethod {
|
||||||
|
id: string;
|
||||||
|
type: 'card' | 'bank_account' | 'paypal';
|
||||||
|
isDefault: boolean;
|
||||||
|
card?: {
|
||||||
|
brand: string;
|
||||||
|
last4: string;
|
||||||
|
expMonth: number;
|
||||||
|
expYear: number;
|
||||||
|
};
|
||||||
|
bankAccount?: {
|
||||||
|
bankName: string;
|
||||||
|
last4: string;
|
||||||
|
accountType: string;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentMethodsListProps {
|
||||||
|
onAddNew?: () => void;
|
||||||
|
onMethodSelect?: (method: PaymentMethod) => void;
|
||||||
|
selectable?: boolean;
|
||||||
|
selectedId?: string;
|
||||||
|
showAddButton?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaymentMethodsList: React.FC<PaymentMethodsListProps> = ({
|
||||||
|
onAddNew,
|
||||||
|
onMethodSelect,
|
||||||
|
selectable = false,
|
||||||
|
selectedId,
|
||||||
|
showAddButton = true,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const [methods, setMethods] = useState<PaymentMethod[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMethods = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getPaymentMethods();
|
||||||
|
setMethods(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load payment methods');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMethods();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetDefault = async (methodId: string) => {
|
||||||
|
setActionLoading(methodId);
|
||||||
|
setOpenMenuId(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setDefaultPaymentMethod(methodId);
|
||||||
|
setMethods((prev) =>
|
||||||
|
prev.map((m) => ({
|
||||||
|
...m,
|
||||||
|
isDefault: m.id === methodId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set default:', err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (methodId: string) => {
|
||||||
|
setActionLoading(methodId);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setOpenMenuId(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removePaymentMethod(methodId);
|
||||||
|
setMethods((prev) => prev.filter((m) => m.id !== methodId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove:', err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCardIcon = (brand: string) => {
|
||||||
|
// In a real app, you'd use card brand icons
|
||||||
|
return <CreditCard className="w-6 h-6" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCardBrandColor = (brand: string) => {
|
||||||
|
switch (brand.toLowerCase()) {
|
||||||
|
case 'visa':
|
||||||
|
return 'text-blue-400';
|
||||||
|
case 'mastercard':
|
||||||
|
return 'text-orange-400';
|
||||||
|
case 'amex':
|
||||||
|
return 'text-blue-300';
|
||||||
|
case 'discover':
|
||||||
|
return 'text-orange-300';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpiringSoon = (expMonth: number, expYear: number) => {
|
||||||
|
const now = new Date();
|
||||||
|
const expDate = new Date(expYear, expMonth - 1);
|
||||||
|
const threeMonths = new Date();
|
||||||
|
threeMonths.setMonth(threeMonths.getMonth() + 3);
|
||||||
|
return expDate <= threeMonths && expDate >= now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (expMonth: number, expYear: number) => {
|
||||||
|
const now = new Date();
|
||||||
|
const expDate = new Date(expYear, expMonth);
|
||||||
|
return expDate < now;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-400 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchMethods}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Payment Methods List */}
|
||||||
|
{methods.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||||
|
<CreditCard className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-400 mb-4">No payment methods saved</p>
|
||||||
|
{showAddButton && onAddNew && (
|
||||||
|
<button
|
||||||
|
onClick={onAddNew}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Add Payment Method
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{methods.map((method) => {
|
||||||
|
const isSelected = selectable && selectedId === method.id;
|
||||||
|
const expired = method.card && isExpired(method.card.expMonth, method.card.expYear);
|
||||||
|
const expiringSoon = method.card && !expired && isExpiringSoon(method.card.expMonth, method.card.expYear);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={method.id}
|
||||||
|
onClick={() => selectable && !expired && onMethodSelect?.(method)}
|
||||||
|
className={`relative flex items-center justify-between p-4 bg-gray-800/50 rounded-xl border transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 ring-2 ring-blue-500/20'
|
||||||
|
: expired
|
||||||
|
? 'border-red-500/30 opacity-60'
|
||||||
|
: 'border-gray-700 hover:border-gray-600'
|
||||||
|
} ${selectable && !expired ? 'cursor-pointer' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Card Info */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-3 bg-gray-900 rounded-lg ${getCardBrandColor(method.card?.brand || '')}`}>
|
||||||
|
{getCardIcon(method.card?.brand || '')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white font-medium capitalize">
|
||||||
|
{method.card?.brand || method.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">•••• {method.card?.last4}</span>
|
||||||
|
{method.isDefault && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs">
|
||||||
|
<Star className="w-3 h-3" />
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className={`text-sm ${expired ? 'text-red-400' : expiringSoon ? 'text-yellow-400' : 'text-gray-500'}`}>
|
||||||
|
{expired ? 'Expired' : `Expires ${method.card?.expMonth}/${method.card?.expYear}`}
|
||||||
|
</span>
|
||||||
|
{expiringSoon && !expired && (
|
||||||
|
<span className="text-xs text-yellow-400">(expiring soon)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{!selectable && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenMenuId(openMenuId === method.id ? null : method.id);
|
||||||
|
}}
|
||||||
|
disabled={actionLoading === method.id}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === method.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{openMenuId === method.id && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setOpenMenuId(null)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-48 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 py-1">
|
||||||
|
{!method.isDefault && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSetDefault(method.id);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-300 hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
Set as Default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmDelete(method.id);
|
||||||
|
setOpenMenuId(null);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection Indicator */}
|
||||||
|
{selectable && isSelected && (
|
||||||
|
<div className="p-1 bg-blue-600 rounded-full">
|
||||||
|
<CheckCircle className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add New Button */}
|
||||||
|
{showAddButton && onAddNew && (
|
||||||
|
<button
|
||||||
|
onClick={onAddNew}
|
||||||
|
className="w-full flex items-center justify-center gap-2 p-4 border-2 border-dashed border-gray-700 hover:border-gray-600 rounded-xl text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Add New Payment Method
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{confirmDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||||
|
<div className="w-full max-w-sm bg-gray-800 rounded-xl p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 mx-auto mb-4 bg-red-500/20 rounded-full flex items-center justify-center">
|
||||||
|
<Trash2 className="w-6 h-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Remove Payment Method?</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-6">
|
||||||
|
This payment method will be permanently removed. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="flex-1 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(confirmDelete)}
|
||||||
|
disabled={actionLoading === confirmDelete}
|
||||||
|
className="flex-1 py-2.5 bg-red-600 hover:bg-red-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === confirmDelete ? (
|
||||||
|
<Loader2 className="w-5 h-5 mx-auto animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Remove'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Security Note */}
|
||||||
|
{!compact && methods.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-gray-900/50 rounded-lg">
|
||||||
|
<Shield className="w-4 h-4 text-green-400" />
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Your payment information is securely stored and encrypted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentMethodsList;
|
||||||
392
src/components/payments/SubscriptionUpgradeFlow.tsx
Normal file
392
src/components/payments/SubscriptionUpgradeFlow.tsx
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* SubscriptionUpgradeFlow Component
|
||||||
|
* Modal for upgrading/downgrading subscription plans with preview
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Zap,
|
||||||
|
Crown,
|
||||||
|
CreditCard,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
previewSubscriptionChange,
|
||||||
|
changeSubscriptionPlan,
|
||||||
|
} from '../../services/payment.service';
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
interval: 'month' | 'year';
|
||||||
|
features: string[];
|
||||||
|
highlighted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangePreview {
|
||||||
|
currentPlan: {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
newPlan: {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
proratedCredit: number;
|
||||||
|
amountDue: number;
|
||||||
|
effectiveDate: string;
|
||||||
|
billingCycleEnd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionUpgradeFlowProps {
|
||||||
|
currentPlanId: string;
|
||||||
|
plans: Plan[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: (newPlanId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubscriptionUpgradeFlow: React.FC<SubscriptionUpgradeFlowProps> = ({
|
||||||
|
currentPlanId,
|
||||||
|
plans,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
||||||
|
const [preview, setPreview] = useState<ChangePreview | null>(null);
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [step, setStep] = useState<'select' | 'preview' | 'success'>('select');
|
||||||
|
|
||||||
|
const currentPlan = plans.find((p) => p.id === currentPlanId);
|
||||||
|
const selectedPlan = plans.find((p) => p.id === selectedPlanId);
|
||||||
|
|
||||||
|
const isUpgrade = selectedPlan && currentPlan && selectedPlan.price > currentPlan.price;
|
||||||
|
const isDowngrade = selectedPlan && currentPlan && selectedPlan.price < currentPlan.price;
|
||||||
|
|
||||||
|
const fetchPreview = async (planId: string) => {
|
||||||
|
setLoadingPreview(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await previewSubscriptionChange(planId);
|
||||||
|
setPreview(data);
|
||||||
|
setStep('preview');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to preview changes');
|
||||||
|
} finally {
|
||||||
|
setLoadingPreview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectPlan = (planId: string) => {
|
||||||
|
if (planId === currentPlanId) return;
|
||||||
|
setSelectedPlanId(planId);
|
||||||
|
fetchPreview(planId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmChange = async () => {
|
||||||
|
if (!selectedPlanId) return;
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changeSubscriptionPlan(selectedPlanId);
|
||||||
|
setStep('success');
|
||||||
|
onSuccess?.(selectedPlanId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to change plan');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlanIcon = (planName: string) => {
|
||||||
|
const name = planName.toLowerCase();
|
||||||
|
if (name.includes('pro') || name.includes('premium')) {
|
||||||
|
return <Crown className="w-5 h-5 text-yellow-400" />;
|
||||||
|
}
|
||||||
|
if (name.includes('business') || name.includes('enterprise')) {
|
||||||
|
return <Zap className="w-5 h-5 text-purple-400" />;
|
||||||
|
}
|
||||||
|
return <Sparkles className="w-5 h-5 text-blue-400" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||||
|
<div className="w-full max-w-2xl max-h-[90vh] overflow-hidden bg-gray-800 rounded-xl shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
{step === 'select' && 'Change Your Plan'}
|
||||||
|
{step === 'preview' && 'Confirm Changes'}
|
||||||
|
{step === 'success' && 'Plan Updated'}
|
||||||
|
</h2>
|
||||||
|
{step === 'select' && (
|
||||||
|
<p className="text-sm text-gray-400">Select a new plan to continue</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="overflow-y-auto max-h-[calc(90vh-140px)] p-4">
|
||||||
|
{/* Step 1: Select Plan */}
|
||||||
|
{step === 'select' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const isCurrent = plan.id === currentPlanId;
|
||||||
|
const isSelected = plan.id === selectedPlanId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={plan.id}
|
||||||
|
onClick={() => handleSelectPlan(plan.id)}
|
||||||
|
disabled={isCurrent || loadingPreview}
|
||||||
|
className={`w-full p-4 rounded-xl border text-left transition-all ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-blue-600/10 border-blue-500/50 cursor-default'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-gray-700 border-blue-500 ring-2 ring-blue-500/20'
|
||||||
|
: 'bg-gray-800/50 border-gray-700 hover:border-gray-600'
|
||||||
|
} ${loadingPreview ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gray-900 rounded-lg">
|
||||||
|
{getPlanIcon(plan.name)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white font-semibold">{plan.name}</span>
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">
|
||||||
|
Current Plan
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{plan.highlighted && !isCurrent && (
|
||||||
|
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">
|
||||||
|
Popular
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400 mt-1">
|
||||||
|
<span className="text-xl font-bold text-white">
|
||||||
|
{formatPrice(plan.price)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">/{plan.interval}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCurrent && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{plan.price > (currentPlan?.price || 0) ? (
|
||||||
|
<span className="flex items-center gap-1 text-green-400 text-sm">
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
Upgrade
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-yellow-400 text-sm">
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
Downgrade
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Preview */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-700/50">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{plan.features.slice(0, 4).map((feature, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Check className="w-3 h-3 text-green-400" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{loadingPreview && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-400">Loading preview...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Preview Changes */}
|
||||||
|
{step === 'preview' && preview && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Plan Comparison */}
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Current Plan</p>
|
||||||
|
<p className="text-white font-semibold">{preview.currentPlan.name}</p>
|
||||||
|
<p className="text-gray-400">{formatPrice(preview.currentPlan.price)}/mo</p>
|
||||||
|
</div>
|
||||||
|
<div className={`p-2 rounded-full ${isUpgrade ? 'bg-green-500/20' : 'bg-yellow-500/20'}`}>
|
||||||
|
<ArrowRight className={`w-5 h-5 ${isUpgrade ? 'text-green-400' : 'text-yellow-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">New Plan</p>
|
||||||
|
<p className="text-white font-semibold">{preview.newPlan.name}</p>
|
||||||
|
<p className="text-gray-400">{formatPrice(preview.newPlan.price)}/mo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Billing Details */}
|
||||||
|
<div className="bg-gray-900/50 rounded-xl p-4 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
Billing Summary
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{preview.proratedCredit > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Prorated credit for unused time</span>
|
||||||
|
<span className="text-green-400">-{formatPrice(preview.proratedCredit)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400">New plan price</span>
|
||||||
|
<span className="text-white">{formatPrice(preview.newPlan.price)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-3 border-t border-gray-700">
|
||||||
|
<span className="text-white font-medium">Amount due today</span>
|
||||||
|
<span className="text-white font-bold text-lg">
|
||||||
|
{formatPrice(preview.amountDue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effective Date */}
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl">
|
||||||
|
<Calendar className="w-5 h-5 text-blue-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm">
|
||||||
|
{isUpgrade ? (
|
||||||
|
<>Your new plan takes effect <strong>immediately</strong></>
|
||||||
|
) : (
|
||||||
|
<>Your new plan takes effect on <strong>{formatDate(preview.billingCycleEnd)}</strong></>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-400 text-xs mt-0.5">
|
||||||
|
{isUpgrade
|
||||||
|
? 'You\'ll have instant access to all new features'
|
||||||
|
: 'You\'ll keep current features until your billing cycle ends'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||||
|
<span className="text-sm text-red-400">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Success */}
|
||||||
|
{step === 'success' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-green-500/20 rounded-full flex items-center justify-center">
|
||||||
|
<Check className="w-8 h-8 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">Plan Updated Successfully!</h3>
|
||||||
|
<p className="text-gray-400 mb-6">
|
||||||
|
You're now on the <strong className="text-white">{selectedPlan?.name}</strong> plan.
|
||||||
|
{isUpgrade && ' Your new features are ready to use.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{step === 'preview' && (
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-gray-700 bg-gray-800/50">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStep('select');
|
||||||
|
setSelectedPlanId(null);
|
||||||
|
setPreview(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={processing}
|
||||||
|
className="px-4 py-2 text-gray-400 hover:text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmChange}
|
||||||
|
disabled={processing}
|
||||||
|
className="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{processing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
Confirm {isUpgrade ? 'Upgrade' : 'Downgrade'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionUpgradeFlow;
|
||||||
@ -20,3 +20,15 @@ export type { BillingInfo } from './BillingInfoForm';
|
|||||||
// Transaction Components
|
// Transaction Components
|
||||||
export { default as TransactionHistory } from './TransactionHistory';
|
export { default as TransactionHistory } from './TransactionHistory';
|
||||||
export type { Transaction, TransactionType, TransactionStatus } from './TransactionHistory';
|
export type { Transaction, TransactionType, TransactionStatus } from './TransactionHistory';
|
||||||
|
|
||||||
|
// Invoice Components
|
||||||
|
export { default as InvoiceList } from './InvoiceList';
|
||||||
|
export type { Invoice } from './InvoiceList';
|
||||||
|
export { default as InvoiceDetail } from './InvoiceDetail';
|
||||||
|
|
||||||
|
// Payment Methods Management
|
||||||
|
export { default as PaymentMethodsList } from './PaymentMethodsList';
|
||||||
|
export type { PaymentMethod } from './PaymentMethodsList';
|
||||||
|
|
||||||
|
// Subscription Management
|
||||||
|
export { default as SubscriptionUpgradeFlow } from './SubscriptionUpgradeFlow';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user