All files / features/donation/components/payment StripePaymentForm.tsx

97.36% Statements 37/38
87.09% Branches 27/31
100% Functions 5/5
100% Lines 36/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129                        1x     1x 3x 3x   3x     1x                 10x 10x 10x 10x 10x 10x   10x 2x 2x   2x 2x               2x 1x 1x 1x 1x   1x       10x     7x                                                               1x 5x 5x 5x 5x   5x         5x 1x     4x 1x     3x                          
import { useState } from 'react'
import { loadStripe, type Stripe } from '@stripe/stripe-js'
import { Elements, useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
import { useTranslation } from 'react-i18next'
import { Button } from '@core/components/ui/button'
import { CardContent, CardFooter } from '@core/components/ui/card'
import type { PaymentProviderProps } from '../../types/payment.types'
 
// Initialize Stripe outside to avoid recreation
// In a real app config.publishableKey would come from prop, but loadStripe runs once.
// We can use a singleton lazily initialized if key changes.
// Singleton promise to prevent multiple Stripe element initializations
let stripePromise: Promise<Stripe | null> | null = null
 
// Lazily load Stripe only when needed, reusing the promise
const getStripePromise = (key: string) => {
    Eif (!stripePromise) {
        stripePromise = loadStripe(key)
    }
    return stripePromise
}
 
const StripeFormContent = ({
    onSuccess,
    onBack,
    onError,
}: {
    onSuccess: () => void
    onBack?: () => void
    onError?: (msg: string) => void
}) => {
    const stripe = useStripe()
    const elements = useElements()
    const { t } = useTranslation('common')
    const [message, setMessage] = useState<string | null>(null)
    const [isProcessing, setIsProcessing] = useState(false)
    const [isStripeReady, setIsStripeReady] = useState(false)
 
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault()
        Iif (!stripe || !elements) return
 
        setIsProcessing(true)
        const { error } = await stripe.confirmPayment({
            elements,
            confirmParams: {
                return_url: window.location.origin + '/donate',
            },
            redirect: 'if_required',
        })
 
        if (error) {
            const msg = error.message || t('donation.error')
            setMessage(msg)
            Eif (onError) onError(msg)
            setIsProcessing(false)
        } else {
            onSuccess()
        }
    }
 
    return (
        <form onSubmit={handleSubmit} className="space-y-6">
            <CardContent>
                <PaymentElement onReady={() => setIsStripeReady(true)} />
                {message && (
                    <div className="mt-4 p-3 bg-red-50 text-red-500 rounded text-sm">{message}</div>
                )}
            </CardContent>
            <CardFooter className="flex gap-4">
                {onBack && (
                    <Button
                        type="button"
                        variant="outline"
                        onClick={onBack}
                        disabled={isProcessing}
                    >
                        {t('common.back', 'Back')}
                    </Button>
                )}
                <Button
                    type="submit"
                    className="w-full"
                    disabled={!stripe || !elements || isProcessing || !isStripeReady}
                    style={{
                        backgroundColor: 'var(--donation-next-button-bg)',
                        color: 'var(--donation-next-button-text)',
                    }}
                >
                    {isProcessing ? t('donation.processing') : t('donation.pay_now')}
                </Button>
            </CardFooter>
        </form>
    )
}
 
export const StripePaymentForm = (props: PaymentProviderProps) => {
    const { t } = useTranslation('common')
    const { sessionData, config } = props
    const clientSecret = sessionData?.clientSecret
    const configKey = config?.publishableKey as string | undefined
    // If config has placeholder, fallback to ENV.
    const publishableKey =
        (configKey && !configKey.includes('placeholder') ? configKey : undefined) ||
        import.meta.env.VITE_STRIPE_PUBLIC_KEY ||
        'pk_test_placeholder'
 
    if (!clientSecret) {
        return <div className="text-red-500">{t('payment.error_generic')}</div>
    }
 
    if (!publishableKey || publishableKey.includes('placeholder')) {
        return <div className="text-red-500">{t('payment.error_missing_config')}</div>
    }
 
    return (
        <Elements
            stripe={getStripePromise(publishableKey)}
            options={{ clientSecret, appearance: { theme: 'stripe' } }}
        >
            <StripeFormContent
                onSuccess={props.onSuccess}
                onBack={props.onBack}
                onError={props.onError}
            />
        </Elements>
    )
}