[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
|
||||
export { default as TransactionHistory } 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