1. New Components- src/components/PaymentMethodCard.js - Displays individual payment method- src/components/AddPaymentMethodDialog.js - Stripe Elements dialog for adding cards- src/components/PaymentMethodsSection.js - Main payment methods UI- src/components/PasswordConfirmDialog.js - Admin password re-entry dialog- src/components/admin/AdminPaymentMethodsPanel.js - Admin panel for user payment methods2. Profile Integration (src/pages/Profile.js)- Replaced placeholder Payment Method section with PaymentMethodsSection3. Admin Integration (src/pages/admin/AdminUserView.js)- Added AdminPaymentMethodsPanel to user detail view
This commit is contained in:
309
src/components/PaymentMethodsSection.js
Normal file
309
src/components/PaymentMethodsSection.js
Normal file
@@ -0,0 +1,309 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user