From 5d085153f6c089aae4ed905f4575b1cf37d5356f Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:09:37 +0700 Subject: [PATCH] 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 --- src/components/AddPaymentMethodDialog.js | 222 ++++++++ src/components/PasswordConfirmDialog.js | 151 +++++ src/components/PaymentMethodCard.js | 186 ++++++ src/components/PaymentMethodsSection.js | 309 ++++++++++ .../admin/AdminPaymentMethodsPanel.js | 531 ++++++++++++++++++ src/pages/Profile.js | 19 +- src/pages/admin/AdminUserView.js | 9 + 7 files changed, 1412 insertions(+), 15 deletions(-) create mode 100644 src/components/AddPaymentMethodDialog.js create mode 100644 src/components/PasswordConfirmDialog.js create mode 100644 src/components/PaymentMethodCard.js create mode 100644 src/components/PaymentMethodsSection.js create mode 100644 src/components/admin/AdminPaymentMethodsPanel.js diff --git a/src/components/AddPaymentMethodDialog.js b/src/components/AddPaymentMethodDialog.js new file mode 100644 index 0000000..0784c8f --- /dev/null +++ b/src/components/AddPaymentMethodDialog.js @@ -0,0 +1,222 @@ +import React, { useState } from 'react'; +import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; +import { Label } from './ui/label'; +import { CreditCard, AlertCircle, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import api from '../utils/api'; + +/** + * AddPaymentMethodDialog - Dialog for adding a new payment method using Stripe Elements + * + * This dialog should be wrapped in an Elements provider with a clientSecret + * + * @param {string} saveEndpoint - Optional custom API endpoint for saving (default: '/payment-methods') + */ +const AddPaymentMethodDialog = ({ + open, + onOpenChange, + onSuccess, + clientSecret, + saveEndpoint = '/payment-methods', +}) => { + const stripe = useStripe(); + const elements = useElements(); + const [loading, setLoading] = useState(false); + const [setAsDefault, setSetAsDefault] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!stripe || !elements) { + return; + } + + setLoading(true); + setError(null); + + try { + // Get the CardElement + const cardElement = elements.getElement(CardElement); + + if (!cardElement) { + setError('Card element not found'); + toast.error('Card element not found'); + setLoading(false); + return; + } + + // Confirm the SetupIntent with the card element + const { error: stripeError, setupIntent } = await stripe.confirmCardSetup( + clientSecret, + { + payment_method: { + card: cardElement, + }, + } + ); + + if (stripeError) { + setError(stripeError.message); + toast.error(stripeError.message); + setLoading(false); + return; + } + + if (setupIntent.status === 'succeeded') { + // Save the payment method to our backend using the specified endpoint + await api.post(saveEndpoint, { + stripe_payment_method_id: setupIntent.payment_method, + set_as_default: setAsDefault, + }); + + toast.success('Payment method added successfully'); + onSuccess?.(); + onOpenChange(false); + } else { + setError(`Setup failed with status: ${setupIntent.status}`); + toast.error('Failed to set up payment method'); + } + } catch (err) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to save payment method'; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( + + + +
+ +
+ + Add Payment Method + + + Enter your card details securely + +
+
+
+ +
+ {/* Stripe Card Element */} +
+ +
+ +
+
+ + {/* Set as Default Checkbox */} +
+ + +
+ + {/* Error Message */} + {error && ( +
+ +

+ {error} +

+
+ )} + + {/* Security Note */} +

+ Your card information is securely processed by Stripe. We never store your full card number. +

+ + + + + +
+
+
+ ); +}; + +export default AddPaymentMethodDialog; diff --git a/src/components/PasswordConfirmDialog.js b/src/components/PasswordConfirmDialog.js new file mode 100644 index 0000000..48dc6ce --- /dev/null +++ b/src/components/PasswordConfirmDialog.js @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Shield, Eye, EyeOff, Loader2 } from 'lucide-react'; + +/** + * PasswordConfirmDialog - Dialog requiring admin password re-entry for sensitive actions + */ +const PasswordConfirmDialog = ({ + open, + onOpenChange, + onConfirm, + title = 'Confirm Your Identity', + description = 'Please enter your password to proceed with this action.', + loading = false, +}) => { + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(null); + + if (!password.trim()) { + setError('Password is required'); + return; + } + + try { + await onConfirm(password); + setPassword(''); + } catch (err) { + setError(err.message || 'Invalid password'); + } + }; + + const handleOpenChange = (isOpen) => { + if (!isOpen) { + setPassword(''); + setError(null); + } + onOpenChange(isOpen); + }; + + return ( + + + +
+ +
+ + {title} + + + {description} + +
+
+
+ +
+
+ +
+ setPassword(e.target.value)} + placeholder="Enter your password" + className="border-[var(--neutral-800)] pr-10" + autoComplete="current-password" + /> + +
+
+ + {error && ( +

+ {error} +

+ )} + + + + + +
+
+
+ ); +}; + +export default PasswordConfirmDialog; diff --git a/src/components/PaymentMethodCard.js b/src/components/PaymentMethodCard.js new file mode 100644 index 0000000..e45fe23 --- /dev/null +++ b/src/components/PaymentMethodCard.js @@ -0,0 +1,186 @@ +import React from 'react'; +import { CreditCard, Trash2, Star, Banknote, Building2, FileCheck } from 'lucide-react'; +import { Button } from './ui/button'; + +/** + * Card brand icon mapping + */ +const getBrandIcon = (brand) => { + const brandLower = brand?.toLowerCase(); + // Return text abbreviation for known brands + switch (brandLower) { + case 'visa': + return 'VISA'; + case 'mastercard': + return 'MC'; + case 'amex': + case 'american_express': + return 'AMEX'; + case 'discover': + return 'DISC'; + default: + return null; + } +}; + +/** + * Get icon for payment method type + */ +const getPaymentTypeIcon = (paymentType) => { + switch (paymentType) { + case 'cash': + return Banknote; + case 'bank_transfer': + return Building2; + case 'check': + return FileCheck; + default: + return CreditCard; + } +}; + +/** + * Format payment type for display + */ +const formatPaymentType = (paymentType) => { + switch (paymentType) { + case 'cash': + return 'Cash'; + case 'bank_transfer': + return 'Bank Transfer'; + case 'check': + return 'Check'; + case 'card': + return 'Card'; + default: + return paymentType; + } +}; + +/** + * PaymentMethodCard - Displays a single payment method + */ +const PaymentMethodCard = ({ + method, + onSetDefault, + onDelete, + loading = false, + showActions = true, +}) => { + const PaymentIcon = getPaymentTypeIcon(method.payment_type); + const brandAbbr = method.card_brand ? getBrandIcon(method.card_brand) : null; + const isExpired = method.card_exp_year && method.card_exp_month && + new Date(method.card_exp_year, method.card_exp_month) < new Date(); + + return ( +
+
+ {/* Payment Method Icon */} +
+ +
+ + {/* Payment Method Details */} +
+ {method.payment_type === 'card' ? ( + <> +
+ {brandAbbr && ( + + {brandAbbr} + + )} + + {method.card_brand ? method.card_brand.charAt(0).toUpperCase() + method.card_brand.slice(1) : 'Card'} •••• {method.card_last4 || '****'} + + {method.is_default && ( + + + Default + + )} +
+

+ {isExpired ? 'Expired' : 'Expires'} {method.card_exp_month?.toString().padStart(2, '0')}/{method.card_exp_year?.toString().slice(-2)} + {method.card_funding && ( + ({method.card_funding}) + )} +

+ + ) : ( + <> +
+ + {formatPaymentType(method.payment_type)} + + {method.is_default && ( + + + Default + + )} +
+ {method.manual_notes && ( +

+ {method.manual_notes} +

+ )} + + )} +
+
+ + {/* Actions */} + {showActions && ( +
+ {!method.is_default && ( + + )} + +
+ )} +
+ ); +}; + +export default PaymentMethodCard; diff --git a/src/components/PaymentMethodsSection.js b/src/components/PaymentMethodsSection.js new file mode 100644 index 0000000..5b67c35 --- /dev/null +++ b/src/components/PaymentMethodsSection.js @@ -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 ( + <> + + {/* Header */} +
+
+ +

+ Payment Methods +

+
+ +
+ + {/* Loading State */} + {loading && ( +
+ + + Loading payment methods... + +
+ )} + + {/* Error State */} + {error && !loading && ( +
+ +

+ {error} +

+ +
+ )} + + {/* Payment Methods List */} + {!loading && !error && ( +
+ {paymentMethods.length === 0 ? ( +
+ +

+ No payment methods saved +

+

+ Add a card to make payments easier +

+ +
+ ) : ( + paymentMethods.map((method) => ( + + )) + )} +
+ )} + + {/* Info Text */} + {!loading && paymentMethods.length > 0 && ( +

+ Your default payment method will be used for subscription renewals and donations. +

+ )} +
+ + {/* Add Payment Method Dialog */} + {clientSecret && stripePromise && ( + + { + setAddDialogOpen(open); + if (!open) setClientSecret(null); + }} + onSuccess={handleAddSuccess} + clientSecret={clientSecret} + /> + + )} + + {/* Delete Confirmation Dialog */} + + + ); +}; + +export default PaymentMethodsSection; diff --git a/src/components/admin/AdminPaymentMethodsPanel.js b/src/components/admin/AdminPaymentMethodsPanel.js new file mode 100644 index 0000000..1376a7a --- /dev/null +++ b/src/components/admin/AdminPaymentMethodsPanel.js @@ -0,0 +1,531 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { Textarea } from '../ui/textarea'; +import { Label } from '../ui/label'; +import { + CreditCard, + Plus, + Loader2, + AlertCircle, + Eye, + Banknote, + Building2, + FileCheck, + Trash2, + Star, +} from 'lucide-react'; +import { toast } from 'sonner'; +import api from '../../utils/api'; +import ConfirmationDialog from '../ConfirmationDialog'; +import PasswordConfirmDialog from '../PasswordConfirmDialog'; +import AddPaymentMethodDialog from '../AddPaymentMethodDialog'; + +// Initialize Stripe with publishable key from environment +const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY); + +/** + * Get icon for payment method type + */ +const getPaymentTypeIcon = (paymentType) => { + switch (paymentType) { + case 'cash': + return Banknote; + case 'bank_transfer': + return Building2; + case 'check': + return FileCheck; + default: + return CreditCard; + } +}; + +/** + * Format payment type for display + */ +const formatPaymentType = (paymentType) => { + switch (paymentType) { + case 'cash': + return 'Cash'; + case 'bank_transfer': + return 'Bank Transfer'; + case 'check': + return 'Check'; + case 'card': + return 'Card'; + default: + return paymentType; + } +}; + +/** + * AdminPaymentMethodsPanel - Admin panel for managing user payment methods + */ +const AdminPaymentMethodsPanel = ({ userId, userName }) => { + const [paymentMethods, setPaymentMethods] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [error, setError] = useState(null); + + // Dialog states + const [addCardDialogOpen, setAddCardDialogOpen] = useState(false); + const [addManualDialogOpen, setAddManualDialogOpen] = useState(false); + const [clientSecret, setClientSecret] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [methodToDelete, setMethodToDelete] = useState(null); + const [revealDialogOpen, setRevealDialogOpen] = useState(false); + const [revealedData, setRevealedData] = useState(null); + + // Manual payment form state + const [manualPaymentType, setManualPaymentType] = useState('cash'); + const [manualNotes, setManualNotes] = useState(''); + const [manualSetDefault, setManualSetDefault] = useState(false); + + /** + * Fetch payment methods from API + */ + const fetchPaymentMethods = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await api.get(`/admin/users/${userId}/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); + } + }, [userId]); + + useEffect(() => { + if (userId) { + fetchPaymentMethods(); + } + }, [userId, fetchPaymentMethods]); + + /** + * Create SetupIntent for adding a card + */ + const handleAddCard = async () => { + try { + setActionLoading(true); + const response = await api.post(`/admin/users/${userId}/payment-methods/setup-intent`); + setClientSecret(response.data.client_secret); + setAddCardDialogOpen(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 card addition + */ + const handleCardAddSuccess = () => { + setAddCardDialogOpen(false); + setClientSecret(null); + fetchPaymentMethods(); + }; + + /** + * Save manual payment method + */ + const handleSaveManualPayment = async () => { + try { + setActionLoading(true); + await api.post(`/admin/users/${userId}/payment-methods/manual`, { + payment_type: manualPaymentType, + manual_notes: manualNotes || null, + set_as_default: manualSetDefault, + }); + toast.success('Manual payment method recorded'); + setAddManualDialogOpen(false); + setManualPaymentType('cash'); + setManualNotes(''); + setManualSetDefault(false); + fetchPaymentMethods(); + } catch (err) { + const errorMessage = err.response?.data?.detail || 'Failed to record payment method'; + toast.error(errorMessage); + console.error('Failed to save manual payment:', err); + } finally { + setActionLoading(false); + } + }; + + /** + * Set a payment method as default + */ + const handleSetDefault = async (methodId) => { + try { + setActionLoading(true); + await api.put(`/admin/users/${userId}/payment-methods/${methodId}/default`); + toast.success('Default payment method updated'); + fetchPaymentMethods(); + } catch (err) { + const errorMessage = err.response?.data?.detail || 'Failed to update default'; + toast.error(errorMessage); + } finally { + setActionLoading(false); + } + }; + + /** + * Confirm and delete payment method + */ + const handleDeleteConfirm = async () => { + if (!methodToDelete) return; + + try { + setActionLoading(true); + await api.delete(`/admin/users/${userId}/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); + } finally { + setActionLoading(false); + } + }; + + /** + * Reveal sensitive payment details with password confirmation + */ + const handleRevealDetails = async (password) => { + try { + setActionLoading(true); + const response = await api.post(`/admin/users/${userId}/payment-methods/reveal`, { + password, + }); + setRevealedData(response.data); + setRevealDialogOpen(false); + toast.success('Sensitive details revealed'); + } catch (err) { + const errorMessage = err.response?.data?.detail || 'Failed to reveal details'; + throw new Error(errorMessage); + } finally { + setActionLoading(false); + } + }; + + // Stripe Elements options - simplified for CardElement + const elementsOptions = { + appearance: { + theme: 'stripe', + variables: { + colorPrimary: '#6b5b95', + colorBackground: '#ffffff', + colorText: '#2d2a4a', + fontFamily: "'Nunito Sans', sans-serif", + borderRadius: '12px', + }, + }, + }; + + return ( + <> + + {/* Header */} +
+
+ +

