Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Buy component #1729

Merged
merged 65 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
3b1985f
initial commit
alissacrane-cb Dec 13, 2024
38b978d
fix typo
alissacrane-cb Dec 13, 2024
4d2accf
lint
alissacrane-cb Dec 13, 2024
b3474a8
fix lint
alissacrane-cb Dec 13, 2024
75a16d4
fix imports
alissacrane-cb Dec 13, 2024
ee0f7bc
adjust swap quote functionality
alissacrane-cb Dec 13, 2024
de3805d
fix swap test
alissacrane-cb Dec 13, 2024
9e98425
adjust svgs
alissacrane-cb Dec 13, 2024
fd8241f
adjust imports
alissacrane-cb Dec 13, 2024
1672671
adjust hook behavior
alissacrane-cb Dec 13, 2024
7f8cdc4
add popup monitor
alissacrane-cb Dec 16, 2024
9570480
fix optional params - revisit
alissacrane-cb Dec 16, 2024
2303671
remove unused file
alissacrane-cb Dec 16, 2024
1f5d208
add leading 0
alissacrane-cb Dec 16, 2024
ddf3654
fix swap error
alissacrane-cb Dec 16, 2024
6b34326
remove SwapLite components
alissacrane-cb Dec 16, 2024
62f9586
add buy components
alissacrane-cb Dec 16, 2024
63f4717
add test coverage
alissacrane-cb Dec 16, 2024
eb0d0ac
remove unused code
alissacrane-cb Dec 16, 2024
64decc8
remove exit handler
alissacrane-cb Dec 16, 2024
66545a6
adjust imports
alissacrane-cb Dec 16, 2024
1e65878
add test coverage
alissacrane-cb Dec 16, 2024
8194392
add test coverage
alissacrane-cb Dec 16, 2024
e792579
fix imports and lint
alissacrane-cb Dec 16, 2024
0cee0d3
fix import
alissacrane-cb Dec 16, 2024
40e8074
update import
alissacrane-cb Dec 16, 2024
24c5d01
throw error if no project id
alissacrane-cb Dec 16, 2024
12c5ae7
fix import
alissacrane-cb Dec 16, 2024
676fed9
fix imports
alissacrane-cb Dec 16, 2024
a30f64e
remove console log
alissacrane-cb Dec 16, 2024
e2837e7
add comments
alissacrane-cb Dec 16, 2024
d60b789
add test coverage
alissacrane-cb Dec 17, 2024
404a730
add test coverage
alissacrane-cb Dec 17, 2024
42d3a11
add test coverage
alissacrane-cb Dec 17, 2024
fe9c92b
remove uneccesary error
alissacrane-cb Dec 17, 2024
215761b
remove project id
alissacrane-cb Dec 17, 2024
c7b0e8c
address pr comments
alissacrane-cb Dec 17, 2024
a43a105
fix lint
alissacrane-cb Dec 17, 2024
c9daec4
fix tests
alissacrane-cb Dec 17, 2024
322db40
refactor useOnrampeventlistener
alissacrane-cb Dec 17, 2024
ad8be9d
add test coverage
alissacrane-cb Dec 17, 2024
ebf9200
add test coverage
alissacrane-cb Dec 17, 2024
81e7ae4
add test coverage
alissacrane-cb Dec 17, 2024
ddd20db
add test coverage
alissacrane-cb Dec 17, 2024
e8672f6
re require transactionReceipt
alissacrane-cb Dec 17, 2024
0b88800
fix test
alissacrane-cb Dec 17, 2024
ec56e5a
update buy button
alissacrane-cb Dec 17, 2024
0e306b5
add quote validation
alissacrane-cb Dec 17, 2024
727e826
validate quote
alissacrane-cb Dec 17, 2024
27320d9
adjust styling
alissacrane-cb Dec 17, 2024
a082d8e
fix lint
alissacrane-cb Dec 17, 2024
8117757
add test coverage
alissacrane-cb Dec 17, 2024
8c13dd2
add test coverage
alissacrane-cb Dec 17, 2024
ede2e92
add test coverage
alissacrane-cb Dec 17, 2024
96de464
fix lint
alissacrane-cb Dec 17, 2024
727bb99
add changelog
alissacrane-cb Dec 18, 2024
1090562
address qa comments
alissacrane-cb Dec 18, 2024
bbda91a
fix test
alissacrane-cb Dec 18, 2024
dc415b8
add z index
alissacrane-cb Dec 18, 2024
4843432
fix lint
alissacrane-cb Dec 18, 2024
5b3afb3
add missing required fields message and address pr comments
alissacrane-cb Dec 18, 2024
7c63536
add test coverage
alissacrane-cb Dec 18, 2024
19512c4
fix lint
alissacrane-cb Dec 18, 2024
1cf2f1a
fix test and build
alissacrane-cb Dec 18, 2024
d527a6b
remove unsued code
alissacrane-cb Dec 18, 2024
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/happy-avocados-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@coinbase/onchainkit': patch
---

