diff --git a/.changeset/eight-knives-cough.md b/.changeset/eight-knives-cough.md new file mode 100644 index 00000000..f477662e --- /dev/null +++ b/.changeset/eight-knives-cough.md @@ -0,0 +1,6 @@ +--- +"@godaddy/localizations": patch +"@godaddy/react": patch +--- + +Add ACH Payment Support diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index ba3079a8..12ff2de4 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -58,6 +58,14 @@ export default async function Home() { processor: 'godaddy', checkoutTypes: ['standard'], }, + ach: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, + express: { + processor: 'godaddy', + checkoutTypes: ['express'], + }, mercadopago: { processor: 'mercadopago', checkoutTypes: ['standard'], diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 11d375a8..170119bd 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -108,6 +108,7 @@ export const deDe = { offline: 'Offline-Zahlungen', mercadopago: 'Mercado Pago', ccavenue: 'Mit CCAvenue bezahlen', + ach: 'Bankkonto', }, descriptions: { creditCard: '', @@ -118,6 +119,7 @@ export const deDe = { offline: '', mercadopago: 'Verwende das MercadoPago-Formular unten, um deinen Kauf sicher abzuschließen.', + ach: '', ccavenue: '', }, noMethodsAvailable: 'Keine Zahlungsmethoden verfügbar', @@ -359,6 +361,7 @@ export const deDe = { 'Lieferadresse oder -methode konnte nicht angewendet werden', DEPENDENCY_ERROR: 'Wir können Ihre Bestellung derzeit nicht bearbeiten. Bitte warten Sie einen Moment und versuchen Sie es erneut', + AUTHORIZATION_FAILED: 'Zahlungsautorisierung fehlgeschlagen', }, storefront: { product: 'Produkt', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 450a28d1..9423ba31 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -106,6 +106,7 @@ export const enIe = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Offline payments', + ach: 'Bank Account', mercadopago: 'Mercado Pago', ccavenue: 'Pay with CCAvenue', }, @@ -116,6 +117,7 @@ export const enIe = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Use the MercadoPago form below to complete your purchase securely.', ccavenue: '', @@ -335,6 +337,7 @@ export const enIe = { MISSING_SHIPPING_INFO: 'Shipping address or method failed to apply', DEPENDENCY_ERROR: "We're unable to process your order right now. Please wait a moment and try again", + AUTHORIZATION_FAILED: 'Failed to authorise payment', }, storefront: { product: 'Product', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index db18689c..398b6358 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -106,6 +106,7 @@ export const enUs = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Offline payments', + ach: 'Bank Account', mercadopago: 'Mercado Pago', ccavenue: 'Pay with CCAvenue', }, @@ -116,6 +117,7 @@ export const enUs = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Use the MercadoPago form below to complete your purchase securely.', ccavenue: '', @@ -335,6 +337,7 @@ export const enUs = { MISSING_SHIPPING_INFO: 'Shipping address or method failed to apply', DEPENDENCY_ERROR: "We're unable to process your order right now. Please wait a moment and try again", + AUTHORIZATION_FAILED: 'Failed to authorize payment', }, storefront: { product: 'Product', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index ae62fa8e..917f77c2 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -107,6 +107,7 @@ export const esAr = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagos en efectivo', + ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'الدفع عبر CCAvenue', }, @@ -117,6 +118,7 @@ export const esAr = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', @@ -342,6 +344,7 @@ export const esAr = { MISSING_SHIPPING_INFO: 'No se pudo aplicar la dirección o método de envío', DEPENDENCY_ERROR: 'No podemos procesar su pedido en este momento. Espere un momento e inténtelo de nuevo', + AUTHORIZATION_FAILED: 'Error al autorizar el pago', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index 6dd7c8fa..e381d156 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -107,6 +107,7 @@ export const esCl = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagos offline', + ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', }, @@ -117,6 +118,7 @@ export const esCl = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', @@ -344,6 +346,7 @@ export const esCl = { MISSING_SHIPPING_INFO: 'No se pudo aplicar la dirección o método de envío', DEPENDENCY_ERROR: 'No podemos procesar su pedido en este momento. Espere un momento e inténtelo de nuevo', + AUTHORIZATION_FAILED: 'Error al autorizar el pago', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index 6fc6b6d8..3d228544 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -107,6 +107,7 @@ export const esCo = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagos sin conexión', + ach: 'Cuenta bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', }, @@ -117,6 +118,7 @@ export const esCo = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', @@ -342,6 +344,7 @@ export const esCo = { MISSING_SHIPPING_INFO: 'No se pudo aplicar la dirección o método de envío', DEPENDENCY_ERROR: 'No podemos procesar su pedido en este momento. Espere un momento e inténtelo de nuevo', + AUTHORIZATION_FAILED: 'Error al autorizar el pago', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index ff845f89..fdceff01 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -107,6 +107,7 @@ export const esEs = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagos sin conexión', + ach: 'Cuenta bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', }, @@ -117,6 +118,7 @@ export const esEs = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', @@ -347,6 +349,7 @@ export const esEs = { MISSING_SHIPPING_INFO: 'No se pudo aplicar la dirección o método de envío', DEPENDENCY_ERROR: 'No podemos procesar su pedido en este momento. Espere un momento e inténtelo de nuevo', + AUTHORIZATION_FAILED: 'Error al autorizar el pago', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index 06f4698d..f4e4f045 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -107,6 +107,7 @@ export const esMx = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagos fuera de línea', + ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', }, @@ -117,6 +118,7 @@ export const esMx = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', @@ -343,6 +345,7 @@ export const esMx = { MISSING_SHIPPING_INFO: 'No se pudo aplicar la dirección o método de envío', DEPENDENCY_ERROR: 'No podemos procesar su pedido en este momento. Espere un momento e inténtelo de nuevo', + AUTHORIZATION_FAILED: 'Error al autorizar el pago', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index b8c82659..0d3c9047 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -107,6 +107,7 @@ export const esPe = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagos en efectivo', + ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', }, @@ -117,6 +118,7 @@ export const esPe = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', @@ -342,6 +344,7 @@ export const esPe = { MISSING_SHIPPING_INFO: 'No se pudo aplicar la dirección o método de envío', DEPENDENCY_ERROR: 'No podemos procesar su pedido en este momento. Espere un momento e inténtelo de nuevo', + AUTHORIZATION_FAILED: 'Error al autorizar el pago', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index e4ab2386..e74068f1 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -107,6 +107,7 @@ export const esUs = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagos offline', + ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', }, @@ -117,6 +118,7 @@ export const esUs = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', @@ -342,6 +344,7 @@ export const esUs = { MISSING_SHIPPING_INFO: 'No se pudo aplicar la dirección o método de envío', DEPENDENCY_ERROR: 'No podemos procesar su pedido en este momento. Espere un momento e inténtelo de nuevo', + AUTHORIZATION_FAILED: 'Error al autorizar el pago', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index d139fb4f..79d65050 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -107,6 +107,7 @@ export const frCa = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Paiements hors ligne', + ach: 'Compte bancaire', mercadopago: 'Mercado Pago', ccavenue: 'Payer avec CCAvenue', }, @@ -117,6 +118,7 @@ export const frCa = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Utilisez le formulaire MercadoPago ci-dessous pour finaliser votre achat en toute sécurité.', ccavenue: '', @@ -359,6 +361,7 @@ export const frCa = { "L'adresse ou la méthode de livraison n'a pas pu être appliquée", DEPENDENCY_ERROR: 'Nous ne pouvons pas traiter votre commande actuellement. Veuillez patienter un moment et réessayer', + AUTHORIZATION_FAILED: "Échec de l'autorisation du paiement", }, storefront: { product: 'Produit', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index 27702e17..b6d7aba0 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -107,6 +107,7 @@ export const frFr = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Paiements hors ligne', + ach: 'Compte bancaire', mercadopago: 'Mercado Pago', ccavenue: 'Payer avec CCAvenue', }, @@ -117,6 +118,7 @@ export const frFr = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Utilisez le formulaire MercadoPago ci-dessous pour finaliser votre achat en toute sécurité.', ccavenue: '', @@ -360,6 +362,7 @@ export const frFr = { "L'adresse ou la méthode de livraison n'a pas pu être appliquée", DEPENDENCY_ERROR: 'Nous ne pouvons pas traiter votre commande actuellement. Veuillez patienter un moment et réessayer', + AUTHORIZATION_FAILED: "Échec de l'autorisation du paiement", }, storefront: { product: 'Produit', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index ebbe647d..1f75ab10 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -106,6 +106,7 @@ export const idId = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pembayaran offline', + ach: 'Rekening Bank', mercadopago: 'Mercado Pago', ccavenue: 'Bayar dengan CCAvenue', }, @@ -116,6 +117,7 @@ export const idId = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Gunakan formulir MercadoPago di bawah untuk menyelesaikan pembelian Anda dengan aman.', ccavenue: '', @@ -334,6 +336,7 @@ export const idId = { MISSING_SHIPPING_INFO: 'Alamat atau metode pengiriman gagal diterapkan', DEPENDENCY_ERROR: 'Kami tidak dapat memproses pesanan Anda saat ini. Silakan tunggu sebentar dan coba lagi', + AUTHORIZATION_FAILED: 'Gagal mengotorisasi pembayaran', }, storefront: { product: 'Produk', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index fd98b6d3..e50584ea 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -107,6 +107,7 @@ export const itIt = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagamenti offline', + ach: 'Conto Bancario', mercadopago: 'Mercado Pago', ccavenue: 'Paga con CCAvenue', }, @@ -117,6 +118,7 @@ export const itIt = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Usa il modulo MercadoPago qui sotto per completare l’acquisto in modo sicuro.', ccavenue: '', @@ -358,6 +360,7 @@ export const itIt = { "Impossibile applicare l'indirizzo o il metodo di spedizione", DEPENDENCY_ERROR: 'Non riusciamo a elaborare il tuo ordine in questo momento. Aspetta un momento e riprova', + AUTHORIZATION_FAILED: "Errore nell'autorizzazione del pagamento", }, storefront: { product: 'Prodotto', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index f4cb7c27..cfede5d9 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -106,6 +106,7 @@ export const ptBr = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Pagamentos offline', + ach: 'Conta Bancária', mercadopago: 'Mercado Pago', ccavenue: 'Pagar com CCAvenue', }, @@ -116,6 +117,7 @@ export const ptBr = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Use o formulário do MercadoPago abaixo para concluir sua compra com segurança.', ccavenue: '', @@ -340,6 +342,7 @@ export const ptBr = { MISSING_SHIPPING_INFO: 'Falha ao aplicar endereço ou método de entrega', DEPENDENCY_ERROR: 'Não conseguimos processar seu pedido no momento. Aguarde um momento e tente novamente', + AUTHORIZATION_FAILED: 'Falha ao autorizar pagamento', }, storefront: { product: 'Produto', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 91ad583f..0e2467f5 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -107,6 +107,7 @@ export const qaPs = { googlePay: '[Göögië Þâÿ Þâÿmëñţ]', paze: '[Þâžë Þâÿmëñţ Šërvîçë]', offline: '[Öfflîñë þâÿmëñţ mëţhödš]', + ach: '[Bâñk Âççöüñţ Þâÿmëñţ]', mercadopago: 'Mercado Pago', ccavenue: '[Þâÿ ïñ ÇÇÂvëñûë]', }, @@ -117,6 +118,7 @@ export const qaPs = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: '[Üšë ţhë MërçâðöÞâgö förm këlöw ţö çömþlëţë ÿöür þürçhâšë šëçürëlÿ.]', ccavenue: '', @@ -344,6 +346,7 @@ export const qaPs = { MISSING_SHIPPING_INFO: '[Šĥîţţîñg âddrëšš ör mëţĥöd fâîlëd ţö âţţļÿ]', DEPENDENCY_ERROR: 'موږ اوس ستاسو امر پروسس نشو کولی. مهرباني وکړئ یو شېبه انتظار وکړئ او بیا هڅه وکړئ', + AUTHORIZATION_FAILED: '[Fâîlëd ţö âüţhörîžë þâÿmëñţ]', }, storefront: { product: '[Product]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index 339867c4..793dc9ae 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -106,6 +106,7 @@ export const trTr = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Çevrimdışı ödemeler', + ach: 'Banka Hesabı', mercadopago: 'Mercado Pago', ccavenue: 'CCAvenue ile öde', }, @@ -116,6 +117,7 @@ export const trTr = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Satın alımınızı güvenle tamamlamak için aşağıdaki MercadoPago formunu kullanın.', ccavenue: '', @@ -335,6 +337,7 @@ export const trTr = { MISSING_SHIPPING_INFO: 'Kargo adresi veya yöntemi uygulanamadı', DEPENDENCY_ERROR: 'Şu anda siparişinizi işleme alamıyoruz. Lütfen bir dakika bekleyin ve tekrar deneyin', + AUTHORIZATION_FAILED: 'Ödeme yetkilendirmesi başarısız', }, storefront: { product: 'Ürün', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index a496cdc0..f3bcf6fa 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -106,6 +106,7 @@ export const viVn = { googlePay: 'Google Pay', paze: 'Paze', offline: 'Thanh toán ngoại tuyến', + ach: 'Tài khoản ngân hàng', mercadopago: 'Mercado Pago', ccavenue: 'Thanh toán bằng CCAvenue', }, @@ -116,6 +117,7 @@ export const viVn = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: 'Hãy sử dụng biểu mẫu MercadoPago bên dưới để hoàn tất mua hàng một cách an toàn.', ccavenue: '', @@ -335,6 +337,7 @@ export const viVn = { 'Địa chỉ hoặc phương thức giao hàng không thể áp dụng', DEPENDENCY_ERROR: 'Chúng tôi không thể xử lý đơn hàng của bạn ngay bây giờ. Vui lòng đợi một chút và thử lại', + AUTHORIZATION_FAILED: 'Không thể ủy quyền thanh toán', }, storefront: { product: 'Sản phẩm', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index d5c0e2a2..c17bb453 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -102,6 +102,7 @@ export const zhCn = { googlePay: 'Google Pay', paze: 'Paze', offline: '线下付款', + ach: '银行账户', mercadopago: 'Mercado Pago', ccavenue: '使用 CCAvenue 支付', }, @@ -112,6 +113,7 @@ export const zhCn = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: '请使用下方的 MercadoPago 表单安全完成购买。', ccavenue: '', }, @@ -323,6 +325,7 @@ export const zhCn = { BILLING_ADDRESS_VERIFICATION_FAILED: '账单地址验证失败', MISSING_SHIPPING_INFO: '配送地址或方式应用失败', DEPENDENCY_ERROR: '我们目前无法处理您的订单。请稍等片刻再试', + AUTHORIZATION_FAILED: '付款授权失败', }, storefront: { product: '产品', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index de14b637..2fc8a914 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -102,6 +102,7 @@ export const zhSg = { googlePay: 'Google Pay', paze: 'Paze', offline: '线下付款', + ach: '银行账户', mercadopago: 'Mercado Pago', ccavenue: '使用 CCAvenue 支付', }, @@ -112,6 +113,7 @@ export const zhSg = { googlePay: '', paze: '', offline: '', + ach: '', mercadopago: '请使用下方的 MercadoPago 表单安全完成购买。', ccavenue: '', }, @@ -323,6 +325,7 @@ export const zhSg = { BILLING_ADDRESS_VERIFICATION_FAILED: '账单地址验证失败', MISSING_SHIPPING_INFO: '配送地址或方式应用失败', DEPENDENCY_ERROR: '我們目前無法處理您的訂單。請稍等片刻再試', + AUTHORIZATION_FAILED: '付款授权失败', }, storefront: { product: '产品', diff --git a/packages/react/src/components/checkout/line-items/line-items.tsx b/packages/react/src/components/checkout/line-items/line-items.tsx index 196e9315..8acc2262 100644 --- a/packages/react/src/components/checkout/line-items/line-items.tsx +++ b/packages/react/src/components/checkout/line-items/line-items.tsx @@ -153,7 +153,7 @@ export function DraftOrderLineItems({ ) : null} - {item.originalPrice && item.quantity ? ( + {item.originalPrice != null && item.quantity ? (
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/ach/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/ach/godaddy.tsx new file mode 100644 index 00000000..f70b7cf9 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/ach/godaddy.tsx @@ -0,0 +1,44 @@ +import { useCallback, useRef } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { usePoyntACHCollect } from '@/components/checkout/payment/utils/poynt-ach-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 ACHCheckoutButton() { + const { collect, isLoadingNonce } = usePoyntACHCollect(); + const { isConfirmingCheckout } = useCheckoutContext(); + const isPaymentDisabled = useIsPaymentDisabled(); + const form = useFormContext(); + const buttonRef = useRef(null); + const { t } = useGoDaddyContext(); + + const handleSubmit = useCallback(async () => { + const valid = await form.trigger(); + if (!valid) { + const firstError = Object.keys(form.formState.errors)[0]; + if (firstError) { + form.setFocus(firstError); + } + return; + } + + collect?.getNonce({}); + }, [form, collect]); + + if (!collect) return null; + + return ( + + ); +} 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 a5c823f1..15b29965 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 @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -63,6 +64,7 @@ export function ExpressCheckoutButton() { const { godaddyPaymentsConfig } = useCheckoutContext(); const { t } = useGoDaddyContext(); const [isCollectLoading, setIsCollectLoading] = useState(true); + const elementId = `gdpay-express-pay-element-${useId()}`; const [walletSource, setWalletSource] = useState( undefined ); @@ -510,7 +512,7 @@ export function ExpressCheckoutButton() { if (paymentMethods.length > 0 && !hasMounted.current) { hasMounted.current = true; // console.log("[poynt collect] Mounting"); - collect?.current?.mount('gdpay-express-pay-element', document, { + collect?.current?.mount(elementId, document, { paymentMethods: paymentMethods, buttonsContainerOptions: { className: 'gap-1 !flex-col sm:!flex-row place-items-center', @@ -1456,7 +1458,7 @@ export function ExpressCheckoutButton() { // return function unmount() { // if (collect.current) { // console.log("poynt collect unmounting"); - // collect.current.unmount("gdpay-express-pay-element", document); + // collect.current.unmount(elementId, document); // } // }; }, [ @@ -1486,7 +1488,7 @@ export function ExpressCheckoutButton() { return ( <>
{isCollectLoading ? ( diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx index 850a3e76..91e3a917 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; @@ -27,6 +27,7 @@ export function PazeCheckoutButton() { const { t } = useGoDaddyContext(); const [isCollectLoading, setIsCollectLoading] = useState(true); const [error, setError] = useState(''); + const elementId = `paze-pay-element-${useId()}`; const { data: totals } = useDraftOrderTotals(); const { poyntStandardRequest } = useBuildPaymentRequest(); @@ -67,7 +68,7 @@ export function PazeCheckoutButton() { if (!hasMounted.current && collect?.current) { hasMounted.current = true; // console.log("[poynt collect] Mounting paze-pay-element"); - collect?.current.mount('paze-pay-element', document, { + collect?.current.mount(elementId, document, { paymentMethods: ['paze'], buttonsContainerOptions: { className: 'gap-1 !flex-col sm:!flex-row place-items-center', @@ -244,7 +245,7 @@ export function PazeCheckoutButton() { return ( <> -
+
{isCollectLoading ? (
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 6edadc0c..eb270c94 100644 --- a/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx +++ b/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx @@ -70,6 +70,24 @@ const LazyComponents = { default: module.PayPalCreditCardCheckoutButton, })) ), + // ACH Form + GoDaddyACHForm: lazy(() => + import('@/components/checkout/payment/payment-methods/ach/godaddy').then( + module => ({ + default: module.GoDaddyACHForm, + }) + ) + ), + + // ACH Buttons + ACHCheckoutButton: lazy(() => + import('@/components/checkout/payment/checkout-buttons/ach/godaddy').then( + module => ({ + default: module.ACHCheckoutButton, + }) + ) + ), + MercadoPagoCheckoutButton: lazy(() => import( '@/components/checkout/payment/checkout-buttons/mercadopago/mercadopago' @@ -190,6 +208,12 @@ type PaymentComponentRegistry = { button: PaymentComponentKey; }; }; + [PaymentMethodType.ACH]?: { + [PaymentProvider.GODADDY]: { + form: PaymentComponentKey; + button: PaymentComponentKey; + }; + }; [PaymentMethodType.MERCADOPAGO]?: { [PaymentProvider.MERCADOPAGO]: { button: PaymentComponentKey; @@ -254,6 +278,12 @@ export const lazyPaymentComponentRegistry: PaymentComponentRegistry = { button: 'MercadoPagoCheckoutButton', }, }, + [PaymentMethodType.ACH]: { + [PaymentProvider.GODADDY]: { + form: 'GoDaddyACHForm', + button: 'ACHCheckoutButton', + }, + }, [PaymentMethodType.CCAVENUE]: { [PaymentProvider.CCAVENUE]: { button: 'CCAvenueCheckoutButton', diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index 23fa3ca4..75b58cf3 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -1,4 +1,10 @@ -import { Circle, CreditCard, LoaderCircle, Wallet } from 'lucide-react'; +import { + Circle, + CreditCard, + Landmark, + LoaderCircle, + Wallet, +} from 'lucide-react'; import React, { useCallback, useEffect, @@ -64,6 +70,7 @@ import { // UI config for payment methods (labels will be resolved from translations) const PAYMENT_METHOD_ICONS: Record = { card: , + ach: , paypal: , applePay: , googlePay: , @@ -108,6 +115,8 @@ export function PaymentForm( switch (key) { case PaymentMethodType.CREDIT_CARD: return t.payment.methods.creditCard; + case PaymentMethodType.ACH: + return t.payment.methods.ach; case PaymentMethodType.PAYPAL: return t.payment.methods.paypal; case PaymentMethodType.APPLE_PAY: @@ -135,6 +144,8 @@ export function PaymentForm( switch (key) { case PaymentMethodType.CREDIT_CARD: return t.payment.descriptions?.creditCard; + case PaymentMethodType.ACH: + return t.payment.descriptions?.ach; case PaymentMethodType.PAYPAL: return t.payment.descriptions?.paypal; case PaymentMethodType.APPLE_PAY: diff --git a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx new file mode 100644 index 00000000..bf8c0472 --- /dev/null +++ b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx @@ -0,0 +1,220 @@ +import { useId, useLayoutEffect, useRef, useState } from 'react'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import type { + TokenizeJs, + TokenizeJsEvent, +} from '@/components/checkout/payment/types'; +import { usePoyntACHCollect } from '@/components/checkout/payment/utils/poynt-ach-provider'; +import { + PaymentProvider, + useConfirmCheckout, +} from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { PaymentMethodType } from '@/types'; + +export function GoDaddyACHForm() { + const { t } = useGoDaddyContext(); + const { session } = useCheckoutContext(); + const { setCollect, setIsLoadingNonce } = usePoyntACHCollect(); + const { isPoyntLoaded } = useLoadPoyntCollect(); + const { godaddyPaymentsConfig, setCheckoutErrors } = useCheckoutContext(); + const [error, setError] = useState(''); + + const confirmCheckout = useConfirmCheckout(); + + const elementId = `gdpay-ach-element-${useId()}`; + + const fontFamily = + '"GD Sherpa", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; + + const baseInputStyle = ` + display: flex; + height: 48px; + width: 100%; + border-radius: 0.4375rem; + border: 1px solid oklch(0.9 0.025 245); + background: oklch(1 0 0); + padding-left: 12px; + padding-right: 12px; + padding-top: 8px; + padding-bottom: 8px; + font-size: 14px; + line-height: 1.5; + color: oklch(0.13 0 0); + font-family: ${fontFamily}; + transition: color 0.2s, background 0.2s, border-color 0.2s; + + &::placeholder { + color: oklch(0.556 0 0); + } + + &:focus-visible { + outline: none; + border-color: oklch(0.57 0.22 255); + box-shadow: 0px 0px 0px 2px oklch(0.57 0.22 255) inset; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + `; + + const options = { + iFrame: { + width: '100%', + height: '360px', + border: '0px', + }, + paymentMethods: ['ach'], + displayComponents: { + labels: true, + }, + customCss: { + container: ` + --collect-radio-size: 16px; + --collect-radio-dot-size: 7px; + --collect-radio-checked-bg-color: oklch(0.8 0.17 185); + --collect-radio-border-color: oklch(0.9 0.025 245); + --collect-radio-dot-color: oklch(0.13 0 0); + --collect-radio-bg-color: oklch(1 0 0); + --collect-border-radius-round: 50%; + --collect-spacing-lg: 10px; + --collect-spacing-md: 8px; + font-family: ${fontFamily}; + `, + + rowAccountHolderName: ` + flex: 1 1 100%; + padding: 0; + margin: 0; + margin-bottom: 16px; + `, + rowAccountHolderType: ` + flex: 1 1 100%; + padding: 0; + margin: 0; + margin-bottom: 16px; + `, + rowRoutingNumber: ` + flex: 1 1 100%; + padding: 0; + margin: 0; + `, + rowBankAccountNumber: ` + flex: 1 1 100%; + padding: 0; + margin: 0; + padding-top: 16px; + `, + + radio: { + accountHolderType: { + label: ` + font-size: 14px; + font-weight: 500; + line-height: 1.5; + color: oklch(0.13 0 0); + cursor: pointer;`, + container: ` + display: inline-flex; + margin-right: 24px; + margin-bottom: 0px; + + .poynt-collect-payment-radio-input:checked + .poynt-collect-payment-radio-label::after { + left: 5.5px; + top: 9px; + } + `, + }, + }, + + input: { + ownerName: baseInputStyle, + routingNumber: baseInputStyle, + accountNumber: baseInputStyle, + }, + }, + }; + + const collect = useRef(null); + + useLayoutEffect(() => { + if ( + !isPoyntLoaded || + !godaddyPaymentsConfig || + collect.current || + (!godaddyPaymentsConfig?.businessId && !session?.businessId) + ) + return; + + collect.current = new (window as any).TokenizeJs({ + businessId: godaddyPaymentsConfig?.businessId || session?.businessId, + storeId: session?.storeId, + channelId: session?.channelId, + applicationId: godaddyPaymentsConfig?.appId, + }); + + collect?.current?.on('ready', () => { + setCollect(collect.current); + }); + + collect?.current?.mount(elementId, document, options); + + collect?.current?.on('nonce', async (event: TokenizeJsEvent) => { + const nonce = event?.data?.nonce; + + if (nonce) { + try { + await confirmCheckout.mutateAsync({ + paymentToken: nonce, + paymentType: PaymentMethodType.ACH, + paymentProvider: PaymentProvider.POYNT, + }); + setIsLoadingNonce(false); + setError(''); + } catch (err: unknown) { + if (err instanceof GraphQLErrorWithCodes) { + setCheckoutErrors(err.codes); + } + } + } else { + setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']); + setIsLoadingNonce(false); + } + }); + + collect?.current?.on('error', (event: TokenizeJsEvent) => { + setError(event?.data?.error?.message || t.errors.errorProcessingPayment); + setIsLoadingNonce(false); + }); + + collect?.current?.on('validated', event => { + if (event?.data?.validated) { + setError(''); + } + }); + }, [ + isPoyntLoaded, + godaddyPaymentsConfig, + confirmCheckout.mutateAsync, + setCollect, + setCheckoutErrors, + t, + setIsLoadingNonce, + session?.businessId, + session?.storeId, + session?.channelId, + ]); + + return ( + <> +
+ {error ? ( +

{error}

+ ) : null} + + ); +} diff --git a/packages/react/src/components/checkout/payment/payment-methods/credit-card/godaddy.tsx b/packages/react/src/components/checkout/payment/payment-methods/credit-card/godaddy.tsx index e269af99..49323175 100644 --- a/packages/react/src/components/checkout/payment/payment-methods/credit-card/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/payment-methods/credit-card/godaddy.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react'; +import { useId, useLayoutEffect, useRef, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; import type { TokenizeJs, @@ -24,6 +24,8 @@ export function GoDaddyCreditCardForm() { const confirmCheckout = useConfirmCheckout(); + const elementId = `gdpay-card-element-${useId()}`; + const options = { iFrame: { width: '100%', @@ -206,7 +208,7 @@ export function GoDaddyCreditCardForm() { setCollect(collect.current); }); - collect?.current?.mount('gdpay-card-element', document, options); + collect?.current?.mount(elementId, document, options); collect?.current?.on('nonce', async (event: TokenizeJsEvent) => { const nonce = event?.data?.nonce; @@ -256,7 +258,7 @@ export function GoDaddyCreditCardForm() { return ( <> -
+
{error ? (

{error}

) : null} 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 2d381b1a..65c42898 100644 --- a/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx +++ b/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx @@ -2,6 +2,7 @@ import { PayPalScriptProvider } from '@paypal/react-paypal-js'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { CCAvenueReturnProvider } from './ccavenue-return-provider'; import { PayPalProvider } from './paypal-provider'; +import { PoyntACHCollectProvider } from './poynt-ach-provider'; import { PoyntCollectProvider } from './poynt-provider'; import { SquareProvider } from './square-provider'; import { StripeProvider } from './stripe-provider'; @@ -24,6 +25,7 @@ export function ConditionalPaymentProviders({ squareConfig, paypalConfig, ccavenueConfig, + session, } = useCheckoutContext(); const { payPalRequest } = useBuildPaymentRequest(); @@ -35,6 +37,16 @@ export function ConditionalPaymentProviders({ wrappedChildren = {wrappedChildren}; } + // Only wrap with PoyntACHCollectProvider if GoDaddy ACH is configured + if ( + godaddyPaymentsConfig?.appId?.trim() && + session?.paymentMethods?.ach?.processor === 'godaddy' + ) { + wrappedChildren = ( + {wrappedChildren} + ); + } + // Only wrap with PoyntCollectProvider (GoDaddy Payments) if configured if (godaddyPaymentsConfig?.appId?.trim()) { wrappedChildren = ( diff --git a/packages/react/src/components/checkout/payment/utils/poynt-ach-provider.tsx b/packages/react/src/components/checkout/payment/utils/poynt-ach-provider.tsx new file mode 100644 index 00000000..56b514c8 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/poynt-ach-provider.tsx @@ -0,0 +1,45 @@ +import React, { + createContext, + type ReactNode, + useContext, + useState, +} from 'react'; +import type { TokenizeJs } from '@/components/checkout/payment/types'; + +type PoyntACHCollectContextType = { + collect: TokenizeJs | null; + setCollect: (collect: TokenizeJs | null) => void; + isLoadingNonce: boolean; + setIsLoadingNonce: (loading: boolean) => void; +}; + +const PoyntACHCollectContext = createContext< + PoyntACHCollectContextType | undefined +>(undefined); + +export const PoyntACHCollectProvider = ({ + children, +}: { + children: ReactNode; +}) => { + const [collect, setCollect] = useState(null); + const [isLoadingNonce, setIsLoadingNonce] = useState(false); + + return ( + + {children} + + ); +}; + +export const usePoyntACHCollect = () => { + const context = useContext(PoyntACHCollectContext); + if (!context) { + throw new Error( + 'usePoyntACHCollect must be used within a PoyntACHCollectProvider' + ); + } + return context; +}; diff --git a/packages/react/src/components/checkout/pickup/local-pickup.tsx b/packages/react/src/components/checkout/pickup/local-pickup.tsx index 6b79944a..fa3eb21d 100644 --- a/packages/react/src/components/checkout/pickup/local-pickup.tsx +++ b/packages/react/src/components/checkout/pickup/local-pickup.tsx @@ -374,7 +374,14 @@ export function LocalPickupForm({ } } + const selectedDayStr = format(selectedDate, 'yyyy-MM-dd'); + while (true) { + // Break if we've rolled past the selected date (midnight overflow) + if (format(currentTime, 'yyyy-MM-dd') !== selectedDayStr) { + break; + } + // Get the current slot's hour and minute for comparison const currentSlotHours = currentTime.getHours(); const currentSlotMins = currentTime.getMinutes(); diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index 4d015870..30c97179 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -1,7 +1,12 @@ -import { useState } from 'react'; +import { useDebouncedValue } from '@tanstack/react-pacer'; +import { useEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; +import { + convertMajorToMinorUnits, + currencyConfigs, + useFormatCurrency, +} from '@/components/checkout/utils/format-currency'; import { Button } from '@/components/ui/button'; import { FormControl, @@ -23,7 +28,6 @@ interface TipsFormProps { export function TipsForm({ total, currencyCode }: TipsFormProps) { const { t } = useGoDaddyContext(); - const { requiredFields } = useCheckoutContext(); const form = useFormContext(); const formatCurrency = useFormatCurrency(); const [showCustomTip, setShowCustomTip] = useState(false); @@ -91,7 +95,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { return (
@@ -122,7 +126,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) {
@@ -150,49 +154,215 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) {
{showCustomTip && ( - ( - - - {t.tips.customTipAmount} - - + + )} +
+ ); +} + +/** + * Isolated component for the custom tip input. + * + * Uses the "format on blur" pattern — the industry standard for currency inputs + * (Stripe, Square, Shopify, etc.): + * + * - While focused: the user edits raw text freely (local state). + * Only non-numeric characters are stripped; intermediate states like + * "10.", "10.5", "" are all preserved so delete/backspace work naturally. + * - On blur: the raw text is parsed, converted to minor units, synced to + * form state, and the display is reformatted (e.g. "10.5" → "10.50"). + * - On focus: if a formatted value exists it is shown as an editable raw + * number so the user can continue editing from where they left off. + */ +interface CustomTipInputProps { + currencyCode?: string; + total: number; + formatCurrency: (options: { + amount: number; + currencyCode: string; + inputInMinorUnits?: boolean; + returnRaw?: boolean; + }) => string; +} + +/** + * Currencies where the symbol is conventionally placed after the number. + * Derived from currencyConfigs entries with `pattern: '#!'`. + */ +const SUFFIX_CURRENCIES = new Set( + Object.entries(currencyConfigs) + .filter(([, cfg]) => cfg.pattern === '#!') + .map(([code]) => code) +); + +/** + * Map symbol character length to Tailwind padding classes. + * Arabic / multi-char symbols need more room than a single `$`. + */ +function symbolPadding(symbol: string, position: 'prefix' | 'suffix') { + const len = symbol.length; + if (position === 'prefix') { + if (len <= 1) return 'pl-7'; // $, €, ¥, ₩, etc. + if (len <= 2) return 'pl-10'; // R$, Rp, S/ + if (len <= 3) return 'pl-12'; // NT$, د.إ, د.ك + return 'pl-14'; // .د.ب, ر.ع. + } + // suffix + if (len <= 1) return 'pr-7'; + if (len <= 2) return 'pr-10'; + if (len <= 3) return 'pr-12'; + return 'pr-14'; +} + +function CustomTipInput({ + currencyCode, + total, + formatCurrency, +}: CustomTipInputProps) { + const { t } = useGoDaddyContext(); + const { requiredFields } = useCheckoutContext(); + const form = useFormContext(); + + const code = currencyCode || 'USD'; + const config = currencyConfigs[code] || { symbol: '$', precision: 2 }; + const { symbol, precision } = config; + const isSuffix = SUFFIX_CURRENCIES.has(code); + + // Local state holds the raw text the user is actively typing. + // `null` means "not focused — derive display from form state". + const [localValue, setLocalValue] = useState(null); + const isFocused = useRef(false); + + // Debounce the local value so the form syncs after 3s of inactivity, + // even if the user hasn't blurred the input yet. This keeps the order + // summary / totals up-to-date while the input stays focused. + const [debouncedLocal] = useDebouncedValue(localValue, { wait: 1500 }); + + /** + * Sanitize input: allow only digits and (for currencies with decimals) + * a single decimal point with at most `precision` fractional digits. + */ + const sanitize = (raw: string): string => { + // Strip everything except digits and '.' + let cleaned = raw.replace(/[^\d.]/g, ''); + + // For zero-precision currencies (JPY, KRW, etc.), strip any decimal + if (precision === 0) { + return cleaned.replace(/\./g, ''); + } + + // Allow only one decimal point + const dotIndex = cleaned.indexOf('.'); + if (dotIndex !== -1) { + const before = cleaned.slice(0, dotIndex); + const after = cleaned.slice(dotIndex + 1).replace(/\./g, ''); + // Limit fractional digits to currency precision + cleaned = `${before}.${after.slice(0, precision)}`; + } + + return cleaned; + }; + + /** + * Format a minor-units value as a raw numeric string for display + * (e.g. 1050 → "10.50" for USD). + */ + const formatRaw = (minorUnits: number): string => { + if (minorUnits <= 0) return ''; + return formatCurrency({ + amount: minorUnits, + currencyCode: code, + inputInMinorUnits: true, + returnRaw: true, + }); + }; + + // When the debounced value settles and the input is still focused, + // sync to form state and format the display — the same effect as blur + // but triggered by 3s of inactivity. This keeps the order summary + // up-to-date and gives the user visual confirmation of their amount. + useEffect(() => { + if (!isFocused.current || debouncedLocal === null) return; + const tipAmount = convertMajorToMinorUnits(debouncedLocal ?? '', code); + form.setValue('tipAmount', tipAmount); + // Clear local state so the display derives from the formatted form + // value (e.g. "10.5" → "10.50"), same as the blur handler. + setLocalValue(null); + }, [debouncedLocal]); // eslint-disable-line react-hooks/exhaustive-deps + + const symbolEl = ( + + ); + + return ( + { + // While focused, show local text. Otherwise, derive from form state. + const displayValue = + localValue !== null ? localValue : formatRaw(field.value); + + return ( + + {t.tips.customTipAmount} + +
+ {symbolEl} 0 - ? formatCurrency({ - amount: field.value, - currencyCode: currencyCode || 'USD', - inputInMinorUnits: true, - returnRaw: true, - }) - : '' + placeholder={ + precision > 0 ? `0.${'0'.repeat(precision)}` : '0' } + className={cn( + 'h-12', + isSuffix + ? symbolPadding(symbol, 'suffix') + : symbolPadding(symbol, 'prefix') + )} + value={displayValue} + onFocus={() => { + isFocused.current = true; + // Seed local state with the current formatted value so + // the user can continue editing naturally. + setLocalValue(formatRaw(field.value)); + }} onChange={e => { - // User inputs in major units (e.g., $10.50), convert to minor units for storage - const inputValue = Number.parseFloat(e.target.value); - if (!Number.isNaN(inputValue)) { - const tipAmount = Math.round(inputValue * 100); - field.onChange(tipAmount); - } else { - field.onChange(0); - } + // Only sanitize (strip invalid chars) — do NOT parse or + // round-trip through minor units. This preserves intermediate + // states like "10.", "10.5", "" so editing feels natural. + setLocalValue(sanitize(e.target.value)); }} onBlur={e => { - // User inputs in major units (e.g., $10.50), convert to minor units for storage - const inputValue = Number.parseFloat(e.target.value); - const tipAmount = !Number.isNaN(inputValue) - ? Math.round(inputValue * 100) - : 0; + isFocused.current = false; + + // Parse the raw text and sync to form state + const tipAmount = convertMajorToMinorUnits( + e.target.value, + code + ); + + field.onChange(tipAmount); + + // Clear local state so display derives from formatted form value + setLocalValue(null); + // Track custom tip amount entry track({ eventId: eventIds.enterCustomTip, @@ -208,12 +378,12 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { }); }} /> - - - - )} - /> - )} - +
+
+ +
+ ); + }} + /> ); } diff --git a/packages/react/src/components/checkout/totals/totals.tsx b/packages/react/src/components/checkout/totals/totals.tsx index acb0ee32..3975e0da 100644 --- a/packages/react/src/components/checkout/totals/totals.tsx +++ b/packages/react/src/components/checkout/totals/totals.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { DiscountStandalone } from '@/components/checkout/discount/discount-standalone'; import { TotalLineItemSkeleton } from '@/components/checkout/totals/totals-skeleton'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; @@ -79,6 +80,11 @@ export function DraftOrderTotals({ // Discount changes are handled by the DiscountStandalone component }; + // Calculates the total plus tips and surcharge + const calculatedTotal = useMemo(() => { + return total + tip; + }, [total, tip]); + return (
@@ -158,7 +164,7 @@ export function DraftOrderTotals({ {formatCurrency({ - amount: total, + amount: calculatedTotal, currencyCode, inputInMinorUnits, })} diff --git a/packages/react/src/components/checkout/utils/format-currency.ts b/packages/react/src/components/checkout/utils/format-currency.ts index e72ab591..46f95cae 100644 --- a/packages/react/src/components/checkout/utils/format-currency.ts +++ b/packages/react/src/components/checkout/utils/format-currency.ts @@ -114,9 +114,12 @@ export function formatCurrency({ * - 0 decimals (JPY, KRW, VND, etc.): multiply by 1 * - 3 decimals (KWD, BHD, JOD, OMR): multiply by 1000 * + * Returns 0 for NaN, negative, null-ish, or otherwise unparseable input + * so callers don't need to guard individually. + * * @param amount - The amount in major units (e.g., "10.50" or 10.50) * @param currencyCode - ISO 4217 currency code (e.g., 'USD', 'JPY', 'KWD') - * @returns The amount in minor units (e.g., 1050 for USD, 10 for JPY, 10500 for KWD) + * @returns The amount in minor units (e.g., 1050 for USD, 10 for JPY, 10500 for KWD), or 0 for invalid input */ export function convertMajorToMinorUnits( amount: number | string, @@ -124,6 +127,7 @@ export function convertMajorToMinorUnits( ): number { const config = currencyConfigs[currencyCode] || { precision: 2 }; const numAmount = typeof amount === 'string' ? Number(amount) : amount; + if (!Number.isFinite(numAmount) || numAmount < 0) return 0; return Math.round(numAmount * Math.pow(10, config.precision)); } diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index c0c4afc2..641911a1 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2984,6 +2984,15 @@ const introspection = { kind: 'OBJECT', name: 'CheckoutSessionPaymentMethods', fields: [ + { + name: 'ach', + type: { + kind: 'OBJECT', + name: 'CheckoutSessionPaymentMethodConfig', + }, + args: [], + isDeprecated: false, + }, { name: 'applePay', type: { @@ -3072,6 +3081,13 @@ const introspection = { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodsInput', inputFields: [ + { + name: 'ach', + type: { + kind: 'INPUT_OBJECT', + name: 'CheckoutSessionPaymentMethodConfigInput', + }, + }, { name: 'applePay', type: { @@ -3108,28 +3124,28 @@ const introspection = { }, }, { - name: 'offline', + name: 'mercadopago', type: { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodConfigInput', }, }, { - name: 'paypal', + name: 'offline', type: { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodConfigInput', }, }, { - name: 'paze', + name: 'paypal', type: { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodConfigInput', }, }, { - name: 'mercadopago', + name: 'paze', type: { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodConfigInput', diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index e3e77f19..4c6bb6a9 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -113,6 +113,10 @@ export const CreateCheckoutSessionMutation = graphql(` processor checkoutTypes } + ach { + processor + checkoutTypes + } } draftOrder { id diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index e463a8c3..f6f3bb54 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -109,6 +109,10 @@ export const GetCheckoutSessionQuery = graphql(` processor checkoutTypes } + ach { + processor + checkoutTypes + } mercadopago { processor checkoutTypes diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 999d2266..a2a06376 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -58,6 +58,7 @@ export type AvailablePaymentProviders = export const PaymentMethodType = { CREDIT_CARD: 'card', + ACH: 'ach', EXPRESS: 'express', PAYPAL: 'paypal', APPLE_PAY: 'applePay',