[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
|
* Payment Components Index
|
||||||
* Export all payment-related components
|
* Export all payment-related components
|
||||||
|
* Epic: OQI-005 Pagos y Stripe
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { PricingCard } from './PricingCard';
|
export { PricingCard } from './PricingCard';
|
||||||
@ -25,6 +26,8 @@ export type { Transaction, TransactionType, TransactionStatus } from './Transact
|
|||||||
export { default as InvoiceList } from './InvoiceList';
|
export { default as InvoiceList } from './InvoiceList';
|
||||||
export type { Invoice } from './InvoiceList';
|
export type { Invoice } from './InvoiceList';
|
||||||
export { default as InvoiceDetail } from './InvoiceDetail';
|
export { default as InvoiceDetail } from './InvoiceDetail';
|
||||||
|
export { default as InvoicePreview } from './InvoicePreview';
|
||||||
|
export type { InvoiceLineItem, InvoiceDiscount, InvoiceTax, InvoicePreviewData } from './InvoicePreview';
|
||||||
|
|
||||||
// Payment Methods Management
|
// Payment Methods Management
|
||||||
export { default as PaymentMethodsList } from './PaymentMethodsList';
|
export { default as PaymentMethodsList } from './PaymentMethodsList';
|
||||||
@ -32,3 +35,13 @@ export type { PaymentMethod } from './PaymentMethodsList';
|
|||||||
|
|
||||||
// Subscription Management
|
// Subscription Management
|
||||||
export { default as SubscriptionUpgradeFlow } from './SubscriptionUpgradeFlow';
|
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