Skip to content

Commit

Permalink
feat: add fund button (#1322)
Browse files Browse the repository at this point in the history
  • Loading branch information
steveviselli-cb authored Oct 1, 2024
1 parent 6fe3ec1 commit c9c7c3e
Show file tree
Hide file tree
Showing 31 changed files with 551 additions and 372 deletions.
5 changes: 5 additions & 0 deletions .changeset/quick-countries-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@coinbase/onchainkit': patch
---

**feat**: Implement the fund button integrated with Coinbase Onramp. By @steveviselli-cb #1322
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@
"import": "./esm/frame/index.js",
"default": "./esm/frame/index.js"
},
"./fund": {
"types": "./esm/fund/index.d.ts",
"module": "./esm/fund/index.js",
"import": "./esm/fund/index.js",
"default": "./esm/fund/index.js"
},
"./identity": {
"types": "./esm/identity/index.d.ts",
"module": "./esm/identity/index.js",
Expand Down
1 change: 1 addition & 0 deletions playground/nextjs-app-router/components/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useConnect, useConnectors } from 'wagmi';
import { WalletPreference } from './form/wallet-type';

export enum OnchainKitComponent {
Fund = 'fund',
Identity = 'identity',
Swap = 'swap',
Transaction = 'transaction',
Expand Down
5 changes: 5 additions & 0 deletions playground/nextjs-app-router/components/Demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PaymasterUrl } from '@/components/form/paymaster';
import { SwapConfig } from '@/components/form/swap-config';
import { WalletType } from '@/components/form/wallet-type';
import { useContext, useEffect, useState } from 'react';
import FundDemo from './demo/Fund';
import IdentityDemo from './demo/Identity';
import SwapDemo from './demo/Swap';
import TransactionDemo from './demo/Transaction';
Expand Down Expand Up @@ -40,6 +41,10 @@ function Demo() {
}`;

function renderActiveComponent() {
if (activeComponent === OnchainKitComponent.Fund) {
return <FundDemo />;
}

if (activeComponent === OnchainKitComponent.Identity) {
return <IdentityDemo />;
}
Expand Down
9 changes: 9 additions & 0 deletions playground/nextjs-app-router/components/demo/Fund.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { FundButton } from '@coinbase/onchainkit/fund';

export default function FundDemo() {
return (
<div className="mx-auto grid w-1/2 gap-8">
<FundButton />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function ActiveComponent() {
<SelectValue placeholder="Select component" />
</SelectTrigger>
<SelectContent>
<SelectItem value={OnchainKitComponent.Fund}>Fund</SelectItem>
<SelectItem value={OnchainKitComponent.Identity}>Identity</SelectItem>
<SelectItem value={OnchainKitComponent.Transaction}>
Transaction
Expand Down
6 changes: 6 additions & 0 deletions playground/nextjs-app-router/onchainkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
"import": "./esm/frame/index.js",
"default": "./esm/frame/index.js"
},
"./fund": {
"types": "./esm/fund/index.d.ts",
"module": "./esm/fund/index.js",
"import": "./esm/fund/index.js",
"default": "./esm/fund/index.js"
},
"./identity": {
"types": "./esm/identity/index.d.ts",
"module": "./esm/identity/index.js",
Expand Down
93 changes: 93 additions & 0 deletions src/fund/components/FundButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { type Mock, afterEach, describe, expect, it, vi } from 'vitest';
import { openPopup } from '../../internal/utils/openPopup';
import { useGetFundingUrl } from '../hooks/useGetFundingUrl';
import { getFundingPopupSize } from '../utils/getFundingPopupSize';
import { FundButton } from './FundButton';

vi.mock('../hooks/useGetFundingUrl', () => ({
useGetFundingUrl: vi.fn(),
}));

vi.mock('../utils/getFundingPopupSize', () => ({
getFundingPopupSize: vi.fn(),
}));

vi.mock('../../internal/utils/openPopup', () => ({
openPopup: vi.fn(),
}));

describe('WalletDropdownFundLink', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('renders the fund button with the fundingUrl prop when it is defined', () => {
const fundingUrl = 'https://props.funding.url';
const { height, width } = { height: 200, width: 100 };
(getFundingPopupSize as Mock).mockReturnValue({ height, width });

render(<FundButton fundingUrl={fundingUrl} />);

expect(useGetFundingUrl).not.toHaveBeenCalled();
const buttonElement = screen.getByRole('button');
expect(screen.getByText('Fund')).toBeInTheDocument();

fireEvent.click(buttonElement);
expect(getFundingPopupSize as Mock).toHaveBeenCalledWith('md', fundingUrl);
expect(openPopup as Mock).toHaveBeenCalledWith({
url: fundingUrl,
height,
width,
target: undefined,
});
});

it('renders the fund button with the default fundingUrl when the fundingUrl prop is undefined', () => {
const fundingUrl = 'https://default.funding.url';
const { height, width } = { height: 200, width: 100 };
(useGetFundingUrl as Mock).mockReturnValue(fundingUrl);
(getFundingPopupSize as Mock).mockReturnValue({ height, width });

render(<FundButton />);

expect(useGetFundingUrl).toHaveBeenCalled();
const buttonElement = screen.getByRole('button');

fireEvent.click(buttonElement);
expect(getFundingPopupSize as Mock).toHaveBeenCalledWith('md', fundingUrl);
expect(openPopup as Mock).toHaveBeenCalledWith({
url: fundingUrl,
height,
width,
target: undefined,
});
});

it('renders a disabled fund button when no funding URL is provided and the default cannot be fetched', () => {
(useGetFundingUrl as Mock).mockReturnValue(undefined);

render(<FundButton />);

expect(useGetFundingUrl).toHaveBeenCalled();
const buttonElement = screen.getByRole('button');
expect(buttonElement).toHaveClass('pointer-events-none');

fireEvent.click(buttonElement);
expect(openPopup as Mock).not.toHaveBeenCalled();
});

it('renders the fund button as a link when the openIn prop is set to tab', () => {
const fundingUrl = 'https://props.funding.url';
const { height, width } = { height: 200, width: 100 };
(getFundingPopupSize as Mock).mockReturnValue({ height, width });

render(<FundButton fundingUrl={fundingUrl} openIn="tab" />);

expect(useGetFundingUrl).not.toHaveBeenCalled();
const linkElement = screen.getByRole('link');
expect(screen.getByText('Fund')).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', fundingUrl);
});
});
86 changes: 86 additions & 0 deletions src/fund/components/FundButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useCallback } from 'react';
import { addSvg } from '../../internal/svg/addSvg';
import { openPopup } from '../../internal/utils/openPopup';
import { cn, color, pressable, text } from '../../styles/theme';
import { useGetFundingUrl } from '../hooks/useGetFundingUrl';
import type { FundButtonReact } from '../types';
import { getFundingPopupSize } from '../utils/getFundingPopupSize';

export function FundButton({
className,
disabled = false,
fundingUrl,
hideIcon = false,
hideText = false,
openIn = 'popup',
popupSize = 'md',
rel,
target,
text: buttonText = 'Fund',
}: FundButtonReact) {
// If the fundingUrl prop is undefined, fallback to our recommended funding URL based on the wallet type
const fundingUrlToRender = fundingUrl ?? useGetFundingUrl();
const isDisabled = disabled || !fundingUrlToRender;

const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
if (fundingUrlToRender) {
const { height, width } = getFundingPopupSize(
popupSize,
fundingUrlToRender,
);
openPopup({
url: fundingUrlToRender,
height,
width,
target,
});
}
},
[fundingUrlToRender, popupSize, target],
);

const classNames = cn(
pressable.primary,
'rounded-xl px-4 py-3',
'inline-flex items-center justify-center space-x-2 disabled',
isDisabled && pressable.disabled,
text.headline,
color.inverse,
className,
);

const buttonContent = (
<>
{/* h-6 is to match the icon height to the line-height set by text.headline */}
{hideIcon || <span className="flex h-6 items-center">{addSvg}</span>}
{hideText || <span>{buttonText}</span>}
</>
);

if (openIn === 'tab') {
return (
<a
className={classNames}
href={fundingUrlToRender}
// If openIn is 'tab', default target to _blank so we don't accidentally navigate in the current tab
target={target ?? '_blank'}
rel={rel}
>
{buttonContent}
</a>
);
}

return (
<button
className={classNames}
onClick={handleClick}
type="button"
disabled={isDisabled}
>
{buttonContent}
</button>
);
}
2 changes: 2 additions & 0 deletions src/fund/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// The base URL for the Coinbase Onramp widget.
export const ONRAMP_BUY_URL = 'https://pay.coinbase.com/buy';
// The recommended height of a Coinbase Onramp popup window.
export const ONRAMP_POPUP_HEIGHT = 720;
// The recommended width of a Coinbase Onramp popup window.
Expand Down
13 changes: 3 additions & 10 deletions src/fund/hooks/useGetFundingUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { type Mock, describe, expect, it, vi } from 'vitest';
import { useAccount } from 'wagmi';
import { useOnchainKit } from '../../useOnchainKit';
import { useIsWalletACoinbaseSmartWallet } from '../../wallet/hooks/useIsWalletACoinbaseSmartWallet';
import { ONRAMP_POPUP_HEIGHT, ONRAMP_POPUP_WIDTH } from '../constants';
import { getCoinbaseSmartWalletFundUrl } from '../utils/getCoinbaseSmartWalletFundUrl';
import { getOnrampBuyUrl } from '../utils/getOnrampBuyUrl';
import { useGetFundingUrl } from './useGetFundingUrl';
Expand Down Expand Up @@ -45,9 +44,7 @@ describe('useGetFundingUrl', () => {

const { result } = renderHook(() => useGetFundingUrl());

expect(result.current?.url).toBe('https://keys.coinbase.com/fund');
expect(result.current?.popupHeight).toBeUndefined();
expect(result.current?.popupWidth).toBeUndefined();
expect(result.current).toBe('https://keys.coinbase.com/fund');
});

it('should return a Coinbase Onramp fund URL if connected wallet is not a Coinbase Smart Wallet', () => {
Expand All @@ -64,9 +61,7 @@ describe('useGetFundingUrl', () => {

const { result } = renderHook(() => useGetFundingUrl());

expect(result.current?.url).toBe('https://pay.coinbase.com/buy');
expect(result.current?.popupHeight).toBe(ONRAMP_POPUP_HEIGHT);
expect(result.current?.popupWidth).toBe(ONRAMP_POPUP_WIDTH);
expect(result.current).toBe('https://pay.coinbase.com/buy');

expect(getOnrampBuyUrl).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -90,9 +85,7 @@ describe('useGetFundingUrl', () => {

const { result } = renderHook(() => useGetFundingUrl());

expect(result.current?.url).toBe('https://pay.coinbase.com/buy');
expect(result.current?.popupHeight).toBe(ONRAMP_POPUP_HEIGHT);
expect(result.current?.popupWidth).toBe(ONRAMP_POPUP_WIDTH);
expect(result.current).toBe('https://pay.coinbase.com/buy');

expect(getOnrampBuyUrl).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
21 changes: 6 additions & 15 deletions src/fund/hooks/useGetFundingUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { useMemo } from 'react';
import { useAccount } from 'wagmi';
import { useOnchainKit } from '../../useOnchainKit';
import { useIsWalletACoinbaseSmartWallet } from '../../wallet/hooks/useIsWalletACoinbaseSmartWallet';
import { ONRAMP_POPUP_HEIGHT, ONRAMP_POPUP_WIDTH } from '../constants';
import type { UseGetFundingUrlResponse } from '../types';
import { getCoinbaseSmartWalletFundUrl } from '../utils/getCoinbaseSmartWalletFundUrl';
import { getOnrampBuyUrl } from '../utils/getOnrampBuyUrl';

Expand All @@ -12,7 +10,7 @@ import { getOnrampBuyUrl } from '../utils/getOnrampBuyUrl';
* user to keys.coinbase.com, otherwise it will send them to pay.coinbase.com.
* @returns the funding URL and optional popup dimensions if the URL requires them
*/
export function useGetFundingUrl(): UseGetFundingUrlResponse | undefined {
export function useGetFundingUrl(): string | undefined {
const { projectId, chain: defaultChain } = useOnchainKit();
const { address, chain: accountChain } = useAccount();
const isCoinbaseSmartWallet = useIsWalletACoinbaseSmartWallet();
Expand All @@ -23,23 +21,16 @@ export function useGetFundingUrl(): UseGetFundingUrlResponse | undefined {

return useMemo(() => {
if (isCoinbaseSmartWallet) {
return {
url: getCoinbaseSmartWalletFundUrl(),
};
return getCoinbaseSmartWalletFundUrl();
}

if (projectId === null || address === undefined) {
return undefined;
}

return {
url: getOnrampBuyUrl({
projectId,
addresses: { [address]: [chain.name.toLowerCase()] },
}),
// The Coinbase Onramp widget is not very responsive, so we need to set a fixed popup size.
popupHeight: ONRAMP_POPUP_HEIGHT,
popupWidth: ONRAMP_POPUP_WIDTH,
};
return getOnrampBuyUrl({
projectId,
addresses: { [address]: [chain.name.toLowerCase()] },
});
}, [isCoinbaseSmartWallet, projectId, address, chain]);
}
1 change: 1 addition & 0 deletions src/fund/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { FundButton } from './components/FundButton';
export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl';
export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl';
export type {
Expand Down
Loading

0 comments on commit c9c7c3e

Please sign in to comment.