diff --git a/src/components/payments/BillingInfoForm.tsx b/src/components/payments/BillingInfoForm.tsx new file mode 100644 index 0000000..08d46c4 --- /dev/null +++ b/src/components/payments/BillingInfoForm.tsx @@ -0,0 +1,406 @@ +/** + * BillingInfoForm Component + * Edit billing address and tax information + */ + +import React, { useState, useEffect } from 'react'; +import { + MapPin, + Building, + Mail, + Phone, + FileText, + Save, + Loader2, + AlertCircle, + CheckCircle, +} from 'lucide-react'; +import { updateBillingInfo, getBillingInfo } from '../../services/payment.service'; + +export interface BillingInfo { + name: string; + email: string; + phone?: string; + company?: string; + taxId?: string; + address: { + line1: string; + line2?: string; + city: string; + state: string; + postalCode: string; + country: string; + }; +} + +interface BillingInfoFormProps { + initialData?: BillingInfo; + onSuccess?: (data: BillingInfo) => void; + onCancel?: () => void; + onError?: (error: string) => void; +} + +const COUNTRIES = [ + { code: 'US', name: 'United States' }, + { code: 'CA', name: 'Canada' }, + { code: 'GB', name: 'United Kingdom' }, + { code: 'DE', name: 'Germany' }, + { code: 'FR', name: 'France' }, + { code: 'ES', name: 'Spain' }, + { code: 'IT', name: 'Italy' }, + { code: 'AU', name: 'Australia' }, + { code: 'JP', name: 'Japan' }, + { code: 'MX', name: 'Mexico' }, + { code: 'BR', name: 'Brazil' }, + { code: 'AR', name: 'Argentina' }, + { code: 'CL', name: 'Chile' }, + { code: 'CO', name: 'Colombia' }, +]; + +const BillingInfoForm: React.FC = ({ + initialData, + onSuccess, + onCancel, + onError, +}) => { + const [formData, setFormData] = useState( + initialData || { + name: '', + email: '', + phone: '', + company: '', + taxId: '', + address: { + line1: '', + line2: '', + city: '', + state: '', + postalCode: '', + country: 'US', + }, + } + ); + const [isLoading, setIsLoading] = useState(!initialData); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // Load existing billing info if not provided + useEffect(() => { + if (!initialData) { + loadBillingInfo(); + } + }, [initialData]); + + const loadBillingInfo = async () => { + try { + const data = await getBillingInfo(); + if (data) { + setFormData(data); + } + } catch (err) { + // Ignore error if no billing info exists + } finally { + setIsLoading(false); + } + }; + + const handleChange = (field: string, value: string) => { + setError(null); + setSuccess(false); + + if (field.startsWith('address.')) { + const addressField = field.replace('address.', ''); + setFormData((prev) => ({ + ...prev, + address: { + ...prev.address, + [addressField]: value, + }, + })); + } else { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + } + }; + + const validateForm = (): boolean => { + if (!formData.name.trim()) { + setError('Please enter your full name'); + return false; + } + + if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + setError('Please enter a valid email address'); + return false; + } + + if (!formData.address.line1.trim()) { + setError('Please enter your street address'); + return false; + } + + if (!formData.address.city.trim()) { + setError('Please enter your city'); + return false; + } + + if (!formData.address.postalCode.trim()) { + setError('Please enter your postal code'); + return false; + } + + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + setIsSaving(true); + setError(null); + + try { + await updateBillingInfo(formData); + setSuccess(true); + onSuccess?.(formData); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update billing info'; + setError(errorMessage); + onError?.(errorMessage); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Personal Information */} +
+

+ Contact Information +

+ +
+
+ + handleChange('name', e.target.value)} + placeholder="John Doe" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ +
+ + handleChange('email', e.target.value)} + placeholder="john@example.com" + className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+
+ +
+
+ +
+ + handleChange('phone', e.target.value)} + placeholder="+1 (555) 000-0000" + className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+
+ +
+ + handleChange('company', e.target.value)} + placeholder="Company name" + className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+
+
+ + {/* Address */} +
+

+ Billing Address +

+ +
+ +
+ + handleChange('address.line1', e.target.value)} + placeholder="123 Main Street" + className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ +
+ + handleChange('address.line2', e.target.value)} + placeholder="Apt 4B" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+ +
+
+ + handleChange('address.city', e.target.value)} + placeholder="New York" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ + handleChange('address.state', e.target.value)} + placeholder="NY" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ +
+
+ + handleChange('address.postalCode', e.target.value)} + placeholder="10001" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ + +
+
+
+ + {/* Tax Information */} +
+

+ Tax Information +

