Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fruity-dots-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@godaddy/react": patch
---

Support tips in unified checkout
1 change: 1 addition & 0 deletions examples/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default async function Home() {
enableTaxCollection: true,
enableNotesCollection: true,
enablePromotionCodes: true,
enableTips: true,
shipping: {
fulfillmentLocationId: 'default-location',
originAddress: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@/tracking/track')>();
Expand Down Expand Up @@ -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,
});
});
});
2 changes: 1 addition & 1 deletion packages/react/src/components/checkout/checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
);
Expand Down
37 changes: 23 additions & 14 deletions packages/react/src/components/checkout/tips/tips-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,19 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) {
<Button
key={percentage}
type='button'
variant={tipPercentage === percentage ? 'default' : 'outline'}
variant='outline'
className={cn(
'h-16 flex flex-col items-center justify-center',
'h-16 flex flex-col items-center justify-center gap-y-0.5 hover:bg-muted',
tipPercentage === percentage
? 'bg-primary text-primary-foreground'
: 'bg-card hover:bg-muted active:ring'
? 'border-muted-foreground'
: 'bg-card active:ring'
)}
onClick={() => handlePercentageSelect(percentage)}
aria-checked={tipPercentage === percentage ? 'true' : 'false'}
>
<span className='text-lg'>{percentage}%</span>
<span className='text-lg leading-tight font-bold'>
{percentage}%
</span>
<span className='text-sm'>
{formatCurrency({
amount: calculateTipAmount(percentage),
Expand All @@ -133,10 +135,10 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) {
>
<Button
type='button'
variant={tipPercentage === 0 ? 'default' : 'outline'}
variant='outline'
className={cn(
'h-12 font-normal',
tipPercentage !== 0 && 'hover:bg-muted'
'h-12 font-normal hover:bg-muted',
tipPercentage === 0 && 'border-muted-foreground'
)}
onClick={handleNoTip}
aria-checked={tipPercentage === 0 ? 'true' : 'false'}
Expand All @@ -145,22 +147,25 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) {
</Button>
<Button
type='button'
variant={showCustomTip ? 'default' : 'outline'}
className={cn('h-12 font-normal', !showCustomTip && 'hover:bg-muted')}
variant='outline'
className={cn(
'h-12 font-normal hover:bg-muted',
showCustomTip && 'border-muted-foreground'
)}
onClick={handleCustomTip}
aria-checked={showCustomTip ? 'true' : 'false'}
>
{t.tips.customAmount}
</Button>
</div>

{showCustomTip && (
{showCustomTip ? (
<CustomTipInput
currencyCode={currencyCode}
total={total}
formatCurrency={formatCurrency}
/>
)}
) : null}
</fieldset>
);
}
Expand Down Expand Up @@ -278,18 +283,22 @@ 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
// up-to-date and gives the user visual confirmation of their amount.
useEffect(() => {
if (!isFocused.current || debouncedLocal === null) return;
const tipAmount = convertMajorToMinorUnits(debouncedLocal ?? '', code);
form.setValue('tipAmount', tipAmount);
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 = (
<span
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/lib/godaddy/checkout-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7804,6 +7804,13 @@ const introspection = {
name: 'MoneyInput',
},
},
{
name: 'tipAmount',
type: {
kind: 'SCALAR',
name: 'Int',
},
},
],
isOneOf: false,
},
Expand Down
6 changes: 3 additions & 3 deletions packages/react/src/lib/godaddy/checkout-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down