diff --git a/.changeset/gentle-crabs-clean.md b/.changeset/gentle-crabs-clean.md new file mode 100644 index 00000000..beafdcc9 --- /dev/null +++ b/.changeset/gentle-crabs-clean.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Add google and apple pay stripe express payment methods diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index a574ee64..a5c823f1 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -30,6 +30,7 @@ import { PaymentProvider, useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect'; import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; @@ -53,8 +54,12 @@ import type { CalculatedAdjustments, CalculatedTaxes } from '@/types'; export function ExpressCheckoutButton() { const formatCurrency = useFormatCurrency(); const convertMajorToMinorUnits = useConvertMajorToMinorUnits(); - const { session, setCheckoutErrors } = useCheckoutContext(); + const { session, setCheckoutErrors, isConfirmingCheckout } = + useCheckoutContext(); + const isPaymentDisabled = useIsPaymentDisabled(); const { isPoyntLoaded } = useLoadPoyntCollect(); + + const isDisabled = isConfirmingCheckout || isPaymentDisabled; const { godaddyPaymentsConfig } = useCheckoutContext(); const { t } = useGoDaddyContext(); const [isCollectLoading, setIsCollectLoading] = useState(true); @@ -183,6 +188,10 @@ export function ExpressCheckoutButton() { const handleExpressPayClick = useCallback( async ({ source }: { source?: 'apple_pay' | 'google_pay' | 'paze' }) => { + if (isDisabled) { + return; + } + // Read from refs to get current values (avoid stale closure) const currentCouponCode = appliedCouponCodeRef.current; const currentAdjustments = calculatedAdjustmentsRef.current; @@ -292,6 +301,7 @@ export function ExpressCheckoutButton() { currencyCode, totals, formatCurrency, + isDisabled, ] ); @@ -1475,7 +1485,10 @@ export function ExpressCheckoutButton() { return ( <> -
+
{isCollectLoading ? (
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 5f5fd508..03fb32fc 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -1,43 +1,683 @@ -import { ExpressCheckoutElement } from '@stripe/react-stripe-js'; -import type { ShippingRate } from '@stripe/stripe-js'; +import { ExpressCheckoutElement, useElements } from '@stripe/react-stripe-js'; +import type { + LineItem, + ShippingRate, + StripeExpressCheckoutElementClickEvent, + StripeExpressCheckoutElementConfirmEvent, + StripeExpressCheckoutElementReadyEvent, + StripeExpressCheckoutElementShippingAddressChangeEvent, + StripeExpressCheckoutElementShippingRateChangeEvent, +} from '@stripe/stripe-js'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; +import { + useDraftOrder, + useDraftOrderTotals, +} from '@/components/checkout/order/use-draft-order'; +import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useStripeCheckout } from '@/components/checkout/payment/utils/use-stripe-checkout'; import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use-stripe-payment-intent'; +import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; +import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; +import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes'; + import { Skeleton } from '@/components/ui/skeleton'; import { useGoDaddyContext } from '@/godaddy-provider'; +import { eventIds } from '@/tracking/events'; +import { TrackingEventType, track } from '@/tracking/track'; +import type { + CalculatedAdjustments, + CalculatedTaxes, + ShippingMethod, +} from '@/types'; + +// Type for partial address from Stripe shipping address change event +interface StripePartialAddress { + city: string; + state: string; + postal_code: string; + country: string; +} export function StripeExpressCheckoutForm() { const { t } = useGoDaddyContext(); + const { session, setCheckoutErrors, isConfirmingCheckout } = + useCheckoutContext(); + const elements = useElements(); + const isPaymentDisabled = useIsPaymentDisabled(); const { handleSubmit } = useStripeCheckout({ mode: 'express', }); - return ( - { - //const address = event.address; - try { - const shippingRates: ShippingRate[] = [ - { - id: 'standard', - amount: 500, - displayName: t.payment.standardShipping, - deliveryEstimate: '3-5 business days', - }, - // TODO: Hook into shipping API - ]; + // Combined disabled state + const isDisabled = isConfirmingCheckout || isPaymentDisabled; + + // Data hooks + const { data: totals } = useDraftOrderTotals(); + const { data: draftOrder } = useDraftOrder(); + const getShippingMethodsByAddress = useGetShippingMethodByAddress(); + const getTaxes = useGetTaxes(); + const getPriceAdjustments = useGetPriceAdjustments(); + + // Currency configuration + const currencyCode = totals?.total?.currencyCode || 'USD'; + + // State for tracking calculated values during express checkout flow + const [calculatedTaxes, setCalculatedTaxes] = + useState(null); + const [shippingMethods, setShippingMethods] = useState< + ShippingMethod[] | null + >(null); + const [selectedShippingRate, setSelectedShippingRate] = + useState(null); + const [shippingAddress, setShippingAddress] = + useState(null); + + // Track the status of coupon code fetching + const [couponFetchStatus, setCouponFetchStatus] = useState< + 'idle' | 'fetching' | 'done' + >('idle'); + + // Use refs for values needed in event handlers to avoid stale closures + const appliedCouponCodeRef = useRef(null); + const calculatedAdjustmentsRef = useRef(null); + + // Extract discount codes from draft order for comparison (stable string) + const draftOrderDiscountCodes = useMemo(() => { + const allCodes = new Set(); + + // Add order-level discount codes + if (draftOrder?.discounts) { + for (const discount of draftOrder.discounts) { + if (discount.code) { + allCodes.add(discount.code); + } + } + } + + // Add line item-level discount codes + if (draftOrder?.lineItems) { + for (const lineItem of draftOrder.lineItems) { + if (lineItem.discounts) { + for (const discount of lineItem.discounts) { + if (discount.code) { + allCodes.add(discount.code); + } + } + } + } + } + + return Array.from(allCodes).sort().join(','); // Stable string for comparison + }, [draftOrder]); + + // Fetch and cache price adjustments for pre-applied coupons + useEffect(() => { + if (!draftOrder) return; + // Prevent concurrent fetches (but allow new fetches when draft order changes) + if (couponFetchStatus === 'fetching') return; + + const fetchPriceAdjustments = async () => { + setCouponFetchStatus('fetching'); + + try { + const allCodes = new Set(); + + // Add order-level discount codes + if (draftOrder?.discounts) { + for (const discount of draftOrder.discounts) { + if (discount.code) { + allCodes.add(discount.code); + } + } + } + + // Add line item-level discount codes + if (draftOrder?.lineItems) { + for (const lineItem of draftOrder.lineItems) { + if (lineItem.discounts) { + for (const discount of lineItem.discounts) { + if (discount.code) { + allCodes.add(discount.code); + } + } + } + } + } + + const discountCodes = Array.from(allCodes); - event.resolve({ - shippingRates, + // Update refs based on what's in the draft order + if (discountCodes?.length && discountCodes?.[0]) { + const result = await getPriceAdjustments.mutateAsync({ + discountCodes: [discountCodes[0]], }); - } catch { + + if (result) { + // Update refs with current coupon state + appliedCouponCodeRef.current = discountCodes[0]; + calculatedAdjustmentsRef.current = result; + } + } else { + // No coupons in draft order - clear refs + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; + } + } finally { + setCouponFetchStatus('done'); + } + }; + + fetchPriceAdjustments(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draftOrder, draftOrderDiscountCodes]); + + // Calculate taxes for express checkout + const calculateExpressTaxes = useCallback( + async ({ + address, + shippingAmount, + discountAdjustments, + }: { + address: StripePartialAddress | null; + shippingAmount: number; + discountAdjustments?: CalculatedAdjustments | null; + }) => { + if (!address || !session?.enableTaxCollection) return null; + + const taxesRequest = { + destination: { + countryCode: address.country || 'US', + postalCode: address.postal_code || '', + adminArea2: address.city || '', + adminArea1: address.state || '', + }, + lines: [ + { + type: 'SHIPPING' as const, + subtotalPrice: { + currencyCode: currencyCode, + value: shippingAmount, + }, + }, + ], + discountAdjustments: discountAdjustments || undefined, + }; + + return await getTaxes.mutateAsync(taxesRequest); + }, + [getTaxes, session?.enableTaxCollection, currencyCode] + ); + + // Get sorted shipping methods for an address + const getSortedShippingMethods = useCallback( + async (address: StripePartialAddress) => { + const shippingMethodsData = await getShippingMethodsByAddress.mutateAsync( + { + countryCode: address.country || 'US', + postalCode: address.postal_code || '', + adminArea2: address.city || '', + adminArea1: address.state || '', + } + ); + + setShippingMethods(shippingMethodsData || null); + + const orderSubTotal = totals?.subTotal?.value || 0; + + return filterAndSortShippingMethods({ + shippingMethods: shippingMethodsData || [], + orderSubTotal, + experimentalRules: session?.experimental_rules, + }); + }, + [ + getShippingMethodsByAddress, + session?.experimental_rules, + totals?.subTotal?.value, + ] + ); + + // Convert shipping methods to Stripe ShippingRate format + const convertToStripeShippingRates = useCallback( + (methods: ShippingMethod[]): ShippingRate[] => { + return methods.map(method => ({ + id: + method.displayName?.replace(/\s+/g, '-')?.toLowerCase() || 'shipping', + amount: method.cost?.value || 0, + displayName: method.displayName || t.totals.shipping, + deliveryEstimate: method.description || undefined, + })); + }, + [t.totals.shipping] + ); + + // Build line items for Stripe payment sheet + const buildLineItems = useCallback( + ({ + shippingAmount = 0, + taxAmount = 0, + discountAmount = 0, + }: { + shippingAmount?: number; + taxAmount?: number; + discountAmount?: number; + }): LineItem[] => { + const items: LineItem[] = []; + const subtotal = totals?.subTotal?.value || 0; + + // Add subtotal + items.push({ + name: t.totals.subtotal, + amount: subtotal, + }); + + // Add shipping if present + if (shippingAmount > 0) { + items.push({ + name: t.totals.shipping, + amount: shippingAmount, + }); + } + + // Add taxes if present + if (taxAmount > 0) { + items.push({ + name: t.totals.estimatedTaxes, + amount: taxAmount, + }); + } + + // Add discount if present (as negative) + if (discountAmount > 0) { + items.push({ + name: t.totals.discount, + amount: -discountAmount, + }); + } + + return items; + }, + [totals?.subTotal?.value, t.totals] + ); + + // Recalculate adjustments when shipping changes + const recalculateAdjustments = useCallback( + async ({ + address, + shippingAmount, + shippingMethodName, + }: { + address: StripePartialAddress; + shippingAmount: number; + shippingMethodName: string; + }) => { + const currentCouponCode = appliedCouponCodeRef.current; + if (!currentCouponCode) return null; + + try { + const shippingLines = [ + { + subTotal: { + currencyCode: currencyCode, + value: shippingAmount, + }, + name: shippingMethodName, + }, + ]; + + const newAdjustments = await getPriceAdjustments.mutateAsync({ + discountCodes: [currentCouponCode], + shippingLines, + }); + + if (newAdjustments?.totalDiscountAmount) { + calculatedAdjustmentsRef.current = newAdjustments; + return newAdjustments; + } + + // Coupon no longer valid + calculatedAdjustmentsRef.current = null; + appliedCouponCodeRef.current = null; + return null; + } catch { + // Error calculating adjustments + calculatedAdjustmentsRef.current = null; + appliedCouponCodeRef.current = null; + return null; + } + }, + [currencyCode, getPriceAdjustments] + ); + + // Handle ready event - track impression + const handleReady = useCallback( + (event: StripeExpressCheckoutElementReadyEvent) => { + if (event.availablePaymentMethods?.applePay) { + track({ + eventId: eventIds.expressApplePayImpression, + type: TrackingEventType.IMPRESSION, + properties: { + provider: 'stripe', + }, + }); + } + }, + [] + ); + + // Handle click event - configure initial details + const handleClick = useCallback( + (event: StripeExpressCheckoutElementClickEvent) => { + // Reject if payment is disabled + if (isDisabled) { + event.reject(); + return; + } + + // Track click + if (event.expressPaymentType === 'apple_pay') { + track({ + eventId: eventIds.expressApplePayClick, + type: TrackingEventType.CLICK, + properties: { + paymentType: 'apple_pay', + provider: 'stripe', + }, + }); + } + + // Reset state for fresh calculation + setCalculatedTaxes(null); + setShippingMethods(null); + setSelectedShippingRate(null); + setShippingAddress(null); + setCheckoutErrors(undefined); + + // Get pre-applied discount amount + const discountAmount = + calculatedAdjustmentsRef.current?.totalDiscountAmount?.value || 0; + + // Configure initial line items + event.resolve({ + lineItems: buildLineItems({ discountAmount }), + }); + }, + [buildLineItems, setCheckoutErrors, isDisabled] + ); + + // Handle shipping address change + const handleShippingAddressChange = useCallback( + async (event: StripeExpressCheckoutElementShippingAddressChangeEvent) => { + const address = event.address; + setShippingAddress(address); + + try { + // Get shipping methods for this address + const methods = await getSortedShippingMethods(address); + + if (!methods || methods.length === 0) { event.reject(); + return; + } + + const stripeShippingRates = convertToStripeShippingRates(methods); + const defaultRate = stripeShippingRates[0]; + setSelectedShippingRate(defaultRate); + + // Recalculate adjustments with new shipping + const adjustments = await recalculateAdjustments({ + address, + shippingAmount: defaultRate.amount, + shippingMethodName: defaultRate.displayName, + }); + + // Calculate taxes + let taxAmount = 0; + if (session?.enableTaxCollection) { + try { + const taxesResult = await calculateExpressTaxes({ + address, + shippingAmount: defaultRate.amount, + discountAdjustments: adjustments, + }); + + if (taxesResult?.totalTaxAmount?.value) { + taxAmount = taxesResult.totalTaxAmount.value; + setCalculatedTaxes(taxesResult); + } + } catch { + // Tax calculation failed - continue without taxes + setCheckoutErrors([ + t.apiErrors?.TAX_CALCULATION_ERROR || 'Tax calculation error', + ]); + } } - }} - // https://docs.stripe.com/js/elements_object/express_checkout_element_confirm_event - onConfirm={handleSubmit} - /> + + const discountAmount = adjustments?.totalDiscountAmount?.value || 0; + const subtotal = totals?.subTotal?.value || 0; + + // Calculate new total and update Elements amount before resolving + // This ensures Stripe's lineItems validation passes + const newTotal = + subtotal + defaultRate.amount + taxAmount - discountAmount; + elements?.update({ amount: newTotal }); + + event.resolve({ + shippingRates: stripeShippingRates, + lineItems: buildLineItems({ + shippingAmount: defaultRate.amount, + taxAmount, + discountAmount, + }), + }); + } catch { + event.reject(); + } + }, + [ + getSortedShippingMethods, + convertToStripeShippingRates, + recalculateAdjustments, + calculateExpressTaxes, + buildLineItems, + session?.enableTaxCollection, + setCheckoutErrors, + elements, + totals?.subTotal?.value, + t.apiErrors, + ] + ); + + // Handle shipping rate change + const handleShippingRateChange = useCallback( + async (event: StripeExpressCheckoutElementShippingRateChangeEvent) => { + const selectedRate = event.shippingRate; + setSelectedShippingRate(selectedRate); + + if (!shippingAddress) { + event.reject(); + return; + } + + try { + // Recalculate adjustments with new shipping rate + const adjustments = await recalculateAdjustments({ + address: shippingAddress, + shippingAmount: selectedRate.amount, + shippingMethodName: selectedRate.displayName, + }); + + // Recalculate taxes + let taxAmount = 0; + if (session?.enableTaxCollection) { + try { + const taxesResult = await calculateExpressTaxes({ + address: shippingAddress, + shippingAmount: selectedRate.amount, + discountAdjustments: adjustments, + }); + + if (taxesResult?.totalTaxAmount?.value) { + taxAmount = taxesResult.totalTaxAmount.value; + setCalculatedTaxes(taxesResult); + } + } catch { + // Tax calculation failed - continue with previous taxes + taxAmount = calculatedTaxes?.totalTaxAmount?.value || 0; + } + } + + const discountAmount = adjustments?.totalDiscountAmount?.value || 0; + const subtotal = totals?.subTotal?.value || 0; + + // Calculate new total and update Elements amount before resolving + // This ensures Stripe's lineItems validation passes + const newTotal = + subtotal + selectedRate.amount + taxAmount - discountAmount; + elements?.update({ amount: newTotal }); + + event.resolve({ + lineItems: buildLineItems({ + shippingAmount: selectedRate.amount, + taxAmount, + discountAmount, + }), + }); + } catch { + event.reject(); + } + }, + [ + shippingAddress, + recalculateAdjustments, + calculateExpressTaxes, + buildLineItems, + session?.enableTaxCollection, + calculatedTaxes, + elements, + totals?.subTotal?.value, + ] + ); + + // Handle confirm event + const handleConfirm = useCallback( + async (event: StripeExpressCheckoutElementConfirmEvent) => { + try { + // Find the selected shipping method from our stored shipping methods + const selectedShippingMethod = shippingMethods?.find( + method => + method.displayName?.replace(/\s+/g, '-')?.toLowerCase() === + selectedShippingRate?.id + ); + + // Pass all the calculated data to handleSubmit + await handleSubmit({ + event, + calculatedTaxes, + calculatedAdjustments: calculatedAdjustmentsRef.current, + shippingTotal: selectedShippingRate + ? { + currencyCode, + value: selectedShippingRate.amount, + } + : null, + selectedShippingMethod: selectedShippingMethod || null, + }); + + // Track successful payment + track({ + eventId: eventIds.expressApplePayCompleted, + type: TrackingEventType.EVENT, + properties: { + paymentType: event.expressPaymentType, + provider: 'stripe', + }, + }); + } catch (error) { + // Track error + track({ + eventId: eventIds.expressCheckoutError, + type: TrackingEventType.EVENT, + properties: { + paymentType: event.expressPaymentType, + provider: 'stripe', + errorType: error instanceof Error ? error.message : 'unknown', + }, + }); + + event.paymentFailed({ + reason: 'fail', + message: t.errors.errorProcessingPayment, + }); + } + }, + [ + handleSubmit, + t.errors.errorProcessingPayment, + shippingMethods, + selectedShippingRate, + calculatedTaxes, + currencyCode, + ] + ); + + // Handle cancel event + const handleCancel = useCallback(() => { + // Reset state when payment sheet is dismissed + setCalculatedTaxes(null); + setShippingMethods(null); + setSelectedShippingRate(null); + setShippingAddress(null); + }, []); + + return ( +
+ +
); } @@ -56,8 +696,7 @@ export function StripeExpressCheckoutButton() { return ( <> {isLoading ? ( -
- +
) : null} diff --git a/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx b/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx index 53771437..81f58f6d 100644 --- a/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx +++ b/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx @@ -42,6 +42,8 @@ export function StripeProvider({ children }: { children: React.ReactNode }) { amount: amount, currency, capture_method: 'manual', + paymentMethodCreation: 'manual', + payment_method_types: ['card'], }} > diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts index bcaa1e3b..3cd0c5f4 100644 --- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts +++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts @@ -1,5 +1,7 @@ -import type { CreateTokenCardData } from '@stripe/stripe-js'; -import type { ConfirmationTokenCreateParams } from '@stripe/stripe-js/dist/api/confirmation-tokens'; +import type { + CreateTokenCardData, + PaymentMethodCreateParams, +} from '@stripe/stripe-js'; import { useMemo } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { @@ -170,7 +172,7 @@ export function useBuildPaymentRequest(): { googlePayRequest: GooglePayRequest; payPalRequest: PayPalRequest; stripePaymentCardRequest: CreateTokenCardData; - stripePaymentExpressRequest: ConfirmationTokenCreateParams; + stripePaymentExpressRequest: PaymentMethodCreateParams; poyntCardRequest: PoyntCardRequest; poyntExpressRequest: PoyntExpressRequest; poyntStandardRequest: PoyntStandardRequest; @@ -508,32 +510,19 @@ export function useBuildPaymentRequest(): { address_country: order?.billing?.address?.countryCode || undefined, }; - const stripePaymentExpressRequest: ConfirmationTokenCreateParams = { - payment_method_data: { - billing_details: { - name: - `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() || - undefined, - email: order?.billing?.email || undefined, - address: { - line1: order?.billing?.address?.addressLine1 || undefined, - line2: order?.billing?.address?.addressLine2 || undefined, - city: order?.billing?.address?.adminArea2 || undefined, - state: order?.billing?.address?.adminArea1 || undefined, - postal_code: order?.billing?.address?.postalCode || undefined, - country: order?.billing?.address?.countryCode || undefined, - }, - }, - }, - shipping: { - name: `${order?.shipping?.firstName || ''} ${order?.shipping?.lastName || ''}`.trim(), + const stripePaymentExpressRequest: PaymentMethodCreateParams = { + billing_details: { + name: + `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() || + undefined, + email: order?.billing?.email || undefined, address: { - line1: order?.shipping?.address?.addressLine1 || null, - line2: order?.shipping?.address?.addressLine2 || null, - city: order?.shipping?.address?.adminArea2 || null, - state: order?.shipping?.address?.adminArea1 || null, - postal_code: order?.shipping?.address?.postalCode || null, - country: order?.shipping?.address?.countryCode || null, + line1: order?.billing?.address?.addressLine1 || undefined, + line2: order?.billing?.address?.addressLine2 || undefined, + city: order?.billing?.address?.adminArea2 || undefined, + state: order?.billing?.address?.adminArea1 || undefined, + postal_code: order?.billing?.address?.postalCode || undefined, + country: order?.billing?.address?.countryCode || undefined, }, }, }; diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts index 982f6366..4ece4284 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts @@ -1,4 +1,5 @@ import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; +import type { StripeExpressCheckoutElementConfirmEvent } from '@stripe/stripe-js'; import { useCallback, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useBuildPaymentRequest } from '@/components/checkout/payment/utils/use-build-payment-request'; @@ -7,6 +8,11 @@ import { useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import type { + CalculatedAdjustments, + CalculatedTaxes, + ShippingMethod, +} from '@/types'; import { PaymentMethodType } from '@/types'; type UseStripeCheckoutOptions = { @@ -14,6 +20,21 @@ type UseStripeCheckoutOptions = { clientSecret?: string | null; }; +// Express checkout data to pass to confirmCheckout +export type StripeExpressCheckoutData = { + // Stripe confirm event data + event: StripeExpressCheckoutElementConfirmEvent; + // Calculated values from express checkout flow + calculatedTaxes?: CalculatedTaxes | null; + calculatedAdjustments?: CalculatedAdjustments | null; + // Shipping info + shippingTotal?: { + currencyCode: string; + value: number; + } | null; + selectedShippingMethod?: ShippingMethod | null; +}; + export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { const stripe = useStripe(); const elements = useElements(); @@ -22,94 +43,188 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { const { stripePaymentExpressRequest } = useBuildPaymentRequest(); const [isProcessingPayment, setIsProcessingPayment] = useState(false); - const handleSubmit = useCallback(async () => { - setIsProcessingPayment(true); - try { - if (!stripe || !elements) { - return; - } - - if (mode === 'card') { - const cardElement = elements.getElement(CardElement); - - if (!cardElement) { + const handleSubmit = useCallback( + async (expressData?: StripeExpressCheckoutData) => { + setIsProcessingPayment(true); + try { + if (!stripe || !elements) { return; } - const { paymentMethod, error } = await stripe.createPaymentMethod({ - card: cardElement, - type: 'card', - }); + if (mode === 'card') { + const cardElement = elements.getElement(CardElement); - if (error) { - setCheckoutErrors([error.code || 'payment_method_creation_failed']); - return; - } + if (!cardElement) { + return; + } + + const { paymentMethod, error } = await stripe.createPaymentMethod({ + card: cardElement, + type: 'card', + }); + + if (error) { + setCheckoutErrors([error.code || 'TRANSACTION_PROCESSING_FAILED']); + return; + } - if (paymentMethod) { - try { - await confirmCheckout.mutateAsync({ - paymentToken: paymentMethod.id, - paymentType: PaymentMethodType.CREDIT_CARD, - paymentProvider: PaymentProvider.STRIPE, - }); - } catch (err: unknown) { - if (err instanceof GraphQLErrorWithCodes) { - setCheckoutErrors(err.codes); + if (paymentMethod) { + try { + await confirmCheckout.mutateAsync({ + paymentToken: paymentMethod.id, + paymentType: PaymentMethodType.CREDIT_CARD, + paymentProvider: PaymentProvider.STRIPE, + }); + } catch (err: unknown) { + if (err instanceof GraphQLErrorWithCodes) { + setCheckoutErrors(err.codes); + } + // Other errors are silently ignored } - // Other errors are silently ignored + } else { + setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']); } - } else { - setCheckoutErrors(['payment_method_creation_failed']); } - } - if (mode === 'express') { - const { error, confirmationToken: expressToken } = - await stripe.createConfirmationToken({ + if (mode === 'express') { + const { error, paymentMethod } = await stripe.createPaymentMethod({ elements, - params: { - ...stripePaymentExpressRequest, - }, + params: stripePaymentExpressRequest, }); - if (error) { - setCheckoutErrors([ - error.code || 'confirmation_token_creation_failed', - ]); - return; - } + if (error) { + setCheckoutErrors([error.code || 'TRANSACTION_PROCESSING_FAILED']); + return; + } + + if (paymentMethod) { + try { + // Build the checkout body similar to godaddy.tsx + const event = expressData?.event; + const currencyCode = + expressData?.shippingTotal?.currencyCode || 'USD'; + + const walletType = paymentMethod.card?.wallet?.type; + const paymentType = + walletType || event?.expressPaymentType || 'card'; - if (expressToken) { - try { - await confirmCheckout.mutateAsync({ - paymentToken: expressToken.id, - paymentType: expressToken?.payment_method_preview?.type, - paymentProvider: PaymentProvider.STRIPE, - }); - } catch (err: unknown) { - if (err instanceof GraphQLErrorWithCodes) { - setCheckoutErrors(err.codes); + // Map Stripe billing details to checkout format + const billing = event?.billingDetails + ? { + email: event.billingDetails.email || '', + phone: event.billingDetails.phone || '', + firstName: event.billingDetails.name?.split(' ')?.[0] || '', + lastName: + event.billingDetails.name + ?.split(' ') + .slice(1) + .join(' ') || '', + address: { + countryCode: event.billingDetails.address?.country || '', + postalCode: + event.billingDetails.address?.postal_code || '', + adminArea1: event.billingDetails.address?.state || '', + adminArea2: event.billingDetails.address?.city || '', + addressLine1: event.billingDetails.address?.line1 || '', + addressLine2: event.billingDetails.address?.line2 || '', + }, + } + : undefined; + + // Map Stripe shipping address to checkout format + const shipping = event?.shippingAddress + ? { + email: event.billingDetails?.email || '', + phone: event.billingDetails?.phone || '', + firstName: + event.shippingAddress.name?.split(' ')?.[0] || '', + lastName: + event.shippingAddress.name + ?.split(' ') + .slice(1) + .join(' ') || '', + address: { + countryCode: event.shippingAddress.address?.country || '', + postalCode: + event.shippingAddress.address?.postal_code || '', + adminArea1: event.shippingAddress.address?.state || '', + adminArea2: event.shippingAddress.address?.city || '', + addressLine1: event.shippingAddress.address?.line1 || '', + addressLine2: event.shippingAddress.address?.line2 || '', + }, + } + : undefined; + + // Build shipping lines from selected shipping method + const shippingLines = expressData?.selectedShippingMethod + ? [ + { + amount: expressData.shippingTotal || { + currencyCode, + value: 0, + }, + name: + expressData.selectedShippingMethod.displayName || '', + requestedProvider: + expressData.selectedShippingMethod.carrierCode || '', + requestedService: + expressData.selectedShippingMethod.serviceCode || '', + totals: { + subTotal: expressData.shippingTotal || { + currencyCode, + value: 0, + }, + taxTotal: { + value: 0, + currencyCode, + }, + }, + }, + ] + : undefined; + + await confirmCheckout.mutateAsync({ + paymentToken: paymentMethod.id, + paymentType, + paymentProvider: PaymentProvider.STRIPE, + isExpress: true, + // Include shipping total if available + ...(expressData?.shippingTotal + ? { shippingTotal: expressData.shippingTotal } + : {}), + // Include calculated taxes if available + ...(expressData?.calculatedTaxes + ? { calculatedTaxes: expressData.calculatedTaxes } + : {}), + // Include calculated adjustments (discounts) if available + ...(expressData?.calculatedAdjustments + ? { calculatedAdjustments: expressData.calculatedAdjustments } + : {}), + // Include billing address if available + ...(billing ? { billing } : {}), + // Include shipping address if available + ...(shipping ? { shipping } : {}), + // Include shipping lines if available + ...(shippingLines ? { shippingLines } : {}), + }); + } catch (err: unknown) { + if (err instanceof GraphQLErrorWithCodes) { + setCheckoutErrors(err.codes); + } + throw err; // Re-throw so caller can handle } - // Other errors are silently ignored + } else { + setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']); } - } else { - setCheckoutErrors(['confirmation_token_creation_failed']); } - } - return { success: false, error: `Mode not supported: ${mode}` }; - } finally { - setIsProcessingPayment(false); - } - }, [ - mode, - stripe, - elements, - stripePaymentExpressRequest, - confirmCheckout.mutateAsync, - setCheckoutErrors, - ]); + return { success: false, error: `Mode not supported: ${mode}` }; + } finally { + setIsProcessingPayment(false); + } + }, + [mode, stripe, elements, confirmCheckout.mutateAsync, setCheckoutErrors] + ); return { handleSubmit,