[OQI-005] feat: Add 4 advanced payment components
- StripeElementsWrapper: PCI-DSS compliance foundation with HOC and hook - InvoicePreview: Pre-checkout invoice display with itemized breakdown - RefundRequestModal: Modal for refund requests with validation - RefundList: Paginated refund history with status filters Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
339c645036
commit
8347c6ad48
439
src/components/payments/InvoicePreview.tsx
Normal file
439
src/components/payments/InvoicePreview.tsx
Normal file
@ -0,0 +1,439 @@
|
||||
/**
|
||||
* InvoicePreview Component
|
||||
* Pre-checkout invoice preview with itemized breakdown
|
||||
* Epic: OQI-005 Pagos y Stripe
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
Tag,
|
||||
Percent,
|
||||
DollarSign,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ChevronRight,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
export interface InvoiceLineItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
type: 'subscription' | 'addon' | 'usage' | 'proration' | 'credit';
|
||||
}
|
||||
|
||||
export interface InvoiceDiscount {
|
||||
id: string;
|
||||
code: string;
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
amount: number;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface InvoiceTax {
|
||||
id: string;
|
||||
name: string;
|
||||
rate: number;
|
||||
amount: number;
|
||||
jurisdiction?: string;
|
||||
}
|
||||
|
||||
export interface InvoicePreviewData {
|
||||
id?: string;
|
||||
planName: string;
|
||||
planId: string;
|
||||
billingCycle: 'monthly' | 'yearly';
|
||||
lineItems: InvoiceLineItem[];
|
||||
discounts: InvoiceDiscount[];
|
||||
taxes: InvoiceTax[];
|
||||
subtotal: number;
|
||||
totalDiscount: number;
|
||||
totalTax: number;
|
||||
total: number;
|
||||
currency: string;
|
||||
effectiveDate: Date;
|
||||
nextBillingDate: Date;
|
||||
paymentMethodLast4?: string;
|
||||
paymentMethodBrand?: string;
|
||||
}
|
||||
|
||||
export interface InvoicePreviewProps {
|
||||
invoice: InvoicePreviewData;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
onEditPayment?: () => void;
|
||||
onEditBilling?: () => void;
|
||||
onRemoveCoupon?: (discountId: string) => void;
|
||||
isProcessing?: boolean;
|
||||
showActions?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// Currency formatter
|
||||
const formatCurrency = (amount: number, currency: string = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Date formatter
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Line item type badge
|
||||
const LineItemTypeBadge: React.FC<{ type: InvoiceLineItem['type'] }> = ({ type }) => {
|
||||
const styles = {
|
||||
subscription: 'bg-blue-900/50 text-blue-400',
|
||||
addon: 'bg-purple-900/50 text-purple-400',
|
||||
usage: 'bg-amber-900/50 text-amber-400',
|
||||
proration: 'bg-emerald-900/50 text-emerald-400',
|
||||
credit: 'bg-emerald-900/50 text-emerald-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${styles[type]}`}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const InvoicePreview: React.FC<InvoicePreviewProps> = ({
|
||||
invoice,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onEditPayment,
|
||||
onEditBilling,
|
||||
onRemoveCoupon,
|
||||
isProcessing = false,
|
||||
showActions = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
// Calculate savings for yearly billing
|
||||
const yearlySavings = useMemo(() => {
|
||||
if (invoice.billingCycle === 'yearly') {
|
||||
// Assume 20% savings on yearly plans
|
||||
const monthlyEquivalent = invoice.subtotal / 12;
|
||||
const monthlyCost = monthlyEquivalent * 1.25; // What it would cost monthly
|
||||
return (monthlyCost * 12) - invoice.subtotal;
|
||||
}
|
||||
return 0;
|
||||
}, [invoice.billingCycle, invoice.subtotal]);
|
||||
|
||||
// Render line items section
|
||||
const renderLineItems = () => (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-slate-400 text-sm font-medium flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Items
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{invoice.lineItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-2 border-b border-slate-800 last:border-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white">{item.description}</span>
|
||||
{!compact && <LineItemTypeBadge type={item.type} />}
|
||||
</div>
|
||||
{item.quantity > 1 && (
|
||||
<span className="text-slate-500 text-sm">
|
||||
{item.quantity} x {formatCurrency(item.unitPrice, invoice.currency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-medium ${item.amount < 0 ? 'text-emerald-400' : 'text-white'}`}>
|
||||
{item.amount < 0 ? '-' : ''}{formatCurrency(Math.abs(item.amount), invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render discounts section
|
||||
const renderDiscounts = () => {
|
||||
if (invoice.discounts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-slate-400 text-sm font-medium flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
Discounts
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{invoice.discounts.map((discount) => (
|
||||
<div
|
||||
key={discount.id}
|
||||
className="flex items-center justify-between py-2 bg-emerald-900/20 rounded-lg px-3 border border-emerald-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Percent className="w-4 h-4 text-emerald-400" />
|
||||
<div>
|
||||
<span className="text-emerald-400 font-medium">{discount.code}</span>
|
||||
<span className="text-slate-400 text-sm ml-2">
|
||||
({discount.type === 'percentage' ? `${discount.value}% off` : formatCurrency(discount.value, invoice.currency)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400 font-medium">
|
||||
-{formatCurrency(discount.amount, invoice.currency)}
|
||||
</span>
|
||||
{onRemoveCoupon && (
|
||||
<button
|
||||
onClick={() => onRemoveCoupon(discount.id)}
|
||||
className="text-slate-500 hover:text-red-400 transition-colors"
|
||||
title="Remove coupon"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render taxes section
|
||||
const renderTaxes = () => {
|
||||
if (invoice.taxes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{invoice.taxes.map((tax) => (
|
||||
<div key={tax.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-400">
|
||||
{tax.name} ({tax.rate}%)
|
||||
{tax.jurisdiction && <span className="text-slate-500"> - {tax.jurisdiction}</span>}
|
||||
</span>
|
||||
<span className="text-slate-300">{formatCurrency(tax.amount, invoice.currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render summary section
|
||||
const renderSummary = () => (
|
||||
<div className="space-y-3 pt-4 border-t border-slate-700">
|
||||
{/* Subtotal */}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Subtotal</span>
|
||||
<span className="text-white">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
||||
</div>
|
||||
|
||||
{/* Discounts */}
|
||||
{invoice.totalDiscount > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Discounts</span>
|
||||
<span className="text-emerald-400">-{formatCurrency(invoice.totalDiscount, invoice.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Taxes */}
|
||||
{renderTaxes()}
|
||||
|
||||
{/* Yearly Savings */}
|
||||
{yearlySavings > 0 && (
|
||||
<div className="flex justify-between py-2 bg-blue-900/20 rounded-lg px-3 border border-blue-800/50">
|
||||
<span className="text-blue-400 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Yearly savings
|
||||
</span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{formatCurrency(yearlySavings, invoice.currency)}/year
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex justify-between pt-3 border-t border-slate-700">
|
||||
<span className="text-white font-semibold text-lg">Total due today</span>
|
||||
<span className="text-white font-bold text-xl">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render billing details
|
||||
const renderBillingDetails = () => (
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-700">
|
||||
{/* Billing Cycle */}
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block mb-1">Billing Cycle</span>
|
||||
<span className="text-white capitalize">{invoice.billingCycle}</span>
|
||||
</div>
|
||||
|
||||
{/* Effective Date */}
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block mb-1">Effective Date</span>
|
||||
<span className="text-white">{formatDate(invoice.effectiveDate)}</span>
|
||||
</div>
|
||||
|
||||
{/* Next Billing Date */}
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block mb-1">Next Billing Date</span>
|
||||
<span className="text-white">{formatDate(invoice.nextBillingDate)}</span>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{invoice.paymentMethodLast4 && (
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block mb-1">Payment Method</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-white">
|
||||
{invoice.paymentMethodBrand || 'Card'} ****{invoice.paymentMethodLast4}
|
||||
</span>
|
||||
{onEditPayment && (
|
||||
<button
|
||||
onClick={onEditPayment}
|
||||
className="text-blue-400 text-sm hover:text-blue-300"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Compact view
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-lg border border-slate-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-blue-400" />
|
||||
<span className="text-white font-medium">{invoice.planName}</span>
|
||||
</div>
|
||||
<span className="text-white font-bold">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{invoice.totalDiscount > 0 && (
|
||||
<div className="text-emerald-400 text-sm mb-2">
|
||||
Includes {formatCurrency(invoice.totalDiscount, invoice.currency)} discount
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-slate-400 text-sm flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Next billing: {formatDate(invoice.nextBillingDate)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full view
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-800">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-600/20 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-white font-semibold">Invoice Preview</h2>
|
||||
<p className="text-slate-400 text-sm">{invoice.planName} - {invoice.billingCycle} plan</p>
|
||||
</div>
|
||||
</div>
|
||||
{invoice.id && (
|
||||
<span className="text-slate-500 text-sm font-mono">#{invoice.id}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Line Items */}
|
||||
{renderLineItems()}
|
||||
|
||||
{/* Discounts */}
|
||||
{renderDiscounts()}
|
||||
|
||||
{/* Summary */}
|
||||
{renderSummary()}
|
||||
|
||||
{/* Billing Details */}
|
||||
{!compact && renderBillingDetails()}
|
||||
|
||||
{/* Info Notice */}
|
||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-lg">
|
||||
<Info className="w-5 h-5 text-slate-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-slate-400 text-sm">
|
||||
<p>
|
||||
By confirming, you authorize us to charge your payment method for{' '}
|
||||
<span className="text-white font-medium">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</span>{' '}
|
||||
today and automatically on each billing date until you cancel.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
You can cancel anytime from your billing settings. Refunds are available
|
||||
within 14 days of purchase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="p-6 border-t border-slate-800 flex justify-between">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isProcessing}
|
||||
className="px-4 py-2 text-slate-300 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isProcessing}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
Confirm Payment
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoicePreview;
|
||||
517
src/components/payments/RefundList.tsx
Normal file
517
src/components/payments/RefundList.tsx
Normal file
@ -0,0 +1,517 @@
|
||||
/**
|
||||
* RefundList Component
|
||||
* Paginated list of refund requests with status tracking
|
||||
* Epic: OQI-005 Pagos y Stripe
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
RotateCcw,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
export type RefundStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'canceled';
|
||||
|
||||
export interface Refund {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
subscriptionName: string;
|
||||
invoiceId?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
reason: string;
|
||||
reasonDetails?: string;
|
||||
status: RefundStatus;
|
||||
refundMethod: 'original' | 'wallet';
|
||||
requestedAt: Date;
|
||||
processedAt?: Date;
|
||||
failureReason?: string;
|
||||
transactionId?: string;
|
||||
}
|
||||
|
||||
export interface RefundListProps {
|
||||
refunds: Refund[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
onViewInvoice?: (invoiceId: string) => void;
|
||||
onRetryRefund?: (refundId: string) => void;
|
||||
onCancelRefund?: (refundId: string) => void;
|
||||
onExport?: () => void;
|
||||
isLoading?: boolean;
|
||||
showFilters?: boolean;
|
||||
}
|
||||
|
||||
// Status configurations
|
||||
const STATUS_CONFIG: Record<RefundStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-900/50',
|
||||
icon: Clock,
|
||||
},
|
||||
processing: {
|
||||
label: 'Processing',
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-900/50',
|
||||
icon: Loader2,
|
||||
},
|
||||
completed: {
|
||||
label: 'Completed',
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-900/50',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
failed: {
|
||||
label: 'Failed',
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-900/50',
|
||||
icon: XCircle,
|
||||
},
|
||||
canceled: {
|
||||
label: 'Canceled',
|
||||
color: 'text-slate-400',
|
||||
bgColor: 'bg-slate-800',
|
||||
icon: XCircle,
|
||||
},
|
||||
};
|
||||
|
||||
// Currency formatter
|
||||
const formatCurrency = (amount: number, currency: string = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Date formatter
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Time formatter
|
||||
const formatTime = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge: React.FC<{ status: RefundStatus }> = ({ status }) => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
|
||||
<Icon className={`w-3 h-3 ${status === 'processing' ? 'animate-spin' : ''}`} />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const RefundList: React.FC<RefundListProps> = ({
|
||||
refunds,
|
||||
totalCount,
|
||||
currentPage,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onViewInvoice,
|
||||
onRetryRefund,
|
||||
onCancelRefund,
|
||||
onExport,
|
||||
isLoading = false,
|
||||
showFilters = true,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<RefundStatus | 'all'>('all');
|
||||
const [expandedRefundId, setExpandedRefundId] = useState<string | null>(null);
|
||||
const [showStatusDropdown, setShowStatusDropdown] = useState(false);
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
|
||||
const startIndex = (currentPage - 1) * pageSize + 1;
|
||||
const endIndex = Math.min(currentPage * pageSize, totalCount);
|
||||
|
||||
// Filter refunds locally (for demo - in production, filtering would be server-side)
|
||||
const filteredRefunds = useMemo(() => {
|
||||
return refunds.filter((refund) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && refund.status !== statusFilter) return false;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
refund.id.toLowerCase().includes(query) ||
|
||||
refund.subscriptionName.toLowerCase().includes(query) ||
|
||||
refund.reason.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [refunds, statusFilter, searchQuery]);
|
||||
|
||||
// Toggle row expansion
|
||||
const toggleExpand = useCallback((refundId: string) => {
|
||||
setExpandedRefundId((prev) => (prev === refundId ? null : refundId));
|
||||
}, []);
|
||||
|
||||
// Render filters
|
||||
const renderFilters = () => (
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search refunds..."
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-white text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowStatusDropdown(!showStatusDropdown)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white hover:border-slate-600 transition-colors"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Status: {statusFilter === 'all' ? 'All' : STATUS_CONFIG[statusFilter].label}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showStatusDropdown && (
|
||||
<div className="absolute z-10 w-48 mt-2 bg-slate-800 border border-slate-700 rounded-lg shadow-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setShowStatusDropdown(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm hover:bg-slate-700 ${
|
||||
statusFilter === 'all' ? 'text-blue-400' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
All Statuses
|
||||
</button>
|
||||
{Object.entries(STATUS_CONFIG).map(([status, config]) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => {
|
||||
setStatusFilter(status as RefundStatus);
|
||||
setShowStatusDropdown(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm hover:bg-slate-700 flex items-center gap-2 ${
|
||||
statusFilter === status ? 'text-blue-400' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
<config.icon className={`w-4 h-4 ${config.color}`} />
|
||||
{config.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
{onExport && (
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white hover:border-slate-600 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render empty state
|
||||
const renderEmptyState = () => (
|
||||
<div className="text-center py-12">
|
||||
<RotateCcw className="w-12 h-12 text-slate-600 mx-auto mb-4" />
|
||||
<h3 className="text-white font-medium mb-2">No Refunds Found</h3>
|
||||
<p className="text-slate-400 text-sm">
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: "You haven't requested any refunds yet"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render loading state
|
||||
const renderLoadingState = () => (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render refund row
|
||||
const renderRefundRow = (refund: Refund) => {
|
||||
const isExpanded = expandedRefundId === refund.id;
|
||||
|
||||
return (
|
||||
<div key={refund.id} className="border-b border-slate-800 last:border-0">
|
||||
{/* Main Row */}
|
||||
<div
|
||||
className="flex items-center gap-4 p-4 hover:bg-slate-800/50 cursor-pointer transition-colors"
|
||||
onClick={() => toggleExpand(refund.id)}
|
||||
>
|
||||
{/* Expand Toggle */}
|
||||
<button className="text-slate-500">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Date */}
|
||||
<div className="w-24 text-slate-400 text-sm">
|
||||
{formatDate(refund.requestedAt)}
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate">{refund.subscriptionName}</p>
|
||||
<p className="text-slate-500 text-xs truncate">{refund.reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="w-28 text-right">
|
||||
<p className="text-white font-medium">
|
||||
{formatCurrency(refund.amount, refund.currency)}
|
||||
</p>
|
||||
<p className="text-slate-500 text-xs capitalize">{refund.refundMethod}</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="w-28">
|
||||
<StatusBadge status={refund.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 ml-8 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-800/50 rounded-lg">
|
||||
{/* Reference ID */}
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block">Reference ID</span>
|
||||
<span className="text-white font-mono text-sm">{refund.id}</span>
|
||||
</div>
|
||||
|
||||
{/* Requested At */}
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block">Requested</span>
|
||||
<span className="text-white text-sm">
|
||||
{formatDate(refund.requestedAt)} at {formatTime(refund.requestedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Processed At */}
|
||||
{refund.processedAt && (
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block">Processed</span>
|
||||
<span className="text-white text-sm">
|
||||
{formatDate(refund.processedAt)} at {formatTime(refund.processedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction ID */}
|
||||
{refund.transactionId && (
|
||||
<div>
|
||||
<span className="text-slate-500 text-xs block">Transaction ID</span>
|
||||
<span className="text-white font-mono text-sm">{refund.transactionId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason Details */}
|
||||
{refund.reasonDetails && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-slate-500 text-xs block mb-1">Details</span>
|
||||
<p className="text-slate-300 text-sm">{refund.reasonDetails}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failure Reason */}
|
||||
{refund.status === 'failed' && refund.failureReason && (
|
||||
<div className="col-span-2">
|
||||
<div className="flex items-start gap-2 p-3 bg-red-900/20 rounded-lg border border-red-800/50">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-red-400 text-sm font-medium block">Failure Reason</span>
|
||||
<span className="text-slate-400 text-sm">{refund.failureReason}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{refund.invoiceId && onViewInvoice && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewInvoice(refund.invoiceId!);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Invoice
|
||||
</button>
|
||||
)}
|
||||
|
||||
{refund.status === 'failed' && onRetryRefund && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRetryRefund(refund.id);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Retry Request
|
||||
</button>
|
||||
)}
|
||||
|
||||
{refund.status === 'pending' && onCancelRefund && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancelRefund(refund.id);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Cancel Request
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render pagination
|
||||
const renderPagination = () => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
|
||||
<div className="text-slate-400 text-sm">
|
||||
Showing {startIndex}-{endIndex} of {totalCount} refunds
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange?.(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-slate-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let page: number;
|
||||
if (totalPages <= 5) {
|
||||
page = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
page = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
page = totalPages - 4 + i;
|
||||
} else {
|
||||
page = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange?.(page)}
|
||||
className={`w-8 h-8 rounded-lg text-sm transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange?.(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-slate-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-800">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RotateCcw className="w-6 h-6 text-blue-400" />
|
||||
<div>
|
||||
<h2 className="text-white text-lg font-semibold">Refund History</h2>
|
||||
<p className="text-slate-400 text-sm">{totalCount} total refund requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && <div className="p-6 border-b border-slate-800">{renderFilters()}</div>}
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-h-[300px]">
|
||||
{isLoading && renderLoadingState()}
|
||||
{!isLoading && filteredRefunds.length === 0 && renderEmptyState()}
|
||||
{!isLoading && filteredRefunds.length > 0 && (
|
||||
<div>{filteredRefunds.map(renderRefundRow)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!isLoading && filteredRefunds.length > 0 && renderPagination()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RefundList;
|
||||
481
src/components/payments/RefundRequestModal.tsx
Normal file
481
src/components/payments/RefundRequestModal.tsx
Normal file
@ -0,0 +1,481 @@
|
||||
/**
|
||||
* RefundRequestModal Component
|
||||
* Modal for requesting refunds on subscriptions/purchases
|
||||
* Epic: OQI-005 Pagos y Stripe
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
RotateCcw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
X,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Info,
|
||||
Loader2,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
export interface RefundEligibility {
|
||||
eligible: boolean;
|
||||
refundableAmount: number;
|
||||
originalAmount: number;
|
||||
daysRemaining: number;
|
||||
maxRefundDays: number;
|
||||
partialRefundAllowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RefundRequestData {
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
reason: RefundReason;
|
||||
reasonDetails?: string;
|
||||
refundMethod: 'original' | 'wallet';
|
||||
}
|
||||
|
||||
export type RefundReason =
|
||||
| 'changed_mind'
|
||||
| 'not_needed'
|
||||
| 'duplicate_charge'
|
||||
| 'service_issue'
|
||||
| 'billing_error'
|
||||
| 'other';
|
||||
|
||||
export interface RefundRequestModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subscriptionId: string;
|
||||
subscriptionName: string;
|
||||
eligibility: RefundEligibility;
|
||||
onSubmit?: (data: RefundRequestData) => Promise<void>;
|
||||
onSuccess?: (refundId: string) => void;
|
||||
}
|
||||
|
||||
const REFUND_REASONS: { value: RefundReason; label: string; requiresDetails: boolean }[] = [
|
||||
{ value: 'changed_mind', label: 'Changed my mind', requiresDetails: false },
|
||||
{ value: 'not_needed', label: "Don't need the service anymore", requiresDetails: false },
|
||||
{ value: 'duplicate_charge', label: 'Duplicate or incorrect charge', requiresDetails: true },
|
||||
{ value: 'service_issue', label: 'Service not working as expected', requiresDetails: true },
|
||||
{ value: 'billing_error', label: 'Billing or pricing error', requiresDetails: true },
|
||||
{ value: 'other', label: 'Other reason', requiresDetails: true },
|
||||
];
|
||||
|
||||
// Currency formatter
|
||||
const formatCurrency = (amount: number, currency: string = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const RefundRequestModal: React.FC<RefundRequestModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
subscriptionId,
|
||||
subscriptionName,
|
||||
eligibility,
|
||||
onSubmit,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [step, setStep] = useState<'form' | 'confirm' | 'success'>('form');
|
||||
const [selectedReason, setSelectedReason] = useState<RefundReason | ''>('');
|
||||
const [reasonDetails, setReasonDetails] = useState('');
|
||||
const [refundAmount, setRefundAmount] = useState<number>(eligibility.refundableAmount);
|
||||
const [refundMethod, setRefundMethod] = useState<'original' | 'wallet'>('original');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refundId, setRefundId] = useState<string | null>(null);
|
||||
|
||||
// Check if details are required
|
||||
const requiresDetails = useMemo(() => {
|
||||
const reason = REFUND_REASONS.find((r) => r.value === selectedReason);
|
||||
return reason?.requiresDetails || false;
|
||||
}, [selectedReason]);
|
||||
|
||||
// Form validation
|
||||
const isFormValid = useMemo(() => {
|
||||
if (!selectedReason) return false;
|
||||
if (requiresDetails && reasonDetails.trim().length < 10) return false;
|
||||
if (refundAmount <= 0 || refundAmount > eligibility.refundableAmount) return false;
|
||||
return true;
|
||||
}, [selectedReason, requiresDetails, reasonDetails, refundAmount, eligibility.refundableAmount]);
|
||||
|
||||
// Handle amount change
|
||||
const handleAmountChange = useCallback((value: string) => {
|
||||
const amount = parseFloat(value) || 0;
|
||||
setRefundAmount(Math.min(amount, eligibility.refundableAmount));
|
||||
}, [eligibility.refundableAmount]);
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!isFormValid || !onSubmit) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
subscriptionId,
|
||||
amount: refundAmount,
|
||||
reason: selectedReason as RefundReason,
|
||||
reasonDetails: requiresDetails ? reasonDetails : undefined,
|
||||
refundMethod,
|
||||
});
|
||||
|
||||
// Generate mock refund ID for success state
|
||||
const mockRefundId = `ref_${Date.now().toString(36)}`;
|
||||
setRefundId(mockRefundId);
|
||||
setStep('success');
|
||||
onSuccess?.(mockRefundId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to process refund request');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [isFormValid, onSubmit, subscriptionId, refundAmount, selectedReason, requiresDetails, reasonDetails, refundMethod, onSuccess]);
|
||||
|
||||
// Reset state on close
|
||||
const handleClose = useCallback(() => {
|
||||
setStep('form');
|
||||
setSelectedReason('');
|
||||
setReasonDetails('');
|
||||
setRefundAmount(eligibility.refundableAmount);
|
||||
setRefundMethod('original');
|
||||
setError(null);
|
||||
setRefundId(null);
|
||||
onClose();
|
||||
}, [onClose, eligibility.refundableAmount]);
|
||||
|
||||
// Render not eligible state
|
||||
const renderNotEligible = () => (
|
||||
<div className="p-6 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-900/20 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-white text-xl font-semibold mb-2">Refund Not Available</h3>
|
||||
<p className="text-slate-400 mb-4">
|
||||
{eligibility.reason || 'This subscription is not eligible for a refund.'}
|
||||
</p>
|
||||
{eligibility.daysRemaining < 0 && (
|
||||
<p className="text-slate-500 text-sm">
|
||||
The {eligibility.maxRefundDays}-day refund window has expired.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="mt-6 px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render form step
|
||||
const renderFormStep = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Eligibility Info */}
|
||||
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-blue-400 font-medium">Refund Window</p>
|
||||
<p className="text-slate-400 text-sm">
|
||||
{eligibility.daysRemaining} days remaining of {eligibility.maxRefundDays}-day policy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refund Amount */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
Refund Amount
|
||||
</label>
|
||||
{eligibility.partialRefundAllowed ? (
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<input
|
||||
type="number"
|
||||
value={refundAmount}
|
||||
onChange={(e) => handleAmountChange(e.target.value)}
|
||||
max={eligibility.refundableAmount}
|
||||
min={0}
|
||||
step="0.01"
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-3 text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-lg px-4 py-3">
|
||||
<span className="text-white text-lg font-semibold">
|
||||
{formatCurrency(eligibility.refundableAmount)}
|
||||
</span>
|
||||
<span className="text-slate-500 text-sm ml-2">(Full refund)</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
Maximum refundable: {formatCurrency(eligibility.refundableAmount)} of{' '}
|
||||
{formatCurrency(eligibility.originalAmount)} original charge
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Refund Reason */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
Reason for Refund
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{REFUND_REASONS.map((reason) => (
|
||||
<label
|
||||
key={reason.value}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedReason === reason.value
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="refundReason"
|
||||
value={reason.value}
|
||||
checked={selectedReason === reason.value}
|
||||
onChange={(e) => setSelectedReason(e.target.value as RefundReason)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedReason === reason.value
|
||||
? 'border-blue-500 bg-blue-500'
|
||||
: 'border-slate-600'
|
||||
}`}>
|
||||
{selectedReason === reason.value && (
|
||||
<div className="w-2 h-2 rounded-full bg-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-white">{reason.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details (if required) */}
|
||||
{requiresDetails && (
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
Please provide details
|
||||
<span className="text-red-400 ml-1">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={reasonDetails}
|
||||
onChange={(e) => setReasonDetails(e.target.value)}
|
||||
placeholder="Please describe your issue..."
|
||||
rows={3}
|
||||
minLength={10}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-white resize-none focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
Minimum 10 characters ({reasonDetails.length}/10)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refund Method */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
Refund To
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setRefundMethod('original')}
|
||||
className={`p-4 rounded-lg border text-left transition-colors ${
|
||||
refundMethod === 'original'
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-5 h-5 text-slate-400 mb-2" />
|
||||
<p className="text-white font-medium">Original Payment</p>
|
||||
<p className="text-slate-400 text-xs">3-5 business days</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRefundMethod('wallet')}
|
||||
className={`p-4 rounded-lg border text-left transition-colors ${
|
||||
refundMethod === 'wallet'
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<DollarSign className="w-5 h-5 text-slate-400 mb-2" />
|
||||
<p className="text-white font-medium">Wallet Balance</p>
|
||||
<p className="text-slate-400 text-xs">Instant credit</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-900/20 border border-red-800/50 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
<span className="text-red-400 text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render confirm step
|
||||
const renderConfirmStep = () => {
|
||||
const selectedReasonLabel = REFUND_REASONS.find((r) => r.value === selectedReason)?.label;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-900/20 flex items-center justify-center mx-auto mb-4">
|
||||
<RotateCcw className="w-8 h-8 text-amber-400" />
|
||||
</div>
|
||||
<h3 className="text-white text-xl font-semibold">Confirm Refund Request</h3>
|
||||
<p className="text-slate-400 mt-1">Please review the details below</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Subscription</span>
|
||||
<span className="text-white">{subscriptionName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Refund Amount</span>
|
||||
<span className="text-white font-semibold">{formatCurrency(refundAmount)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Reason</span>
|
||||
<span className="text-white">{selectedReasonLabel}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Refund To</span>
|
||||
<span className="text-white capitalize">{refundMethod === 'original' ? 'Original payment' : 'Wallet'}</span>
|
||||
</div>
|
||||
{reasonDetails && (
|
||||
<div className="pt-3 border-t border-slate-700">
|
||||
<span className="text-slate-400 text-sm block mb-1">Additional Details</span>
|
||||
<p className="text-slate-300 text-sm">{reasonDetails}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-amber-900/20 rounded-lg border border-amber-800/50">
|
||||
<Info className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-slate-300 text-sm">
|
||||
This action cannot be undone. Your subscription will be canceled immediately
|
||||
upon refund approval.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render success step
|
||||
const renderSuccessStep = () => (
|
||||
<div className="p-6 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-900/20 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-white text-xl font-semibold mb-2">Refund Requested</h3>
|
||||
<p className="text-slate-400 mb-4">
|
||||
Your refund request has been submitted successfully.
|
||||
</p>
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 mb-6">
|
||||
<p className="text-slate-500 text-sm">Reference Number</p>
|
||||
<p className="text-white font-mono">{refundId}</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-left bg-slate-800/50 rounded-lg p-4 mb-6">
|
||||
<p className="text-white font-medium">What happens next?</p>
|
||||
<ul className="text-slate-400 text-sm space-y-1">
|
||||
<li>1. Our team will review your request within 24 hours</li>
|
||||
<li>2. You'll receive an email confirmation once approved</li>
|
||||
<li>3. {refundMethod === 'original'
|
||||
? 'Funds will be returned to your original payment method in 3-5 business days'
|
||||
: 'Funds will be credited to your wallet instantly'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={step === 'success' ? handleClose : undefined}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg bg-slate-900 rounded-xl border border-slate-800 shadow-2xl mx-4 max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
{step !== 'success' && (
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<RotateCcw className="w-6 h-6 text-blue-400" />
|
||||
<h2 className="text-white text-lg font-semibold">Request Refund</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[60vh]">
|
||||
{!eligibility.eligible && renderNotEligible()}
|
||||
{eligibility.eligible && step === 'form' && renderFormStep()}
|
||||
{eligibility.eligible && step === 'confirm' && renderConfirmStep()}
|
||||
{step === 'success' && renderSuccessStep()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{eligibility.eligible && step !== 'success' && (
|
||||
<div className="flex justify-between gap-4 p-6 border-t border-slate-800">
|
||||
<button
|
||||
onClick={step === 'form' ? handleClose : () => setStep('form')}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-slate-300 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{step === 'form' ? 'Cancel' : 'Back'}
|
||||
</button>
|
||||
<button
|
||||
onClick={step === 'form' ? () => setStep('confirm') : handleSubmit}
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : step === 'form' ? (
|
||||
'Continue'
|
||||
) : (
|
||||
'Submit Request'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RefundRequestModal;
|
||||
251
src/components/payments/StripeElementsWrapper.tsx
Normal file
251
src/components/payments/StripeElementsWrapper.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* StripeElementsWrapper Component
|
||||
* Provider component for Stripe Elements integration
|
||||
* Epic: OQI-005 Pagos y Stripe
|
||||
*
|
||||
* IMPORTANT: This component is required for PCI-DSS compliance.
|
||||
* All payment forms must be wrapped with this provider.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { loadStripe, Stripe, StripeElementsOptions } from '@stripe/stripe-js';
|
||||
import {
|
||||
CreditCard,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
export interface StripeConfig {
|
||||
publicKey: string;
|
||||
locale?: 'en' | 'es' | 'auto';
|
||||
appearance?: 'stripe' | 'night' | 'flat';
|
||||
}
|
||||
|
||||
export interface StripeElementsWrapperProps {
|
||||
children: React.ReactNode;
|
||||
config?: Partial<StripeConfig>;
|
||||
clientSecret?: string;
|
||||
onReady?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
showLoadingState?: boolean;
|
||||
fallbackComponent?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Default Stripe appearance for dark theme
|
||||
const DARK_THEME_APPEARANCE: StripeElementsOptions['appearance'] = {
|
||||
theme: 'night',
|
||||
variables: {
|
||||
colorPrimary: '#3b82f6',
|
||||
colorBackground: '#1e293b',
|
||||
colorText: '#f1f5f9',
|
||||
colorDanger: '#ef4444',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSizeBase: '14px',
|
||||
borderRadius: '8px',
|
||||
spacingUnit: '4px',
|
||||
},
|
||||
rules: {
|
||||
'.Input': {
|
||||
backgroundColor: '#0f172a',
|
||||
border: '1px solid #334155',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'.Input:focus': {
|
||||
borderColor: '#3b82f6',
|
||||
boxShadow: '0 0 0 1px #3b82f6',
|
||||
},
|
||||
'.Input--invalid': {
|
||||
borderColor: '#ef4444',
|
||||
},
|
||||
'.Label': {
|
||||
color: '#94a3b8',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
'.Error': {
|
||||
color: '#ef4444',
|
||||
fontSize: '12px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Stripe public key from environment
|
||||
const STRIPE_PUBLIC_KEY = import.meta.env.VITE_STRIPE_PUBLIC_KEY ||
|
||||
process.env.REACT_APP_STRIPE_PUBLIC_KEY || '';
|
||||
|
||||
// Loading component
|
||||
const LoadingState: React.FC = () => (
|
||||
<div className="flex flex-col items-center justify-center p-8 bg-slate-900/50 rounded-lg border border-slate-800">
|
||||
<div className="relative">
|
||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||
<CreditCard className="w-4 h-4 text-slate-400 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm mt-4">Loading payment system...</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-emerald-500 text-xs">Secured by Stripe</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Error component
|
||||
const ErrorState: React.FC<{ error: string; onRetry?: () => void }> = ({ error, onRetry }) => (
|
||||
<div className="flex flex-col items-center justify-center p-8 bg-red-900/20 rounded-lg border border-red-800/50">
|
||||
<AlertCircle className="w-8 h-8 text-red-400 mb-4" />
|
||||
<p className="text-red-400 font-medium">Payment System Error</p>
|
||||
<p className="text-slate-400 text-sm mt-2 text-center max-w-md">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-4 px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Configuration missing component
|
||||
const ConfigMissing: React.FC = () => (
|
||||
<div className="flex flex-col items-center justify-center p-8 bg-amber-900/20 rounded-lg border border-amber-800/50">
|
||||
<AlertCircle className="w-8 h-8 text-amber-400 mb-4" />
|
||||
<p className="text-amber-400 font-medium">Stripe Configuration Missing</p>
|
||||
<p className="text-slate-400 text-sm mt-2 text-center max-w-md">
|
||||
The Stripe public key is not configured. Please set VITE_STRIPE_PUBLIC_KEY
|
||||
in your environment variables.
|
||||
</p>
|
||||
<div className="mt-4 bg-slate-800 rounded p-3 font-mono text-xs text-slate-400">
|
||||
VITE_STRIPE_PUBLIC_KEY=pk_test_...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StripeElementsWrapper: React.FC<StripeElementsWrapperProps> = ({
|
||||
children,
|
||||
config = {},
|
||||
clientSecret,
|
||||
onReady,
|
||||
onError,
|
||||
showLoadingState = true,
|
||||
fallbackComponent,
|
||||
}) => {
|
||||
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Merge config with defaults
|
||||
const stripeConfig = useMemo(() => ({
|
||||
publicKey: config.publicKey || STRIPE_PUBLIC_KEY,
|
||||
locale: config.locale || 'auto',
|
||||
appearance: config.appearance || 'night',
|
||||
}), [config]);
|
||||
|
||||
// Initialize Stripe
|
||||
useEffect(() => {
|
||||
if (!stripeConfig.publicKey) {
|
||||
setIsLoading(false);
|
||||
setError('Stripe public key is not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const initStripe = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const stripe = loadStripe(stripeConfig.publicKey, {
|
||||
locale: stripeConfig.locale as 'en' | 'es' | undefined,
|
||||
});
|
||||
|
||||
setStripePromise(stripe);
|
||||
|
||||
// Wait for Stripe to load and verify
|
||||
const stripeInstance = await stripe;
|
||||
if (!stripeInstance) {
|
||||
throw new Error('Failed to initialize Stripe');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
onReady?.();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error loading Stripe';
|
||||
setError(errorMessage);
|
||||
setIsLoading(false);
|
||||
onError?.(err instanceof Error ? err : new Error(errorMessage));
|
||||
}
|
||||
};
|
||||
|
||||
initStripe();
|
||||
}, [stripeConfig.publicKey, stripeConfig.locale, onReady, onError]);
|
||||
|
||||
// Retry handler
|
||||
const handleRetry = () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
// Re-trigger useEffect by updating state
|
||||
setStripePromise(null);
|
||||
};
|
||||
|
||||
// Elements options
|
||||
const elementsOptions: StripeElementsOptions = useMemo(() => ({
|
||||
clientSecret: clientSecret || undefined,
|
||||
appearance: DARK_THEME_APPEARANCE,
|
||||
loader: 'auto',
|
||||
}), [clientSecret]);
|
||||
|
||||
// Check for missing config
|
||||
if (!stripeConfig.publicKey) {
|
||||
return fallbackComponent || <ConfigMissing />;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading && showLoadingState) {
|
||||
return fallbackComponent || <LoadingState />;
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return fallbackComponent || <ErrorState error={error} onRetry={handleRetry} />;
|
||||
}
|
||||
|
||||
// Render with Stripe Elements
|
||||
if (!stripePromise) {
|
||||
return fallbackComponent || <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Elements stripe={stripePromise} options={elementsOptions}>
|
||||
{children}
|
||||
</Elements>
|
||||
);
|
||||
};
|
||||
|
||||
// Higher-order component for wrapping payment forms
|
||||
export function withStripeElements<P extends object>(
|
||||
WrappedComponent: React.ComponentType<P>,
|
||||
config?: Partial<StripeConfig>
|
||||
): React.FC<P> {
|
||||
return function WithStripeElementsWrapper(props: P) {
|
||||
return (
|
||||
<StripeElementsWrapper config={config}>
|
||||
<WrappedComponent {...props} />
|
||||
</StripeElementsWrapper>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Hook to check if Stripe is available
|
||||
export function useStripeAvailable(): boolean {
|
||||
const [available, setAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailable(!!STRIPE_PUBLIC_KEY);
|
||||
}, []);
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
export default StripeElementsWrapper;
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Payment Components Index
|
||||
* Export all payment-related components
|
||||
* Epic: OQI-005 Pagos y Stripe
|
||||
*/
|
||||
|
||||
export { PricingCard } from './PricingCard';
|
||||
@ -25,6 +26,8 @@ export type { Transaction, TransactionType, TransactionStatus } from './Transact
|
||||
export { default as InvoiceList } from './InvoiceList';
|
||||
export type { Invoice } from './InvoiceList';
|
||||
export { default as InvoiceDetail } from './InvoiceDetail';
|
||||
export { default as InvoicePreview } from './InvoicePreview';
|
||||
export type { InvoiceLineItem, InvoiceDiscount, InvoiceTax, InvoicePreviewData } from './InvoicePreview';
|
||||
|
||||
// Payment Methods Management
|
||||
export { default as PaymentMethodsList } from './PaymentMethodsList';
|
||||
@ -32,3 +35,13 @@ export type { PaymentMethod } from './PaymentMethodsList';
|
||||
|
||||
// Subscription Management
|
||||
export { default as SubscriptionUpgradeFlow } from './SubscriptionUpgradeFlow';
|
||||
|
||||
// Refund Components (OQI-005)
|
||||
export { default as RefundRequestModal } from './RefundRequestModal';
|
||||
export type { RefundEligibility, RefundRequestData, RefundReason } from './RefundRequestModal';
|
||||
export { default as RefundList } from './RefundList';
|
||||
export type { Refund, RefundStatus } from './RefundList';
|
||||
|
||||
// Stripe Integration (OQI-005)
|
||||
export { default as StripeElementsWrapper, withStripeElements, useStripeAvailable } from './StripeElementsWrapper';
|
||||
export type { StripeConfig } from './StripeElementsWrapper';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user