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:
222
src/components/AddPaymentMethodDialog.js
Normal file
222
src/components/AddPaymentMethodDialog.js
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
|
||||
<DialogHeader className="bg-brand-purple text-white px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-6 w-6" />
|
||||
<div>
|
||||
<DialogTitle
|
||||
className="text-lg font-semibold text-white"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Add Payment Method
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
className="text-white/80 text-sm"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Enter your card details securely
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Stripe Card Element */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
className="text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Card Information
|
||||
</Label>
|
||||
<div className="border border-[var(--neutral-800)] rounded-xl p-4 bg-white">
|
||||
<CardElement
|
||||
options={{
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#2d2a4a',
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
'::placeholder': {
|
||||
color: '#9ca3af',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#ef4444',
|
||||
},
|
||||
},
|
||||
hidePostalCode: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Set as Default Checkbox */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="setAsDefault"
|
||||
checked={setAsDefault}
|
||||
onCheckedChange={setSetAsDefault}
|
||||
className="border-brand-purple data-[state=checked]:bg-brand-purple"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="setAsDefault"
|
||||
className="text-sm text-brand-purple cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Set as default payment method for future payments
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p
|
||||
className="text-sm text-red-600"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Note */}
|
||||
<p
|
||||
className="text-xs text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Your card information is securely processed by Stripe. We never store your full card number.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="flex-row gap-3 justify-end pt-4 border-t border-[var(--neutral-800)]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!stripe || loading}
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Add Card'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPaymentMethodDialog;
|
||||
151
src/components/PasswordConfirmDialog.js
Normal file
151
src/components/PasswordConfirmDialog.js
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
|
||||
<DialogHeader className="bg-brand-purple text-white px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-6 w-6" />
|
||||
<div>
|
||||
<DialogTitle
|
||||
className="text-lg font-semibold text-white"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
className="text-white/80 text-sm"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Your Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="border-[var(--neutral-800)] pr-10"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-brand-purple hover:text-[var(--purple-ink)]"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
className="text-sm text-red-500"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row gap-3 justify-end pt-4 border-t border-[var(--neutral-800)]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !password.trim()}
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Confirm'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordConfirmDialog;
|
||||
186
src/components/PaymentMethodCard.js
Normal file
186
src/components/PaymentMethodCard.js
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 border rounded-xl ${
|
||||
method.is_default
|
||||
? 'border-brand-purple bg-[var(--lavender-500)]'
|
||||
: 'border-[var(--neutral-800)] bg-white'
|
||||
} ${isExpired ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Payment Method Icon */}
|
||||
<div className={`p-3 rounded-full ${
|
||||
method.is_default
|
||||
? 'bg-brand-purple text-white'
|
||||
: 'bg-[var(--lavender-300)] text-brand-purple'
|
||||
}`}>
|
||||
<PaymentIcon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Payment Method Details */}
|
||||
<div>
|
||||
{method.payment_type === 'card' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{brandAbbr && (
|
||||
<span className="text-xs font-bold text-[var(--purple-ink)] bg-[var(--lavender-300)] px-2 py-0.5 rounded">
|
||||
{brandAbbr}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-medium text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{method.card_brand ? method.card_brand.charAt(0).toUpperCase() + method.card_brand.slice(1) : 'Card'} •••• {method.card_last4 || '****'}
|
||||
</span>
|
||||
{method.is_default && (
|
||||
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm ${isExpired ? 'text-red-500' : 'text-brand-purple'}`}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{isExpired ? 'Expired' : 'Expires'} {method.card_exp_month?.toString().padStart(2, '0')}/{method.card_exp_year?.toString().slice(-2)}
|
||||
{method.card_funding && (
|
||||
<span className="ml-2 text-xs capitalize">({method.card_funding})</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-medium text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{formatPaymentType(method.payment_type)}
|
||||
</span>
|
||||
{method.is_default && (
|
||||
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{method.manual_notes && (
|
||||
<p
|
||||
className="text-sm text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{method.manual_notes}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{!method.is_default && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSetDefault?.(method.id)}
|
||||
disabled={loading}
|
||||
className="border border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg text-xs px-3"
|
||||
>
|
||||
Set Default
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete?.(method.id)}
|
||||
disabled={loading}
|
||||
className="border border-red-500 text-red-500 hover:bg-red-50 rounded-lg p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodCard;
|
||||
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;
|
||||
531
src/components/admin/AdminPaymentMethodsPanel.js
Normal file
531
src/components/admin/AdminPaymentMethodsPanel.js
Normal file
@@ -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 (
|
||||
<>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5 text-brand-purple" />
|
||||
<h2
|
||||
className="text-lg font-semibold text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Payment Methods
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setRevealDialogOpen(true)}
|
||||
disabled={actionLoading || paymentMethods.length === 0}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Reveal Details
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setAddManualDialogOpen(true)}
|
||||
disabled={actionLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg"
|
||||
>
|
||||
<Banknote className="h-4 w-4 mr-1" />
|
||||
Add Manual
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddCard}
|
||||
disabled={actionLoading}
|
||||
size="sm"
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-lg"
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Card
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
|
||||
</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">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchPaymentMethods}
|
||||
className="ml-auto"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Methods List */}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.length === 0 ? (
|
||||
<p
|
||||
className="text-center py-6 text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
No payment methods on file for this user.
|
||||
</p>
|
||||
) : (
|
||||
(revealedData || paymentMethods).map((method) => {
|
||||
const PaymentIcon = getPaymentTypeIcon(method.payment_type);
|
||||
return (
|
||||
<div
|
||||
key={method.id}
|
||||
className={`flex items-center justify-between p-4 border rounded-xl ${
|
||||
method.is_default
|
||||
? 'border-brand-purple bg-[var(--lavender-500)]'
|
||||
: 'border-[var(--neutral-800)] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
method.is_default
|
||||
? 'bg-brand-purple text-white'
|
||||
: 'bg-[var(--lavender-300)] text-brand-purple'
|
||||
}`}
|
||||
>
|
||||
<PaymentIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
{method.payment_type === 'card' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-medium text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{method.card_brand
|
||||
? method.card_brand.charAt(0).toUpperCase() +
|
||||
method.card_brand.slice(1)
|
||||
: 'Card'}{' '}
|
||||
•••• {method.card_last4 || '****'}
|
||||
</span>
|
||||
{method.is_default && (
|
||||
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Expires {method.card_exp_month?.toString().padStart(2, '0')}/
|
||||
{method.card_exp_year?.toString().slice(-2)}
|
||||
{revealedData && method.stripe_payment_method_id && (
|
||||
<span className="ml-2 text-xs font-mono bg-[var(--lavender-300)] px-2 py-0.5 rounded">
|
||||
{method.stripe_payment_method_id}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-medium text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{formatPaymentType(method.payment_type)}
|
||||
</span>
|
||||
{method.is_default && (
|
||||
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{method.manual_notes && (
|
||||
<p
|
||||
className="text-sm text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{method.manual_notes}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!method.is_default && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSetDefault(method.id)}
|
||||
disabled={actionLoading}
|
||||
className="text-xs"
|
||||
>
|
||||
Set Default
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMethodToDelete(method.id);
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50 p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Add Card Dialog */}
|
||||
{clientSecret && stripePromise && (
|
||||
<Elements stripe={stripePromise} options={elementsOptions}>
|
||||
<AddPaymentMethodDialog
|
||||
open={addCardDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAddCardDialogOpen(open);
|
||||
if (!open) setClientSecret(null);
|
||||
}}
|
||||
onSuccess={handleCardAddSuccess}
|
||||
clientSecret={clientSecret}
|
||||
saveEndpoint={`/admin/users/${userId}/payment-methods`}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
|
||||
{/* Add Manual Payment Method Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={addManualDialogOpen}
|
||||
onOpenChange={setAddManualDialogOpen}
|
||||
onConfirm={handleSaveManualPayment}
|
||||
title="Record Manual Payment Method"
|
||||
description={
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Payment Type</Label>
|
||||
<Select value={manualPaymentType} onValueChange={setManualPaymentType}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cash">Cash</SelectItem>
|
||||
<SelectItem value="check">Check</SelectItem>
|
||||
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Textarea
|
||||
value={manualNotes}
|
||||
onChange={(e) => setManualNotes(e.target.value)}
|
||||
placeholder="e.g., Check #1234, received 01/15/2026"
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
confirmText="Save"
|
||||
variant="info"
|
||||
loading={actionLoading}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
variant="danger"
|
||||
loading={actionLoading}
|
||||
/>
|
||||
|
||||
{/* Password Confirm Dialog for Reveal */}
|
||||
<PasswordConfirmDialog
|
||||
open={revealDialogOpen}
|
||||
onOpenChange={setRevealDialogOpen}
|
||||
onConfirm={handleRevealDetails}
|
||||
title="Reveal Sensitive Details"
|
||||
description="Enter your password to view Stripe payment method IDs. This action will be logged."
|
||||
loading={actionLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPaymentMethodsPanel;
|
||||
@@ -11,6 +11,7 @@ import Navbar from '../components/Navbar';
|
||||
import { User, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2, Eye, CreditCard, Handshake, ArrowLeft } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
|
||||
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
||||
import PaymentMethodsSection from '../components/PaymentMethodsSection';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const Profile = () => {
|
||||
@@ -265,22 +266,10 @@ const Profile = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="h-6 w-6 text-brand-purple" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-2 border-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg px-4 py-2"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment Methods Section */}
|
||||
<PaymentMethodsSection />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import ChangeRoleDialog from '../../components/ChangeRoleDialog';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import TransactionHistory from '../../components/TransactionHistory';
|
||||
import AdminPaymentMethodsPanel from '../../components/admin/AdminPaymentMethodsPanel';
|
||||
|
||||
const AdminUserView = () => {
|
||||
const { userId } = useParams();
|
||||
@@ -417,6 +418,14 @@ const AdminUserView = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment Methods Panel */}
|
||||
<div className="mb-8">
|
||||
<AdminPaymentMethodsPanel
|
||||
userId={userId}
|
||||
userName={`${user.first_name} ${user.last_name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Details */}
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
|
||||
Reference in New Issue
Block a user