+ +
+ +
+ + handleChange('taxId', e.target.value)} + placeholder="XX-XXXXXXX" + className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+

+ Optional. Used for tax exemption in supported regions. +

+
+
+ + {/* Error/Success Messages */} + {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + Billing information updated successfully +
+ )} + + {/* Actions */} +
+ {onCancel && ( + + )} + +
+
+ ); +}; + +export default BillingInfoForm; diff --git a/src/components/payments/CouponForm.tsx b/src/components/payments/CouponForm.tsx new file mode 100644 index 0000000..c956b05 --- /dev/null +++ b/src/components/payments/CouponForm.tsx @@ -0,0 +1,186 @@ +/** + * CouponForm Component + * Apply and validate discount codes for subscriptions + */ + +import React, { useState } from 'react'; +import { + Tag, + Check, + X, + Loader2, + Percent, + AlertCircle, +} from 'lucide-react'; +import { validateCoupon } from '../../services/payment.service'; + +export interface CouponInfo { + code: string; + valid: boolean; + discountType: 'percent' | 'fixed'; + discountValue: number; + discountAmount?: number; + expiresAt?: string; + minPurchase?: number; + maxUses?: number; + usedCount?: number; +} + +interface CouponFormProps { + planId?: string; + amount?: number; + onApply: (couponInfo: CouponInfo) => void; + onClear?: () => void; + appliedCoupon?: CouponInfo | null; + disabled?: boolean; +} + +const CouponForm: React.FC = ({ + planId, + amount, + onApply, + onClear, + appliedCoupon, + disabled = false, +}) => { + const [code, setCode] = useState(''); + const [isValidating, setIsValidating] = useState(false); + const [error, setError] = useState(null); + + const handleValidate = async () => { + if (!code.trim()) { + setError('Please enter a coupon code'); + return; + } + + setIsValidating(true); + setError(null); + + try { + const result = await validateCoupon(code.trim().toUpperCase(), planId); + + if (result.valid) { + // Calculate discount amount if we have the price + let discountAmount: number | undefined; + if (amount) { + discountAmount = result.discountType === 'percent' + ? (amount * result.discountValue) / 100 + : Math.min(result.discountValue, amount); + } + + onApply({ + ...result, + code: code.trim().toUpperCase(), + discountAmount, + }); + setCode(''); + } else { + setError(result.message || 'Invalid coupon code'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to validate coupon'); + } finally { + setIsValidating(false); + } + }; + + const handleClear = () => { + setCode(''); + setError(null); + onClear?.(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !isValidating && code.trim()) { + e.preventDefault(); + handleValidate(); + } + }; + + // Show applied coupon state + if (appliedCoupon) { + return ( +
+
+
+ +
+
+
+ {appliedCoupon.code} + +
+

+ {appliedCoupon.discountType === 'percent' ? ( + <>{appliedCoupon.discountValue}% off + ) : ( + <>${appliedCoupon.discountValue} off + )} + {appliedCoupon.discountAmount && ( + + (-${appliedCoupon.discountAmount.toFixed(2)}) + + )} +

+
+
+ +
+ ); + } + + return ( +
+
+
+ + { + setCode(e.target.value.toUpperCase()); + setError(null); + }} + onKeyDown={handleKeyDown} + placeholder="Enter coupon code" + disabled={disabled || isValidating} + className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 disabled:opacity-50 uppercase" + /> +
+ +
+ + {error && ( +
+ + {error} +
+ )} +
+ ); +}; + +export default CouponForm; diff --git a/src/components/payments/PaymentMethodForm.tsx b/src/components/payments/PaymentMethodForm.tsx new file mode 100644 index 0000000..f06d178 --- /dev/null +++ b/src/components/payments/PaymentMethodForm.tsx @@ -0,0 +1,273 @@ +/** + * PaymentMethodForm Component + * Add payment methods using Stripe Elements + */ + +import React, { useState } from 'react'; +import { + CreditCard, + Check, + Loader2, + AlertCircle, + Shield, + Lock, +} from 'lucide-react'; +import { addPaymentMethod } from '../../services/payment.service'; + +interface PaymentMethodFormProps { + onSuccess: (paymentMethodId: string) => void; + onCancel?: () => void; + onError?: (error: string) => void; + setAsDefault?: boolean; +} + +const PaymentMethodForm: React.FC = ({ + onSuccess, + onCancel, + onError, + setAsDefault = true, +}) => { + const [cardNumber, setCardNumber] = useState(''); + const [expiry, setExpiry] = useState(''); + const [cvc, setCvc] = useState(''); + const [cardholderName, setCardholderName] = useState(''); + const [makeDefault, setMakeDefault] = useState(setAsDefault); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Format card number with spaces + const formatCardNumber = (value: string) => { + const v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, ''); + const matches = v.match(/\d{4,16}/g); + const match = (matches && matches[0]) || ''; + const parts = []; + for (let i = 0, len = match.length; i < len; i += 4) { + parts.push(match.substring(i, i + 4)); + } + return parts.length ? parts.join(' ') : value; + }; + + // Format expiry as MM/YY + const formatExpiry = (value: string) => { + const v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, ''); + if (v.length >= 2) { + return v.substring(0, 2) + '/' + v.substring(2, 4); + } + return v; + }; + + // Get card type based on number + const getCardType = (number: string): string => { + const cleanNumber = number.replace(/\s/g, ''); + if (/^4/.test(cleanNumber)) return 'Visa'; + if (/^5[1-5]/.test(cleanNumber)) return 'Mastercard'; + if (/^3[47]/.test(cleanNumber)) return 'Amex'; + if (/^6(?:011|5)/.test(cleanNumber)) return 'Discover'; + return 'Card'; + }; + + const validateForm = (): boolean => { + const cleanCardNumber = cardNumber.replace(/\s/g, ''); + + if (!cardholderName.trim()) { + setError('Please enter the cardholder name'); + return false; + } + + if (cleanCardNumber.length < 13 || cleanCardNumber.length > 19) { + setError('Please enter a valid card number'); + return false; + } + + const [month, year] = expiry.split('/'); + if (!month || !year || parseInt(month) < 1 || parseInt(month) > 12) { + setError('Please enter a valid expiry date'); + return false; + } + + // Check if card is not expired + const now = new Date(); + const currentYear = now.getFullYear() % 100; + const currentMonth = now.getMonth() + 1; + if (parseInt(year) < currentYear || (parseInt(year) === currentYear && parseInt(month) < currentMonth)) { + setError('This card has expired'); + return false; + } + + if (cvc.length < 3 || cvc.length > 4) { + setError('Please enter a valid CVC'); + return false; + } + + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!validateForm()) return; + + setIsSubmitting(true); + + try { + // In a real implementation, this would use Stripe.js to create a PaymentMethod + // For now, we simulate the API call + const result = await addPaymentMethod({ + type: 'card', + card: { + number: cardNumber.replace(/\s/g, ''), + exp_month: parseInt(expiry.split('/')[0]), + exp_year: 2000 + parseInt(expiry.split('/')[1]), + cvc, + }, + billing_details: { + name: cardholderName, + }, + setAsDefault: makeDefault, + }); + + onSuccess(result.id); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to add payment method'; + setError(errorMessage); + onError?.(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + const cardType = getCardType(cardNumber); + + return ( +
+ {/* Card Number */} +
+ +
+ + setCardNumber(formatCardNumber(e.target.value))} + placeholder="1234 5678 9012 3456" + maxLength={19} + className="w-full pl-11 pr-20 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> + + {cardType} + +
+
+ + {/* Cardholder Name */} +
+ + setCardholderName(e.target.value)} + placeholder="John Doe" + className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+ + {/* Expiry & CVC */} +
+
+ + setExpiry(formatExpiry(e.target.value))} + placeholder="MM/YY" + maxLength={5} + className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ +
+ setCvc(e.target.value.replace(/\D/g, '').slice(0, 4))} + placeholder="123" + maxLength={4} + className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> + +
+
+
+ + {/* Set as Default */} + + + {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Security Note */} +
+ +

