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:
Koncept Kit
2026-01-31 01:09:37 +07:00
parent 7152382dca
commit 5d085153f6
7 changed files with 1412 additions and 15 deletions

View 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;