diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 9ae69eb8..b345f4e3 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -55,7 +55,7 @@ export default async function Home() { ], paymentMethods: { card: { - processor: 'godaddy', + processor: 'paypal', checkoutTypes: ['standard'], }, express: { diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 9c186fa8..a58bf364 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -248,6 +248,11 @@ export const deDe = { selectState: 'Bundesland/Provinz auswählen', selectCountry: 'Land auswählen', enterCountry: 'Land eingeben', + invalidCardNumber: 'Ungültige Kartennummer', + invalidExpiry: 'Ungültiges Ablaufdatum', + invalidCvv: 'Ungültiger Sicherheitscode', + paymentSubmissionFailed: + 'Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Serverfehler', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 8e3baf6f..5751044b 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -243,6 +243,10 @@ export const enIe = { selectState: 'Select a county', selectCountry: 'Select a country', enterCountry: 'Enter a country', + invalidCardNumber: 'Invalid card number', + invalidExpiry: 'Invalid expiration date', + invalidCvv: 'Invalid security code', + paymentSubmissionFailed: 'Payment submission failed. Please try again.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Server error', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index 2458daa0..b9472309 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -243,6 +243,10 @@ export const enUs = { selectState: 'Select a state/province', selectCountry: 'Select a country', enterCountry: 'Enter a country', + invalidCardNumber: 'Invalid card number', + invalidExpiry: 'Invalid expiration date', + invalidCvv: 'Invalid security code', + paymentSubmissionFailed: 'Payment submission failed. Please try again.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Server error', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index c55877dd..92f66711 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -245,6 +245,11 @@ export const esAr = { selectState: 'Seleccioná una provincia/región', selectCountry: 'Seleccioná un país', enterCountry: 'Ingresá un país', + invalidCardNumber: 'Número de tarjeta no válido', + invalidExpiry: 'Fecha de vencimiento no válida', + invalidCvv: 'Código de seguridad no válido', + paymentSubmissionFailed: + 'Error al procesar el pago. Por favor, inténtalo de nuevo.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Error del servidor', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index 100aa97b..d1438c84 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -247,6 +247,11 @@ export const esCl = { selectState: 'Selecciona una región/provincia', selectCountry: 'Selecciona un país', enterCountry: 'Ingresa un país', + invalidCardNumber: 'Número de tarjeta no válido', + invalidExpiry: 'Fecha de vencimiento no válida', + invalidCvv: 'Código de seguridad no válido', + paymentSubmissionFailed: + 'Error al procesar el pago. Por favor, inténtalo de nuevo.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Error del servidor', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index 5c34644b..e82930d5 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -245,6 +245,11 @@ export const esCo = { selectState: 'Selecciona un departamento/región', selectCountry: 'Selecciona un país', enterCountry: 'Ingresa un país', + invalidCardNumber: 'Número de tarjeta no válido', + invalidExpiry: 'Fecha de vencimiento no válida', + invalidCvv: 'Código de seguridad no válido', + paymentSubmissionFailed: + 'Error al procesar el pago. Por favor, inténtalo de nuevo.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Error del servidor', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index a2220308..51a51eec 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -245,6 +245,11 @@ export const esEs = { selectState: 'Selecciona un estado/provincia', selectCountry: 'Selecciona un país', enterCountry: 'Introduce un país', + invalidCardNumber: 'Número de tarjeta no válido', + invalidExpiry: 'Fecha de vencimiento no válida', + invalidCvv: 'Código de seguridad no válido', + paymentSubmissionFailed: + 'Error al procesar el pago. Por favor, inténtalo de nuevo.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Error del servidor', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index c484e103..2a34cb82 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -245,6 +245,11 @@ export const esMx = { selectState: 'Seleccione un estado/provincia', selectCountry: 'Seleccione un país', enterCountry: 'Ingrese un país', + invalidCardNumber: 'Número de tarjeta no válido', + invalidExpiry: 'Fecha de vencimiento no válida', + invalidCvv: 'Código de seguridad no válido', + paymentSubmissionFailed: + 'Error al procesar el pago. Por favor, inténtalo de nuevo.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Error del servidor', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index ccae27f8..9dbeca8e 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -245,6 +245,11 @@ export const esPe = { selectState: 'Seleccione un departamento/región', selectCountry: 'Seleccione un país', enterCountry: 'Ingrese un país', + invalidCardNumber: 'Número de tarjeta no válido', + invalidExpiry: 'Fecha de vencimiento no válida', + invalidCvv: 'Código de seguridad no válido', + paymentSubmissionFailed: + 'Error al procesar el pago. Por favor, inténtalo de nuevo.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Error del servidor', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index da390efe..ad231176 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -245,6 +245,11 @@ export const esUs = { selectState: 'Seleccione un estado/provincia', selectCountry: 'Seleccione un país', enterCountry: 'Ingrese un país', + invalidCardNumber: 'Número de tarjeta no válido', + invalidExpiry: 'Fecha de vencimiento no válida', + invalidCvv: 'Código de seguridad no válido', + paymentSubmissionFailed: + 'Error al procesar el pago. Por favor, inténtalo de nuevo.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Error del servidor', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index 26edeb97..1e136e32 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -250,6 +250,11 @@ export const frCa = { selectState: 'Sélectionnez une province/état', selectCountry: 'Sélectionnez un pays', enterCountry: 'Entrez un pays', + invalidCardNumber: 'Numéro de carte invalide', + invalidExpiry: "Date d'expiration invalide", + invalidCvv: 'Code de sécurité invalide', + paymentSubmissionFailed: + 'Échec de la soumission du paiement. Veuillez réessayer.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Erreur de serveur', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index 13abdf5f..04b2a67c 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -250,6 +250,11 @@ export const frFr = { selectState: 'Sélectionnez un état/province', selectCountry: 'Sélectionnez un pays', enterCountry: 'Entrez un pays', + invalidCardNumber: 'Numéro de carte invalide', + invalidExpiry: "Date d'expiration invalide", + invalidCvv: 'Code de sécurité invalide', + paymentSubmissionFailed: + 'Échec de la soumission du paiement. Veuillez réessayer.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Erreur du serveur', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 280aac48..a0b2b8a6 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -243,6 +243,10 @@ export const idId = { selectState: 'Pilih provinsi', selectCountry: 'Pilih negara', enterCountry: 'Masukkan negara', + invalidCardNumber: 'Nomor kartu tidak valid', + invalidExpiry: 'Tanggal kedaluwarsa tidak valid', + invalidCvv: 'Kode keamanan tidak valid', + paymentSubmissionFailed: 'Pengiriman pembayaran gagal. Silakan coba lagi.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Error server', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index 20fb884a..e6d5a3d3 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -251,6 +251,10 @@ export const itIt = { selectState: 'Seleziona una regione/provincia', selectCountry: 'Seleziona un paese', enterCountry: 'Inserisci un paese', + invalidCardNumber: 'Numero di carta non valido', + invalidExpiry: 'Data di scadenza non valida', + invalidCvv: 'Codice di sicurezza non valido', + paymentSubmissionFailed: 'Invio del pagamento non riuscito. Riprova.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Errore del server', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index 64a36964..40dac240 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -245,6 +245,11 @@ export const ptBr = { selectState: 'Selecione um estado/província', selectCountry: 'Selecione um país', enterCountry: 'Digite um país', + invalidCardNumber: 'Número de cartão inválido', + invalidExpiry: 'Data de validade inválida', + invalidCvv: 'Código de segurança inválido', + paymentSubmissionFailed: + 'Falha no envio do pagamento. Por favor, tente novamente.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Erro no servidor', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 2b320271..2824a45a 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -247,6 +247,11 @@ export const qaPs = { selectState: '[Šëlëçţ â šţâţë/þrövîñçë frôm ţhë âvâîlâblë öþţîöñš]', selectCountry: '[Šëlëçţ â çöüñţrÿ frôm ţhë âvâîlâblë öþţîöñš]', enterCountry: '[Ëñţër â çöüñţrÿ ñâmë fôr löçâţîöñ]', + invalidCardNumber: '[Invalid card number ℓσяєм]', + invalidExpiry: '[Invalid expiration date ℓσяєм ιρѕυм]', + invalidCvv: '[Invalid security code ℓσяєм]', + paymentSubmissionFailed: + '[Payment submission failed. Please try again. ℓσяєм ιρѕυм ∂σℓσя]', }, apiErrors: { INTERNAL_SERVER_ERROR: '[Šërvër ërrör öççürrëd]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index 14cfdfa1..72afea53 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -244,6 +244,11 @@ export const trTr = { selectState: 'Bir il/eyalet seçin', selectCountry: 'Bir ülke seçin', enterCountry: 'Bir ülke girin', + invalidCardNumber: 'Geçersiz kart numarası', + invalidExpiry: 'Geçersiz son kullanma tarihi', + invalidCvv: 'Geçersiz güvenlik kodu', + paymentSubmissionFailed: + 'Ödeme gönderimi başarısız oldu. Lütfen tekrar deneyin.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Sunucu hatası', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index 436c37ca..c1460e6c 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -243,6 +243,10 @@ export const viVn = { selectState: 'Chọn tỉnh/thành phố', selectCountry: 'Chọn quốc gia', enterCountry: 'Nhập quốc gia', + invalidCardNumber: 'Số thẻ không hợp lệ', + invalidExpiry: 'Ngày hết hạn không hợp lệ', + invalidCvv: 'Mã bảo mật không hợp lệ', + paymentSubmissionFailed: 'Gửi thanh toán thất bại. Vui lòng thử lại.', }, apiErrors: { INTERNAL_SERVER_ERROR: 'Lỗi máy chủ', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 6cf7c1d9..e71929b9 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -236,6 +236,10 @@ export const zhCn = { selectState: '选择省份/地区', selectCountry: '选择国家', enterCountry: '请输入国家', + invalidCardNumber: '卡号无效', + invalidExpiry: '有效期无效', + invalidCvv: '安全码无效', + paymentSubmissionFailed: '付款提交失败。请重试。', }, apiErrors: { INTERNAL_SERVER_ERROR: '服务器错误', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index 1aaa6d12..286d58ff 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -236,6 +236,10 @@ export const zhSg = { selectState: '选择州/省', selectCountry: '选择国家', enterCountry: '输入国家', + invalidCardNumber: '卡號無效', + invalidExpiry: '有效期無效', + invalidCvv: '安全碼無效', + paymentSubmissionFailed: '付款提交失敗。請重試。', }, apiErrors: { INTERNAL_SERVER_ERROR: '服务器错误', diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/paypal.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/paypal.tsx new file mode 100644 index 00000000..b28f95f5 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/paypal.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { usePayPalProvider } from '@/components/checkout/payment/utils/paypal-provider'; +import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; +import { Button } from '@/components/ui/button'; +import { useGoDaddyContext } from '@/godaddy-provider'; + +export function PayPalCreditCardCheckoutButton() { + const { t } = useGoDaddyContext(); + const { isConfirmingCheckout } = useCheckoutContext(); + const isPaymentDisabled = useIsPaymentDisabled(); + const form = useFormContext(); + const { cardFieldsRef, isCardFieldsReady } = usePayPalProvider(); + const [_isProcessing, setIsProcessing] = useState(false); + + const handleSubmit = useCallback(async () => { + try { + setIsProcessing(true); + + // Validate the form first + const valid = await form.trigger(); + if (!valid) { + const firstError = Object.keys(form.formState.errors)[0]; + if (firstError) { + form.setFocus(firstError); + } + return; + } + + // Check if PayPal card fields are ready + if (!cardFieldsRef.current || !cardFieldsRef.current.isEligible()) { + return; + } + + // Submit the PayPal payment + await cardFieldsRef.current.submit(); + } catch (_error) { + // PayPal checkout failed + } finally { + setIsProcessing(false); + } + }, [form, cardFieldsRef]); + + if (!isCardFieldsReady || !cardFieldsRef.current) { + return null; + } + + return ( + + ); +} diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx index 4495dbfe..d531444a 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx @@ -1,8 +1,4 @@ -import { - PayPalButtons, - PayPalScriptProvider, - usePayPalScriptReducer, -} from '@paypal/react-paypal-js'; +import { PayPalButtons, usePayPalScriptReducer } from '@paypal/react-paypal-js'; import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; @@ -113,7 +109,6 @@ function PayPalButtonsWrapper() { export function PayPalCheckoutButton() { const { t } = useGoDaddyContext(); const { paypalConfig } = useCheckoutContext(); - const { payPalRequest } = useBuildPaymentRequest(); if (!paypalConfig) { return ( @@ -123,22 +118,7 @@ export function PayPalCheckoutButton() { return (
- - - +
); } diff --git a/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx b/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx index 93dcd888..00bb6c53 100644 --- a/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx +++ b/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx @@ -33,6 +33,13 @@ const LazyComponents = { default: module.SquareCreditCardForm, })) ), + PayPalCreditCardForm: lazy(() => + import( + '@/components/checkout/payment/payment-methods/credit-card/paypal' + ).then(module => ({ + default: module.PayPalCreditCardForm, + })) + ), // Credit Card Buttons CreditCardCheckoutButton: lazy(() => @@ -56,6 +63,13 @@ const LazyComponents = { default: module.SquareCreditCardCheckoutButton, })) ), + PayPalCreditCardCheckoutButton: lazy(() => + import( + '@/components/checkout/payment/checkout-buttons/credit-card/paypal' + ).then(module => ({ + default: module.PayPalCreditCardCheckoutButton, + })) + ), // Express Buttons ExpressCheckoutButton: lazy(() => @@ -132,6 +146,10 @@ type PaymentComponentRegistry = { form: PaymentComponentKey; button: PaymentComponentKey; }; + [PaymentProvider.PAYPAL]: { + form: PaymentComponentKey; + button: PaymentComponentKey; + }; }; [PaymentMethodType.EXPRESS]?: { [PaymentProvider.GODADDY]: { @@ -177,6 +195,10 @@ export const lazyPaymentComponentRegistry: PaymentComponentRegistry = { form: 'SquareCreditCardForm', button: 'SquareCreditCardCheckoutButton', }, + [PaymentProvider.PAYPAL]: { + form: 'PayPalCreditCardForm', + button: 'PayPalCreditCardCheckoutButton', + }, }, [PaymentMethodType.EXPRESS]: { [PaymentProvider.STRIPE]: { diff --git a/packages/react/src/components/checkout/payment/payment-methods/credit-card/paypal.tsx b/packages/react/src/components/checkout/payment/payment-methods/credit-card/paypal.tsx new file mode 100644 index 00000000..b36fc395 --- /dev/null +++ b/packages/react/src/components/checkout/payment/payment-methods/credit-card/paypal.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { + PayPalCardFieldsForm, + PayPalCardFieldsProvider, + usePayPalCardFields, + usePayPalScriptReducer, +} from '@paypal/react-paypal-js'; +import { useEffect } from 'react'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { usePayPalProvider } from '@/components/checkout/payment/utils/paypal-provider'; +import { useAuthorizeCheckout } from '@/components/checkout/payment/utils/use-authorize-checkout'; +import { + PaymentProvider, + useConfirmCheckout, +} from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { PaymentMethodType } from '@/types'; + +function parsePayPalSubmissionError(error: any, t: any): string { + if (error && typeof error === 'object') { + if (error?.message?.includes('INVALID_NUMBER')) { + return t.validation.invalidCardNumber; + } + if (error?.message?.includes('INVALID_EXPIRY')) { + return t.validation.invalidExpiry; + } + if (error?.message?.includes('INVALID_CVV')) { + return t.validation.invalidCvv; + } + } + return t.validation.paymentSubmissionFailed; +} + +function PayPalCardFieldsFormContent() { + const { + setIsCardFieldsReady, + setCardFieldsError, + cardFieldsRef, + fieldValidationErrors, + cardFieldsError, + } = usePayPalProvider(); + const { cardFieldsForm } = usePayPalCardFields(); + const [{ isResolved, isPending }] = usePayPalScriptReducer(); + const { t } = useGoDaddyContext(); + + useEffect(() => { + if (!cardFieldsForm) return; + + cardFieldsRef.current = { + submit: async () => { + try { + await cardFieldsForm.submit(); + } catch (error) { + // Parse PayPal specific errors + const errorMessage = parsePayPalSubmissionError(error, t); + setCardFieldsError(errorMessage); + throw error; + } + }, + isEligible: () => !!cardFieldsForm, + }; + + setIsCardFieldsReady(true); + setCardFieldsError(null); + + return () => { + setIsCardFieldsReady(false); + cardFieldsRef.current = null; + }; + }, [ + cardFieldsForm, + setIsCardFieldsReady, + setCardFieldsError, + cardFieldsRef, + t, + ]); + + if (isPending || !isResolved) { + return ; + } + + return ( +
+ + + {/* Display field validation errors */} + {Object.entries(fieldValidationErrors).map(([field, error]) => ( +

+ {error} +

+ ))} + {/* Display general submission errors */} + {cardFieldsError && ( +

+ {cardFieldsError} +

+ )} +
+ ); +} + +export function PayPalCreditCardForm() { + const { paypalConfig, setCheckoutErrors } = useCheckoutContext(); + const { t } = useGoDaddyContext(); + + const confirmCheckout = useConfirmCheckout(); + const authorizeCheckout = useAuthorizeCheckout(); + + if (!paypalConfig?.clientId) { + return ( +
+ {t.errors.paypalConfigMissing || 'PayPal configuration missing'} +
+ ); + } + + return ( + { + const result = await authorizeCheckout.mutateAsync({ + paymentType: PaymentMethodType.CREDIT_CARD, + paymentProvider: PaymentProvider.PAYPAL, + }); + return result?.id ?? ''; + }} + onApprove={async data => { + try { + await confirmCheckout.mutateAsync({ + paymentToken: data.orderID, + paymentType: PaymentMethodType.CREDIT_CARD, + paymentProvider: PaymentProvider.PAYPAL, + }); + } catch (error) { + if (error instanceof GraphQLErrorWithCodes) { + setCheckoutErrors(error.codes); + } else { + setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']); + } + throw error; + } + }} + onError={_error => { + // PayPal Card Fields provider error + }} + style={{ + '.card-field-name': { + border: '1px solid oklch(0.9 0.025 245)', + // @ts-ignore + 'border-radius': '0.375rem', + 'background-color': 'oklch(1 0 0)', + padding: '0.5rem 0.75rem', + 'font-size': '0.875rem', + 'line-height': '1.25rem', + height: '3rem', + color: 'oklch(0.13 0 0)', + }, + '.card-field-number': { + // @ts-ignore + border: '1px solid oklch(0.9 0.025 245) !important', + // @ts-ignore + 'border-radius': '0.375rem', + 'background-color': 'oklch(1 0 0)', + padding: '0.5rem 0.75rem', + 'font-size': '0.875rem', + 'line-height': '1.25rem', + height: '3rem', + color: 'oklch(0.13 0 0)', + }, + '.card-field-expiry': { + // @ts-ignore + border: '1px solid oklch(0.9 0.025 245)', + // @ts-ignore + 'border-radius': '0.375rem', + 'background-color': 'oklch(1 0 0)', + padding: '0.5rem 0.75rem', + 'font-size': '0.875rem', + 'line-height': '1.25rem', + height: '3rem', + color: 'oklch(0.13 0 0)', + }, + '.card-field-cvv': { + border: '1px solid oklch(0.9 0.025 245)', + // @ts-ignore + 'border-radius': '0.375rem', + 'background-color': 'oklch(1 0 0)', + padding: '0.5rem 0.75rem', + 'font-size': '0.875rem', + 'line-height': '1.25rem', + height: '3rem', + color: 'oklch(0.13 0 0)', + }, + '.card-field-name:focus': { + outline: 'none', + // @ts-ignore + 'box-shadow': 'unset', + }, + '.card-field-number:focus': { + outline: 'none', + // @ts-ignore + 'box-shadow': 'unset', + }, + '.card-field-expiry:focus': { + outline: 'none', + // @ts-ignore + 'box-shadow': 'unset', + }, + '.card-field-cvv:focus': { + outline: 'none', + // @ts-ignore + 'box-shadow': 'unset', + }, + '.card-field-name.invalid': { + // @ts-ignore + 'box-shadow': 'unset', + border: '1px solid oklch(0.577 0.245 27.325) !important', + }, + '.card-field-number.invalid': { + // @ts-ignore + 'box-shadow': 'unset', + border: '1px solid oklch(0.577 0.245 27.325) !important', + }, + '.card-field-expiry.invalid': { + // @ts-ignore + 'box-shadow': 'unset', + border: '1px solid oklch(0.577 0.245 27.325) !important', + }, + '.card-field-cvv.invalid': { + // @ts-ignore + 'box-shadow': 'unset', + border: '1px solid oklch(0.577 0.245 27.325) !important', + }, + '.card-field-name.invalid:focus': { + outline: 'none', + // @ts-ignore + 'box-shadow': 'unset', + }, + '.card-field-number.invalid:focus': { + outline: 'none', + // @ts-ignore + 'box-shadow': 'unset', + }, + '.card-field-expiry.invalid:focus': { + outline: 'none', + // @ts-ignore + 'box-shadow': 'unset', + }, + '.card-field-cvv.invalid:focus': { + outline: 'none', + // @ts-ignore + 'box-shadow': 'unset', + }, + '::placeholder': { + color: 'oklch(0.556 0 0)', + }, + }} + > + + + ); +} diff --git a/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx b/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx index 8194087b..95df6409 100644 --- a/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx +++ b/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx @@ -1,7 +1,10 @@ +import { PayPalScriptProvider } from '@paypal/react-paypal-js'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { PayPalProvider } from './paypal-provider'; import { PoyntCollectProvider } from './poynt-provider'; import { SquareProvider } from './square-provider'; import { StripeProvider } from './stripe-provider'; +import { useBuildPaymentRequest } from './use-build-payment-request'; interface ConditionalPaymentProvidersProps { children: React.ReactNode; @@ -14,8 +17,9 @@ interface ConditionalPaymentProvidersProps { export function ConditionalPaymentProviders({ children, }: ConditionalPaymentProvidersProps) { - const { stripeConfig, godaddyPaymentsConfig, squareConfig } = + const { stripeConfig, godaddyPaymentsConfig, squareConfig, paypalConfig } = useCheckoutContext(); + const { payPalRequest } = useBuildPaymentRequest(); // Start with the children and conditionally wrap with providers let wrappedChildren = children; @@ -37,6 +41,24 @@ export function ConditionalPaymentProviders({ wrappedChildren = {wrappedChildren}; } + // Only wrap with PayPal providers if PayPal is configured + if (paypalConfig?.clientId?.trim()) { + wrappedChildren = ( + + {wrappedChildren} + + ); + } + return <>{wrappedChildren}; } diff --git a/packages/react/src/components/checkout/payment/utils/paypal-provider.tsx b/packages/react/src/components/checkout/payment/utils/paypal-provider.tsx new file mode 100644 index 00000000..6e30adfd --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/paypal-provider.tsx @@ -0,0 +1,66 @@ +'use client'; + +import type React from 'react'; +import { + createContext, + type ReactNode, + useContext, + useRef, + useState, +} from 'react'; + +interface PayPalCardFieldsRef { + submit: () => Promise; + isEligible: () => boolean; +} + +interface PayPalProviderContextValue { + cardFieldsRef: React.MutableRefObject; + isCardFieldsReady: boolean; + setIsCardFieldsReady: (ready: boolean) => void; + cardFieldsError: string | null; + setCardFieldsError: (error: string | null) => void; + fieldValidationErrors: Record; + setFieldValidationErrors: (errors: Record) => void; +} + +const PayPalProviderContext = createContext( + null +); + +interface PayPalProviderProps { + children: ReactNode; +} + +export function PayPalProvider({ children }: PayPalProviderProps) { + const cardFieldsRef = useRef(null); + const [isCardFieldsReady, setIsCardFieldsReady] = useState(false); + const [cardFieldsError, setCardFieldsError] = useState(null); + const [fieldValidationErrors, setFieldValidationErrors] = useState< + Record + >({}); + + const value = { + cardFieldsRef, + isCardFieldsReady, + setIsCardFieldsReady, + cardFieldsError, + setCardFieldsError, + fieldValidationErrors, + setFieldValidationErrors, + }; + + return ( + + {children} + + ); +} + +export function usePayPalProvider() { + const context = useContext(PayPalProviderContext); + if (!context) { + throw new Error('usePayPalProvider must be used within a PayPalProvider'); + } + return context; +} diff --git a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts new file mode 100644 index 00000000..86eccf05 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { authorizeCheckoutSession } from '@/lib/godaddy/godaddy'; +import type { AuthorizeCheckoutSessionInput } from '@/types'; + +export function useAuthorizeCheckout() { + const { session, jwt } = useCheckoutContext(); + const { apiHost } = useGoDaddyContext(); + + return useMutation({ + mutationFn: async (input: AuthorizeCheckoutSessionInput['input']) => { + const result = jwt + ? await authorizeCheckoutSession(input, { accessToken: jwt }, apiHost) + : await authorizeCheckoutSession(input, session, apiHost); + + return result.authorizeCheckoutSession; + }, + }); +} diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 024dbb84..228affb3 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -6391,6 +6391,26 @@ const introspection = { ], isDeprecated: false, }, + { + name: 'authorizeCheckoutSession', + type: { + kind: 'OBJECT', + name: 'CheckoutSession', + }, + args: [ + { + name: 'input', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'INPUT_OBJECT', + name: 'MutationAuthorizeCheckoutSessionInput', + }, + }, + }, + ], + isDeprecated: false, + }, { name: 'calculateCheckoutSessionTaxes', type: { @@ -6670,6 +6690,40 @@ const introspection = { ], isOneOf: false, }, + { + kind: 'INPUT_OBJECT', + name: 'MutationAuthorizeCheckoutSessionInput', + inputFields: [ + { + name: 'paymentProvider', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + }, + { + name: 'paymentToken', + type: { + kind: 'SCALAR', + name: 'String', + }, + }, + { + name: 'paymentType', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + }, + ], + isOneOf: false, + }, { kind: 'INPUT_OBJECT', name: 'MutationConfirmCheckoutSessionInput', diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index e6778eab..07846812 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -447,3 +447,12 @@ export const RefreshCheckoutTokenMutation = graphql(` } } `); + +export const AuthorizeCheckoutSessionMutation = graphql(` + mutation AuthorizeCheckoutSession($input: MutationAuthorizeCheckoutSessionInput!) { + authorizeCheckoutSession(input: $input) { + id + token + } + } +`); diff --git a/packages/react/src/lib/godaddy/godaddy.ts b/packages/react/src/lib/godaddy/godaddy.ts index 5d45662b..5af1b86d 100644 --- a/packages/react/src/lib/godaddy/godaddy.ts +++ b/packages/react/src/lib/godaddy/godaddy.ts @@ -24,6 +24,7 @@ import type { ApplyCheckoutSessionFulfillmentLocationInput, ApplyCheckoutSessionShippingMethodInput, ApplyDiscountCodesInput, + AuthorizeCheckoutSessionInput, CheckoutSession, CheckoutSessionInput, ConfirmCheckoutMutationInput, @@ -44,6 +45,7 @@ import { ApplyCheckoutSessionDiscountMutation, ApplyCheckoutSessionFulfillmentLocationMutation, ApplyCheckoutSessionShippingMethodMutation, + AuthorizeCheckoutSessionMutation, CalculateCheckoutSessionTaxesMutation, ConfirmCheckoutSessionMutation, CreateCheckoutSessionMutation, @@ -853,6 +855,62 @@ export function confirmCheckout( ); } +export function authorizeCheckoutSession( + input: AuthorizeCheckoutSessionInput['input'], + session: CheckoutSession | undefined | null, + apiHost?: string +): Promise>; +export function authorizeCheckoutSession( + input: AuthorizeCheckoutSessionInput['input'], + auth: { accessToken: string | undefined }, + apiHost?: string +): Promise>; +export function authorizeCheckoutSession( + input: AuthorizeCheckoutSessionInput['input'], + sessionOrAuth: + | CheckoutSession + | undefined + | null + | { accessToken: string | undefined }, + apiHost?: string +) { + const GODADDY_HOST = getHostByEnvironment(apiHost); + + if (sessionOrAuth && 'accessToken' in sessionOrAuth) { + if (!sessionOrAuth.accessToken) { + throw new Error('No access token provided'); + } + return graphqlRequestWithErrors< + ResultOf + >( + GODADDY_HOST, + AuthorizeCheckoutSessionMutation, + { input }, + { + Authorization: `Bearer ${sessionOrAuth.accessToken}`, + } + ); + } + + const session = sessionOrAuth; + if (!session?.token || !session?.id) { + throw new Error('No session token or ID provided'); + } + + return graphqlRequestWithErrors< + ResultOf + >( + GODADDY_HOST, + AuthorizeCheckoutSessionMutation, + { input }, + { + 'x-session-token': `${session.token}`, + 'x-session-id': session.id, + 'x-store-id': session.storeId, + } + ); +} + export function getDraftOrderShippingMethods( session: CheckoutSession | undefined | null, destination?: GetCheckoutSessionShippingRatesInput['destination'], diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index d4b06da6..2cfc2edd 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -9,6 +9,7 @@ import type { ApplyCheckoutSessionDiscountMutation, ApplyCheckoutSessionFulfillmentLocationMutation, ApplyCheckoutSessionShippingMethodMutation, + AuthorizeCheckoutSessionMutation, CalculateCheckoutSessionTaxesMutation, ConfirmCheckoutSessionMutation, CreateCheckoutSessionMutation, @@ -160,6 +161,10 @@ export type ConfirmCheckoutMutationInput = VariablesOf< typeof ConfirmCheckoutSessionMutation >; +export type AuthorizeCheckoutSessionInput = VariablesOf< + typeof AuthorizeCheckoutSessionMutation +>; + export type GetCheckoutSessionShippingRatesInput = VariablesOf< typeof DraftOrderShippingRatesQuery >;