+ Your card information is encrypted and secure +

+
+ + {/* Actions */} +
+ {onCancel && ( + + )} + +
+
+ ); +}; + +export default PaymentMethodForm; diff --git a/src/components/payments/TransactionHistory.tsx b/src/components/payments/TransactionHistory.tsx new file mode 100644 index 0000000..dce51f6 --- /dev/null +++ b/src/components/payments/TransactionHistory.tsx @@ -0,0 +1,318 @@ +/** + * TransactionHistory Component + * Full wallet transaction history with filtering and pagination + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { + ArrowDownLeft, + ArrowUpRight, + Filter, + ChevronLeft, + ChevronRight, + Download, + Loader2, + Clock, + CheckCircle, + XCircle, + AlertCircle, + RefreshCw, +} from 'lucide-react'; +import { getWalletTransactions } from '../../services/payment.service'; + +export type TransactionType = 'deposit' | 'withdrawal' | 'payment' | 'refund' | 'transfer' | 'all'; +export type TransactionStatus = 'pending' | 'completed' | 'failed' | 'cancelled'; + +export interface Transaction { + id: string; + type: TransactionType; + amount: number; + currency: string; + status: TransactionStatus; + description: string; + createdAt: string; + completedAt?: string; + reference?: string; + metadata?: Record; +} + +interface TransactionHistoryProps { + walletId?: string; + filterType?: TransactionType; + itemsPerPage?: number; + showPagination?: boolean; + showFilter?: boolean; + showExport?: boolean; + onTransactionClick?: (transaction: Transaction) => void; + compact?: boolean; +} + +const TransactionHistory: React.FC = ({ + walletId, + filterType: initialFilter = 'all', + itemsPerPage = 10, + showPagination = true, + showFilter = true, + showExport = true, + onTransactionClick, + compact = false, +}) => { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [filterType, setFilterType] = useState(initialFilter); + + const fetchTransactions = async () => { + setLoading(true); + setError(null); + + try { + const result = await getWalletTransactions(walletId, { + type: filterType !== 'all' ? filterType : undefined, + page: currentPage, + limit: itemsPerPage, + }); + + setTransactions(result.transactions); + setTotalPages(Math.ceil(result.total / itemsPerPage)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load transactions'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTransactions(); + }, [walletId, filterType, currentPage, itemsPerPage]); + + const handleExport = () => { + // Generate CSV + const headers = ['Date', 'Type', 'Description', 'Amount', 'Status', 'Reference']; + const rows = transactions.map((tx) => [ + new Date(tx.createdAt).toLocaleString(), + tx.type, + tx.description, + `${tx.amount >= 0 ? '+' : ''}${tx.amount.toFixed(2)} ${tx.currency}`, + tx.status, + tx.reference || '', + ]); + + const csv = [headers, ...rows].map((row) => row.join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `transactions-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + + if (diff < 60000) return 'Just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + if (diff < 604800000) return date.toLocaleDateString('en-US', { weekday: 'short' }); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + const getTypeIcon = (type: TransactionType) => { + switch (type) { + case 'deposit': + return ; + case 'withdrawal': + return ; + case 'payment': + return ; + case 'refund': + return ; + case 'transfer': + return ; + default: + return ; + } + }; + + const getStatusIcon = (status: TransactionStatus) => { + switch (status) { + case 'completed': + return ; + case 'pending': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + } + }; + + const getStatusColor = (status: TransactionStatus) => { + switch (status) { + case 'completed': + 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 'cancelled': + return 'text-gray-400 bg-gray-500/20'; + } + }; + + if (loading && transactions.length === 0) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } + + return ( +
+ {/* Header with Filter & Export */} + {(showFilter || showExport) && ( +
+ {showFilter && ( +
+ + +
+ )} + +
+ + {showExport && transactions.length > 0 && ( + + )} +
+
+ )} + + {/* Transaction List */} + {transactions.length === 0 ? ( +
+ +

No transactions found

+
+ ) : ( +
+ {transactions.map((tx) => ( +
onTransactionClick?.(tx)} + className={`flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700 ${ + onTransactionClick ? 'cursor-pointer hover:bg-gray-800' : '' + } transition-colors`} + > +
+
+ {getTypeIcon(tx.type)} +
+
+

+ {tx.description || tx.type.charAt(0).toUpperCase() + tx.type.slice(1)} +

+
+ {formatDate(tx.createdAt)} + {tx.reference && !compact && ( + <> + + {tx.reference} + + )} +
+
+
+ +
+

= 0 ? 'text-green-400' : 'text-red-400' + }`}> + {tx.amount >= 0 ? '+' : ''}{tx.amount.toFixed(2)} {tx.currency} +

+
+ {getStatusIcon(tx.status)} + {tx.status} +
+
+
+ ))} +
+ )} + + {/* Pagination */} + {showPagination && totalPages > 1 && ( +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ )} +
+ ); +}; + +export default TransactionHistory; diff --git a/src/components/payments/index.ts b/src/components/payments/index.ts index 8de556c..5e08de2 100644 --- a/src/components/payments/index.ts +++ b/src/components/payments/index.ts @@ -9,3 +9,14 @@ export { WalletCard } from './WalletCard'; export { UsageProgress } from './UsageProgress'; export { WalletDepositModal } from './WalletDepositModal'; export { WalletWithdrawModal } from './WalletWithdrawModal'; + +// Form Components +export { default as CouponForm } from './CouponForm'; +export type { CouponInfo } from './CouponForm'; +export { default as PaymentMethodForm } from './PaymentMethodForm'; +export { default as BillingInfoForm } from './BillingInfoForm'; +export type { BillingInfo } from './BillingInfoForm'; + +// Transaction Components +export { default as TransactionHistory } from './TransactionHistory'; +export type { Transaction, TransactionType, TransactionStatus } from './TransactionHistory';