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