[OQI-005] feat: Add payment form components

- CouponForm: Apply and validate discount codes
- PaymentMethodForm: Add payment methods with card validation
- TransactionHistory: Wallet transactions with filtering/pagination
- BillingInfoForm: Edit billing address and tax information

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 11:15:12 -06:00
parent ed2e1472f4
commit d2c0a09b1b
5 changed files with 1194 additions and 0 deletions

View File

@ -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<BillingInfoFormProps> = ({
initialData,
onSuccess,
onCancel,
onError,
}) => {
const [formData, setFormData] = useState<BillingInfo>(
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<string | null>(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 (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Personal Information */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wide">
Contact Information
</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-300 mb-1.5">Full Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1.5">Email *</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="email"
value={formData.email}
onChange={(e) => 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"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-300 mb-1.5">Phone</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="tel"
value={formData.phone || ''}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1.5">Company</label>
<div className="relative">
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={formData.company || ''}
onChange={(e) => 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"
/>
</div>
</div>
</div>
</div>
{/* Address */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wide">
Billing Address
</h4>
<div>
<label className="block text-sm text-gray-300 mb-1.5">Street Address *</label>
<div className="relative">
<MapPin className="absolute left-3 top-3 w-4 h-4 text-gray-500" />
<input
type="text"
value={formData.address.line1}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1.5">Apartment, Suite, etc.</label>
<input
type="text"
value={formData.address.line2 || ''}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-300 mb-1.5">City *</label>
<input
type="text"
value={formData.address.city}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1.5">State / Province</label>
<input
type="text"
value={formData.address.state}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-300 mb-1.5">Postal Code *</label>
<input
type="text"
value={formData.address.postalCode}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1.5">Country *</label>
<select
value={formData.address.country}
onChange={(e) => handleChange('address.country', e.target.value)}
className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
{COUNTRIES.map((country) => (
<option key={country.code} value={country.code}>
{country.name}
</option>
))}
</select>
</div>
</div>
</div>
{/* Tax Information */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wide">
Tax Information
</h4>
<div>
<label className="block text-sm text-gray-300 mb-1.5">Tax ID / VAT Number</label>
<div className="relative">
<FileText className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={formData.taxId || ''}
onChange={(e) => 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"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Optional. Used for tax exemption in supported regions.
</p>
</div>
</div>
{/* Error/Success Messages */}
{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 flex-shrink-0" />
<span className="text-sm text-red-400">{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0" />
<span className="text-sm text-green-400">Billing information updated successfully</span>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="flex-1 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
)}
<button
type="submit"
disabled={isSaving}
className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{isSaving ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-5 h-5" />
Save Changes
</>
)}
</button>
</div>
</form>
);
};
export default BillingInfoForm;

View File

@ -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<CouponFormProps> = ({
planId,
amount,
onApply,
onClear,
appliedCoupon,
disabled = false,
}) => {
const [code, setCode] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-between p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/20 rounded-lg">
<Tag className="w-4 h-4 text-green-400" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-green-400">{appliedCoupon.code}</span>
<Check className="w-4 h-4 text-green-400" />
</div>
<p className="text-sm text-gray-400">
{appliedCoupon.discountType === 'percent' ? (
<>{appliedCoupon.discountValue}% off</>
) : (
<>${appliedCoupon.discountValue} off</>
)}
{appliedCoupon.discountAmount && (
<span className="text-green-400 ml-1">
(-${appliedCoupon.discountAmount.toFixed(2)})
</span>
)}
</p>
</div>
</div>
<button
onClick={handleClear}
disabled={disabled}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors disabled:opacity-50"
title="Remove coupon"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={code}
onChange={(e) => {
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"
/>
</div>
<button
onClick={handleValidate}
disabled={disabled || isValidating || !code.trim()}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isValidating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Checking...
</>
) : (
<>
<Percent className="w-4 h-4" />
Apply
</>
)}
</button>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-400">
<AlertCircle className="w-4 h-4" />
<span>{error}</span>
</div>
)}
</div>
);
};
export default CouponForm;

View File

@ -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<PaymentMethodFormProps> = ({
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<string | null>(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 (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Card Number */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Card Number
</label>
<div className="relative">
<CreditCard className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
value={cardNumber}
onChange={(e) => 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"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">
{cardType}
</span>
</div>
</div>
{/* Cardholder Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Cardholder Name
</label>
<input
type="text"
value={cardholderName}
onChange={(e) => 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"
/>
</div>
{/* Expiry & CVC */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Expiry Date
</label>
<input
type="text"
value={expiry}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
CVC
</label>
<div className="relative">
<input
type="text"
value={cvc}
onChange={(e) => 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"
/>
<Lock className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
</div>
</div>
</div>
{/* Set as Default */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={makeDefault}
onChange={(e) => setMakeDefault(e.target.checked)}
className="w-4 h-4 rounded border-gray-600 bg-gray-900 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Set as default payment method</span>
</label>
{/* Error */}
{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 flex-shrink-0" />
<span className="text-sm text-red-400">{error}</span>
</div>
)}
{/* Security Note */}
<div className="flex items-center gap-2 p-3 bg-gray-900/50 rounded-lg">
<Shield className="w-5 h-5 text-green-400" />
<p className="text-sm text-gray-400">
Your card information is encrypted and secure
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="flex-1 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
)}
<button
type="submit"
disabled={isSubmitting}
className="flex-1 flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Adding...
</>
) : (
<>
<Check className="w-5 h-5" />
Add Card
</>
)}
</button>
</div>
</form>
);
};
export default PaymentMethodForm;

View File

@ -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<string, unknown>;
}
interface TransactionHistoryProps {
walletId?: string;
filterType?: TransactionType;
itemsPerPage?: number;
showPagination?: boolean;
showFilter?: boolean;
showExport?: boolean;
onTransactionClick?: (transaction: Transaction) => void;
compact?: boolean;
}
const TransactionHistory: React.FC<TransactionHistoryProps> = ({
walletId,
filterType: initialFilter = 'all',
itemsPerPage = 10,
showPagination = true,
showFilter = true,
showExport = true,
onTransactionClick,
compact = false,
}) => {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [filterType, setFilterType] = useState<TransactionType>(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 <ArrowDownLeft className="w-4 h-4 text-green-400" />;
case 'withdrawal':
return <ArrowUpRight className="w-4 h-4 text-red-400" />;
case 'payment':
return <ArrowUpRight className="w-4 h-4 text-orange-400" />;
case 'refund':
return <ArrowDownLeft className="w-4 h-4 text-blue-400" />;
case 'transfer':
return <ArrowUpRight className="w-4 h-4 text-purple-400" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const getStatusIcon = (status: TransactionStatus) => {
switch (status) {
case 'completed':
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 'cancelled':
return <AlertCircle className="w-4 h-4 text-gray-400" />;
}
};
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 (
<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={fetchTransactions}
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">
{/* Header with Filter & Export */}
{(showFilter || showExport) && (
<div className="flex items-center justify-between">
{showFilter && (
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-500" />
<select
value={filterType}
onChange={(e) => {
setFilterType(e.target.value as TransactionType);
setCurrentPage(1);
}}
className="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500"
>
<option value="all">All Transactions</option>
<option value="deposit">Deposits</option>
<option value="withdrawal">Withdrawals</option>
<option value="payment">Payments</option>
<option value="refund">Refunds</option>
</select>
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={fetchTransactions}
disabled={loading}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
{showExport && transactions.length > 0 && (
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Export
</button>
)}
</div>
</div>
)}
{/* Transaction List */}
{transactions.length === 0 ? (
<div className="text-center py-8">
<Clock className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No transactions found</p>
</div>
) : (
<div className="space-y-2">
{transactions.map((tx) => (
<div
key={tx.id}
onClick={() => 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`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-900/50 rounded-lg">
{getTypeIcon(tx.type)}
</div>
<div>
<p className="text-white font-medium">
{tx.description || tx.type.charAt(0).toUpperCase() + tx.type.slice(1)}
</p>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{formatDate(tx.createdAt)}</span>
{tx.reference && !compact && (
<>
<span></span>
<span className="font-mono text-xs">{tx.reference}</span>
</>
)}
</div>
</div>
</div>
<div className="text-right">
<p className={`font-medium font-mono ${
tx.amount >= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{tx.amount >= 0 ? '+' : ''}{tx.amount.toFixed(2)} {tx.currency}
</p>
<div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${getStatusColor(tx.status)}`}>
{getStatusIcon(tx.status)}
<span className="capitalize">{tx.status}</span>
</div>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{showPagination && totalPages > 1 && (
<div className="flex items-center justify-between pt-2">
<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 TransactionHistory;

View File

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