/** * BraintreePayment: fetches client token, dynamically loads braintree-web-drop-in, * mounts the drop-in UI, handles 3D Secure and payment submission. */ import { useState, useCallback, useEffect, useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Button, Flex, Notice } from '@wordpress/components'; import { Spinner } from '@woocommerce/components'; import apiFetch from '@wordpress/api-fetch'; import type { PaymentProvider, BraintreeTokenResponse, GenericPaymentResponse, GenericErrorResponse, } from '../../../types'; import type { DropinInstance } from 'braintree-web-drop-in'; import { PaymentOrderResponse } from '../../../types/payment'; import { addBreadcrumb, captureException } from '../../sentry'; let BraintreeWebDropIn: typeof import('braintree-web-drop-in').default | null = null; interface BraintreePaymentProps { paymentOrder: PaymentOrderResponse; providers: PaymentProvider[]; onSuccess: (response: GenericPaymentResponse, paymentOrder: PaymentOrderResponse) => void; } export default function BraintreePayment({ paymentOrder, providers, onSuccess, }: BraintreePaymentProps) { const starting = useRef(false); const braintreeInstance = useRef(null); const [error, setError] = useState(null); const [paying, setPaying] = useState(false); const [showButton, setShowButton] = useState(false); const [tokenLoading, setTokenLoading] = useState(true); const [tokenData, setTokenData] = useState(null); const [success, setSuccess] = useState(false); const { orderId, hash } = paymentOrder.orderResult; const refs = paymentOrder.order?.items?.map( (item) => item.ref, ) ?? 'woo-payment'; // Fetch Braintree client token useEffect(() => { let cancelled = false; const fetchToken = async () => { setTokenLoading(true); try { const data = (await apiFetch({ path: '/parcel2go-shipping/v1/payment/braintree/token', method: 'POST', data: { orderId, hash }, })) as {success: boolean; result: BraintreeTokenResponse; validationErrors: GenericErrorResponse[] | null}; if (!cancelled && data.success && data.result) setTokenData(data.result); } catch (err: unknown) { captureException(err instanceof Error ? err : new Error(String(err))); if (!cancelled) { setError(String((err as { message: string }).message)); } else { setError(__('Failed to load payment system. Please try again', 'parcel2go-shipping')); } } finally { if (!cancelled) setTokenLoading(false); } }; fetchToken(); return () => { cancelled = true; }; }, [orderId, hash]); // Initialize Braintree drop-in once token is available useEffect(() => { if (tokenLoading || !tokenData?.clientToken || braintreeInstance.current || starting.current) { return; } starting.current = true; const loadBraintree = async () => { if (!BraintreeWebDropIn) { BraintreeWebDropIn = (await import('braintree-web-drop-in')).default; } BraintreeWebDropIn.create( { authorization: tokenData.clientToken, container: '#p2g-dropin-container', dataCollector: true, threeDSecure: true, card: providers.some((p) => p.type === 'creditcard') ? { cardholderName: { required: true } } : undefined, googlePay: tokenData.metadata?.googlePay?.enabled && tokenData.metadata?.googlePay?.merchantId && providers.some((p) => p.type === 'googlepay') ? { merchantId: tokenData.metadata.googlePay.merchantId, googlePayVersion: 2, transactionInfo: { currencyCode: tokenData.currency, totalPriceStatus: 'FINAL', totalPrice: (tokenData.totalInUnits / 100).toFixed(2), transactionId: orderId || undefined, }, } : undefined, applePay: tokenData.metadata?.applePay?.enabled && providers.some((p) => p.type === 'applepay') ? { displayName: 'Parcel2Go', paymentRequest: { countryCode: tokenData.billingAddress?.iso2Code, merchantCapabilities: ['supports3DS'], supportedNetworks: ['visa', 'masterCard'], currencyCode: tokenData.currency, total: { amount: (tokenData.totalInUnits / 100).toFixed(2), label: 'Total', }, requiredBillingContactFields: ['postalAddress'], }, } : undefined, paypal: providers.some((p) => p.type === 'paypal') ? { flow: 'checkout', amount: tokenData.totalInUnits / 100, currency: tokenData.currency, } : undefined, }, (createErr, instance) => { starting.current = false; if (createErr) { setError(__('Failed to initialize payment form.', 'parcel2go-shipping')); captureException(createErr instanceof Error ? createErr : new Error(String(createErr))); addBreadcrumb('Braintree create error', {createErr: createErr}, 'payment'); return; } setError(null); braintreeInstance.current = instance || null; if (instance?.isPaymentMethodRequestable()) { setShowButton(true); } instance?.on('paymentMethodRequestable', (event) => { if (event?.type === 'CreditCard' || event?.paymentMethodIsSelected) { setShowButton(true); } }); instance?.on('noPaymentMethodRequestable', () => { setShowButton(false); }); } ); }; loadBraintree().catch((loadErr) => { captureException(loadErr instanceof Error ? loadErr : new Error(String(loadErr))); addBreadcrumb('Braintree load error', {loadErr: loadErr}, 'payment'); setError(__('Failed to load payment system. Please try again', 'parcel2go-shipping')); starting.current = false; }); }, [tokenData, tokenLoading, orderId, providers]); // Teardown on unmount useEffect(() => { return () => { braintreeInstance.current?.teardown(); braintreeInstance.current = null; }; }, []); const handlePayment = useCallback(async () => { if (!braintreeInstance.current || !tokenData) return; setPaying(true); const payload = { amount: (tokenData.totalInUnits / 100).toFixed(2), mobilePhoneNumber: tokenData.billingAddress?.telephone, email: tokenData.billingAddress?.email, billingAddress: { givenName: tokenData.billingAddress?.contactName.split(' ')[0], surname: tokenData.billingAddress?.contactName.split(' ')[1], phoneNumber: tokenData.billingAddress?.telephone, streetAddress: tokenData.billingAddress.street, locality: tokenData.billingAddress?.town, countryCodeAlpha2: tokenData.billingAddress?.iso2Code, }, }; braintreeInstance.current.requestPaymentMethod( { threeDSecure: payload, }, async (err, payload) => { try { if (err) { captureException(err instanceof Error ? err : new Error(String(err))); addBreadcrumb('Braintree payment error', {err: err}, 'payment'); const errMsg = err.message || __('Payment verification failed. Please try again or use a different payment method.', 'parcel2go-shipping'); setError(errMsg); setPaying(false); return; } const response = (await apiFetch({ path: '/parcel2go-shipping/v1/payment/braintree', method: 'POST', data: { orderId, hash, nonce: payload.nonce, deviceData: payload.deviceData, refs, }, })) as {success: boolean; result: GenericPaymentResponse; validationErrors: GenericErrorResponse[] | null}; if (response.success && response.result && response.result.completeHash) { setError(null); setSuccess(true); onSuccess({ orderId: response.result.orderId ?? orderId, success: response.success, error: null, completeHash: response.result.completeHash, }, paymentOrder); } else if (response.validationErrors?.length > 0) { setError(response.validationErrors?.[0]?.detail || ''); } else { setError(__('An unexpected error occurred while processing your payment. Please try again.', 'parcel2go-shipping')); captureException(new Error('An unexpected error occurred while processing your payment. Please try again.')); addBreadcrumb('Braintree payment error', {error: 'An unexpected error occurred while processing your payment. Please try again.'}, 'payment'); } } catch (submitErr: unknown) { captureException(submitErr instanceof Error ? submitErr : new Error(String(submitErr))); const msg = formatPaymentErrorMessage(submitErr); setError(msg); } finally { setPaying(false); } } ); }, [tokenData, orderId, hash, onSuccess]); if (tokenLoading) { return ( ); } return ( {error && ( {error} )} {success && (

{__('Payment successful. Saving booking details to your order…', 'parcel2go-shipping')}

)} {starting.current && ( )}
{showButton && !paying && ( )} ); } type ApiError = { code?: string; data?: { status?: number; }; message?: string; }; function formatPaymentErrorMessage(error:ApiError): string { if (!error || typeof error !== "object") { return "Something went wrong. Please try again."; } const statusMessages: Record = { 400: "Invalid request. Please check your input.", 401: "You are not authorised. Please log in again.", 403: "You do not have permission to perform this action.", 404: "The requested resource was not found.", 409: "There was a conflict with your request.", 422: "Some fields are invalid. Please review and try again.", 500: "Something went wrong on our side. Please try again later.", 503: "The service is temporarily unavailable. Please try again later.", }; if(error.data?.status && statusMessages[error.data.status]){ return statusMessages[error.data.status]; } return "Unexpected error. Please contact support if this continues."; }