[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:
Adrian Flores Cortes 2026-01-25 11:39:33 -06:00
parent c956ac0c0f
commit c7626f841c
5 changed files with 1503 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View File

@ -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';