532 lines
18 KiB
JavaScript
532 lines
18 KiB
JavaScript
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;
|