diff --git a/.changeset/fruity-dots-jog.md b/.changeset/fruity-dots-jog.md new file mode 100644 index 00000000..975be411 --- /dev/null +++ b/.changeset/fruity-dots-jog.md @@ -0,0 +1,13 @@ +--- +"@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. + +For redirect gateways (CCAvenue), the authorized tip is persisted across the redirect so the confirmation on the return leg records the tip the customer was actually charged. Checkout refuses to redirect when a non-zero tip cannot be persisted, rather than sending the customer to pay a tip the order would not include. + +Fixes the Poynt express wallet total, which showed the item subtotal instead of the order total and so understated tax and shipping. This applies to every Poynt express order, not only tipped ones. + +Also gives every `Button` a `cursor-pointer`, so buttons rendered as ` + + + ); +} + +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 2ec647bc..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 @@ -33,15 +33,25 @@ export function useStripePaymentIntent({ const draftOrderTotalsQuery = useDraftOrderTotals(); const { data: totals, isLoading: isLoadingTotals } = draftOrderTotalsQuery; - const amount = totals?.total?.value || 0; + const total = totals?.total?.value || 0; + const tipAmount = form?.watch('tipAmount') || 0; + 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)); @@ -83,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); @@ -108,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) { @@ -126,7 +153,7 @@ export function useStripePaymentIntent({ amount, currency, updateIntent, - intentId, + intentId: existingIntentId ?? intentId, }); }, [ amount, @@ -134,7 +161,8 @@ export function useStripePaymentIntent({ updateIntent, intentId, isLoading, - form, + existingClientSecret, + existingIntentId, paymentIntentMutation.mutate, enableClientSecret, ]); @@ -142,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, diff --git a/packages/react/src/components/checkout/tips/tips-form.test.tsx b/packages/react/src/components/checkout/tips/tips-form.test.tsx new file mode 100644 index 00000000..bc2e99d7 --- /dev/null +++ b/packages/react/src/components/checkout/tips/tips-form.test.tsx @@ -0,0 +1,156 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ComponentProps, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; +import { checkoutContext } from '@/components/checkout/checkout'; +import { TipsForm } from '@/components/checkout/tips/tips-form'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + buildCheckoutSession, + createTestQueryClient, +} from '../__tests__/checkout-test-env'; + +type TipsOptions = ComponentProps['options']; + +/** + * The subtotal is owned by the harness rather than by a fixture, because these + * tests are about what happens to a selection when the subtotal moves under it — + * routine, since the tips section renders before the draft-order totals resolve. + */ +function Harness({ + initialSubtotal, + nextSubtotal, + options, +}: { + initialSubtotal: number; + nextSubtotal: number; + options?: TipsOptions; +}) { + const [subtotal, setSubtotal] = useState(initialSubtotal); + const form = useForm({ defaultValues: { tipAmount: 0 } }); + + return ( + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + }} + > + + + + + + + ); +} + +/** 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 e9523110..3144bdbf 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -2,6 +2,7 @@ import { useDebouncedValue } from '@tanstack/react-pacer'; import { useEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { TIP_SERVER_ERROR_TYPE } from '@/components/checkout/tips/utils/tip-field-errors'; import { convertMajorToMinorUnits, currencyConfigs, @@ -21,27 +22,84 @@ 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) { +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 = + typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'; + +export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { const { t } = useGoDaddyContext(); const form = useFormContext(); const formatCurrency = useFormatCurrency(); const [showCustomTip, setShowCustomTip] = useState(false); + // Which preset the customer picked. Selection is matched by index as well as + // by value so a merchant that lists the same amount twice does not light up + // both buttons; the form value stays authoritative. + const [selectedIndex, setSelectedIndex] = useState(null); + + const calculateTipAmount = (percentage: number): number => + percentageToAmount(subtotal, percentage); + + const handleAmountSelect = (amount: number, index: number) => { + form.setValue('tipAmount', amount); + form.setValue('tipPercentage', null); + setSelectedIndex(index); + setShowCustomTip(false); - const calculateTipAmount = (percentage: number): number => { - // total is in minor units, so calculate percentage and return in minor units - return Math.round((total * percentage) / 100); + // Track tip amount selection + track({ + eventId: eventIds.selectTipAmount, + type: TrackingEventType.CLICK, + properties: { + tipPercentage: null, + tipAmount: amount, + totalBeforeTip: subtotal, + currencyCode, + }, + }); }; - const handlePercentageSelect = (percentage: number) => { + const handlePercentageSelect = (percentage: number, index: number) => { const tipAmount = calculateTipAmount(percentage); form.setValue('tipAmount', tipAmount); form.setValue('tipPercentage', percentage); + setSelectedIndex(index); setShowCustomTip(false); // Track tip percentage selection @@ -51,7 +109,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { properties: { tipPercentage: percentage, tipAmount: tipAmount, - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); @@ -60,6 +118,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { const handleNoTip = () => { form.setValue('tipAmount', 0); form.setValue('tipPercentage', 0); + setSelectedIndex(null); setShowCustomTip(false); // Track no tip selection @@ -69,14 +128,18 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { properties: { tipPercentage: 0, tipAmount: 0, - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); }; const handleCustomTip = () => { + const currentTipAmount = form.getValues('tipAmount') || 0; + setShowCustomTip(true); + setSelectedIndex(null); + form.setValue('tipAmount', currentTipAmount); form.setValue('tipPercentage', null); // Track custom tip selection @@ -84,14 +147,98 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { eventId: eventIds.enterCustomTip, type: TrackingEventType.CLICK, properties: { - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); }; - const tipPercentages = [15, 18, 20]; const tipPercentage = form.watch('tipPercentage'); + let tipPercentages = options?.default?.percentages; + + const tipAmount = form.watch('tipAmount'); + let tipAmounts = options?.default?.amounts; + + const matchingThresholds = + options?.thresholds?.filter( + thres => + (thres?.minSubtotal == null || subtotal >= thres.minSubtotal) && + (thres?.maxSubtotal == null || subtotal <= thres.maxSubtotal) + ) ?? []; + const threshold = matchingThresholds[0]; + const matchCount = matchingThresholds.length; + + // Overlapping ranges make the order of `thresholds` load-bearing, which is + // never what a merchant intends and is invisible at runtime — the first match + // simply wins. Warned about in development so the config gets fixed rather + // than the array quietly reordered later. + useEffect(() => { + if (!IS_DEV || matchCount <= 1) return; + + // biome-ignore lint/suspicious/noConsole: a misconfiguration only the developer integrating the SDK can fix, and only reachable in development + console.warn( + `[godaddy-checkout] tips.thresholds has ${matchCount} entries matching a subtotal of ${subtotal}. The first match is used; overlapping ranges make the array order significant.` + ); + }, [matchCount, subtotal]); + + if (threshold) { + if (threshold.amounts?.length) { + tipAmounts = threshold.amounts; + tipPercentages = undefined; + } else if (threshold.percentages?.length) { + tipPercentages = threshold.percentages; + tipAmounts = undefined; + } + } + + const percentagePresets = tipPercentages?.length + ? tipPercentages + : DEFAULT_TIP_PERCENTAGES; + + 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 + // the customer can see which field to fix. + const tipFieldError = form.formState.errors.tipAmount; + + // Ref to avoid `form` (unstable reference) in the dependency array. + 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(() => { + if ( + formRef.current.formState.errors.tipAmount?.type === TIP_SERVER_ERROR_TYPE + ) { + formRef.current.clearErrors('tipAmount'); + } + }, [tipAmount]); return (
@@ -100,30 +247,70 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { role='radiogroup' aria-label={t.tips?.title || 'Tip amount'} > - {tipPercentages.map(percentage => ( - - ))} + {tipAmounts?.length + ? tipAmounts.map((amount, index) => { + const isSelected = + !showCustomTip && + tipAmount === amount && + index === activeAmountIndex; + + return ( + + ); + }) + : percentagePresets.map((percentage, index) => { + const isSelected = + tipPercentage === percentage && index === activePercentageIndex; + + return ( + + ); + })}
- {showCustomTip && ( + {showCustomTip ? ( + ) : ( + // When the custom input is open its own FormMessage renders this, wired + // to the input via aria-describedby. + tipFieldError?.message && ( +

+ {String(tipFieldError.message)} +

+ ) )}
); @@ -181,7 +386,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { */ interface CustomTipInputProps { currencyCode?: string; - total: number; + subtotal: number; formatCurrency: (options: FormatCurrencyOptions) => string; } @@ -216,7 +421,7 @@ function symbolPadding(symbol: string, position: 'prefix' | 'suffix') { function CustomTipInput({ currencyCode, - total, + subtotal, formatCurrency, }: CustomTipInputProps) { const { t } = useGoDaddyContext(); @@ -278,6 +483,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 +494,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 = ( 0 - ? Number(((tipAmount / total) * 100).toFixed(2)) + subtotal > 0 + ? Number(((tipAmount / subtotal) * 100).toFixed(2)) : 0, currencyCode, }, diff --git a/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts b/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts new file mode 100644 index 00000000..40e0dcf3 --- /dev/null +++ b/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts @@ -0,0 +1,44 @@ +import type { UseFormReturn } from 'react-hook-form'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; + +/** + * Marks a tip error as set from a server response rather than by the form + * resolver, so `TipsForm` can clear it once the customer changes the amount — + * react-hook-form only clears the errors its own resolver produced. + */ +export const TIP_SERVER_ERROR_TYPE = 'server'; + +/** + * Attach a tip rejection to the tip field. + * + * The API tags its tip errors (`TIP_EXCEEDS_LIMIT`, `INVALID_TIP_AMOUNT`, + * `TIPS_NOT_ENABLED`) with `extensions.path: ['tipAmount']`, so the field is + * taken from the response rather than an allow-list of codes that would have to + * be kept in step with the API. + * + * The error also stays in the checkout-wide list, which scrolls itself into view + * and covers the case where the tip section is not rendered at all. + * + * @param translate resolves an error code to localized copy + * @returns true when the error was attributed to the tip field + */ +export function applyTipFieldError( + form: Pick | null | undefined, + error: unknown, + translate: (code: string) => string | undefined +): boolean { + if (!form || !(error instanceof GraphQLErrorWithCodes)) return false; + + const tipError = error.errors.find(item => item.path?.[0] === 'tipAmount'); + if (!tipError) return false; + + form.setError('tipAmount', { + type: TIP_SERVER_ERROR_TYPE, + // The API message is developer-facing and untranslated, so prefer localized + // copy for the code and fall back to the bare code, matching what + // CheckoutErrorList renders for an unmapped code. + message: (tipError.code && translate(tipError.code)) || tipError.code, + }); + + return true; +} diff --git a/packages/react/src/components/checkout/totals/utils/use-is-order-free.ts b/packages/react/src/components/checkout/totals/utils/use-is-order-free.ts deleted file mode 100644 index 9c103eca..00000000 --- a/packages/react/src/components/checkout/totals/utils/use-is-order-free.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; - -export function useCheckIsOrderFree() { - const { data: totals, isLoading } = useDraftOrderTotals(); - - /* TODO: Will need logic for handling tips */ - return { - isFree: totals?.total?.value === 0, - isLoading, - }; -} 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: { diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index c9ab79e9..f9b1b1a2 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2139,6 +2139,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "tips", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionTips" + }, + "args": [], + "isDeprecated": false + }, { "name": "token", "type": { @@ -3999,6 +4008,242 @@ const introspection = { ], "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 + } + ], + "interfaces": [] + }, + { + "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 + } + ], + "interfaces": [] + }, + { + "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": "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": "OBJECT", + "name": "CheckoutSessionTipsThreshold", + "fields": [ + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "maxSubtotal", + "type": { + "kind": "SCALAR", + "name": "Int" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "minSubtotal", + "type": { + "kind": "SCALAR", + "name": "Int" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsThresholdInput", + "inputFields": [ + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + }, + { + "name": "maxSubtotal", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + { + "name": "minSubtotal", + "type": { + "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": "CheckoutSessionTotalTaxAmount", @@ -6334,6 +6579,46 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "ENUM", + "name": "FeeProgramType", + "enumValues": [ + { + "name": "CASH_DISCOUNT", + "isDeprecated": false + }, + { + "name": "CONVENIENCE_FEE", + "isDeprecated": false + }, + { + "name": "SERVICE_FEE", + "isDeprecated": false + }, + { + "name": "SURCHARGE", + "isDeprecated": false + } + ] + }, + { + "kind": "ENUM", + "name": "FeeType", + "enumValues": [ + { + "name": "FIXED", + "isDeprecated": false + }, + { + "name": "HYBRID", + "isDeprecated": false + }, + { + "name": "PERCENTAGE", + "isDeprecated": false + } + ] + }, { "kind": "SCALAR", "name": "Float" @@ -7680,6 +7965,19 @@ const introspection = { "kind": "INPUT_OBJECT", "name": "MutationAuthorizeCheckoutSessionInput", "inputFields": [ + { + "name": "fees", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TransactionFeeInput" + } + } + } + }, { "name": "paymentProvider", "type": { @@ -7706,6 +8004,13 @@ const introspection = { "name": "String" } } + }, + { + "name": "tipAmount", + "type": { + "kind": "SCALAR", + "name": "Int" + } } ], "isOneOf": false @@ -7735,6 +8040,19 @@ const introspection = { "name": "CalculatedTaxesInput" } }, + { + "name": "fees", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TransactionFeeInput" + } + } + } + }, { "name": "fulfillmentEndAt", "type": { @@ -7819,6 +8137,13 @@ const introspection = { "kind": "INPUT_OBJECT", "name": "MoneyInput" } + }, + { + "name": "tipAmount", + "type": { + "kind": "SCALAR", + "name": "Int" + } } ], "isOneOf": false @@ -8091,6 +8416,13 @@ const introspection = { "name": "CheckoutSessionTaxesOptionsInput" } }, + { + "name": "tips", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput" + } + }, { "name": "url", "type": { @@ -8523,6 +8855,13 @@ const introspection = { "name": "String" } }, + { + "name": "tips", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput" + } + }, { "name": "url", "type": { @@ -10844,6 +11183,57 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "INPUT_OBJECT", + "name": "TransactionFeeInput", + "inputFields": [ + { + "name": "amount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + { + "name": "feeProgramType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "FeeProgramType" + } + } + }, + { + "name": "feeType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "FeeType" + } + } + }, + { + "name": "required", + "type": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + { + "name": "signature", + "type": { + "kind": "SCALAR", + "name": "String" + } + } + ], + "isOneOf": false + }, { "kind": "OBJECT", "name": "TransactionFundingSource", diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 39f4780a..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 @@ -394,10 +406,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(` 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 diff --git a/packages/react/src/lib/graphql-with-errors.ts b/packages/react/src/lib/graphql-with-errors.ts index 94ffb4b1..78a078b1 100644 --- a/packages/react/src/lib/graphql-with-errors.ts +++ b/packages/react/src/lib/graphql-with-errors.ts @@ -7,9 +7,10 @@ import { // Define the shape of GraphQL errors explicitly export class GraphQLErrorWithCodes< - T extends { message?: string; code?: string } = { + T extends { message?: string; code?: string; path?: string[] } = { message?: string; code?: string; + path?: string[]; }, > extends Error { constructor(public errors: T[]) { @@ -47,6 +48,11 @@ export async function graphqlRequestWithErrors( const parsedErrors = err.response.errors.map(e => ({ message: e.message as string, code: e.extensions?.code as string, + // The input path the API blamed, e.g. `['tipAmount']`. Read from + // `extensions` rather than the GraphQL `path`, which points at the + // response field. Lets a caller attach the error to that form field + // instead of only the checkout-wide error list. + path: e.extensions?.path as string[] | undefined, })); throw new GraphQLErrorWithCodes(parsedErrors); } diff --git a/packages/react/src/lib/redirect-tip-storage.test.ts b/packages/react/src/lib/redirect-tip-storage.test.ts new file mode 100644 index 00000000..40a7043d --- /dev/null +++ b/packages/react/src/lib/redirect-tip-storage.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearRedirectTipAmount, + getRedirectTipAmount, + setRedirectTipAmount, +} from './redirect-tip-storage'; + +const KEY_PREFIX = 'godaddy-checkout-redirect-tip'; +const keyFor = (sessionId: string) => `${KEY_PREFIX}:${sessionId}`; + +function clearAll() { + window.sessionStorage.clear(); + window.localStorage.clear(); +} + +describe('redirect tip storage', () => { + beforeEach(clearAll); + + afterEach(() => { + vi.restoreAllMocks(); + clearAll(); + }); + + it('round-trips a tip for the session it was saved for', () => { + expect(setRedirectTipAmount('session-1', 500)).toBe(true); + + expect(getRedirectTipAmount('session-1')).toBe(500); + }); + + it('saves a zero tip distinctly from nothing saved', () => { + setRedirectTipAmount('session-1', 0); + + expect(getRedirectTipAmount('session-1')).toBe(0); + }); + + it('returns null when nothing was saved', () => { + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('returns null for a different session id', () => { + setRedirectTipAmount('session-1', 500); + + expect(getRedirectTipAmount('session-2')).toBeNull(); + }); + + it('keeps a tip a later session in the same tab saved alongside it', () => { + setRedirectTipAmount('session-1', 500); + setRedirectTipAmount('session-2', 750); + + expect(getRedirectTipAmount('session-1')).toBe(500); + expect(getRedirectTipAmount('session-2')).toBe(750); + }); + + it('ignores a request without a session id', () => { + expect(setRedirectTipAmount('', 500)).toBe(false); + + expect(getRedirectTipAmount('')).toBeNull(); + }); + + it('returns null for unparsable stored data', () => { + window.sessionStorage.setItem(keyFor('session-1'), 'not-json'); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('returns null when the stored tip is not a number', () => { + window.sessionStorage.setItem( + keyFor('session-1'), + JSON.stringify({ tipAmount: '500', savedAt: Date.now() }) + ); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('clears the saved tip', () => { + setRedirectTipAmount('session-1', 500); + clearRedirectTipAmount('session-1'); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('clears the tip from every store it was mirrored to', () => { + setRedirectTipAmount('session-1', 500); + clearRedirectTipAmount('session-1'); + + expect(window.sessionStorage.getItem(keyFor('session-1'))).toBeNull(); + expect(window.localStorage.getItem(keyFor('session-1'))).toBeNull(); + }); + + it('overwrites the tip saved for an earlier redirect', () => { + setRedirectTipAmount('session-1', 500); + setRedirectTipAmount('session-1', 750); + + expect(getRedirectTipAmount('session-1')).toBe(750); + }); + + describe('durability across tabs', () => { + it('mirrors the tip to localStorage so a return in a new tab can read it', () => { + setRedirectTipAmount('session-1', 500); + + // sessionStorage is per-tab; a gateway returning to a different tab sees + // only localStorage. + window.sessionStorage.clear(); + + expect(getRedirectTipAmount('session-1')).toBe(500); + }); + + it('reports success when only one store accepted the write', () => { + vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { + throw new Error('storage full'); + }); + + expect(setRedirectTipAmount('session-1', 500)).toBe(true); + expect(getRedirectTipAmount('session-1')).toBe(500); + }); + }); + + describe('staleness', () => { + it('ignores a tip older than the maximum age', () => { + const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000; + window.sessionStorage.setItem( + keyFor('session-1'), + JSON.stringify({ tipAmount: 500, savedAt: twoDaysAgo }) + ); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('ignores a tip with no saved timestamp', () => { + window.sessionStorage.setItem( + keyFor('session-1'), + JSON.stringify({ tipAmount: 500 }) + ); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('sweeps expired entries on the next write', () => { + const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000; + window.localStorage.setItem( + keyFor('abandoned'), + JSON.stringify({ tipAmount: 500, savedAt: twoDaysAgo }) + ); + + setRedirectTipAmount('session-1', 750); + + expect(window.localStorage.getItem(keyFor('abandoned'))).toBeNull(); + expect(getRedirectTipAmount('session-1')).toBe(750); + }); + + it('leaves unrelated keys alone when sweeping', () => { + window.localStorage.setItem('some-other-app-key', 'keep me'); + + setRedirectTipAmount('session-1', 500); + + expect(window.localStorage.getItem('some-other-app-key')).toBe('keep me'); + }); + }); + + describe('when storage is unavailable', () => { + it('reports failure rather than throwing', () => { + for (const method of ['setItem', 'getItem', 'removeItem'] as const) { + vi.spyOn(Storage.prototype, method).mockImplementation(() => { + throw new Error('storage disabled'); + }); + } + + expect(setRedirectTipAmount('session-1', 500)).toBe(false); + expect(getRedirectTipAmount('session-1')).toBeNull(); + expect(() => clearRedirectTipAmount('session-1')).not.toThrow(); + }); + + it('reports failure when a write is accepted but not readable back', () => { + // Safari with storage blocked accepts setItem and then returns null. + vi.spyOn(Storage.prototype, 'setItem').mockImplementation( + () => undefined + ); + + expect(setRedirectTipAmount('session-1', 500)).toBe(false); + }); + }); +}); diff --git a/packages/react/src/lib/redirect-tip-storage.ts b/packages/react/src/lib/redirect-tip-storage.ts new file mode 100644 index 00000000..022792f1 --- /dev/null +++ b/packages/react/src/lib/redirect-tip-storage.ts @@ -0,0 +1,194 @@ +const REDIRECT_TIP_KEY_PREFIX = 'godaddy-checkout-redirect-tip'; + +// A gateway round-trip takes minutes. An older entry belongs to a checkout the +// customer abandoned at the gateway, so it is ignored on read and swept up on +// the next write rather than accumulating in localStorage. +const REDIRECT_TIP_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +type StoredRedirectTip = { + tipAmount: number; + savedAt: number; +}; + +/** + * Entries are keyed per checkout session so a later session started in the same + * tab cannot overwrite a tip an earlier one is still waiting to confirm. + */ +function keyFor(sessionId: string): string { + return `${REDIRECT_TIP_KEY_PREFIX}:${sessionId}`; +} + +/** + * The stores the tip is mirrored across. + * + * `sessionStorage` is scoped to a single tab, so a gateway that returns the + * customer to a different one — routine in mobile in-app browsers — cannot see + * it. `localStorage` survives that. Both are written and either can satisfy a + * read, so losing the tip takes both being unavailable. + */ +function getStores(): Storage[] { + if (typeof window === 'undefined') { + // SSR safety + return []; + } + + const stores: Storage[] = []; + for (const read of [() => window.sessionStorage, () => window.localStorage]) { + try { + const store = read(); + if (store) { + stores.push(store); + } + } catch { + // Touching the property itself throws when storage is blocked outright. + } + } + + return stores; +} + +/** + * Drop expired entries, and any this version cannot read, before writing a new + * one. Keeps abandoned checkouts from accumulating in localStorage, which — + * unlike sessionStorage — outlives the tab. + */ +function pruneExpired(store: Storage): void { + const now = Date.now(); + const stale: string[] = []; + + for (let index = 0; index < store.length; index++) { + const key = store.key(index); + if (!key?.startsWith(`${REDIRECT_TIP_KEY_PREFIX}:`)) { + continue; + } + + try { + const raw = store.getItem(key); + const savedAt = raw + ? (JSON.parse(raw) as Partial | null)?.savedAt + : undefined; + if ( + typeof savedAt !== 'number' || + now - savedAt > REDIRECT_TIP_MAX_AGE_MS + ) { + stale.push(key); + } + } catch { + // Unparsable, so it can never be read back either way. + stale.push(key); + } + } + + for (const key of stale) { + try { + store.removeItem(key); + } catch { + // Storage can become unwritable between the read and the remove. + } + } +} + +/** + * Save the tip a gateway redirect was authorized for. + * + * Redirect providers (CCAvenue) authorize on one page load and confirm on + * another: the customer leaves for the gateway and comes back to a fresh + * document where react-hook-form state no longer exists. The gateway collects + * the tip-inclusive amount, and `confirmCheckoutSession` records whatever tip + * the client sends — the API defaults a missing `tipAmount` to `0` rather than + * inheriting the authorized one. So if this value does not survive the + * redirect, the order is recorded for less than the customer paid. + * + * @returns true when the tip was written somewhere it can be read back. A false + * return means the tip cannot survive the redirect, so the caller must not send + * the customer to a gateway that will charge it. + */ +export function setRedirectTipAmount( + sessionId: string, + tipAmount: number +): boolean { + if (!sessionId) { + return false; + } + + const key = keyFor(sessionId); + const payload = JSON.stringify({ + tipAmount, + savedAt: Date.now(), + } satisfies StoredRedirectTip); + let saved = false; + + for (const store of getStores()) { + try { + pruneExpired(store); + store.setItem(key, payload); + // Read back rather than trusting setItem: with storage blocked, Safari + // accepts the write and then hands back null, and a quota failure can + // evict the entry immediately after it is accepted. + if (store.getItem(key) === payload) { + saved = true; + } + } catch { + // Storage can be unavailable (private browsing, disabled storage) or full. + } + } + + return saved; +} + +/** + * Read the tip saved for `sessionId`. + * + * Returns null when nothing was saved for this session, the entry is too old to + * belong to the redirect in progress, or every store is unreadable. + */ +export function getRedirectTipAmount(sessionId: string): number | null { + if (!sessionId) { + return null; + } + + const key = keyFor(sessionId); + + for (const store of getStores()) { + try { + const raw = store.getItem(key); + if (!raw) { + continue; + } + + const stored = JSON.parse(raw) as Partial | null; + if (typeof stored?.tipAmount !== 'number') { + continue; + } + if ( + typeof stored.savedAt !== 'number' || + Date.now() - stored.savedAt > REDIRECT_TIP_MAX_AGE_MS + ) { + continue; + } + + return stored.tipAmount; + } catch { + // Unreadable or unparsable — try the next store. + } + } + + return null; +} + +/** + * Remove the tip saved for `sessionId`. + */ +export function clearRedirectTipAmount(sessionId: string): void { + if (!sessionId) { + return; + } + + for (const store of getStores()) { + try { + store.removeItem(keyFor(sessionId)); + } catch { + // Storage can be unavailable (private browsing, disabled storage). + } + } +} diff --git a/packages/react/src/tracking/events.ts b/packages/react/src/tracking/events.ts index eafe52df..8c6f3f31 100644 --- a/packages/react/src/tracking/events.ts +++ b/packages/react/src/tracking/events.ts @@ -60,6 +60,9 @@ export const eventIds = { // Tips events selectTipAmount: 'select_tip_amount.click', enterCustomTip: 'enter_custom_tip.click', + // A redirect gateway charged a tip-inclusive amount but the tip could not be + // recovered on the return leg, so the order is recorded without it. + redirectTipUnrecoverable: 'redirect_tip_unrecoverable.event', // Notes events addOrderNote: 'add_order_note.click',