[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:
parent
ed2e1472f4
commit
d2c0a09b1b
406
src/components/payments/BillingInfoForm.tsx
Normal file
406
src/components/payments/BillingInfoForm.tsx
Normal 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;
|
||||
186
src/components/payments/CouponForm.tsx
Normal file
186
src/components/payments/CouponForm.tsx
Normal 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;
|
||||
273
src/components/payments/PaymentMethodForm.tsx
Normal file
273
src/components/payments/PaymentMethodForm.tsx
Normal 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;
|
||||
318
src/components/payments/TransactionHistory.tsx
Normal file
318
src/components/payments/TransactionHistory.tsx
Normal 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;
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user