Skip to content

Commit

Permalink
fix: Checkout component success event (#2053)
Browse files Browse the repository at this point in the history
  • Loading branch information
cpcramer authored Mar 5, 2025
1 parent 43c54f9 commit 22601f6
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 102 deletions.
177 changes: 129 additions & 48 deletions src/checkout/components/CheckoutButton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
type Mock,
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { useAnalytics } from '../../core/analytics/hooks/useAnalytics';
import { CheckoutEvent } from '../../core/analytics/types';
import { useIcon } from '../../internal/hooks/useIcon';
import { CHECKOUT_LIFECYCLESTATUS } from '../constants';
import { CheckoutButton } from './CheckoutButton';
import { useCheckoutContext } from './CheckoutProvider';
Expand All @@ -8,88 +19,158 @@ vi.mock('./CheckoutProvider', () => ({
useCheckoutContext: vi.fn(),
}));

vi.mock('@/internal/components/Spinner', () => ({
Spinner: () => <div data-testid="spinner">Loading...</div>,
vi.mock('../../internal/hooks/useIcon', () => ({
useIcon: vi.fn(),
}));

vi.mock('@/internal/hooks/useIcon', () => ({
useIcon: vi.fn(() => <svg data-testid="icon" />),
vi.mock('../../core/analytics/hooks/useAnalytics', () => ({
useAnalytics: vi.fn(),
}));

const useCheckoutContextMock = useCheckoutContext as Mock;

describe('CheckoutButton', () => {
const mockOnSubmit = vi.fn();
let mockOnSubmit: Mock;
let mockSendAnalytics: Mock;

beforeEach(() => {
mockOnSubmit.mockClear();
useCheckoutContextMock.mockReturnValue({
lifecycleStatus: { statusName: CHECKOUT_LIFECYCLESTATUS.INIT },
mockOnSubmit = vi.fn();
mockSendAnalytics = vi.fn();

(useCheckoutContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: 'ready' },
onSubmit: mockOnSubmit,
});

(useIcon as Mock).mockReturnValue('<svg>Icon</svg>');

(useAnalytics as Mock).mockReturnValue({
sendAnalytics: mockSendAnalytics,
});
});

afterEach(() => {
vi.resetAllMocks();
});

it('should render button with default text "Pay" when not loading', () => {
it('renders with default text', () => {
render(<CheckoutButton />);
const button: HTMLInputElement = screen.getByRole('button');
expect(button.textContent).toBe('Pay');
expect(button.disabled).toBe(false);
expect(screen.getByText('Pay')).toBeInTheDocument();
});

it('renders with custom text', () => {
render(<CheckoutButton text="Custom Pay" />);
expect(screen.getByText('Custom Pay')).toBeInTheDocument();
});

it('renders with Coinbase branding when coinbaseBranded is true', () => {
(useIcon as Mock).mockReturnValue('<svg>Coinbase Pay Icon</svg>');
render(<CheckoutButton coinbaseBranded={true} />);
expect(screen.getByText('Pay')).toBeInTheDocument();
});

it('should render Spinner when loading', () => {
useCheckoutContextMock.mockReturnValue({
it('disables button when disabled prop is true', () => {
render(<CheckoutButton disabled={true} />);
expect(screen.getByRole('button')).toBeDisabled();
});

it('disables button when lifecycle status is PENDING', () => {
(useCheckoutContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: CHECKOUT_LIFECYCLESTATUS.PENDING },
onSubmit: mockOnSubmit,
});

render(<CheckoutButton />);
expect(screen.getByRole('button')).toBeDisabled();
});

it('disables button when lifecycle status is FETCHING_DATA', () => {
(useCheckoutContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: CHECKOUT_LIFECYCLESTATUS.FETCHING_DATA },
onSubmit: mockOnSubmit,
});

render(<CheckoutButton />);
expect(screen.getByTestId('spinner')).toBeDefined();
expect((screen.getByRole('button') as HTMLInputElement).disabled).toBe(
true,
);
expect(screen.getByRole('button')).toBeDisabled();
});

it('should render "View payment details" when transaction is successful', () => {
useCheckoutContextMock.mockReturnValue({
it('shows spinner when lifecycle status is PENDING', () => {
(useCheckoutContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: CHECKOUT_LIFECYCLESTATUS.PENDING },
onSubmit: mockOnSubmit,
});

render(<CheckoutButton />);
expect(screen.getByRole('button')).not.toHaveTextContent('Pay');
// Note: We can't easily test for the Spinner component directly,
// but we can verify the button text is not displayed
});

it('changes button text to "View payment details" when transaction is successful', () => {
(useCheckoutContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: CHECKOUT_LIFECYCLESTATUS.SUCCESS },
onSubmit: mockOnSubmit,
});

render(<CheckoutButton />);
expect(screen.getByRole('button').textContent).toBe('View payment details');
expect(screen.getByText('View payment details')).toBeInTheDocument();
});

it('should call onSubmit when clicked', () => {
it('calls onSubmit when button is clicked', () => {
render(<CheckoutButton />);
fireEvent.click(screen.getByRole('button'));
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
});

it('should apply additional className correctly', () => {
const customClass = 'custom-class';
render(<CheckoutButton className={customClass} />);
expect(screen.getByRole('button').className).toContain(customClass);
});
it('tracks checkout success analytics when transaction is successful', async () => {
const transactionHash = '0xabc123';
const chargeId = 'charge-123';

it('should render Coinbase branded button when coinbaseBranded prop is true', () => {
render(<CheckoutButton coinbaseBranded={true} />);
const button = screen.getByRole('button');
expect(button.textContent).toBe('Pay');
expect(screen.getByTestId('icon')).toBeDefined();
});
(useCheckoutContext as Mock).mockReturnValue({
lifecycleStatus: {
statusName: CHECKOUT_LIFECYCLESTATUS.SUCCESS,
statusData: {
transactionReceipts: [{ transactionHash }],
chargeId,
},
},
onSubmit: mockOnSubmit,
});

it('should render custom text when provided', () => {
render(<CheckoutButton text="Custom Text" />);
expect(screen.getByRole('button').textContent).toBe('Custom Text');
render(<CheckoutButton />);

await waitFor(() => {
expect(mockSendAnalytics).toHaveBeenCalledWith(
CheckoutEvent.CheckoutSuccess,
{
chargeHandlerId: chargeId,
transactionHash,
},
);
});
});

it('should render icon when provided and not in a special state', () => {
render(<CheckoutButton icon="someIcon" />);
expect(screen.getByTestId('icon')).toBeDefined();
it('does not track analytics when transaction hash or charge ID is missing', () => {
(useCheckoutContext as Mock).mockReturnValue({
lifecycleStatus: {
statusName: CHECKOUT_LIFECYCLESTATUS.SUCCESS,
statusData: {
transactionReceipts: [{ transactionHash: null }],
chargeId: 'charge-123',
},
},
onSubmit: mockOnSubmit,
});

render(<CheckoutButton />);
expect(mockSendAnalytics).not.toHaveBeenCalled();
});

it('should be disabled when disabled prop is true', () => {
render(<CheckoutButton disabled={true} />);
expect((screen.getByRole('button') as HTMLInputElement).disabled).toBe(
true,
);
it('does not track analytics when status is not SUCCESS', () => {
(useCheckoutContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: CHECKOUT_LIFECYCLESTATUS.READY },
onSubmit: mockOnSubmit,
});

render(<CheckoutButton />);
expect(mockSendAnalytics).not.toHaveBeenCalled();
});
});
22 changes: 21 additions & 1 deletion src/checkout/components/CheckoutButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client';

import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useAnalytics } from '../../core/analytics/hooks/useAnalytics';
import { CheckoutEvent } from '../../core/analytics/types';
import { Spinner } from '../../internal/components/Spinner';
import { useIcon } from '../../internal/hooks/useIcon';
import {
Expand All @@ -27,6 +29,7 @@ export function CheckoutButton({
}
const { lifecycleStatus, onSubmit } = useCheckoutContext();
const iconSvg = useIcon({ icon });
const { sendAnalytics } = useAnalytics();

const isLoading =
lifecycleStatus?.statusName === CHECKOUT_LIFECYCLESTATUS.PENDING;
Expand All @@ -41,6 +44,23 @@ export function CheckoutButton({
}, [lifecycleStatus?.statusName, text]);
const shouldRenderIcon = buttonText === text && iconSvg;

useEffect(() => {
if (
lifecycleStatus?.statusName === CHECKOUT_LIFECYCLESTATUS.SUCCESS &&
lifecycleStatus.statusData
) {
const { transactionReceipts, chargeId } = lifecycleStatus.statusData;
const transactionHash = transactionReceipts?.[0]?.transactionHash;

if (transactionHash && chargeId) {
sendAnalytics(CheckoutEvent.CheckoutSuccess, {
chargeHandlerId: chargeId,
transactionHash,
});
}
}
}, [lifecycleStatus, sendAnalytics]);

return (
<button
className={cn(
Expand Down
37 changes: 1 addition & 36 deletions src/checkout/components/CheckoutProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -581,46 +581,10 @@ describe('CheckoutProvider', () => {
await waitFor(() => {
expect(sendAnalytics).toHaveBeenCalledWith(
CheckoutEvent.CheckoutInitiated,
{
amount: 10,
productId: 'test-product',
},
);
});
});

it('should track checkout success', async () => {
const mockReceipt = {
status: 'success',
transactionHash: '0x123',
};

(useWaitForTransactionReceipt as Mock).mockReturnValue({
data: mockReceipt,
});

(useCallsStatus as Mock).mockReturnValue({
data: { receipts: [{ transactionHash: '0x123' }] },
});

render(
<CheckoutProvider productId="test-product" isSponsored={true}>
<TestComponent />
</CheckoutProvider>,
);

fireEvent.click(screen.getByText('Submit'));

await waitFor(() => {
expect(sendAnalytics).toHaveBeenCalledWith(
CheckoutEvent.CheckoutSuccess,
{
address: '0x123',
amount: 10,
productId: 'test-product',
chargeHandlerId: '',
isSponsored: true,
transactionHash: '0x123',
},
);
});
Expand Down Expand Up @@ -672,6 +636,7 @@ describe('CheckoutProvider', () => {
1,
CheckoutEvent.CheckoutInitiated,
{
address: '0x123',
amount: 0,
productId: 'test-product',
},
Expand Down
15 changes: 2 additions & 13 deletions src/checkout/components/CheckoutProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,8 @@ export function CheckoutProvider({
const handleSubmit = useCallback(async () => {
try {
handleAnalytics(CheckoutEvent.CheckoutInitiated, {
amount: Number(priceInUSDCRef.current || 0),
address,
amount: Number(priceInUSDCRef.current),
productId: productId || '',
});

Expand Down Expand Up @@ -324,17 +325,6 @@ export function CheckoutProvider({
}
: undefined,
});

if (receipt?.status === 'success') {
handleAnalytics(CheckoutEvent.CheckoutSuccess, {
address: connectedAddress,
amount: Number(priceInUSDCRef.current),
productId: productId || '',
chargeHandlerId: chargeId,
isSponsored: !!isSponsored,
transactionHash: receipt.transactionHash,
});
}
} catch (error) {
handleAnalytics(CheckoutEvent.CheckoutFailure, {
error: error instanceof Error ? error.message : 'Checkout failed',
Expand Down Expand Up @@ -382,7 +372,6 @@ export function CheckoutProvider({
updateLifecycleStatus,
writeContractsAsync,
handleAnalytics,
receipt,
productId,
]);

Expand Down
5 changes: 1 addition & 4 deletions src/core/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,18 +237,15 @@ export type BuyEventData = {
*/
export type CheckoutEventData = {
[CheckoutEvent.CheckoutSuccess]: CommonAnalyticsData & {
address: string;
amount: number;
productId: string;
chargeHandlerId: string;
isSponsored: boolean;
transactionHash: string | undefined;
};
[CheckoutEvent.CheckoutFailure]: CommonAnalyticsData & {
error: string;
metadata: Record<string, unknown> | undefined;
};
[CheckoutEvent.CheckoutInitiated]: CommonAnalyticsData & {
address: string | undefined;
amount: number;
productId: string;
};
Expand Down

0 comments on commit 22601f6

Please sign in to comment.