223 lines
7.0 KiB
JavaScript
223 lines
7.0 KiB
JavaScript
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;
|