From c4c5f0219a3fc87ec9fcfebb74cff537b85947a3 Mon Sep 17 00:00:00 2001 From: Catherine Shing Date: Tue, 30 Jun 2026 23:02:29 -0700 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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) => (