- **feat**: Added Buy component. By @abcrane123. #1729
1 change: 1 addition & 0 deletions site/docs/pages/token/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type TokenChipReact = {
token: Token; // Rendered token
onClick?: (token: Token) => void;
className?: string;
isPressable?: boolean; // Default: true
};
```

Expand Down
158 changes: 158 additions & 0 deletions src/buy/components/Buy.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type Config,
type UseConnectReturnType,
useAccount,
useConnect,
} from 'wagmi';
import { useOnchainKit } from '../../core-react/useOnchainKit';
import { degenToken } from '../../token/constants';
import { useOutsideClick } from '../../ui/react/internal/hooks/useOutsideClick';
import { Buy } from './Buy';
import { useBuyContext } from './BuyProvider';

vi.mock('./BuyProvider', () => ({
useBuyContext: vi.fn(),
BuyProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-BuyProvider">{children}</div>
),
}));

vi.mock('./BuyDropdown', () => ({
BuyDropdown: () => <div data-testid="mock-BuyDropdown">BuyDropdown</div>,
}));

vi.mock('../../core-react/internal/hooks/useTheme', () => ({
useTheme: vi.fn(),
}));

vi.mock('../../ui/react/internal/hooks/useOutsideClick', () => ({
useOutsideClick: vi.fn(),
}));

vi.mock('../../core-react/useOnchainKit', () => ({
useOnchainKit: vi.fn(),
}));

vi.mock('wagmi', () => ({
useAccount: vi.fn(),
useConnect: vi.fn(),
}));

type useOutsideClickType = ReturnType<
typeof vi.fn<
(
ref: React.RefObject<HTMLElement>,
callback: (event: MouseEvent) => void,
) => void
>
>;

describe('Buy', () => {
let mockSetIsOpen: ReturnType<typeof vi.fn>;
let mockOutsideClickCallback: (e: MouseEvent) => void;

beforeEach(() => {
mockSetIsOpen = vi.fn();
(useBuyContext as Mock).mockReturnValue({
isDropdownOpen: false,
setIsDropdownOpen: mockSetIsOpen,
lifecycleStatus: {
statusName: 'idle',
statusData: {
maxSlippage: 10,
},
},
to: {
token: degenToken,
amount: 10,
setAmount: vi.fn(),
},
address: '0x123',
});

(useAccount as Mock).mockReturnValue({
address: '0x123',
});

vi.mocked(useConnect).mockReturnValue({
connectors: [{ id: 'mockConnector' }],
connect: vi.fn(),
status: 'connected',
} as unknown as UseConnectReturnType<Config, unknown>);

(useOutsideClick as unknown as useOutsideClickType).mockImplementation(
(_, callback) => {
mockOutsideClickCallback = callback;
},
);

(useOnchainKit as Mock).mockReturnValue({
projectId: 'mock-project-id',
});

vi.clearAllMocks();
});

it('renders the Buy component', () => {
render(<Buy className="test-class" toToken={degenToken} />);

expect(screen.getByText('Buy')).toBeInTheDocument();
expect(screen.getByText('DEGEN')).toBeInTheDocument();
});

it('closes the dropdown when clicking outside the container', () => {
(useBuyContext as Mock).mockReturnValue({
isDropdownOpen: true,
setIsDropdownOpen: mockSetIsOpen,
lifecycleStatus: {
statusName: 'idle',
statusData: {
maxSlippage: 10,
},
},
to: {
token: degenToken,
amount: 10,
setAmount: vi.fn(),
},
});

render(<Buy className="test-class" toToken={degenToken} />);

expect(screen.getByTestId('mock-BuyDropdown')).toBeDefined();
mockOutsideClickCallback({} as MouseEvent);

expect(mockSetIsOpen).toHaveBeenCalledWith(false);
});

it('does not close the dropdown when clicking inside the container', () => {
(useBuyContext as Mock).mockReturnValue({
isDropdownOpen: true,
setIsDropdownOpen: mockSetIsOpen,
lifecycleStatus: {
statusName: 'idle',
statusData: {
maxSlippage: 10,
},
},
to: {
token: degenToken,
amount: 10,
setAmount: vi.fn(),
},
});

render(<Buy className="test-class" toToken={degenToken} />);

expect(screen.getByTestId('mock-BuyDropdown')).toBeDefined();
fireEvent.click(screen.getByTestId('mock-BuyDropdown'));
expect(mockSetIsOpen).not.toHaveBeenCalled();
});

it('should not trigger click handler when dropdown is closed', () => {
render(<Buy className="test-class" toToken={degenToken} />);
expect(screen.queryByTestId('mock-BuyDropdown')).not.toBeInTheDocument();
});
});
65 changes: 65 additions & 0 deletions src/buy/components/Buy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useOutsideClick } from '@/ui-react/internal/hooks/useOutsideClick';
import { useRef } from 'react';
import { useTheme } from '../../core-react/internal/hooks/useTheme';
import { cn } from '../../styles/theme';
import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants';
import type { BuyReact } from '../types';
import { BuyAmountInput } from './BuyAmountInput';
import { BuyButton } from './BuyButton';
import { BuyDropdown } from './BuyDropdown';
import { BuyMessage } from './BuyMessage';
import { BuyProvider, useBuyContext } from './BuyProvider';

function BuyContent({ className }: { className?: string }) {
const componentTheme = useTheme();
const { isDropdownOpen, setIsDropdownOpen } = useBuyContext();
const buyContainerRef = useRef<HTMLDivElement>(null);

useOutsideClick(buyContainerRef, () => {
if (isDropdownOpen) {
setIsDropdownOpen(false);
}
});

return (
<div
ref={buyContainerRef}
className={cn('relative flex flex-col gap-2', componentTheme, className)}
>
<div className={cn('flex items-center gap-4')}>
<BuyAmountInput />
<BuyButton />
{isDropdownOpen && <BuyDropdown />}
</div>
<BuyMessage />
</div>
);
}
export function Buy({
config = {
maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE,
},
className,
experimental = { useAggregator: false },
isSponsored = false,
onError,
onStatus,
onSuccess,
toToken,
fromToken,
}: BuyReact) {
return (
<BuyProvider
config={config}
experimental={experimental}
isSponsored={isSponsored}
onError={onError}
onStatus={onStatus}
onSuccess={onSuccess}
toToken={toToken}
fromToken={fromToken}
>
<BuyContent className={className} />
</BuyProvider>
);
}
111 changes: 111 additions & 0 deletions src/buy/components/BuyAmountInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { BuyAmountInput } from './BuyAmountInput';
import { useBuyContext } from './BuyProvider';

vi.mock('./BuyProvider', () => ({
useBuyContext: vi.fn(),
}));

vi.mock('../../internal/components/TextInput', () => ({
TextInput: ({
value,
setValue,
onChange,
disabled,
}: {
disabled: boolean;
value: string;
setValue: (value: string) => void;
onChange: (value: string) => void;
}) => (
<input
data-testid="text-input"
value={value}
onChange={(e) => {
onChange(e.target.value);
setValue(e.target.value);
}}
disabled={disabled}
/>
),
}));

vi.mock('../../token', () => ({
TokenChip: ({ token }: { token: string }) => (
<div data-testid="token-chip">{token}</div>
),
}));

describe('BuyAmountInput', () => {
const mockHandleAmountChange = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
(useBuyContext as Mock).mockReturnValue({
to: {
token: 'ETH',
amount: 10,
setAmount: vi.fn(),
loading: false,
},
handleAmountChange: mockHandleAmountChange,
});
});

it('renders null when there is no token', () => {
(useBuyContext as Mock).mockReturnValue({
to: { token: null },
handleAmountChange: mockHandleAmountChange,
});

const { container } = render(<BuyAmountInput />);
expect(container.firstChild).toBeNull();
});

it('renders the input and token chip when a token is present', () => {
render(<BuyAmountInput />);

expect(screen.getByTestId('text-input')).toBeInTheDocument();
expect(screen.getByTestId('token-chip')).toBeInTheDocument();
expect(screen.getByTestId('token-chip')).toHaveTextContent('ETH');
});

it('calls handleAmountChange and setAmount on input change', () => {
const mockSetAmount = vi.fn();
(useBuyContext as Mock).mockReturnValue({
to: {
token: 'ETH',
amount: 10,
setAmount: mockSetAmount,
loading: false,
},
handleAmountChange: mockHandleAmountChange,
});

render(<BuyAmountInput />);

const input = screen.getByTestId('text-input');
fireEvent.change(input, { target: { value: '20' } });

expect(mockHandleAmountChange).toHaveBeenCalledWith('20');
expect(mockSetAmount).toHaveBeenCalledWith('20');
});

it('disables the input when loading is true', () => {
(useBuyContext as Mock).mockReturnValue({
to: {
token: 'ETH',
amount: 10,
setAmount: vi.fn(),
loading: true,
},
handleAmountChange: mockHandleAmountChange,
});

render(<BuyAmountInput />);

const input = screen.getByTestId('text-input');
expect(input).toBeDisabled();
});
});
44 changes: 44 additions & 0 deletions src/buy/components/BuyAmountInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { isValidAmount } from '../../core/utils/isValidAmount';
import { TextInput } from '../../internal/components/TextInput';
import { background, cn, color } from '../../styles/theme';
import { formatAmount } from '../../swap/utils/formatAmount';
import { TokenChip } from '../../token';
import { useBuyContext } from './BuyProvider';

export function BuyAmountInput() {
const { to, handleAmountChange } = useBuyContext();

if (!to?.token) {
return null;
}

return (
<div
className={cn(
'flex h-full items-center rounded-lg border px-2 pl-4',
background.default,
)}
>
<TextInput
className={cn(
'mr-2 w-full border-none font-display',
'leading-none outline-none disabled:cursor-not-allowed',
background.default,
color.foreground,
)}
placeholder="0.0"
delayMs={1000}
value={formatAmount(to.amount)}
setValue={to.setAmount}
disabled={to.loading}
onChange={handleAmountChange}
inputValidator={isValidAmount}
/>
<TokenChip
className={cn(color.foreground, 'rounded-md')}
token={to.token}
isPressable={false}
/>
</div>
);
}
Loading
Loading