+ Payment Methods +

+
+
+ + + +
+
+ + {/* Loading State */} + {loading && ( +
+ +
+ )} + + {/* Error State */} + {error && !loading && ( +
+ +

{error}

+ +
+ )} + + {/* Payment Methods List */} + {!loading && !error && ( +
+ {paymentMethods.length === 0 ? ( +

+ No payment methods on file for this user. +

+ ) : ( + (revealedData || paymentMethods).map((method) => { + const PaymentIcon = getPaymentTypeIcon(method.payment_type); + return ( +
+
+
+ +
+
+ {method.payment_type === 'card' ? ( + <> +
+ + {method.card_brand + ? method.card_brand.charAt(0).toUpperCase() + + method.card_brand.slice(1) + : 'Card'}{' '} + •••• {method.card_last4 || '****'} + + {method.is_default && ( + + + Default + + )} +
+

+ Expires {method.card_exp_month?.toString().padStart(2, '0')}/ + {method.card_exp_year?.toString().slice(-2)} + {revealedData && method.stripe_payment_method_id && ( + + {method.stripe_payment_method_id} + + )} +

+ + ) : ( + <> +
+ + {formatPaymentType(method.payment_type)} + + {method.is_default && ( + + + Default + + )} +
+ {method.manual_notes && ( +

+ {method.manual_notes} +

+ )} + + )} +
+
+ + {/* Actions */} +
+ {!method.is_default && ( + + )} + +
+
+ ); + }) + )} +
+ )} +
+ + {/* Add Card Dialog */} + {clientSecret && stripePromise && ( + + { + setAddCardDialogOpen(open); + if (!open) setClientSecret(null); + }} + onSuccess={handleCardAddSuccess} + clientSecret={clientSecret} + saveEndpoint={`/admin/users/${userId}/payment-methods`} + /> + + )} + + {/* Add Manual Payment Method Dialog */} + +
+ + +
+
+ +