diff --git a/src/components/payments/InvoiceDetail.tsx b/src/components/payments/InvoiceDetail.tsx new file mode 100644 index 0000000..6f7d2dc --- /dev/null +++ b/src/components/payments/InvoiceDetail.tsx @@ -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 = ({ + invoiceId, + onClose, + onDownload, +}) => { + const [invoice, setInvoice] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + {status} + + ); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Invoice Details

+ {invoice && ( +

{invoice.number}

+ )} +
+
+ +
+ + {/* Content */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+ +

{error}

+
+ ) : invoice ? ( +
+ {/* Status & Dates */} +
+ {getStatusBadge(invoice.status)} +
+
+ + Issued: {formatDate(invoice.createdAt)} +
+ {invoice.paidAt && ( +
+ + Paid: {formatDate(invoice.paidAt)} +
+ )} +
+
+ + {/* Billing Details */} + {invoice.billingDetails && ( +
+
+
+ + Bill To +
+

{invoice.billingDetails.name}

+ {invoice.billingDetails.company && ( +

{invoice.billingDetails.company}

+ )} +

{invoice.billingDetails.email}

+ {invoice.billingDetails.address && ( +

+ {invoice.billingDetails.address.line1} + {invoice.billingDetails.address.line2 && <>, {invoice.billingDetails.address.line2}} +
+ {invoice.billingDetails.address.city}, {invoice.billingDetails.address.state} {invoice.billingDetails.address.postalCode} +

+ )} +
+ + {invoice.paymentMethod && ( +
+
+ + Payment Method +
+

+ {invoice.paymentMethod.brand} •••• {invoice.paymentMethod.last4} +

+
+ )} +
+ )} + + {/* Line Items */} +
+

Items

+
+ + + + + + + + + + + {invoice.lineItems.map((item) => ( + + + + + + + ))} + +
DescriptionQtyUnit PriceAmount
{item.description}{item.quantity} + {formatAmount(item.unitPrice, invoice.currency)} + + {formatAmount(item.amount, invoice.currency)} +
+
+
+ + {/* Totals */} +
+
+
+ Subtotal + {formatAmount(invoice.subtotal, invoice.currency)} +
+ {invoice.discount && invoice.discount > 0 && ( +
+ Discount + -{formatAmount(invoice.discount, invoice.currency)} +
+ )} + {invoice.tax > 0 && ( +
+ + Tax {invoice.taxRate && `(${invoice.taxRate}%)`} + + {formatAmount(invoice.tax, invoice.currency)} +
+ )} +
+ Total + + {formatAmount(invoice.amount, invoice.currency)} + +
+
+
+
+ ) : null} +
+ + {/* Footer Actions */} + {invoice && ( +
+
+ + +
+ +
+ )} +
+
+ ); +}; + +export default InvoiceDetail; diff --git a/src/components/payments/InvoiceList.tsx b/src/components/payments/InvoiceList.tsx new file mode 100644 index 0000000..66d609a --- /dev/null +++ b/src/components/payments/InvoiceList.tsx @@ -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 = ({ + onInvoiceClick, + onDownload, + itemsPerPage = 10, + showFilters = true, + compact = false, +}) => { + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [statusFilter, setStatusFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [downloadingId, setDownloadingId] = useState(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 ; + case 'pending': + return ; + case 'failed': + return ; + case 'refunded': + return ; + case 'void': + return ; + } + }; + + 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } + + return ( +
+ {/* Filters */} + {showFilters && ( +
+
+ + 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" + /> +
+ +
+ + +
+ + +
+ )} + + {/* Invoice Table */} + {invoices.length === 0 ? ( +
+ +

No invoices found

+
+ ) : ( +
+ + + + + + + + + + + + {invoices.map((invoice) => ( + onInvoiceClick?.(invoice)} + > + + + + + + + ))} + +
InvoiceDateStatusAmountActions
+
+
+ +
+
+

{invoice.number}

+

+ {invoice.description} +

+
+
+
+

{formatDate(invoice.createdAt)}

+ {invoice.dueDate && invoice.status === 'pending' && ( +

Due: {formatDate(invoice.dueDate)}

+ )} +
+ + {getStatusIcon(invoice.status)} + {invoice.status} + + + + {formatAmount(invoice.amount, invoice.currency)} + + +
+ {onInvoiceClick && ( + + )} + +
+
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ )} +
+ ); +}; + +export default InvoiceList; diff --git a/src/components/payments/PaymentMethodsList.tsx b/src/components/payments/PaymentMethodsList.tsx new file mode 100644 index 0000000..c62dbfb --- /dev/null +++ b/src/components/payments/PaymentMethodsList.tsx @@ -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 = ({ + onAddNew, + onMethodSelect, + selectable = false, + selectedId, + showAddButton = true, + compact = false, +}) => { + const [methods, setMethods] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + const [openMenuId, setOpenMenuId] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(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 ; + }; + + 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } + + return ( +
+ {/* Payment Methods List */} + {methods.length === 0 ? ( +
+ +

No payment methods saved

+ {showAddButton && onAddNew && ( + + )} +
+ ) : ( + <> + {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 ( +
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 */} +
+
+ {getCardIcon(method.card?.brand || '')} +
+
+
+ + {method.card?.brand || method.type} + + •••• {method.card?.last4} + {method.isDefault && ( + + + Default + + )} +
+
+ + {expired ? 'Expired' : `Expires ${method.card?.expMonth}/${method.card?.expYear}`} + + {expiringSoon && !expired && ( + (expiring soon) + )} +
+
+
+ + {/* Actions */} + {!selectable && ( +
+ + + {/* Dropdown Menu */} + {openMenuId === method.id && ( + <> +
setOpenMenuId(null)} + /> +
+ {!method.isDefault && ( + + )} + +
+ + )} +
+ )} + + {/* Selection Indicator */} + {selectable && isSelected && ( +
+ +
+ )} +
+ ); + })} + + {/* Add New Button */} + {showAddButton && onAddNew && ( + + )} + + )} + + {/* Delete Confirmation Modal */} + {confirmDelete && ( +
+
+
+
+ +
+

Remove Payment Method?

+

+ This payment method will be permanently removed. This action cannot be undone. +

+
+ + +
+
+
+
+ )} + + {/* Security Note */} + {!compact && methods.length > 0 && ( +
+ +

+ Your payment information is securely stored and encrypted +

+
+ )} +
+ ); +}; + +export default PaymentMethodsList; diff --git a/src/components/payments/SubscriptionUpgradeFlow.tsx b/src/components/payments/SubscriptionUpgradeFlow.tsx new file mode 100644 index 0000000..c13ecb8 --- /dev/null +++ b/src/components/payments/SubscriptionUpgradeFlow.tsx @@ -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 = ({ + currentPlanId, + plans, + onClose, + onSuccess, +}) => { + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [preview, setPreview] = useState(null); + const [loadingPreview, setLoadingPreview] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(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 ; + } + if (name.includes('business') || name.includes('enterprise')) { + return ; + } + return ; + }; + + 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 ( +
+
+ {/* Header */} +
+
+

+ {step === 'select' && 'Change Your Plan'} + {step === 'preview' && 'Confirm Changes'} + {step === 'success' && 'Plan Updated'} +

+ {step === 'select' && ( +

Select a new plan to continue

+ )} +
+ +
+ + {/* Content */} +
+ {/* Step 1: Select Plan */} + {step === 'select' && ( +
+ {plans.map((plan) => { + const isCurrent = plan.id === currentPlanId; + const isSelected = plan.id === selectedPlanId; + + return ( + + ); + })} + + {loadingPreview && ( +
+ + Loading preview... +
+ )} +
+ )} + + {/* Step 2: Preview Changes */} + {step === 'preview' && preview && ( +
+ {/* Plan Comparison */} +
+
+

Current Plan

+

{preview.currentPlan.name}

+

{formatPrice(preview.currentPlan.price)}/mo

+
+
+ +
+
+

New Plan

+

{preview.newPlan.name}

+

{formatPrice(preview.newPlan.price)}/mo

+
+
+ + {/* Billing Details */} +
+

+ + Billing Summary +

+ +
+ {preview.proratedCredit > 0 && ( +
+ Prorated credit for unused time + -{formatPrice(preview.proratedCredit)} +
+ )} + +
+ New plan price + {formatPrice(preview.newPlan.price)} +
+ +
+ Amount due today + + {formatPrice(preview.amountDue)} + +
+
+
+ + {/* Effective Date */} +
+ +
+

+ {isUpgrade ? ( + <>Your new plan takes effect immediately + ) : ( + <>Your new plan takes effect on {formatDate(preview.billingCycleEnd)} + )} +

+

+ {isUpgrade + ? 'You\'ll have instant access to all new features' + : 'You\'ll keep current features until your billing cycle ends'} +

+
+
+ + {error && ( +
+ + {error} +
+ )} +
+ )} + + {/* Step 3: Success */} + {step === 'success' && ( +
+
+ +
+

Plan Updated Successfully!

+

+ You're now on the {selectedPlan?.name} plan. + {isUpgrade && ' Your new features are ready to use.'} +

+ +
+ )} +
+ + {/* Footer */} + {step === 'preview' && ( +
+ + +
+ )} +
+
+ ); +}; + +export default SubscriptionUpgradeFlow; diff --git a/src/components/payments/index.ts b/src/components/payments/index.ts index 5e08de2..73203a1 100644 --- a/src/components/payments/index.ts +++ b/src/components/payments/index.ts @@ -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';