From c4c5f0219a3fc87ec9fcfebb74cff537b85947a3 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:02:29 -0700 Subject: [PATCH 01/54] add button cursor-pointer --- packages/react/src/components/ui/button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/ui/button.tsx b/packages/react/src/components/ui/button.tsx index 25d70383..ae374c7a 100644 --- a/packages/react/src/components/ui/button.tsx +++ b/packages/react/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { useCheckoutContext } from '@/components/checkout/checkout'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { From 17853222375216311ace1dabded310c41e3cf847 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:03:01 -0700 Subject: [PATCH 02/54] improve tip btn styling, fix custom tip input --- .../components/checkout/tips/tips-form.tsx | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index e9523110..5ba3873f 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -104,17 +104,19 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { - {showCustomTip && ( + {showCustomTip ? ( - )} + ) : null} ); } @@ -278,6 +283,10 @@ function CustomTipInput({ }); }; + // Ref to avoid `form` (unstable reference) in the dependency array. + const formRef = useRef(form); + formRef.current = form; + // 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 1.5s of inactivity. This keeps the order summary @@ -285,11 +294,11 @@ function CustomTipInput({ useEffect(() => { if (!isFocused.current || debouncedLocal === null) return; const tipAmount = convertMajorToMinorUnits(debouncedLocal ?? '', code); - form.setValue('tipAmount', tipAmount); + formRef.current.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, code, form]); + }, [debouncedLocal, code]); const symbolEl = ( Date: Tue, 30 Jun 2026 23:03:29 -0700 Subject: [PATCH 03/54] enable tips in nextjs example --- examples/nextjs/app/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 12ff2de4..88778aaa 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -21,6 +21,7 @@ export default async function Home() { enableTaxCollection: true, enableNotesCollection: true, enablePromotionCodes: true, + enableTips: true, shipping: { fulfillmentLocationId: 'default-location', originAddress: { From cfed7a4106202731884a23de842466c118e794ae Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:03:59 -0700 Subject: [PATCH 04/54] fix tipPercentage schema --- packages/react/src/components/checkout/checkout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index 143ea113..1dd496cb 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -186,7 +186,7 @@ export const baseCheckoutSchema = z.object({ pickupLeadTime: z.number().nullish(), pickupTimezone: z.string().nullish(), tipAmount: z.number().optional(), - tipPercentage: z.number().optional(), + tipPercentage: z.number().nullish(), paymentMethod: z.string().min(1, 'Select a payment method'), stripePaymentIntent: z.string().optional(), stripePaymentIntentId: z.string().optional(), From 0d559e53ef3393e7524d24a50b599f2fb8690831 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:04:29 -0700 Subject: [PATCH 05/54] pass tipAmount in confirmCheckout --- .../payment/utils/use-confirm-checkout.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index 998fb3f9..0e72d58f 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -169,6 +169,12 @@ export function useConfirmCheckout() { defaultTimezone: session?.defaultOperatingHours?.timeZone, }) : {}; + const tipAmount = form.getValues('tipAmount'); + const payload = { + ...confirmCheckoutInput, + ...pickUpData, + tipAmount, + } // keep for debugging // console.log({ @@ -195,18 +201,12 @@ export function useConfirmCheckout() { const data = jwt ? await confirmCheckout( - { - ...confirmCheckoutInput, - ...(isPickup ? pickUpData : {}), - }, + payload, { accessToken: jwt, sessionId: session?.id || '' }, apiHost ) : await confirmCheckout( - { - ...confirmCheckoutInput, - ...(isPickup ? pickUpData : {}), - }, + payload, session, apiHost ); From ac650af48231818382d1d95498735f7b5a94b0ef Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:05:16 -0700 Subject: [PATCH 06/54] add tipAmount definition --- packages/react/src/lib/godaddy/checkout-env.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 4e7d5c1c..7d5ac970 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -7804,6 +7804,13 @@ const introspection = { name: 'MoneyInput', }, }, + { + name: 'tipAmount', + type: { + kind: 'SCALAR', + name: 'Int', + }, + }, ], isOneOf: false, }, From 813e2a3247e52ddb7de3fe0a15363bb59b73b17c Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:05:27 -0700 Subject: [PATCH 07/54] formatting --- packages/react/src/lib/godaddy/checkout-mutations.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index b5af9be9..8b94ffbb 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -392,10 +392,10 @@ export const ApplyCheckoutSessionDiscountMutation = graphql(` export const ConfirmCheckoutSessionMutation = graphql(` mutation ConfirmCheckoutSession($input: MutationConfirmCheckoutSessionInput!, $sessionId: String!) { - confirmCheckoutSession(input: $input, sessionId: $sessionId) { - status - } + confirmCheckoutSession(input: $input, sessionId: $sessionId) { + status } + } `); export const ApplyCheckoutSessionShippingMethodMutation = graphql(` From 3878b05a31785866de9d281ad7b1bff613bc1048 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:05:49 -0700 Subject: [PATCH 08/54] add tests --- .../checkout/__tests__/checkout-tips.test.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx index d634fea9..3af41864 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx @@ -8,6 +8,7 @@ import { waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; vi.mock('@/tracking/track', async importOriginal => { const actual = await importOriginal(); @@ -346,4 +347,119 @@ describe('Checkout tips', () => { expect(screen.queryByPlaceholderText('0')).not.toBeInTheDocument(); }); }); + + it('includes tipAmount in the ConfirmCheckoutSession mutation payload', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + await waitFor(() => { + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + }); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + tipAmount: 500, + }); + }); + + it('includes a custom tipAmount when entering a custom tip before confirming', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /custom amount/i }) + ); + const input = await screen.findByPlaceholderText('0.00'); + await user.click(input); + await user.type(input, '7.50'); + await user.tab(); + + await waitFor(() => { + expect(screen.getAllByText('$7.50').length).toBeGreaterThan(0); + }); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + tipAmount: 750, + }); + }); + + it('sends tipAmount as 0 when no tip is selected', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + tipAmount: 0, + }); + }); }); From d1d3bb30bc8fd7d95d7f4a50c0e2b98730b5a0e7 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:23:33 -0700 Subject: [PATCH 09/54] changeset --- .changeset/fruity-dots-jog.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fruity-dots-jog.md diff --git a/.changeset/fruity-dots-jog.md b/.changeset/fruity-dots-jog.md new file mode 100644 index 00000000..e9a4fbd5 --- /dev/null +++ b/.changeset/fruity-dots-jog.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Support tips in unified checkout From 9f4fc312bb69d088ffed37abe79222bee954a3c1 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Thu, 9 Jul 2026 14:25:56 -0700 Subject: [PATCH 10/54] calculate tips from subtotal instead of total --- .../checkout/form/checkout-form.tsx | 3 +- .../components/checkout/tips/tips-form.tsx | 28 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index c6045576..da16bd53 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -434,7 +434,8 @@ export function CheckoutForm({ diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index 5ba3873f..d15dc5c1 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -21,13 +21,15 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { cn } from '@/lib/utils'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; +import { type CheckoutSession } from '@/types'; interface TipsFormProps { - total: number; + subtotal: number; + options?: CheckoutSession['tips']; currencyCode?: string; } -export function TipsForm({ total, currencyCode }: TipsFormProps) { +export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { const { t } = useGoDaddyContext(); const form = useFormContext(); const formatCurrency = useFormatCurrency(); @@ -35,7 +37,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { const calculateTipAmount = (percentage: number): number => { // total is in minor units, so calculate percentage and return in minor units - return Math.round((total * percentage) / 100); + return Math.round((subtotal * percentage) / 100); }; const handlePercentageSelect = (percentage: number) => { @@ -51,7 +53,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { properties: { tipPercentage: percentage, tipAmount: tipAmount, - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); @@ -69,7 +71,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { properties: { tipPercentage: 0, tipAmount: 0, - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); @@ -84,13 +86,13 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { eventId: eventIds.enterCustomTip, type: TrackingEventType.CLICK, properties: { - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); }; - const tipPercentages = [15, 18, 20]; + const tipPercentages = options?.default?.percentages || [15, 18, 20]; const tipPercentage = form.watch('tipPercentage'); return ( @@ -162,7 +164,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { {showCustomTip ? ( ) : null} @@ -186,7 +188,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { */ interface CustomTipInputProps { currencyCode?: string; - total: number; + subtotal: number; formatCurrency: (options: FormatCurrencyOptions) => string; } @@ -221,7 +223,7 @@ function symbolPadding(symbol: string, position: 'prefix' | 'suffix') { function CustomTipInput({ currencyCode, - total, + subtotal, formatCurrency, }: CustomTipInputProps) { const { t } = useGoDaddyContext(); @@ -375,10 +377,10 @@ function CustomTipInput({ type: TrackingEventType.CLICK, properties: { tipAmount: tipAmount, - totalBeforeTip: total, + totalBeforeTip: subtotal, tipPercentage: - total > 0 - ? Number(((tipAmount / total) * 100).toFixed(2)) + subtotal > 0 + ? Number(((tipAmount / subtotal) * 100).toFixed(2)) : 0, currencyCode, }, From 112c1616e39c232222ca677400802ac81b5ef38a Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Thu, 9 Jul 2026 14:27:28 -0700 Subject: [PATCH 11/54] add tips definition --- examples/nextjs/app/page.tsx | 12 + packages/react/README.md | 38 +++ .../react/src/lib/godaddy/checkout-env.ts | 243 ++++++++++++++++++ .../src/lib/godaddy/checkout-mutations.ts | 12 + .../react/src/lib/godaddy/checkout-queries.ts | 12 + 5 files changed, 317 insertions(+) diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 88778aaa..17134c62 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -22,6 +22,18 @@ export default async function Home() { enableNotesCollection: true, enablePromotionCodes: true, enableTips: true, + tips: { + default: { + percentages: [ 20, 40, 60 ] + }, + thresholds: [ + { + minSubtotal: 0, + maxSubtotal: 1000, + amounts: [ 300, 500, 700 ] + } + ] + }, shipping: { fulfillmentLocationId: 'default-location', originAddress: { diff --git a/packages/react/README.md b/packages/react/README.md index 25641b21..7e6b81c1 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -41,6 +41,7 @@ The first parameter accepts all checkout session configuration options from the - **`enableSurcharge`** (boolean): Enable surcharge fees - **`enableTaxCollection`** (boolean): Enable tax collection - **`enableTips`** (boolean): Enable tip/gratuity options +- **`tips`** (CheckoutSessionTipsInput): Tip option configuration (see [Tips](#tips)) - **`enabledLocales`** ([String!]): List of enabled locales - **`enabledPaymentProviders`** ([String!]): List of enabled payment providers - **`environment`** (enum): Environment - `ote`, `prod` @@ -135,6 +136,43 @@ operatingHours: { - **Timezone handling** — All date/time logic uses the store's `timeZone`, not the customer's browser timezone. A store in Phoenix shows Phoenix hours regardless of where the customer is browsing from. - **No available slots** — In `dateAndTime` mode, when leadTime exceeds the entire pickup window, no days are enabled, or no selectable slots exist, a "No available time slots" banner is shown. +### Tips + +The `tips` field configures preset tip options shown to the customer when `enableTips` is `true`. Tips supports a `default` preset and optional `thresholds` that activate based on the order subtotal. Only one of `amounts` or `percentages` should be provided — not both. + +```typescript +tips: { + default: { + percentages: [15, 18, 20], + }, + thresholds: [ + { + minSubtotal: 0, + maxSubtotal: 1000, + amounts: [100, 200, 500], + }, + ], +} +``` + +#### `tips.default` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `amounts` | number[] | No | Fixed tip amounts in the smallest currency unit (e.g. cents). | +| `percentages` | number[] | No | Tip percentage options (integers between 0 and 100). | + +#### `tips.thresholds` + +An array of threshold objects that override the default tips when the order subtotal falls within the specified range. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `minSubtotal` | number | No | Minimum order subtotal (inclusive) in the smallest currency unit for this threshold to apply. | +| `maxSubtotal` | number | No | Maximum order subtotal (exclusive) in the smallest currency unit for this threshold to apply. | +| `amounts` | number[] | No | Fixed tip amounts in the smallest currency unit (e.g. cents). | +| `percentages` | number[] | No | Tip percentage options (integers between 0 and 100). | + ### Appearance The `appearance` field customizes the checkout's look and feel. diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 3f6f8c2d..7dd9d28c 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -1870,6 +1870,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "tips", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionTips" + }, + "args": [], + "isDeprecated": false + }, { "name": "enabledLocales", "type": { @@ -3679,6 +3688,233 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput", + "inputFields": [ + { + "name": "default", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsDefaultInput" + } + }, + { + "name": "thresholds", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsThresholdInput" + } + } + } + } + ], + "isOneOf": false + }, + { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsDefaultInput", + "inputFields": [ + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + } + ], + "isOneOf": false + }, + { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsThresholdInput", + "inputFields": [ + { + "name": "minSubtotal", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "maxSubtotal", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + } + ], + "isOneOf": false + }, + { + "kind": "OBJECT", + "name": "CheckoutSessionTips", + "fields": [ + { + "name": "default", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionTipsDefault" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "thresholds", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "CheckoutSessionTipsThreshold" + } + } + }, + "args": [], + "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", + "name": "CheckoutSessionTipsDefault", + "fields": [ + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", + "name": "CheckoutSessionTipsThreshold", + "fields": [ + { + "name": "minSubtotal", + "type": { + "kind": "SCALAR", + "name": "Int" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "maxSubtotal", + "type": { + "kind": "SCALAR", + "name": "Int" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + } + ] + }, { "kind": "OBJECT", "name": "CheckoutSessionShippingOptions", @@ -7953,6 +8189,13 @@ const introspection = { "name": "Boolean" } }, + { + "name": "tips", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput" + } + }, { "name": "enabledLocales", "type": { diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index d9febd2f..89c49081 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -16,6 +16,18 @@ export const CreateCheckoutSessionMutation = graphql(` storeName environment enableTips + tips { + default { + amounts + percentages + } + thresholds { + minSubtotal + maxSubtotal + amounts + percentages + } + } enabledLocales enableSurcharge enableLocalPickup diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index 4e3e6185..5c3315b0 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -16,6 +16,18 @@ export const GetCheckoutSessionQuery = graphql(` storeName environment enableTips + tips { + default { + amounts + percentages + } + thresholds { + minSubtotal + maxSubtotal + amounts + percentages + } + } enabledLocales enableSurcharge enableLocalPickup From 8197e348288f2c0ba3151f07faccfb836c5ec32f Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Thu, 9 Jul 2026 15:08:19 -0700 Subject: [PATCH 12/54] handle tip amounts --- .../components/checkout/tips/tips-form.tsx | 88 +++++++++++++------ 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index d15dc5c1..bd16b8ce 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -29,6 +29,8 @@ interface TipsFormProps { currencyCode?: string; } +const DEFAULT_TIP_PERCENTAGES = [15, 18, 20]; + export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { const { t } = useGoDaddyContext(); const form = useFormContext(); @@ -92,8 +94,16 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { }); }; - const tipPercentages = options?.default?.percentages || [15, 18, 20]; const tipPercentage = form.watch('tipPercentage'); + const tipPercentages = options?.default?.percentages || DEFAULT_TIP_PERCENTAGES; + + const tipAmount = form.watch('tipAmount'); + let tipAmounts: number[] = []; + if (options?.thresholds?.[0]?.maxSubtotal && subtotal < Number(options?.thresholds?.[0]?.maxSubtotal)) { + tipAmounts = options?.thresholds?.[0]?.amounts || []; + } else if (options?.default?.amounts) { + tipAmounts = options?.default?.amounts; + } return (
@@ -102,31 +112,57 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { role='radiogroup' aria-label={t.tips?.title || 'Tip amount'} > - {tipPercentages.map(percentage => ( - + {tipAmounts?.length ? ( + tipAmounts.map((amount) => ( + + )) + ) : ( + tipPercentages.map(percentage => ( + + ) ))} From 2eb2f6488d2cecfb80be9e5dcde779fe3fcb19c0 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Thu, 9 Jul 2026 15:47:51 -0700 Subject: [PATCH 13/54] fix tip amount selection --- .../components/checkout/tips/tips-form.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index bd16b8ce..f711223b 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -42,6 +42,24 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { return Math.round((subtotal * percentage) / 100); }; + const handleAmountSelect = (amount: number) => { + form.setValue('tipAmount', amount); + form.setValue('tipPercentage', null); + setShowCustomTip(false); + + // Track tip amount selection + track({ + eventId: eventIds.selectTipAmount, + type: TrackingEventType.CLICK, + properties: { + tipPercentage: null, + tipAmount: amount, + totalBeforeTip: subtotal, + currencyCode, + }, + }); + }; + const handlePercentageSelect = (percentage: number) => { const tipAmount = calculateTipAmount(percentage); form.setValue('tipAmount', tipAmount); @@ -81,6 +99,7 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { const handleCustomTip = () => { setShowCustomTip(true); + form.setValue('tipAmount', 0); form.setValue('tipPercentage', null); // Track custom tip selection @@ -124,7 +143,7 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { ? 'border-muted-foreground' : 'bg-card active:ring' )} - onClick={() => form.setValue('tipAmount', amount)} + onClick={() => handleAmountSelect(amount)} aria-checked={tipAmount === amount ? 'true' : 'false'} > @@ -176,10 +195,10 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { variant='outline' className={cn( 'h-12 font-normal hover:bg-muted', - tipPercentage === 0 && 'border-muted-foreground' + !tipAmount && tipPercentage === 0 && 'border-muted-foreground' )} onClick={handleNoTip} - aria-checked={tipPercentage === 0 ? 'true' : 'false'} + aria-checked={!tipAmount && tipPercentage === 0 ? 'true' : 'false'} > {t.tips.noTip} From 0e5649f0ceae3ae1714970f465a8ea1ab64964d4 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Fri, 10 Jul 2026 09:04:37 -0700 Subject: [PATCH 14/54] lint --- .../payment/utils/use-confirm-checkout.ts | 8 +- .../components/checkout/tips/tips-form.tsx | 110 +++++++++--------- 2 files changed, 58 insertions(+), 60 deletions(-) diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index 32d7c1ce..cdcb190e 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -184,7 +184,7 @@ export function useConfirmCheckout() { ...confirmCheckoutInput, ...pickUpData, tipAmount, - } + }; // keep for debugging // console.log({ @@ -215,11 +215,7 @@ export function useConfirmCheckout() { { accessToken: jwt, sessionId: session?.id || '' }, apiHost ) - : await confirmCheckout( - payload, - session, - apiHost - ); + : await confirmCheckout(payload, session, apiHost); if (!data) { throw new Error('Checkout confirmation failed'); diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index f711223b..4fd45811 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -114,11 +114,15 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { }; const tipPercentage = form.watch('tipPercentage'); - const tipPercentages = options?.default?.percentages || DEFAULT_TIP_PERCENTAGES; + const tipPercentages = + options?.default?.percentages || DEFAULT_TIP_PERCENTAGES; const tipAmount = form.watch('tipAmount'); let tipAmounts: number[] = []; - if (options?.thresholds?.[0]?.maxSubtotal && subtotal < Number(options?.thresholds?.[0]?.maxSubtotal)) { + if ( + options?.thresholds?.[0]?.maxSubtotal && + subtotal < Number(options?.thresholds?.[0]?.maxSubtotal) + ) { tipAmounts = options?.thresholds?.[0]?.amounts || []; } else if (options?.default?.amounts) { tipAmounts = options?.default?.amounts; @@ -131,58 +135,56 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { role='radiogroup' aria-label={t.tips?.title || 'Tip amount'} > - {tipAmounts?.length ? ( - tipAmounts.map((amount) => ( - - )) - ) : ( - tipPercentages.map(percentage => ( - - ) - ))} + {tipAmounts?.length + ? tipAmounts.map(amount => ( + + )) + : tipPercentages.map(percentage => ( + + ))}
Date: Fri, 10 Jul 2026 10:11:27 -0700 Subject: [PATCH 15/54] handle tip thresholds --- .../checkout/__tests__/checkout-tips.test.tsx | 422 ++++++++++++++++++ .../components/checkout/tips/tips-form.tsx | 28 +- 2 files changed, 439 insertions(+), 11 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx index 3af41864..c757ed08 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx @@ -462,4 +462,426 @@ describe('Checkout tips', () => { tipAmount: 0, }); }); + + describe('options.thresholds', () => { + it('uses default percentages when no thresholds match the subtotal', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [10, 15, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 100000, + maxSubtotal: 200000, + percentages: [5, 8, 12], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /10%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /15%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /20%/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /\b5%/ }) + ).not.toBeInTheDocument(); + }); + + it('uses threshold percentages when subtotal falls within a threshold range', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [10, 15, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + percentages: [5, 8, 12], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /5%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /8%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /12%/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /10%/ }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /15%/ }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /20%/ }) + ).not.toBeInTheDocument(); + }); + + it('uses threshold amounts (flat values) when a matching threshold specifies amounts', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [10, 15, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + amounts: [100, 200, 500], + percentages: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /\$1\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$2\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$5\.00/ }) + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: /10%/ }) + ).not.toBeInTheDocument(); + }); + + it('threshold amounts take priority over threshold percentages when both are provided', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [10, 15, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + amounts: [100, 200, 500], + percentages: [5, 8, 12], + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /\$1\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$2\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$5\.00/ }) + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: /5%/ }) + ).not.toBeInTheDocument(); + }); + + it('matches the correct threshold when multiple thresholds are defined', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [15, 18, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 1000, + maxSubtotal: 3000, + percentages: [5, 8, 10], + amounts: null, + }, + { + minSubtotal: 3001, + maxSubtotal: 10000, + percentages: [3, 5, 7], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 5000, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 5000, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /3%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /5%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /7%/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /15%/ }) + ).not.toBeInTheDocument(); + }); + + it('applies threshold at boundary: subtotal equals minSubtotal', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [15, 18, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2500, + maxSubtotal: 5000, + percentages: [5, 8, 12], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /5%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /8%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /12%/ })).toBeVisible(); + }); + + it('applies threshold at boundary: subtotal equals maxSubtotal', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [15, 18, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 2500, + percentages: [5, 8, 12], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /5%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /8%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /12%/ })).toBeVisible(); + }); + + it('clicking a threshold amount button selects it and updates the total', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: null, amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + amounts: [200, 500, 1000], + percentages: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + const fiveDollarBtn = await screen.findByRole('button', { + name: /\$5\.00/, + }); + await user.click(fiveDollarBtn); + + await waitFor(() => { + expect(fiveDollarBtn).toHaveAttribute('aria-checked', 'true'); + expect(screen.getAllByText('$30.00').length).toBeGreaterThan(0); + }); + }); + + it('uses default amounts when options.default.amounts is provided and no threshold matches', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: null, amounts: [100, 300, 500] }, + thresholds: [ + { + minSubtotal: 100000, + maxSubtotal: 200000, + percentages: [1, 2, 3], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /\$1\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$3\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$5\.00/ }) + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: /15%/ }) + ).not.toBeInTheDocument(); + }); + + it('falls back to DEFAULT_TIP_PERCENTAGES when no options are provided', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: null, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /15%/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /18%/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /20%/ }) + ).toBeVisible(); + }); + }); }); diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index 4fd45811..59cd405b 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -114,18 +114,24 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { }; const tipPercentage = form.watch('tipPercentage'); - const tipPercentages = - options?.default?.percentages || DEFAULT_TIP_PERCENTAGES; + let tipPercentages = options?.default?.percentages; const tipAmount = form.watch('tipAmount'); - let tipAmounts: number[] = []; - if ( - options?.thresholds?.[0]?.maxSubtotal && - subtotal < Number(options?.thresholds?.[0]?.maxSubtotal) - ) { - tipAmounts = options?.thresholds?.[0]?.amounts || []; - } else if (options?.default?.amounts) { - tipAmounts = options?.default?.amounts; + let tipAmounts = options?.default?.amounts; + + const threshold = options?.thresholds?.find( + thres => + thres?.minSubtotal && + thres?.maxSubtotal && + subtotal >= thres.minSubtotal && + subtotal <= thres.maxSubtotal + ); + if (threshold) { + if (threshold.amounts) { + tipAmounts = threshold.amounts; + } else if (threshold.percentages) { + tipPercentages = threshold.percentages; + } } return ( @@ -159,7 +165,7 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { )) - : tipPercentages.map(percentage => ( + : (tipPercentages || DEFAULT_TIP_PERCENTAGES).map(percentage => ( + + + ); +} + +function Host({ + hostIntent = false, + enableClientSecret = true, + updateIntent = true, +}: { + hostIntent?: boolean; + enableClientSecret?: boolean; + updateIntent?: boolean; +}) { + const methods = useForm({ + defaultValues: { + tipAmount: 0, + ...(hostIntent + ? { + stripePaymentIntent: 'pi_host_secret', + stripePaymentIntentId: 'pi_host', + } + : {}), + } as Partial, + }); + + return ( + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + }} + > + + + + + ); +} + +function renderProbe(props: Parameters[0] = {}) { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + + + ); + return { user }; +} + +async function waitForClientSecret(value: string) { + await waitFor(() => { + expect(screen.getByTestId('client-secret')).toHaveTextContent(value); + }); +} + +describe('useStripePaymentIntent', () => { + beforeEach(() => { + requests = []; + totalValue = 2500; + stubIntentApi(); + }); + + it('creates the intent for the tip-inclusive amount', async () => { + renderProbe(); + + await waitForClientSecret('pi_1_secret'); + expect(requests).toEqual([ + { url: '/api/create-payment-intent', amount: 2500, id: undefined }, + ]); + }); + + it('updates the intent when a tip is added after it was created', async () => { + const { user } = renderProbe(); + await waitForClientSecret('pi_1_secret'); + + await user.click(screen.getByTestId('add-tip')); + + await waitFor(() => { + expect(requests).toHaveLength(2); + }); + expect(requests[1]).toEqual({ + url: '/api/update-payment-intent', + amount: 3000, + id: 'pi_1', + }); + expect(screen.getByTestId('amount')).toHaveTextContent('3000'); + }); + + it('recreates the intent for the new amount when updates are disabled', async () => { + const { user } = renderProbe({ updateIntent: false }); + await waitForClientSecret('pi_1_secret'); + + await user.click(screen.getByTestId('add-tip')); + + await waitFor(() => { + expect(requests).toHaveLength(2); + }); + expect(requests[1]).toMatchObject({ + url: '/api/create-payment-intent', + amount: 3000, + }); + await waitForClientSecret('pi_2_secret'); + }); + + it('updates a host-supplied intent when a tip is added', async () => { + const { user } = renderProbe({ hostIntent: true }); + await waitForClientSecret('pi_host_secret'); + expect(requests).toHaveLength(0); + + await user.click(screen.getByTestId('add-tip')); + + await waitFor(() => { + expect(requests).toHaveLength(1); + }); + expect(requests[0]).toEqual({ + url: '/api/update-payment-intent', + amount: 3000, + id: 'pi_host', + }); + }); + + it('adopts a replacement intent supplied by the host', async () => { + const { user } = renderProbe({ hostIntent: true }); + await waitForClientSecret('pi_host_secret'); + + await user.click(screen.getByTestId('replace-host-intent')); + + await waitForClientSecret('pi_host_2_secret'); + expect(requests).toHaveLength(0); + }); + + it('does not touch the intent while the amount is unchanged', async () => { + const { user } = renderProbe(); + await waitForClientSecret('pi_1_secret'); + + await user.click(screen.getByTestId('add-tip')); + await waitFor(() => { + expect(requests).toHaveLength(2); + }); + + await user.click(screen.getByTestId('add-tip')); + await waitForClientSecret('pi_1_secret'); + + expect(requests).toHaveLength(2); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.ts index 3d1a0085..5bee8506 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.ts +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.ts @@ -38,12 +38,20 @@ export function useStripePaymentIntent({ const amount = session?.enableTips ? total + tipAmount : total; const currency = totals?.total?.currencyCode?.toLowerCase() || 'usd'; + const existingClientSecret = form?.watch('stripePaymentIntent'); + const existingIntentId = form?.watch('stripePaymentIntentId'); + const [stripePromise, setStripePromise] = useState | null>(null); const [clientSecret, setClientSecret] = useState(null); const [intentId, setIntentId] = useState(null); const [error, setError] = useState(null); + const syncedIntentRef = useRef<{ + clientSecret: string; + amount: number; + } | null>(null); + useEffect(() => { if (stripeConfig?.publishableKey?.trim()) { setStripePromise(getStripe(stripeConfig.publishableKey)); @@ -85,13 +93,21 @@ export function useStripePaymentIntent({ return res.json(); }, onMutate: () => { + syncedIntentRef.current = null; setClientSecret(null); setIntentId(null); form?.setValue('stripePaymentIntent', undefined); form?.setValue('stripePaymentIntentId', undefined); setError(null); }, - onSuccess: ({ clientSecret: responseClientSecret, id: responseId }) => { + onSuccess: ( + { clientSecret: responseClientSecret, id: responseId }, + variables + ) => { + syncedIntentRef.current = { + clientSecret: responseClientSecret, + amount: variables.amount, + }; setClientSecret(responseClientSecret); setIntentId(responseId); form?.setValue('stripePaymentIntent', responseClientSecret); @@ -110,14 +126,23 @@ export function useStripePaymentIntent({ isCreatingPaymentIntent; const initializePaymentIntent = useCallback(() => { - const existingClientSecret = form?.getValues('stripePaymentIntent'); - const existingIntentId = form?.getValues('stripePaymentIntentId'); - if (existingClientSecret && existingIntentId) { - setClientSecret(existingClientSecret); - setIntentId(existingIntentId); - setError(null); - return; + // An intent we haven't seen yet: adopt it for the current amount. + if (syncedIntentRef.current?.clientSecret !== existingClientSecret) { + syncedIntentRef.current = { + clientSecret: existingClientSecret, + amount, + }; + setClientSecret(existingClientSecret); + setIntentId(existingIntentId); + setError(null); + return; + } + + // The intent already covers this amount. + if (syncedIntentRef.current.amount === amount) { + return; + } } if (isLoading || !enableClientSecret) { @@ -128,7 +153,7 @@ export function useStripePaymentIntent({ amount, currency, updateIntent, - intentId, + intentId: existingIntentId ?? intentId, }); }, [ amount, @@ -136,7 +161,8 @@ export function useStripePaymentIntent({ updateIntent, intentId, isLoading, - form, + existingClientSecret, + existingIntentId, paymentIntentMutation.mutate, enableClientSecret, ]); @@ -144,11 +170,18 @@ export function useStripePaymentIntent({ const amountRef = useRef(null); useEffect(() => { - if (amountRef.current !== amount && !isLoading) { + if (isLoading) { + return; + } + + const isIntentStale = + syncedIntentRef.current?.clientSecret !== existingClientSecret; + + if (amountRef.current !== amount || isIntentStale) { initializePaymentIntent(); amountRef.current = amount; } - }, [initializePaymentIntent, amount, isLoading]); + }, [initializePaymentIntent, amount, isLoading, existingClientSecret]); return { stripePromise, From 5485aa0d79044bbc9c8d5b88c6488479ae284a4e Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Fri, 7 Aug 2026 11:45:57 -0700 Subject: [PATCH 39/54] claude feedback --- .../checkout/__tests__/checkout-tips.test.tsx | 67 +++++++++++++++++++ .../mercadopago/mercadopago.tsx | 1 + .../utils/use-build-payment-request.test.tsx | 63 +++++++++++++++++ .../utils/use-build-payment-request.ts | 2 +- .../components/checkout/tips/tips-form.tsx | 6 +- 5 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx index 16e8f5d4..54ad0601 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx @@ -805,6 +805,73 @@ describe('Checkout tips', () => { }); }); + it('deselects a threshold amount button when switching to "Custom amount"', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: null, amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + amounts: [200, 500, 1000], + percentages: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + const fiveDollarBtn = await screen.findByRole('button', { + name: /\$5\.00/, + }); + await user.click(fiveDollarBtn); + await waitFor(() => { + expect(fiveDollarBtn).toHaveAttribute('aria-checked', 'true'); + }); + + const customBtn = await screen.findByRole('button', { + name: /custom amount/i, + }); + await user.click(customBtn); + + // The custom input carries the $5.00 over, but the preset must not stay + // checked — a radiogroup can only have one checked option. + await waitFor(() => { + expect(customBtn).toHaveAttribute('aria-checked', 'true'); + expect(fiveDollarBtn).toHaveAttribute('aria-checked', 'false'); + }); + + const checked = screen + .getAllByRole('radiogroup') + .flatMap(group => + Array.from(group.querySelectorAll('[aria-checked="true"]')) + ); + expect(checked).toEqual([customBtn]); + + // Selecting the preset again re-checks it and clears the custom input. + await user.click(fiveDollarBtn); + await waitFor(() => { + expect(fiveDollarBtn).toHaveAttribute('aria-checked', 'true'); + expect(customBtn).toHaveAttribute('aria-checked', 'false'); + }); + }); + it('uses default amounts when options.default.amounts is provided and no threshold matches', async () => { renderCheckout({ sessionOverrides: { diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx index fc0930ee..142a14d2 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx @@ -265,6 +265,7 @@ export function MercadoPagoCheckoutButton() { await handleSubmit({ formData }); } else { setIsBrickReady(false); + setBrickRevision(revision => revision + 1); } }; diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx index ee26d99a..352a7ced 100644 --- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx @@ -590,4 +590,67 @@ describe('useBuildPaymentRequest', () => { expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) ); }); + + it.each([ + { scenario: 'the tip is explicitly zero', tipAmount: 0 }, + { scenario: 'no tip has been selected yet', tipAmount: undefined }, + ])( + 'omits the Tip line item when enableTips is true and $scenario', + async ({ tipAmount }) => { + const { requests } = await renderUseBuildPaymentRequest({ + sessionOverrides: { + enableTips: true, + }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(0), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [], + totals: { + subTotal: money(2000), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(0), + feeTotal: money(0), + total: money(2000), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + formDefaultValues: { tipAmount }, + }); + + // A zero tip must not reach the wallet sheets as a "$0.00 Tip" row. + expect(requests.applePayRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect( + requests.googlePayRequest.transactionInfo.displayItems + ).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.payPalRequest.purchase_units[0].items).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'Tip' })]) + ); + expect(requests.poyntStandardRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.poyntExpressRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + + expect(requests.applePayRequest.total.amount).toBe('$20.00'); + expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); + } + ); }); 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 e1df4611..d1251b5c 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 @@ -320,7 +320,7 @@ export function useBuildPaymentRequest(): { }), type: 'final', }, - ...(session?.enableTips + ...(session?.enableTips && tipAmount ? [ { label: 'Tip', diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index 12150376..1b1e807a 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -151,12 +151,14 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { variant='outline' className={cn( 'h-16 flex flex-col items-center justify-center gap-y-0.5 hover:bg-muted', - tipAmount === amount + !showCustomTip && tipAmount === amount ? 'border-muted-foreground' : 'bg-card active:ring' )} onClick={() => handleAmountSelect(amount)} - aria-checked={tipAmount === amount ? 'true' : 'false'} + aria-checked={ + !showCustomTip && tipAmount === amount ? 'true' : 'false' + } > {formatCurrency({ From e700f6089b67d8ce67f6000e46a22fb27acc5109 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Fri, 7 Aug 2026 15:31:47 -0700 Subject: [PATCH 40/54] claude feedback --- .../checkout-mercadopago-tips.test.tsx | 22 ++++++++ .../mercadopago/mercadopago.tsx | 56 +++++++++++++++---- .../utils/use-authorize-checkout.test.tsx | 30 +++++++++- .../payment/utils/use-authorize-checkout.ts | 9 ++- .../utils/use-build-payment-request.test.tsx | 53 ++++++++++++++++++ 5 files changed, 152 insertions(+), 18 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx index e8e4f12b..8f7dfca1 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx @@ -138,6 +138,28 @@ describe('Checkout MercadoPago tips', () => { ]); }); + it('coalesces a burst of tip changes into a single rebuild and authorization', async () => { + const { user } = renderMercadoPagoCheckout(); + await waitForBrickCalls(1); + clearOperations(); + + // Back-to-back taps inside the debounce window: only the last one should + // reach the provider, since every rebuild authorizes the session again. + await user.click(await screen.findByRole('button', { name: /20%/ })); + await user.click(await screen.findByRole('button', { name: /15%/ })); + + await waitForBrickCalls(2); + await waitFor(() => { + expect(getOperations('AuthorizeCheckoutSession')).toHaveLength(1); + }); + + expect(brickCalls).toHaveLength(2); + expect(brickCalls.at(-1)).toMatchObject({ amount: 28.75 }); + expect(getAuthorizeInputs()).toEqual([ + expect.objectContaining({ tipAmount: 375 }), + ]); + }); + it('does not rebuild the brick when the tip-inclusive total is unchanged', async () => { const { user } = renderMercadoPagoCheckout(); await waitForBrickCalls(1); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx index 142a14d2..74711d8c 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx @@ -25,6 +25,9 @@ let brickCreationPromise: Promise | null = null; let brickAmount: number | null = null; let isSubmitting = false; +// Rebuilds re-authorize the session, so bursts of tip changes are coalesced. +const BRICK_REBUILD_DEBOUNCE_MS = 400; + function getMercadoPagoInstance(publicKey: string) { if (!mpInstance) { mpInstance = new (window as any).MercadoPago(publicKey); @@ -67,11 +70,12 @@ export function MercadoPagoCheckoutButton() { const elementId = 'mercadopago-brick-container'; const tipAmount = form.watch('tipAmount'); + // The tip the brick amount below is derived from. Passed to the authorization + // so the preference, the brick and the authorization all describe one amount. + const brickTipAmount = session?.enableTips ? tipAmount || 0 : 0; const rawAmount = parseFloat( formatCurrency({ - amount: - (totals?.total?.value || 0) + - (session?.enableTips ? tipAmount || 0 : 0), + amount: (totals?.total?.value || 0) + brickTipAmount, currencyCode: totals?.total?.currencyCode || 'USD', inputInMinorUnits: true, returnRaw: true, @@ -82,11 +86,16 @@ export function MercadoPagoCheckoutButton() { const amountRef = useRef(amount); amountRef.current = amount; - const getPreferenceId = async () => { + // Whether this checkout has built a brick before. Tracked per instance rather + // than read off `brickController`, which an earlier rebuild may have cleared. + const hasBuiltBrickRef = useRef(false); + + const getPreferenceId = async (tipForBrick: number) => { const response = await authorizeCheckout.mutateAsync({ paymentToken: '', paymentType: PaymentMethodType.MERCADOPAGO, paymentProvider: PaymentProvider.MERCADOPAGO, + tipAmount: tipForBrick, }); return response?.transactionRefNum; }; @@ -140,6 +149,7 @@ export function MercadoPagoCheckoutButton() { useLayoutEffect(() => { const canInitialize = isMercadoPagoLoaded && mercadoPagoConfig?.publicKey; + let rebuildTimer: ReturnType | undefined; if (canInitialize) { if (brickCreationPromise) { @@ -148,12 +158,15 @@ export function MercadoPagoCheckoutButton() { // Brick already exists for this amount, onReady callback will mark as ready setIsBrickReady(true); } else { + const isRebuild = hasBuiltBrickRef.current; + setIsBrickReady(false); unmountBrick(); // Create new brick const renderBrick = async () => { const total = amount; + const tip = brickTipAmount; try { const container = document.getElementById(elementId); @@ -164,7 +177,7 @@ export function MercadoPagoCheckoutButton() { const { bricksBuilderInstance: bricksBuilder } = getMercadoPagoInstance(mercadoPagoConfig.publicKey); - const mercadoPagoPreferenceId = await getPreferenceId(); + const mercadoPagoPreferenceId = await getPreferenceId(tip); const controller = await bricksBuilder.create( 'payment', @@ -222,17 +235,35 @@ export function MercadoPagoCheckoutButton() { } }; - brickCreationPromise = renderBrick(); - brickCreationPromise.finally(() => { - brickCreationPromise = null; - if (brickController && brickAmount !== amountRef.current) { - setBrickRevision(revision => revision + 1); - } - }); + const startBrickCreation = () => { + hasBuiltBrickRef.current = true; + brickCreationPromise = renderBrick(); + brickCreationPromise.finally(() => { + brickCreationPromise = null; + if (brickController && brickAmount !== amountRef.current) { + setBrickRevision(revision => revision + 1); + } + }); + }; + + if (isRebuild) { + // Every rebuild authorizes the session again to get a fresh + // preference, so coalesce bursts of tip changes into one rebuild + // instead of one per tap. The button is already disabled above. + rebuildTimer = setTimeout( + startBrickCreation, + BRICK_REBUILD_DEBOUNCE_MS + ); + } else { + startBrickCreation(); + } } } return () => { + if (rebuildTimer) { + clearTimeout(rebuildTimer); + } // Don't unmount if submitting (parent replaces component with loading button) // or if creation is in progress (React Strict Mode double-invocation) if (brickController && !brickCreationPromise && !isSubmitting) { @@ -244,6 +275,7 @@ export function MercadoPagoCheckoutButton() { mercadoPagoConfig?.publicKey, elementId, amount, + brickTipAmount, brickRevision, t.errors.failedToInitializePayment, ]); diff --git a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx index 2f05338b..78f3304b 100644 --- a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx @@ -109,14 +109,38 @@ describe('useAuthorizeCheckout', () => { expect((await authorizedInput())?.tipAmount).toBeUndefined(); }); - it('ignores a caller-supplied tip so the authorized amount cannot drift', async () => { + it('prefers a caller-supplied tip over the current form value', async () => { + // A provider that commits to an amount before authorizing (MercadoPago + // builds its brick up front) passes that tip explicitly so the brick, the + // preference and the authorization all describe the same amount, even if + // the customer has since changed the tip. const { result } = renderHook(() => useAuthorizeCheckout(), { wrapper: wrapper({ enableTips: true, tipAmount: 500 }), }); - await result.current.mutateAsync({ ...cardFieldsInput, tipAmount: 999 }); + await result.current.mutateAsync({ ...cardFieldsInput, tipAmount: 250 }); - expect((await authorizedInput())?.tipAmount).toBe(500); + expect((await authorizedInput())?.tipAmount).toBe(250); + }); + + it('honors a caller-supplied zero tip rather than falling back to the form', async () => { + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: true, tipAmount: 500 }), + }); + + await result.current.mutateAsync({ ...cardFieldsInput, tipAmount: 0 }); + + expect((await authorizedInput())?.tipAmount).toBe(0); + }); + + it('sends no tip when tips are disabled even if the caller supplies one', async () => { + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: false, tipAmount: 500 }), + }); + + await result.current.mutateAsync({ ...cardFieldsInput, tipAmount: 250 }); + + expect((await authorizedInput())?.tipAmount).toBeUndefined(); }); it('returns the transaction used as the provider order reference', async () => { 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 index bedd4fbe..3e14f4fe 100644 --- a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts @@ -16,12 +16,15 @@ export function useAuthorizeCheckout() { mutationFn: async (input: AuthorizeCheckoutSessionInput['input']) => { await flushCheckoutSync(); - // Authorize for the same amount confirmCheckout later captures. Read the - // tip after the sync flush so pending form state is settled. + // Authorize for the same amount confirmCheckout later captures. Prefer an + // explicit tip from the caller so a provider that has already committed to + // an amount (MercadoPago builds its brick up front) authorizes that exact + // amount; otherwise read the tip after the sync flush, once pending form + // state has settled. const payload = { ...input, tipAmount: session?.enableTips - ? (form?.getValues('tipAmount') ?? 0) + ? (input.tipAmount ?? form?.getValues('tipAmount') ?? 0) : undefined, }; diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx index 352a7ced..f2597da7 100644 --- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx @@ -532,6 +532,59 @@ describe('useBuildPaymentRequest', () => { expect(requests.poyntExpressRequest.total.amount).toBe('25.00'); }); + it('charges the full order total, not the subtotal, when tips are disabled', async () => { + // poyntExpressRequest.total used to be the bare subtotal, which under-charged + // any order carrying tax, shipping or a discount. Keep subtotal and total + // distinct here so a regression cannot hide behind equal fixtures. + const { requests } = await renderUseBuildPaymentRequest({ + sessionOverrides: { + enableTips: false, + }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(500), + feeTotal: money(0), + taxTotal: money(200), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [ + { + id: 'shipping-line-1', + requestedService: 'ground', + requestedProvider: 'shippo', + name: 'Ground', + amount: money(1000), + discounts: [], + }, + ], + totals: { + subTotal: money(2000), + discountTotal: money(500), + shippingTotal: money(1000), + taxTotal: money(200), + feeTotal: money(0), + total: money(2700), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + formDefaultValues: { tipAmount: 500 }, + }); + + // subtotal $20.00 - discount $5.00 + shipping $10.00 + tax $2.00 = $27.00 + expect(requests.poyntExpressRequest.total.amount).toBe('27.00'); + expect(requests.poyntStandardRequest.total.amount).toBe('27.00'); + expect(requests.applePayRequest.total.amount).toBe('$27.00'); + expect(requests.squarePaymentRequest.amount).toBe('27.00'); + }); + it('excludes tipAmount from payment requests when enableTips is false', async () => { const { requests } = await renderUseBuildPaymentRequest({ sessionOverrides: { From 60214ae75f2e1029fc27abb05cc27b8e464d6249 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Fri, 7 Aug 2026 16:36:08 -0700 Subject: [PATCH 41/54] claude feedback --- .changeset/fruity-dots-jog.md | 4 +- packages/react/README.md | 15 ++-- .../checkout/__tests__/checkout-tips.test.tsx | 68 +++++++++++++++++++ .../utils/use-build-payment-request.test.tsx | 53 ++++++++++++++- .../utils/use-build-payment-request.ts | 2 +- .../payment/utils/use-confirm-checkout.ts | 17 +++-- .../components/checkout/tips/tips-form.tsx | 15 ++-- 7 files changed, 154 insertions(+), 20 deletions(-) diff --git a/.changeset/fruity-dots-jog.md b/.changeset/fruity-dots-jog.md index e9a4fbd5..888c4575 100644 --- a/.changeset/fruity-dots-jog.md +++ b/.changeset/fruity-dots-jog.md @@ -1,5 +1,7 @@ --- -"@godaddy/react": patch +"@godaddy/react": minor --- Support tips in unified checkout + +Adds the `tips` session config surface (`default` and threshold-based `amounts`/`percentages` presets) alongside `enableTips`, and includes the selected tip in wallet sheet totals and the authorized/confirmed amount. diff --git a/packages/react/README.md b/packages/react/README.md index e297559d..c3d7d516 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -140,6 +140,8 @@ operatingHours: { The `tips` field configures preset tip options shown to the customer when `enableTips` is `true`. Tips supports a `default` preset and optional `thresholds` that activate based on the order subtotal. +Throughout this section, "subtotal" means the order's **item subtotal** (`totals.subTotal`) — the sum of item prices **before** discounts, shipping, fees and tax. It is not the order total the customer pays. See [Subtotal basis](#behavior-notes) below. + Every option list — `default` and each threshold — must supply **exactly one** of `amounts` or `percentages`, with **exactly three** values. The API rejects sessions that provide both, neither, or a different number of values. Three values is also what the tip selector is laid out for. ```typescript @@ -171,23 +173,26 @@ tips: { #### `tips.thresholds` -An array of threshold objects that override the default tips when the order subtotal falls within the specified range. +An array of threshold objects that override the default tips when the order's item subtotal falls within the specified range. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `minSubtotal` | number | Yes | Minimum order subtotal (inclusive) in the smallest currency unit for this threshold to apply. Required by the API — omitting it fails with `INVALID_TIP_THRESHOLD`. | -| `maxSubtotal` | number | Yes | Maximum order subtotal (inclusive) in the smallest currency unit for this threshold to apply. Required by the API — omitting it fails with `INVALID_TIP_THRESHOLD`. | +| `minSubtotal` | number | Yes | Minimum item subtotal (inclusive) in the smallest currency unit for this threshold to apply. Required by the API — omitting it fails with `INVALID_TIP_THRESHOLD`. | +| `maxSubtotal` | number | Yes | Maximum item subtotal (inclusive) in the smallest currency unit for this threshold to apply. Required by the API — omitting it fails with `INVALID_TIP_THRESHOLD`. Must be greater than `minSubtotal`. | | `amounts` | number[] | Conditional | Fixed tip amounts in the smallest currency unit (e.g. cents). Exactly three values. Mutually exclusive with `percentages`. | | `percentages` | number[] | Conditional | Tip percentage options (integers between 0 and 100). Exactly three values. Mutually exclusive with `amounts`. | #### Behavior Notes -- **Threshold matching** — Checkout uses the **first** threshold whose range contains the order subtotal. Both bounds are inclusive, so a subtotal equal to `minSubtotal` or `maxSubtotal` matches. +- **Subtotal basis** — `minSubtotal`, `maxSubtotal` and every `percentages` calculation use the order's item subtotal (`totals.subTotal`), which is the sum of item prices **before discounts, shipping, fees and tax**. A $50 cart with a $20 discount, $6 shipping and $2 tax has a subtotal of `5000`, not the `3800` the customer pays, so it matches a `0–5000` threshold and `20%` offers `1000`. Configure ranges against the pre-discount cart value, not the amount charged. +- **Threshold matching is client-side** — Checkout selects the preset list. The API stores `tips` and validates its shape, but never re-derives which threshold applied. +- **Tip ceiling is measured against the order total** — Independent of the presets, the API rejects a `tipAmount` above 100% of the **order total** (post-discount, tax and shipping included) or `2000` minor units, whichever is greater, with `TIP_EXCEEDS_LIMIT`. Note the asymmetry: thresholds bucket on the subtotal, this bound uses the total. Large fixed `amounts` can therefore be rejected on a heavily discounted order — e.g. `amounts: [2500, 5000, 10000]` on an order totalling `1000` allows at most `2000`. +- **Threshold matching** — Checkout uses the **first** threshold whose range contains the item subtotal. Both bounds are inclusive, so a subtotal equal to `minSubtotal` or `maxSubtotal` matches. - **Overlaps are not validated** — The API checks neither overlap nor full coverage of the subtotal range. Adjacent thresholds that share a boundary (e.g. `0–1000` and `1000–2000`) are accepted and resolve silently to whichever comes first in the array. Make ranges contiguous but non-overlapping (e.g. `0–999` then `1000–1999`) so the applied threshold is unambiguous. - **Gaps fall back to `default`** — A subtotal outside every threshold range uses `tips.default`. - **No `tips` configured** — When `enableTips` is `true` but `tips` is omitted, checkout shows `15%`, `18%`, and `20%`. - **Both lists on one threshold** — Should a threshold reach checkout with both `amounts` and `percentages` (the API rejects this), `amounts` wins and the percentages are ignored. -- **Customer overrides** — The presets are suggestions. The tip selector also offers "No tip" and "Custom amount", so the confirmed `tipAmount` need not match any preset. +- **Customer overrides** — The presets are suggestions. The tip selector also offers "No tip" and "Custom amount", and the API does not check `tipAmount` against the configured options, so the confirmed tip need not match any preset. ### Appearance diff --git a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx index 54ad0601..62c8a92f 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx @@ -463,6 +463,31 @@ describe('Checkout tips', () => { }); }); + it('omits tipAmount entirely from the confirm payload when tips are disabled', async () => { + // Not just `tipAmount: undefined` — the key should not be in the request. + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: false, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).not.toHaveProperty('tipAmount'); + }); + describe('options.thresholds', () => { it('uses default percentages when no thresholds match the subtotal', async () => { renderCheckout({ @@ -504,6 +529,49 @@ describe('Checkout tips', () => { ).not.toBeInTheDocument(); }); + it('keeps the default presets when a matching threshold configures an empty list', async () => { + // An empty array is not a configured option. Treating it as one used to + // clear the default without replacing it, falling through to the + // hardcoded 15/18/20 — so the percentages below deliberately avoid those. + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [7, 9, 11], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + percentages: null, + amounts: [], + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /7%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /9%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /11%/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /18%/ }) + ).not.toBeInTheDocument(); + }); + it('uses threshold percentages when subtotal falls within a threshold range', async () => { renderCheckout({ sessionOverrides: { diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx index f2597da7..f05e0434 100644 --- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx @@ -84,11 +84,14 @@ async function renderUseBuildPaymentRequest({ sessionOverrides, products = [productNode()], formDefaultValues, + withoutForm = false, }: { draftOrderOverrides?: DeepPartial; sessionOverrides?: DeepPartial; products?: SKUProduct[]; formDefaultValues?: Partial; + /** Render outside any FormProvider, so `useFormContext()` returns null. */ + withoutForm?: boolean; } = {}) { const queryClient = createTestQueryClient(); const draftOrder = buildDraftOrder(draftOrderOverrides); @@ -123,9 +126,13 @@ async function renderUseBuildPaymentRequest({ setCheckoutErrors: () => undefined, }} > - + {withoutForm ? ( - + ) : ( + + + + )} ); @@ -706,4 +713,46 @@ describe('useBuildPaymentRequest', () => { expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); } ); + + it('builds requests outside a form provider without a tip', async () => { + // Every shipping caller sits inside CustomFormProvider, so this guards the + // hook's own contract rather than a reachable path: reading the tip must not + // require a form context the way the rest of the hook does not. + const { requests } = await renderUseBuildPaymentRequest({ + withoutForm: true, + sessionOverrides: { enableTips: true }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(0), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [], + totals: { + subTotal: money(2000), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(0), + feeTotal: money(0), + total: money(2000), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + }); + + expect(requests.applePayRequest.total.amount).toBe('$20.00'); + expect(requests.applePayRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); + }); }); 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 d1251b5c..69e47d87 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 @@ -208,7 +208,7 @@ export function useBuildPaymentRequest(): { 0 ) || 0; const discountMinorUnits = totals?.discountTotal?.value || 0; - const tipAmount = form.watch('tipAmount') || 0; + const tipAmount = form?.watch('tipAmount') || 0; const totalMinorUnits = totals?.total?.value || 0; const totalWithTipMinorUnits = totalMinorUnits + tipAmount; diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index 916c1955..30fd726b 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -184,13 +184,20 @@ export function useConfirmCheckout() { : undefined, }) : {}; - const tipAmount = session.enableTips - ? (confirmCheckoutInput.tipAmount ?? form.getValues('tipAmount') ?? 0) - : undefined; + // Destructured out so the key is omitted entirely when tips are off, + // rather than sent as `tipAmount: undefined` — and so a caller-supplied + // tip cannot ride along on the spread past that gate. + const { tipAmount: suppliedTipAmount, ...inputWithoutTip } = + confirmCheckoutInput; const payload = { - ...confirmCheckoutInput, + ...inputWithoutTip, ...pickUpData, - tipAmount, + ...(session.enableTips + ? { + tipAmount: + suppliedTipAmount ?? form.getValues('tipAmount') ?? 0, + } + : {}), }; // keep for debugging diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index 1b1e807a..03a8895b 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -127,10 +127,10 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { (thres?.maxSubtotal == null || subtotal <= thres.maxSubtotal) ); if (threshold) { - if (threshold.amounts) { + if (threshold.amounts?.length) { tipAmounts = threshold.amounts; tipPercentages = undefined; - } else if (threshold.percentages) { + } else if (threshold.percentages?.length) { tipPercentages = threshold.percentages; tipAmounts = undefined; } @@ -144,9 +144,9 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { aria-label={t.tips?.title || 'Tip amount'} > {tipAmounts?.length - ? tipAmounts.map(amount => ( + ? tipAmounts.map((amount, index) => ( )) - : (tipPercentages || DEFAULT_TIP_PERCENTAGES).map(percentage => ( + : (tipPercentages?.length + ? tipPercentages + : DEFAULT_TIP_PERCENTAGES + ).map((percentage, index) => ( - )) - : (tipPercentages?.length - ? tipPercentages - : DEFAULT_TIP_PERCENTAGES - ).map((percentage, index) => ( - - ))} + ? tipAmounts.map((amount, index) => { + const isSelected = + !showCustomTip && + tipAmount === amount && + index === activeAmountIndex; + + return ( + + ); + }) + : percentagePresets.map((percentage, index) => { + const isSelected = + tipPercentage === percentage && index === activePercentageIndex; + + return ( + + ); + })}
+ + + + ); +} + +/** Exposes the values that actually get charged. */ +function TipState() { + const form = useFormContext(); + return ( + <> +
{String(form.watch('tipAmount'))}
+
+ {String(form.watch('tipPercentage'))} +
+ + ); +} + +function renderTipsForm(props: ComponentProps) { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + + + ); + return { user }; +} + +describe('TipsForm when the subtotal moves under a selection', () => { + it('re-derives what a percentage preset is worth', async () => { + // The totals had not arrived when the customer picked a tip, so 20% of the + // subtotal was 20% of nothing. + const { user } = renderTipsForm({ initialSubtotal: 0, nextSubtotal: 2500 }); + + await user.click(screen.getByRole('radio', { name: /20%/ })); + expect(screen.getByTestId('tip-amount')).toHaveTextContent('0'); + + await user.click(screen.getByTestId('move-subtotal')); + + // What the button reads is what gets charged. + const preset = screen.getByRole('radio', { name: /20%/ }); + expect(preset).toHaveTextContent('$5.00'); + expect(preset).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('tip-amount')).toHaveTextContent('500'); + }); + + it('leaves a fixed-amount preset alone', async () => { + const { user } = renderTipsForm({ + initialSubtotal: 2500, + nextSubtotal: 5000, + options: { + default: { amounts: [300, 500, 700], percentages: null }, + thresholds: null, + }, + }); + + await user.click(screen.getByRole('radio', { name: /\$5\.00/ })); + expect(screen.getByTestId('tip-amount')).toHaveTextContent('500'); + + await user.click(screen.getByTestId('move-subtotal')); + + // A fixed amount is not a proportion of anything, so it does not move. + expect(screen.getByTestId('tip-amount')).toHaveTextContent('500'); + expect(screen.getByRole('radio', { name: /\$5\.00/ })).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + + it('keeps the selected percentage checked when a threshold swaps the presets', async () => { + const { user } = renderTipsForm({ + initialSubtotal: 2500, + nextSubtotal: 5000, + options: { + default: { percentages: [15, 18, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 5000, + maxSubtotal: null, + percentages: [20, 25, 30], + amounts: null, + }, + ], + }, + }); + + // 20% is the last preset before the threshold and the first one after it, so + // the index the customer clicked no longer points at their choice. + await user.click(screen.getByRole('radio', { name: /20%/ })); + + await user.click(screen.getByTestId('move-subtotal')); + + expect(screen.getByTestId('tip-percentage')).toHaveTextContent('20'); + expect(screen.getByRole('radio', { name: /20%/ })).toHaveAttribute( + 'aria-checked', + 'true' + ); + expect(screen.getByRole('radio', { name: /25%/ })).toHaveAttribute( + 'aria-checked', + 'false' + ); + expect(screen.getByTestId('tip-amount')).toHaveTextContent('1000'); + }); +}); diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index edd31d5d..3144bdbf 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -32,6 +32,32 @@ interface TipsFormProps { const DEFAULT_TIP_PERCENTAGES = [15, 18, 20]; +/** `subtotal` is in minor units, so the tip is too. */ +function percentageToAmount(subtotal: number, percentage: number): number { + return Math.round((subtotal * percentage) / 100); +} + +/** + * Which preset index counts as selected. + * + * The clicked index wins, since that is what tells two presets of the same value + * apart — but only while it still holds the selected value. It stops doing so + * when the subtotal crosses a threshold and swaps the list out from under it, + * and it was never set at all for a tip the host app preselected. Both fall back + * to matching by value. + */ +function resolveActiveIndex( + clickedIndex: number | null, + presets: readonly (number | null | undefined)[] | null | undefined, + value: unknown +): number { + if (!presets) return -1; + if (clickedIndex != null && presets[clickedIndex] === value) { + return clickedIndex; + } + return presets.indexOf(value as number); +} + // A library cannot assume `process` exists, and bundlers replace this expression // at build time, so the warning below is compiled out of production apps. const IS_DEV = @@ -47,10 +73,8 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { // both buttons; the form value stays authoritative. const [selectedIndex, setSelectedIndex] = useState(null); - const calculateTipAmount = (percentage: number): number => { - // total is in minor units, so calculate percentage and return in minor units - return Math.round((subtotal * percentage) / 100); - }; + const calculateTipAmount = (percentage: number): number => + percentageToAmount(subtotal, percentage); const handleAmountSelect = (amount: number, index: number) => { form.setValue('tipAmount', amount); @@ -171,13 +195,16 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { ? tipPercentages : DEFAULT_TIP_PERCENTAGES; - // Derived by value when the tip did not come from a click here — a preset - // preselected by the host app still shows as selected — and by the clicked - // index otherwise, which is what separates duplicate presets. - const activeAmountIndex = - selectedIndex ?? tipAmounts?.indexOf(tipAmount) ?? -1; - const activePercentageIndex = - selectedIndex ?? percentagePresets.indexOf(tipPercentage); + const activeAmountIndex = resolveActiveIndex( + selectedIndex, + tipAmounts, + tipAmount + ); + const activePercentageIndex = resolveActiveIndex( + selectedIndex, + percentagePresets, + tipPercentage + ); // A rejection the API attributed to `tipAmount` (TIP_EXCEEDS_LIMIT and // friends) is shown here rather than only in the checkout-wide error list, so @@ -188,6 +215,21 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { const formRef = useRef(form); formRef.current = form; + // A percentage preset is worth whatever it is worth now. The amount shown under + // the button is recomputed from the current subtotal on every render, so form + // state has to follow it — otherwise a preset picked before the draft-order + // totals arrived stays worth a percentage of nothing while displaying, and + // reporting as selected, the amount it would be worth today. + useEffect(() => { + const percentage = formRef.current.getValues('tipPercentage'); + if (!percentage) return; + + const nextTipAmount = percentageToAmount(subtotal, percentage); + if (formRef.current.getValues('tipAmount') !== nextTipAmount) { + formRef.current.setValue('tipAmount', nextTipAmount); + } + }, [subtotal]); + // That rejection goes stale as soon as the customer picks a different amount, // and react-hook-form leaves manually-set errors in place on its own. useEffect(() => { From 5babfb3179705c813c68792f26c0a77a65767f23 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 18 Aug 2026 11:04:57 -0700 Subject: [PATCH 47/54] fix test --- .../checkout/__tests__/checkout-mercadopago-tips.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx index 2e179150..a342bc13 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlushDraftOrderSyncResult } from '@/components/checkout/order/draft-order-sync-provider'; import { CheckoutType, PaymentMethodType, PaymentProvider } from '@/types'; import { clearOperations, @@ -25,6 +26,11 @@ vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({ flushGate = null; await gate; } + // The stub still has to answer with the real hook's result shape: + // `useConfirmCheckout` destructures `latestOrder` off it and would throw on + // `undefined`. No patch is sent here, and `latestOrder` left absent makes + // the caller fall back to the draft order already in the query cache. + return { patchSent: false } satisfies FlushDraftOrderSyncResult; }, })); From 698564238aba4e6a51714741d0b9e28fa9315c85 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 18 Aug 2026 12:13:49 -0700 Subject: [PATCH 48/54] regenerate checkout-env --- .../react/src/lib/godaddy/checkout-env.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index f9b1b1a2..65038eb1 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2979,6 +2979,80 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "OBJECT", + "name": "CheckoutSessionFee", + "fields": [ + { + "name": "amount", + "type": { + "kind": "SCALAR", + "name": "Int" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "feeProgramId", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "feeProgramType", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "feeType", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "signature", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "CheckoutSessionFeesResult", + "fields": [ + { + "name": "fees", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "CheckoutSessionFee" + } + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "CheckoutSessionFreeShippingRule", @@ -6693,6 +6767,24 @@ const introspection = { } ] }, + { + "kind": "ENUM", + "name": "FundingSourceType", + "enumValues": [ + { + "name": "CREDIT", + "isDeprecated": false + }, + { + "name": "DEBIT", + "isDeprecated": false + }, + { + "name": "PREPAID", + "isDeprecated": false + } + ] + }, { "kind": "OBJECT", "name": "GeoCoordinates", @@ -7682,6 +7774,26 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "calculateCheckoutSessionFees", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionFeesResult" + }, + "args": [ + { + "name": "fundingSourceType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "FundingSourceType" + } + } + } + ], + "isDeprecated": false + }, { "name": "calculateCheckoutSessionTaxes", "type": { From 08dcac035cb51bbbecd4af9cf16b0c2c3b4d3df7 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 18 Aug 2026 15:21:05 -0700 Subject: [PATCH 49/54] fix PayPal tip drift and CCAvenue confirmation when jwt arrives late --- .../checkout-buttons/paypal/paypal.test.tsx | 230 ++++++++++++++++++ .../checkout-buttons/paypal/paypal.tsx | 12 +- .../utils/ccavenue-return-provider.test.tsx | 136 +++++++++++ .../utils/ccavenue-return-provider.tsx | 15 +- 4 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.test.tsx create mode 100644 packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.test.tsx diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.test.tsx new file mode 100644 index 00000000..1c4a8bf0 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.test.tsx @@ -0,0 +1,230 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { FormProvider, type UseFormReturn, useForm } from 'react-hook-form'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The real SDK renders its buttons in a cross-origin iframe and drives the flow +// from a popup, neither of which exists in jsdom. This mock hands the props +// PayPal would call back to the test so it can drive the flow itself. It has to +// live here rather than in the shared harness: the harness stubs the whole +// button component out, which is what these tests exercise. +vi.mock('@paypal/react-paypal-js', () => ({ + PayPalScriptProvider: ({ children }: { children: React.ReactNode }) => + children, + FUNDING: { PAYPAL: 'paypal' }, + usePayPalScriptReducer: () => [ + { isResolved: true, isPending: false, isInitial: false, isRejected: false }, + () => undefined, + ], + PayPalButtons: (props: PayPalButtonsMockProps) => { + payPalButtonsProps = props; + return ; + }, +})); + +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { PayPalCheckoutButton } from '@/components/checkout/payment/checkout-buttons/paypal/paypal'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, + getOperations, + mockGodaddyApi, + restoreWindowLocation, + setupCheckoutTestGlobals, +} from '../../../__tests__/checkout-test-utils'; + +interface PayPalOrderActions { + order: { + create: (order: Record) => Promise; + get: () => Promise<{ id: string; payer: { payer_id: string } }>; + }; +} + +interface PayPalButtonsMockProps { + disabled?: boolean; + createOrder?: (data: unknown, actions: PayPalOrderActions) => Promise; + onApprove?: (data: unknown, actions: PayPalOrderActions) => Promise; +} + +let payPalButtonsProps: PayPalButtonsMockProps | undefined; + +function getPayPalButtonsProps() { + if (!payPalButtonsProps) { + throw new Error('PayPalButtons has not rendered'); + } + return payPalButtonsProps; +} + +const noop = () => undefined; + +const PAYPAL_ORDER_ID = 'paypal-order-1'; + +let form: UseFormReturn | undefined; + +function renderPayPalButton({ enableTips = true, tipAmount = 0 } = {}) { + const session = buildCheckoutSession({ enableTips }); + const draftOrder = buildDraftOrder(); + mockGodaddyApi({ session, draftOrder }); + const queryClient = createTestQueryClient(); + + function Harness() { + const methods = useForm({ + defaultValues: { tipAmount } as CheckoutFormData, + }); + form = methods; + + return ( + + + + + + + + + + ); + } + + return render(); +} + +// PayPal calls `createOrder` when the buyer opens the popup and `onApprove` +// after they approve inside it — two separate round trips, with the page still +// live in between. +function payPalActions(createdOrders: Array>) { + return { + order: { + create: async (order: Record) => { + createdOrders.push(order); + return PAYPAL_ORDER_ID; + }, + get: async () => ({ + id: PAYPAL_ORDER_ID, + payer: { payer_id: 'payer-1' }, + }), + }, + } satisfies PayPalOrderActions; +} + +function tipMinorUnitsInOrder(order: Record) { + const purchaseUnit = ( + order.purchase_units as Array<{ + items?: Array<{ name: string; unit_amount: { value: string } }>; + }> + )[0]; + const tipItem = purchaseUnit?.items?.find(item => item.name === 'Tip'); + return tipItem ? Math.round(Number(tipItem.unit_amount.value) * 100) : 0; +} + +function confirmInput() { + return getOperations('ConfirmCheckoutSession').at(-1)?.input as + | Record + | undefined; +} + +describe('PayPalCheckoutButton', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + setupCheckoutTestGlobals(); + form = undefined; + payPalButtonsProps = undefined; + }); + + afterEach(() => { + act(() => { + vi.runOnlyPendingTimers(); + }); + vi.useRealTimers(); + vi.restoreAllMocks(); + restoreWindowLocation(); + }); + + it('confirms with the tip PayPal authorized, not a tip chosen afterwards', async () => { + // The popup stays open across `createOrder` → `onApprove`, so the tip + // control can move underneath it. Confirming with the current form value + // would charge the authorized amount but record a different tip. + renderPayPalButton({ enableTips: true, tipAmount: 500 }); + const createdOrders: Array> = []; + + await act(async () => { + await getPayPalButtonsProps().createOrder?.( + {}, + payPalActions(createdOrders) + ); + }); + + expect(tipMinorUnitsInOrder(createdOrders[0])).toBe(500); + + act(() => { + form?.setValue('tipAmount', 100); + }); + + await act(async () => { + await getPayPalButtonsProps().onApprove?.( + {}, + payPalActions(createdOrders) + ); + }); + + await waitFor(() => { + expect(confirmInput()).toBeDefined(); + }); + expect(confirmInput()).toMatchObject({ + paymentToken: `${PAYPAL_ORDER_ID}:payer-1`, + paymentType: 'paypal', + tipAmount: 500, + }); + }); + + it('confirms with the selected tip when it does not change', async () => { + renderPayPalButton({ enableTips: true, tipAmount: 500 }); + const createdOrders: Array> = []; + + await act(async () => { + await getPayPalButtonsProps().createOrder?.( + {}, + payPalActions(createdOrders) + ); + await getPayPalButtonsProps().onApprove?.({}, payPalActions([])); + }); + + await waitFor(() => { + expect(confirmInput()).toMatchObject({ tipAmount: 500 }); + }); + }); + + it('sends no tip when the session has tips disabled', async () => { + // A stale tipAmount can linger in form state after tips are turned off. + renderPayPalButton({ enableTips: false, tipAmount: 500 }); + const createdOrders: Array> = []; + + await act(async () => { + await getPayPalButtonsProps().createOrder?.( + {}, + payPalActions(createdOrders) + ); + await getPayPalButtonsProps().onApprove?.({}, payPalActions([])); + }); + + await waitFor(() => { + expect(confirmInput()).toBeDefined(); + }); + expect(tipMinorUnitsInOrder(createdOrders[0])).toBe(0); + expect(confirmInput()?.tipAmount).toBeUndefined(); + }); +}); 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 125654ec..c3805191 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 @@ -3,7 +3,7 @@ import { PayPalButtons, usePayPalScriptReducer, } from '@paypal/react-paypal-js'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; @@ -19,7 +19,7 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; function PayPalButtonsWrapper() { - const { setCheckoutErrors } = useCheckoutContext(); + const { session, setCheckoutErrors } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const form = useFormContext(); const { payPalRequest } = useBuildPaymentRequest(); @@ -29,6 +29,10 @@ function PayPalButtonsWrapper() { const deliveryMethod = form.watch('deliveryMethod'); const isPickup = deliveryMethod === DeliveryMethods.PICKUP; const [{ isResolved, isPending }] = usePayPalScriptReducer(); + const tipAmount = form.watch('tipAmount') || 0; + // PayPal's popup can stay open while the tip changes underneath, so confirm + // sends the tip `createOrder` submitted rather than the current form value. + const authorizedTipAmount = useRef(null); // PayPal onClick handler that returns Promise const handleClick = async (_data, actions) => { @@ -54,6 +58,7 @@ function PayPalButtonsWrapper() { }; const createOrder = async (_data, actions) => { + authorizedTipAmount.current = session?.enableTips ? tipAmount : null; const order = { ...payPalRequest, purchase_units: payPalRequest.purchase_units @@ -80,6 +85,9 @@ function PayPalButtonsWrapper() { paymentToken: `${details.id}:${details.payer.payer_id}`, paymentType: 'paypal', paymentProvider: PaymentProvider.PAYPAL, + ...(authorizedTipAmount.current === null + ? {} + : { tipAmount: authorizedTipAmount.current }), }); } catch (err: unknown) { if (err instanceof GraphQLErrorWithCodes) { diff --git a/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.test.tsx b/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.test.tsx new file mode 100644 index 00000000..d8a27a95 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.test.tsx @@ -0,0 +1,136 @@ +import { render, waitFor } from '@testing-library/react'; +import type React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { CCAvenueReturnProvider } from '@/components/checkout/payment/utils/ccavenue-return-provider'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { setRedirectTipAmount } from '@/lib/redirect-tip-storage'; +import type { CheckoutSession } from '@/types'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, + getOperations, + mockGodaddyApi, + setCheckoutUrl, +} from '../../__tests__/checkout-test-env'; + +// Stable across renders, like the state setters the real provider supplies — +// an inline callback would re-run the effect on every render and mask whether +// the declared dependencies are complete. +const noop = () => undefined; + +// The session cookie is absent in the token-exchange path, so `jwt` is the only +// credential the return leg can authenticate with. +function buildCookielessSession(): CheckoutSession { + return { + ...buildCheckoutSession({ enableTips: true }), + token: null, + }; +} + +function renderReturnProvider(session: CheckoutSession) { + const queryClient = createTestQueryClient(); + + function Harness({ jwt }: { jwt?: string }) { + const methods = useForm(); + + return ( + + + + + +
checkout
+
+
+
+
+
+ ); + } + + const view = render(); + return { + setJwt: (jwt: string) => view.rerender(), + }; +} + +function confirmOperations() { + return getOperations('ConfirmCheckoutSession'); +} + +describe('CCAvenueReturnProvider', () => { + beforeEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + mockGodaddyApi({ + session: buildCookielessSession(), + draftOrder: buildDraftOrder(), + }); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + search: 'encResp=enc-resp-1', + }); + }); + + it('confirms with the authorized tip once the jwt arrives after the first render', async () => { + // The effect reads `jwt`, so it has to re-run when the token lands or the + // customer is left paid at the gateway with no order. + setRedirectTipAmount('checkout-session-1', 500); + + const { setJwt } = renderReturnProvider(buildCookielessSession()); + expect(confirmOperations()).toHaveLength(0); + + setJwt('jwt-1'); + + await waitFor(() => { + expect(confirmOperations()).toHaveLength(1); + }); + expect(confirmOperations()[0]?.input).toMatchObject({ + paymentToken: 'enc-resp-1', + paymentType: 'ccavenue', + tipAmount: 500, + }); + }); + + it('confirms only once when the context changes again after the confirmation', async () => { + setRedirectTipAmount('checkout-session-1', 500); + + const { setJwt } = renderReturnProvider(buildCookielessSession()); + setJwt('jwt-1'); + await waitFor(() => { + expect(confirmOperations()).toHaveLength(1); + }); + + setJwt('jwt-2'); + + await waitFor(() => { + expect(confirmOperations()).toHaveLength(1); + }); + }); + + it('does not confirm while no credential is available', async () => { + setRedirectTipAmount('checkout-session-1', 500); + + renderReturnProvider(buildCookielessSession()); + + await waitFor(() => { + expect(confirmOperations()).toHaveLength(0); + }); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.tsx b/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.tsx index d667fcdc..278ab3a9 100644 --- a/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.tsx +++ b/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.tsx @@ -80,7 +80,20 @@ export function CCAvenueReturnProvider({ ]); } }); - }, [session?.token, session?.id, setCheckoutErrors]); + // Every value the effect reads is a dependency, `jwt` included: in the + // token-exchange path the session cookie is absent, so `jwt` is the only + // credential that unblocks the gate above, and it can arrive after the + // first run. Re-running is safe — `hasRun` makes the confirmation + // fire-once regardless of how many times the effect is re-invoked. + }, [ + session?.token, + session?.id, + session?.enableTips, + session?.draftOrder?.id, + jwt, + confirmCheckout.mutateAsync, + setCheckoutErrors, + ]); return <>{children}; } From b0c552ee974e1b31adfe47392c833c6234d56a74 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 18 Aug 2026 16:22:07 -0700 Subject: [PATCH 50/54] add missing tip apiErrors translations to every locale --- .changeset/tip-api-error-translations.md | 5 +++++ packages/localizations/src/deDe.ts | 3 +++ packages/localizations/src/enAu.ts | 3 +++ packages/localizations/src/enIe.ts | 3 +++ packages/localizations/src/esAr.ts | 3 +++ packages/localizations/src/esCl.ts | 3 +++ packages/localizations/src/esCo.ts | 3 +++ packages/localizations/src/esEs.ts | 3 +++ packages/localizations/src/esMx.ts | 3 +++ packages/localizations/src/esPe.ts | 3 +++ packages/localizations/src/esUs.ts | 3 +++ packages/localizations/src/frCa.ts | 3 +++ packages/localizations/src/frFr.ts | 3 +++ packages/localizations/src/idId.ts | 3 +++ packages/localizations/src/itIt.ts | 3 +++ packages/localizations/src/ptBr.ts | 3 +++ packages/localizations/src/qaPs.ts | 3 +++ packages/localizations/src/trTr.ts | 3 +++ packages/localizations/src/viVn.ts | 3 +++ packages/localizations/src/zhCn.ts | 3 +++ packages/localizations/src/zhSg.ts | 3 +++ 21 files changed, 65 insertions(+) create mode 100644 .changeset/tip-api-error-translations.md diff --git a/.changeset/tip-api-error-translations.md b/.changeset/tip-api-error-translations.md new file mode 100644 index 00000000..257263e6 --- /dev/null +++ b/.changeset/tip-api-error-translations.md @@ -0,0 +1,5 @@ +--- +"@godaddy/localizations": patch +--- + +Add the missing tip `apiErrors` translations to every locale diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 1842a654..ffcb8c22 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -369,6 +369,9 @@ export const deDe = { DEPENDENCY_ERROR: 'Wir können Ihre Bestellung derzeit nicht bearbeiten. Bitte warten Sie einen Moment und versuchen Sie es erneut', AUTHORIZATION_FAILED: 'Zahlungsautorisierung fehlgeschlagen', + TIP_EXCEEDS_LIMIT: 'Das Trinkgeld ist für diese Bestellung zu hoch', + INVALID_TIP_AMOUNT: 'Gültigen Trinkgeldbetrag eingeben', + TIPS_NOT_ENABLED: 'Für diese Bestellung wird kein Trinkgeld akzeptiert', }, storefront: { product: 'Produkt', diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index 7c8eb760..501226df 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -345,6 +345,9 @@ export const enAu = { DEPENDENCY_ERROR: "We're unable to process your order right now. Please wait a moment and try again", AUTHORIZATION_FAILED: 'Failed to authorise payment', + TIP_EXCEEDS_LIMIT: 'Tip is too large for this order', + INVALID_TIP_AMOUNT: 'Enter a valid tip amount', + TIPS_NOT_ENABLED: 'Tips are not accepted for this order', }, storefront: { product: 'Product', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index e87a00a5..01cde455 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -345,6 +345,9 @@ export const enIe = { DEPENDENCY_ERROR: "We're unable to process your order right now. Please wait a moment and try again", AUTHORIZATION_FAILED: 'Failed to authorise payment', + TIP_EXCEEDS_LIMIT: 'Tip is too large for this order', + INVALID_TIP_AMOUNT: 'Enter a valid tip amount', + TIPS_NOT_ENABLED: 'Tips are not accepted for this order', }, storefront: { product: 'Product', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index cf8a2e19..381d12f1 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -352,6 +352,9 @@ export const esAr = { 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', + TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', + INVALID_TIP_AMOUNT: 'Ingresá un monto de propina válido', + TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index 72b4ccc5..2988f6cb 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -354,6 +354,9 @@ export const esCl = { 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', + TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', + INVALID_TIP_AMOUNT: 'Ingresa un monto de propina válido', + TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index e78cdae5..6d121fc6 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -352,6 +352,9 @@ export const esCo = { 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', + TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', + INVALID_TIP_AMOUNT: 'Ingresa un monto de propina válido', + TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index 34f1005d..e14eb52c 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -357,6 +357,9 @@ export const esEs = { 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', + TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', + INVALID_TIP_AMOUNT: 'Introduce una cantidad de propina válida', + TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index 94447505..497753eb 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -353,6 +353,9 @@ export const esMx = { 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', + TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', + INVALID_TIP_AMOUNT: 'Ingrese una cantidad de propina válida', + TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index b5145772..083abb8d 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -352,6 +352,9 @@ export const esPe = { 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', + TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', + INVALID_TIP_AMOUNT: 'Ingrese un monto de propina válido', + TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index 4d0089e1..eab98050 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -352,6 +352,9 @@ export const esUs = { 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', + TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', + INVALID_TIP_AMOUNT: 'Ingrese una cantidad de propina válida', + TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index 99c4888b..c3c8bd3c 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -369,6 +369,9 @@ export const frCa = { 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", + TIP_EXCEEDS_LIMIT: 'Le pourboire est trop élevé pour cette commande', + INVALID_TIP_AMOUNT: 'Entrez un montant de pourboire valide', + TIPS_NOT_ENABLED: 'Les pourboires ne sont pas acceptés pour cette commande', }, storefront: { product: 'Produit', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index c8345087..8ac8e324 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -370,6 +370,9 @@ export const frFr = { 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", + TIP_EXCEEDS_LIMIT: 'Le pourboire est trop élevé pour cette commande', + INVALID_TIP_AMOUNT: 'Entrez un montant de pourboire valide', + TIPS_NOT_ENABLED: 'Les pourboires ne sont pas acceptés pour cette commande', }, storefront: { product: 'Produit', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 41c794e4..2f073e3b 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -344,6 +344,9 @@ export const idId = { DEPENDENCY_ERROR: 'Kami tidak dapat memproses pesanan Anda saat ini. Silakan tunggu sebentar dan coba lagi', AUTHORIZATION_FAILED: 'Gagal mengotorisasi pembayaran', + TIP_EXCEEDS_LIMIT: 'Tip terlalu besar untuk pesanan ini', + INVALID_TIP_AMOUNT: 'Masukkan jumlah tip yang valid', + TIPS_NOT_ENABLED: 'Tip tidak diterima untuk pesanan ini', }, storefront: { product: 'Produk', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index 89525d46..c757ccb6 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -368,6 +368,9 @@ export const itIt = { DEPENDENCY_ERROR: 'Non riusciamo a elaborare il tuo ordine in questo momento. Aspetta un momento e riprova', AUTHORIZATION_FAILED: "Errore nell'autorizzazione del pagamento", + TIP_EXCEEDS_LIMIT: 'La mancia è troppo alta per questo ordine', + INVALID_TIP_AMOUNT: 'Inserisci un importo della mancia valido', + TIPS_NOT_ENABLED: 'Le mance non sono accettate per questo ordine', }, storefront: { product: 'Prodotto', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index ae23da6e..9660470f 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -350,6 +350,9 @@ export const ptBr = { DEPENDENCY_ERROR: 'Não conseguimos processar seu pedido no momento. Aguarde um momento e tente novamente', AUTHORIZATION_FAILED: 'Falha ao autorizar pagamento', + TIP_EXCEEDS_LIMIT: 'A gorjeta é muito alta para este pedido', + INVALID_TIP_AMOUNT: 'Digite um valor de gorjeta válido', + TIPS_NOT_ENABLED: 'Gorjetas não são aceitas para este pedido', }, storefront: { product: 'Produto', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 3f72740e..760f758b 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -354,6 +354,9 @@ export const qaPs = { DEPENDENCY_ERROR: 'موږ اوس ستاسو امر پروسس نشو کولی. مهرباني وکړئ یو شېبه انتظار وکړئ او بیا هڅه وکړئ', AUTHORIZATION_FAILED: '[Fâîlëd ţö âüţhörîžë þâÿmëñţ]', + TIP_EXCEEDS_LIMIT: '[Ţîþ îš ţöö lârgë för ţhîš örðër]', + INVALID_TIP_AMOUNT: '[Ëñţër â vâlîd ţîþ âmöüñţ]', + TIPS_NOT_ENABLED: '[Ţîþš ârë ñöţ âççëþţëd för ţhîš örðër]', }, storefront: { product: '[Product]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index 7854783b..a638690f 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -345,6 +345,9 @@ export const trTr = { 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', + TIP_EXCEEDS_LIMIT: 'Bahşiş bu sipariş için çok yüksek', + INVALID_TIP_AMOUNT: 'Geçerli bir bahşiş tutarı girin', + TIPS_NOT_ENABLED: 'Bu sipariş için bahşiş kabul edilmiyor', }, storefront: { product: 'Ürün', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index d829fe58..bab6aa5f 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -345,6 +345,9 @@ export const viVn = { 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', + TIP_EXCEEDS_LIMIT: 'Tiền tip quá lớn cho đơn hàng này', + INVALID_TIP_AMOUNT: 'Nhập số tiền tip hợp lệ', + TIPS_NOT_ENABLED: 'Đơn hàng này không nhận tiền tip', }, storefront: { product: 'Sản phẩm', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 229f9bb8..85c6be22 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -332,6 +332,9 @@ export const zhCn = { MISSING_SHIPPING_INFO: '配送地址或方式应用失败', DEPENDENCY_ERROR: '我们目前无法处理您的订单。请稍等片刻再试', AUTHORIZATION_FAILED: '付款授权失败', + TIP_EXCEEDS_LIMIT: '小费金额超出此订单的上限', + INVALID_TIP_AMOUNT: '请输入有效的小费金额', + TIPS_NOT_ENABLED: '此订单不接受小费', }, storefront: { product: '产品', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index d380a7a1..8e9c3ca2 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -332,6 +332,9 @@ export const zhSg = { MISSING_SHIPPING_INFO: '配送地址或方式应用失败', DEPENDENCY_ERROR: '我們目前無法處理您的訂單。請稍等片刻再試', AUTHORIZATION_FAILED: '付款授权失败', + TIP_EXCEEDS_LIMIT: '小费金额超出此订单的上限', + INVALID_TIP_AMOUNT: '输入有效的小费金额', + TIPS_NOT_ENABLED: '此订单不接受小费', }, storefront: { product: '产品', From 62f8ab0ba13cceead67f5f373a55e9c3afca2d63 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Wed, 19 Aug 2026 10:04:53 -0700 Subject: [PATCH 51/54] keep express checkout tip-free --- .changeset/fruity-dots-jog.md | 4 +- .../checkout-buttons/express/godaddy.tsx | 12 +- .../checkout-buttons/express/stripe.tsx | 32 +----- .../payment/utils/conditional-providers.tsx | 4 +- .../payment/utils/stripe-provider.tsx | 10 +- .../utils/use-build-payment-request.test.tsx | 31 +++-- .../utils/use-build-payment-request.ts | 44 +++----- .../use-confirm-express-checkout.test.tsx | 106 ++---------------- .../utils/use-confirm-express-checkout.ts | 32 +----- .../utils/use-stripe-payment-intent.test.tsx | 22 ++++ .../utils/use-stripe-payment-intent.ts | 7 +- 11 files changed, 91 insertions(+), 213 deletions(-) diff --git a/.changeset/fruity-dots-jog.md b/.changeset/fruity-dots-jog.md index 975be411..bd001e74 100644 --- a/.changeset/fruity-dots-jog.md +++ b/.changeset/fruity-dots-jog.md @@ -1,5 +1,5 @@ --- -"@godaddy/react": minor +"@godaddy/react": patch --- Support tips in unified checkout @@ -8,6 +8,6 @@ Adds the `tips` session config surface (`default` and threshold-based `amounts`/ For redirect gateways (CCAvenue), the authorized tip is persisted across the redirect so the confirmation on the return leg records the tip the customer was actually charged. Checkout refuses to redirect when a non-zero tip cannot be persisted, rather than sending the customer to pay a tip the order would not include. -Fixes the Poynt express wallet total, which showed the item subtotal instead of the order total and so understated tax and shipping. This applies to every Poynt express order, not only tipped ones. +Express checkout stays tip-free: its wallet sheets open on the item subtotal and add the shipping and taxes they calculate in their own event flows, and its confirmation records no tip. Also gives every `Button` a `cursor-pointer`, so buttons rendered as ` @@ -82,7 +94,11 @@ describe('TipsForm when the subtotal moves under a selection', () => { it('re-derives what a percentage preset is worth', async () => { // The totals had not arrived when the customer picked a tip, so 20% of the // subtotal was 20% of nothing. - const { user } = renderTipsForm({ initialSubtotal: 0, nextSubtotal: 2500 }); + const { user } = renderTipsForm({ + initialSubtotal: 0, + nextSubtotal: 2500, + isTotalsLoading: true, + }); await user.click(screen.getByRole('radio', { name: /20%/ })); expect(screen.getByTestId('tip-amount')).toHaveTextContent('0'); @@ -154,3 +170,66 @@ describe('TipsForm when the subtotal moves under a selection', () => { expect(screen.getByTestId('tip-amount')).toHaveTextContent('1000'); }); }); + +describe('TipsForm presets on a zero subtotal', () => { + it('drops the percentage presets and still offers a custom amount', async () => { + // Every percentage of nothing is nothing, so the presets would be $0.00 + // buttons that leave the tip at zero when picked. + const { user } = renderTipsForm({ initialSubtotal: 0, nextSubtotal: 0 }); + + expect( + screen.queryByRole('radio', { name: /15%/ }) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /%/ })).not.toBeInTheDocument(); + + // A tip is still possible, it just cannot be a proportion of the subtotal. + await user.click(screen.getByRole('radio', { name: /custom amount/i })); + const input = await screen.findByPlaceholderText('0.00'); + await user.type(input, '5'); + await user.tab(); + + expect(screen.getByTestId('tip-amount')).toHaveTextContent('500'); + }); + + it('keeps the percentage presets while the totals load', async () => { + renderTipsForm({ + initialSubtotal: 0, + nextSubtotal: 2500, + isTotalsLoading: true, + }); + + // Hiding them here would flash them in once the draft order lands. + expect(screen.getByRole('radio', { name: /15%/ })).toHaveTextContent( + '$0.00' + ); + }); + + it('drops the percentage presets when the totals land on a zero subtotal', async () => { + const { user } = renderTipsForm({ + initialSubtotal: 0, + nextSubtotal: 0, + isTotalsLoading: true, + }); + + expect(screen.getByRole('radio', { name: /15%/ })).toBeInTheDocument(); + + await user.click(screen.getByTestId('move-subtotal')); + + expect( + screen.queryByRole('radio', { name: /15%/ }) + ).not.toBeInTheDocument(); + }); + + it('keeps fixed-amount presets, which are worth what they say', async () => { + renderTipsForm({ + initialSubtotal: 0, + nextSubtotal: 0, + options: { + default: { amounts: [300, 500, 700], percentages: null }, + thresholds: null, + }, + }); + + expect(screen.getByRole('radio', { name: /\$5\.00/ })).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index 3144bdbf..3cc2f29c 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -28,6 +28,8 @@ interface TipsFormProps { subtotal: number; options?: CheckoutSession['tips']; currencyCode?: string; + /** The subtotal arrives with the draft order, so it reads as 0 until then. */ + isTotalsLoading?: boolean; } const DEFAULT_TIP_PERCENTAGES = [15, 18, 20]; @@ -63,7 +65,12 @@ function resolveActiveIndex( const IS_DEV = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'; -export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { +export function TipsForm({ + subtotal, + options, + currencyCode, + isTotalsLoading = false, +}: TipsFormProps) { const { t } = useGoDaddyContext(); const form = useFormContext(); const formatCurrency = useFormatCurrency(); @@ -195,6 +202,14 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { ? tipPercentages : DEFAULT_TIP_PERCENTAGES; + // Percentages of a zero subtotal are all worth nothing, so the presets would be + // $0.00 buttons that do nothing when picked; Custom Amount still tips. Fixed + // amounts are worth what they say. A subtotal still loading keeps the presets + // rather than flashing them in once the draft order lands. + const showAmountPresets = Boolean(tipAmounts?.length); + const showPercentagePresets = + !showAmountPresets && (isTotalsLoading || subtotal > 0); + const activeAmountIndex = resolveActiveIndex( selectedIndex, tipAmounts, @@ -242,76 +257,79 @@ export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { return (
-
- {tipAmounts?.length - ? tipAmounts.map((amount, index) => { - const isSelected = - !showCustomTip && - tipAmount === amount && - index === activeAmountIndex; - - return ( - - ); - }) - : percentagePresets.map((percentage, index) => { - const isSelected = - tipPercentage === percentage && index === activePercentageIndex; - - return ( - - ); - })} -
+ {showAmountPresets || showPercentagePresets ? ( +
+ {tipAmounts?.length + ? tipAmounts.map((amount, index) => { + const isSelected = + !showCustomTip && + tipAmount === amount && + index === activeAmountIndex; + + return ( + + ); + }) + : percentagePresets.map((percentage, index) => { + const isSelected = + tipPercentage === percentage && + index === activePercentageIndex; + + return ( + + ); + })} +
+ ) : null}
Date: Wed, 19 Aug 2026 13:16:31 -0700 Subject: [PATCH 54/54] point a rejected tip-only charge at the tip field --- packages/localizations/src/deDe.ts | 2 + packages/localizations/src/enAu.ts | 1 + packages/localizations/src/enIe.ts | 1 + packages/localizations/src/enUs.ts | 1 + packages/localizations/src/esAr.ts | 1 + packages/localizations/src/esCl.ts | 1 + packages/localizations/src/esCo.ts | 1 + packages/localizations/src/esEs.ts | 1 + packages/localizations/src/esMx.ts | 1 + packages/localizations/src/esPe.ts | 1 + packages/localizations/src/esUs.ts | 1 + packages/localizations/src/frCa.ts | 2 + packages/localizations/src/frFr.ts | 2 + packages/localizations/src/idId.ts | 1 + packages/localizations/src/itIt.ts | 2 + packages/localizations/src/ptBr.ts | 1 + packages/localizations/src/qaPs.ts | 1 + packages/localizations/src/trTr.ts | 1 + packages/localizations/src/viVn.ts | 1 + packages/localizations/src/zhCn.ts | 1 + packages/localizations/src/zhSg.ts | 1 + .../__tests__/checkout-ccavenue-tips.test.tsx | 48 +++++++++++ .../checkout/__tests__/checkout-tips.test.tsx | 79 +++++++++++++++++++ .../payment/utils/use-authorize-checkout.ts | 20 +++-- .../payment/utils/use-confirm-checkout.ts | 22 ++++-- .../checkout/tips/utils/tip-field-errors.ts | 33 ++++++++ 26 files changed, 215 insertions(+), 12 deletions(-) diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index ffcb8c22..7d870290 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -372,6 +372,8 @@ export const deDe = { TIP_EXCEEDS_LIMIT: 'Das Trinkgeld ist für diese Bestellung zu hoch', INVALID_TIP_AMOUNT: 'Gültigen Trinkgeldbetrag eingeben', TIPS_NOT_ENABLED: 'Für diese Bestellung wird kein Trinkgeld akzeptiert', + TIP_CHARGE_FAILED: + 'Anderen Trinkgeldbetrag versuchen oder Kein Trinkgeld auswählen', }, storefront: { product: 'Produkt', diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index 501226df..3fd62ad4 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -348,6 +348,7 @@ export const enAu = { TIP_EXCEEDS_LIMIT: 'Tip is too large for this order', INVALID_TIP_AMOUNT: 'Enter a valid tip amount', TIPS_NOT_ENABLED: 'Tips are not accepted for this order', + TIP_CHARGE_FAILED: 'Try a different tip amount, or choose No Tip', }, storefront: { product: 'Product', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 01cde455..3f59c4d7 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -348,6 +348,7 @@ export const enIe = { TIP_EXCEEDS_LIMIT: 'Tip is too large for this order', INVALID_TIP_AMOUNT: 'Enter a valid tip amount', TIPS_NOT_ENABLED: 'Tips are not accepted for this order', + TIP_CHARGE_FAILED: 'Try a different tip amount, or choose No Tip', }, storefront: { product: 'Product', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index 36c8395e..14e3b3bc 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -348,6 +348,7 @@ export const enUs = { TIP_EXCEEDS_LIMIT: 'Tip is too large for this order', INVALID_TIP_AMOUNT: 'Enter a valid tip amount', TIPS_NOT_ENABLED: 'Tips are not accepted for this order', + TIP_CHARGE_FAILED: 'Try a different tip amount, or choose No Tip', }, storefront: { product: 'Product', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index 381d12f1..cfebd1be 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -355,6 +355,7 @@ export const esAr = { TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', INVALID_TIP_AMOUNT: 'Ingresá un monto de propina válido', TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', + TIP_CHARGE_FAILED: 'Probá otro monto de propina o elegí Sin Propina', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index 2988f6cb..0a1a2b5f 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -357,6 +357,7 @@ export const esCl = { TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', INVALID_TIP_AMOUNT: 'Ingresa un monto de propina válido', TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', + TIP_CHARGE_FAILED: 'Prueba otro monto de propina o elige Sin Propina', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index 6d121fc6..78da2704 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -355,6 +355,7 @@ export const esCo = { TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', INVALID_TIP_AMOUNT: 'Ingresa un monto de propina válido', TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', + TIP_CHARGE_FAILED: 'Prueba otro monto de propina o elige Sin propina', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index e14eb52c..fa5cd91f 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -360,6 +360,7 @@ export const esEs = { TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', INVALID_TIP_AMOUNT: 'Introduce una cantidad de propina válida', TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', + TIP_CHARGE_FAILED: 'Prueba otra cantidad de propina o elige Sin propina', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index 497753eb..53dfad66 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -356,6 +356,7 @@ export const esMx = { TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', INVALID_TIP_AMOUNT: 'Ingrese una cantidad de propina válida', TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', + TIP_CHARGE_FAILED: 'Pruebe otra cantidad de propina o elija Sin Propina', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index 083abb8d..bab42968 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -355,6 +355,7 @@ export const esPe = { TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', INVALID_TIP_AMOUNT: 'Ingrese un monto de propina válido', TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', + TIP_CHARGE_FAILED: 'Pruebe otro monto de propina o elija Sin Propina', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index eab98050..6a5bbd4c 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -355,6 +355,7 @@ export const esUs = { TIP_EXCEEDS_LIMIT: 'La propina es demasiado alta para este pedido', INVALID_TIP_AMOUNT: 'Ingrese una cantidad de propina válida', TIPS_NOT_ENABLED: 'No se aceptan propinas para este pedido', + TIP_CHARGE_FAILED: 'Pruebe otra cantidad de propina o elija Sin Propina', }, storefront: { product: 'Producto', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index c3c8bd3c..99135512 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -372,6 +372,8 @@ export const frCa = { TIP_EXCEEDS_LIMIT: 'Le pourboire est trop élevé pour cette commande', INVALID_TIP_AMOUNT: 'Entrez un montant de pourboire valide', TIPS_NOT_ENABLED: 'Les pourboires ne sont pas acceptés pour cette commande', + TIP_CHARGE_FAILED: + 'Essayez un autre montant de pourboire ou choisissez Aucun pourboire', }, storefront: { product: 'Produit', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index 8ac8e324..997967d5 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -373,6 +373,8 @@ export const frFr = { TIP_EXCEEDS_LIMIT: 'Le pourboire est trop élevé pour cette commande', INVALID_TIP_AMOUNT: 'Entrez un montant de pourboire valide', TIPS_NOT_ENABLED: 'Les pourboires ne sont pas acceptés pour cette commande', + TIP_CHARGE_FAILED: + 'Essayez un autre montant de pourboire ou choisissez Aucun pourboire', }, storefront: { product: 'Produit', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 2f073e3b..8d691f25 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -347,6 +347,7 @@ export const idId = { TIP_EXCEEDS_LIMIT: 'Tip terlalu besar untuk pesanan ini', INVALID_TIP_AMOUNT: 'Masukkan jumlah tip yang valid', TIPS_NOT_ENABLED: 'Tip tidak diterima untuk pesanan ini', + TIP_CHARGE_FAILED: 'Coba jumlah tip lain atau pilih Tanpa Tip', }, storefront: { product: 'Produk', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index c757ccb6..97798914 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -371,6 +371,8 @@ export const itIt = { TIP_EXCEEDS_LIMIT: 'La mancia è troppo alta per questo ordine', INVALID_TIP_AMOUNT: 'Inserisci un importo della mancia valido', TIPS_NOT_ENABLED: 'Le mance non sono accettate per questo ordine', + TIP_CHARGE_FAILED: + 'Prova un altro importo della mancia o scegli Nessuna Mancia', }, storefront: { product: 'Prodotto', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index 9660470f..654b891a 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -353,6 +353,7 @@ export const ptBr = { TIP_EXCEEDS_LIMIT: 'A gorjeta é muito alta para este pedido', INVALID_TIP_AMOUNT: 'Digite um valor de gorjeta válido', TIPS_NOT_ENABLED: 'Gorjetas não são aceitas para este pedido', + TIP_CHARGE_FAILED: 'Tente outro valor de gorjeta ou escolha Sem Gorjeta', }, storefront: { product: 'Produto', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 760f758b..c3b79131 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -357,6 +357,7 @@ export const qaPs = { TIP_EXCEEDS_LIMIT: '[Ţîþ îš ţöö lârgë för ţhîš örðër]', INVALID_TIP_AMOUNT: '[Ëñţër â vâlîd ţîþ âmöüñţ]', TIPS_NOT_ENABLED: '[Ţîþš ârë ñöţ âççëþţëd för ţhîš örðër]', + TIP_CHARGE_FAILED: '[Ţrÿ â dîffërëñţ ţîþ âmöüñţ, ör çhööšë Ñö Ţîþ]', }, storefront: { product: '[Product]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index a638690f..6935558c 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -348,6 +348,7 @@ export const trTr = { TIP_EXCEEDS_LIMIT: 'Bahşiş bu sipariş için çok yüksek', INVALID_TIP_AMOUNT: 'Geçerli bir bahşiş tutarı girin', TIPS_NOT_ENABLED: 'Bu sipariş için bahşiş kabul edilmiyor', + TIP_CHARGE_FAILED: 'Farklı bir bahşiş tutarı deneyin veya Bahşiş Yok seçin', }, storefront: { product: 'Ürün', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index bab6aa5f..537c624c 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -348,6 +348,7 @@ export const viVn = { TIP_EXCEEDS_LIMIT: 'Tiền tip quá lớn cho đơn hàng này', INVALID_TIP_AMOUNT: 'Nhập số tiền tip hợp lệ', TIPS_NOT_ENABLED: 'Đơn hàng này không nhận tiền tip', + TIP_CHARGE_FAILED: 'Hãy thử số tiền tip khác hoặc chọn Không tip', }, storefront: { product: 'Sản phẩm', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 85c6be22..7c53bb4e 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -335,6 +335,7 @@ export const zhCn = { TIP_EXCEEDS_LIMIT: '小费金额超出此订单的上限', INVALID_TIP_AMOUNT: '请输入有效的小费金额', TIPS_NOT_ENABLED: '此订单不接受小费', + TIP_CHARGE_FAILED: '请尝试其他小费金额或选择无小费', }, storefront: { product: '产品', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index 8e9c3ca2..20a94c08 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -335,6 +335,7 @@ export const zhSg = { TIP_EXCEEDS_LIMIT: '小费金额超出此订单的上限', INVALID_TIP_AMOUNT: '输入有效的小费金额', TIPS_NOT_ENABLED: '此订单不接受小费', + TIP_CHARGE_FAILED: '请尝试其他小费金额或选择无小费', }, storefront: { product: '产品', diff --git a/packages/react/src/components/checkout/__tests__/checkout-ccavenue-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-ccavenue-tips.test.tsx index df88381b..85970a7d 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-ccavenue-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-ccavenue-tips.test.tsx @@ -1,6 +1,7 @@ import { enUs } from '@godaddy/localizations'; import { screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { getRedirectTipAmount, setRedirectTipAmount, @@ -206,6 +207,53 @@ describe('Checkout CCAvenue tips', () => { expect(submit).toHaveBeenCalled(); }); }); + + it('points at the tip when the authorization refuses a tip-only charge', async () => { + // Nothing is owed on the order, so the tip is the only amount being + // authorized and the only one the customer can change — and the API blamed + // nothing, so the field can only say what to do about it. + vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation( + () => undefined + ); + const { user } = renderCheckout({ + checkoutProps: CCAVENUE_PROPS, + sessionOverrides: CCAVENUE_SESSION, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 2500, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + }, + apiOverrides: { + errors: { + authorizeCheckoutSession: new GraphQLErrorWithCodes([ + { + message: 'Amount must be at least $0.50 USD', + code: 'TRANSACTION_PROCESSING_FAILED', + }, + ]), + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await screen.findByRole('radio', { name: /20%/ })); + await user.click( + await screen.findByRole('button', { name: /pay with ccavenue/i }) + ); + await waitForOperation('AuthorizeCheckoutSession'); + + await waitFor(() => { + expect( + screen.getByText(enUs.apiErrors.TIP_CHARGE_FAILED) + ).toBeInTheDocument(); + }); + }); }); describe('return from the gateway', () => { diff --git a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx index d456481e..3a633ae9 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx @@ -519,6 +519,36 @@ describe('Checkout tips', () => { ); } + /** + * A processor decline, which is all the API can say when the charge itself is + * refused: a generic code and no `path` to blame. + */ + function rejectConfirmBlamingNothing() { + setApiError( + 'confirmCheckout', + new GraphQLErrorWithCodes([ + { + message: 'Amount must be at least $0.50 USD', + code: 'TRANSACTION_PROCESSING_FAILED', + }, + ]) + ); + } + + /** Nothing is owed on the order, but the subtotal the tip runs on is not zero. */ + function fullyDiscountedTotals() { + return { + totals: { + subTotal: { value: 5000, currencyCode: 'USD' }, + discountTotal: { value: 5000, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + }; + } + it('surfaces TIP_EXCEEDS_LIMIT on the tip field, not only in the error list', async () => { const { user } = renderCheckout({ sessionOverrides: tipsOnlySession(), @@ -567,6 +597,55 @@ describe('Checkout tips', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); }); + + it('points at the tip when the tip is the whole charge', async () => { + // Nothing is owed on the order, so the tip is the only amount being charged + // and the only one the customer can change. The API blamed nothing, so the + // reason stays in the error list and the field just says what to do. + const { user } = renderCheckout({ + sessionOverrides: tipsOnlySession(), + draftOrderOverrides: fullyDiscountedTotals(), + }); + await waitForCheckoutReady(); + + await user.click(await screen.findByRole('radio', { name: /15%/ })); + clearOperations(); + rejectConfirmBlamingNothing(); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + await waitFor(() => { + expect( + screen.getByText(/try a different tip amount, or choose no tip/i) + ).toBeInTheDocument(); + }); + }); + + it('leaves the tip out of it when the order itself is being charged', async () => { + // The order total is owed with or without the tip, so changing the tip + // would not get the customer any further. + const { user } = renderCheckout({ + sessionOverrides: tipsOnlySession(), + }); + await waitForCheckoutReady(); + + await user.click(await screen.findByRole('radio', { name: /15%/ })); + clearOperations(); + rejectConfirmBlamingNothing(); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + await waitFor(() => { + expect( + screen.getByText(/failed to process transaction/i) + ).toBeInTheDocument(); + }); + expect( + screen.queryByText(/try a different tip amount/i) + ).not.toBeInTheDocument(); + }); }); describe('options.thresholds', () => { 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 index 1ef22ccd..5c11f8fb 100644 --- a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts @@ -1,8 +1,12 @@ import { useMutation } from '@tanstack/react-query'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync'; -import { applyTipFieldError } from '@/components/checkout/tips/utils/tip-field-errors'; +import { + applyTipFieldError, + applyTipOnlyChargeError, +} from '@/components/checkout/tips/utils/tip-field-errors'; import { useGoDaddyContext } from '@/godaddy-provider'; import { authorizeCheckoutSession } from '@/lib/godaddy/godaddy'; import type { AuthorizeCheckoutSessionInput } from '@/types'; @@ -11,6 +15,7 @@ export function useAuthorizeCheckout() { const { session, jwt } = useCheckoutContext(); const { apiHost, t } = useGoDaddyContext(); const form = useFormContext(); + const { data: totals } = useDraftOrderTotals(); const flushCheckoutSync = useFlushCheckoutSync(); return useMutation({ @@ -42,11 +47,14 @@ export function useAuthorizeCheckout() { return result.authorizeCheckoutSession; }, onError: (error: unknown) => { - applyTipFieldError( - form, - error, - code => t.apiErrors?.[code as keyof typeof t.apiErrors] - ); + const translate = (code: string) => + t.apiErrors?.[code as keyof typeof t.apiErrors]; + + // An unattributed rejection still belongs on the tip field when the tip is + // the only thing being charged. + if (!applyTipFieldError(form, error, translate) && session?.enableTips) { + applyTipOnlyChargeError(form, totals?.total?.value || 0, translate); + } }, }); } diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index 868cbd57..a4f48010 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -11,7 +11,10 @@ import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-fl import { buildPickupPayload } from '@/components/checkout/pickup/utils/build-pickup-payload'; import { getPickupMode } from '@/components/checkout/pickup/utils/generate-pickup-time-slots'; import { getShippingFulfillmentSyncKey } from '@/components/checkout/shipping/utils/should-apply-shipping-method'; -import { applyTipFieldError } from '@/components/checkout/tips/utils/tip-field-errors'; +import { + applyTipFieldError, + applyTipOnlyChargeError, +} from '@/components/checkout/tips/utils/tip-field-errors'; import { isDigitalLineItem } from '@/components/checkout/utils/fulfillment'; import { useGoDaddyContext } from '@/godaddy-provider'; import { confirmCheckout } from '@/lib/godaddy/godaddy'; @@ -280,11 +283,18 @@ export function useConfirmCheckout() { onError: (error: unknown, data) => { if (isCheckoutConfirmationBlockedError(error)) return; - applyTipFieldError( - form, - error, - code => t.apiErrors?.[code as keyof typeof t.apiErrors] - ); + const translate = (code: string) => + t.apiErrors?.[code as keyof typeof t.apiErrors]; + + // An unattributed rejection still belongs on the tip field when the tip is + // the only thing being charged. + if (!applyTipFieldError(form, error, translate) && session?.enableTips) { + applyTipOnlyChargeError( + form, + order?.totals?.total?.value || 0, + translate + ); + } // Track checkout error event track({ diff --git a/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts b/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts index 40e0dcf3..efac2ad3 100644 --- a/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts +++ b/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts @@ -8,6 +8,9 @@ import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; */ export const TIP_SERVER_ERROR_TYPE = 'server'; +/** Copy for a rejection the API did not attribute, where the tip is the charge. */ +export const TIP_CHARGE_FAILED_CODE = 'TIP_CHARGE_FAILED'; + /** * Attach a tip rejection to the tip field. * @@ -42,3 +45,33 @@ export function applyTipFieldError( return true; } + +/** + * Blame the tip field for a rejection the API did not attribute itself. + * + * Only when the tip is the whole charge. Nothing is owed on a zero-total order, + * so the tip is both the only amount being charged and the only one the customer + * can change — a processor minimum (Stripe's is around $0.50) rejects a small + * one, and so would any ordinary decline. Which of those it was stays in the + * checkout-wide error list; this only points at the field to change, and the copy + * claims no cause it cannot know. + * + * @param orderTotal the live order total, tip excluded + * @param translate resolves an error code to localized copy + * @returns true when the error was attributed to the tip field + */ +export function applyTipOnlyChargeError( + form: Pick | null | undefined, + orderTotal: number, + translate: (code: string) => string | undefined +): boolean { + if (!form || orderTotal > 0) return false; + if ((form.getValues('tipAmount') || 0) <= 0) return false; + + form.setError('tipAmount', { + type: TIP_SERVER_ERROR_TYPE, + message: translate(TIP_CHARGE_FAILED_CODE) || TIP_CHARGE_FAILED_CODE, + }); + + return true; +}