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:
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;
|
||||
Reference in New Issue
Block a user