310 lines
9.7 KiB
JavaScript
310 lines
9.7 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { loadStripe } from '@stripe/stripe-js';
|
|
import { Elements } from '@stripe/react-stripe-js';
|
|
import { Card } from './ui/card';
|
|
import { Button } from './ui/button';
|
|
import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import api from '../utils/api';
|
|
import PaymentMethodCard from './PaymentMethodCard';
|
|
import AddPaymentMethodDialog from './AddPaymentMethodDialog';
|
|
import ConfirmationDialog from './ConfirmationDialog';
|
|
|
|
// Initialize Stripe with publishable key from environment
|
|
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);
|
|
|
|
/**
|
|
* PaymentMethodsSection - Manages user payment methods
|
|
*
|
|
* Features:
|
|
* - List saved payment methods
|
|
* - Add new payment method via Stripe SetupIntent
|
|
* - Set default payment method
|
|
* - Delete payment methods
|
|
*/
|
|
const PaymentMethodsSection = () => {
|
|
const [paymentMethods, setPaymentMethods] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
|
|
// Dialog states
|
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
|
const [clientSecret, setClientSecret] = useState(null);
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
const [methodToDelete, setMethodToDelete] = useState(null);
|
|
|
|
/**
|
|
* Fetch payment methods from API
|
|
*/
|
|
const fetchPaymentMethods = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const response = await api.get('/payment-methods');
|
|
setPaymentMethods(response.data);
|
|
} catch (err) {
|
|
const errorMessage = err.response?.data?.detail || 'Failed to load payment methods';
|
|
setError(errorMessage);
|
|
console.error('Failed to fetch payment methods:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchPaymentMethods();
|
|
}, [fetchPaymentMethods]);
|
|
|
|
/**
|
|
* Create SetupIntent and open add dialog
|
|
*/
|
|
const handleAddNew = async () => {
|
|
try {
|
|
setActionLoading(true);
|
|
const response = await api.post('/payment-methods/setup-intent');
|
|
setClientSecret(response.data.client_secret);
|
|
setAddDialogOpen(true);
|
|
} catch (err) {
|
|
const errorMessage = err.response?.data?.detail || 'Failed to initialize payment setup';
|
|
toast.error(errorMessage);
|
|
console.error('Failed to create setup intent:', err);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle successful payment method addition
|
|
*/
|
|
const handleAddSuccess = () => {
|
|
setAddDialogOpen(false);
|
|
setClientSecret(null);
|
|
fetchPaymentMethods();
|
|
};
|
|
|
|
/**
|
|
* Set a payment method as default
|
|
*/
|
|
const handleSetDefault = async (methodId) => {
|
|
try {
|
|
setActionLoading(true);
|
|
await api.put(`/payment-methods/${methodId}/default`);
|
|
toast.success('Default payment method updated');
|
|
fetchPaymentMethods();
|
|
} catch (err) {
|
|
const errorMessage = err.response?.data?.detail || 'Failed to update default payment method';
|
|
toast.error(errorMessage);
|
|
console.error('Failed to set default:', err);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Open delete confirmation dialog
|
|
*/
|
|
const handleDeleteClick = (methodId) => {
|
|
setMethodToDelete(methodId);
|
|
setDeleteConfirmOpen(true);
|
|
};
|
|
|
|
/**
|
|
* Confirm and delete payment method
|
|
*/
|
|
const handleDeleteConfirm = async () => {
|
|
if (!methodToDelete) return;
|
|
|
|
try {
|
|
setActionLoading(true);
|
|
await api.delete(`/payment-methods/${methodToDelete}`);
|
|
toast.success('Payment method removed');
|
|
setDeleteConfirmOpen(false);
|
|
setMethodToDelete(null);
|
|
fetchPaymentMethods();
|
|
} catch (err) {
|
|
const errorMessage = err.response?.data?.detail || 'Failed to remove payment method';
|
|
toast.error(errorMessage);
|
|
console.error('Failed to delete payment method:', err);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
// Stripe Elements options - simplified for CardElement
|
|
const elementsOptions = {
|
|
appearance: {
|
|
theme: 'stripe',
|
|
variables: {
|
|
colorPrimary: '#6b5b95',
|
|
colorBackground: '#ffffff',
|
|
colorText: '#2d2a4a',
|
|
colorDanger: '#ef4444',
|
|
fontFamily: "'Nunito Sans', sans-serif",
|
|
borderRadius: '12px',
|
|
},
|
|
},
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Card className="space-y-4 px-6 pb-6">
|
|
{/* Header */}
|
|
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-0 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<CreditCard className="h-5 w-5" />
|
|
<h3
|
|
className="font-semibold"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
Payment Methods
|
|
</h3>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
onClick={handleAddNew}
|
|
disabled={actionLoading}
|
|
size="sm"
|
|
className="bg-white text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg px-3 py-1"
|
|
>
|
|
{actionLoading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
Add
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Loading State */}
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
|
|
<span
|
|
className="ml-2 text-brand-purple"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
>
|
|
Loading payment methods...
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error State */}
|
|
{error && !loading && (
|
|
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
|
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
|
|
<p
|
|
className="text-sm text-red-600"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
>
|
|
{error}
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={fetchPaymentMethods}
|
|
className="ml-auto border-red-500 text-red-500 hover:bg-red-50 rounded-lg"
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Payment Methods List */}
|
|
{!loading && !error && (
|
|
<div className="space-y-3">
|
|
{paymentMethods.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<CreditCard className="h-12 w-12 text-[var(--lavender-500)] mx-auto mb-3" />
|
|
<p
|
|
className="text-brand-purple mb-2"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
>
|
|
No payment methods saved
|
|
</p>
|
|
<p
|
|
className="text-sm text-brand-purple/70 mb-4"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
>
|
|
Add a card to make payments easier
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
onClick={handleAddNew}
|
|
disabled={actionLoading}
|
|
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
|
>
|
|
{actionLoading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Setting up...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Payment Method
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
paymentMethods.map((method) => (
|
|
<PaymentMethodCard
|
|
key={method.id}
|
|
method={method}
|
|
onSetDefault={handleSetDefault}
|
|
onDelete={handleDeleteClick}
|
|
loading={actionLoading}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Info Text */}
|
|
{!loading && paymentMethods.length > 0 && (
|
|
<p
|
|
className="text-xs text-brand-purple/70 pt-2 border-t border-[var(--neutral-800)]"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
>
|
|
Your default payment method will be used for subscription renewals and donations.
|
|
</p>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Add Payment Method Dialog */}
|
|
{clientSecret && stripePromise && (
|
|
<Elements stripe={stripePromise} options={elementsOptions}>
|
|
<AddPaymentMethodDialog
|
|
open={addDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setAddDialogOpen(open);
|
|
if (!open) setClientSecret(null);
|
|
}}
|
|
onSuccess={handleAddSuccess}
|
|
clientSecret={clientSecret}
|
|
/>
|
|
</Elements>
|
|
)}
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<ConfirmationDialog
|
|
open={deleteConfirmOpen}
|
|
onOpenChange={setDeleteConfirmOpen}
|
|
onConfirm={handleDeleteConfirm}
|
|
title="Remove Payment Method"
|
|
description="Are you sure you want to remove this payment method? This action cannot be undone."
|
|
confirmText="Remove"
|
|
cancelText="Cancel"
|
|
variant="danger"
|
|
loading={actionLoading}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default PaymentMethodsSection;
|