Skip to content

Commit

Permalink
add buy components
Browse files Browse the repository at this point in the history
  • Loading branch information
alissacrane-cb committed Dec 16, 2024
1 parent b545333 commit 6b54db1
Show file tree
Hide file tree
Showing 14 changed files with 1,083 additions and 1 deletion.
75 changes: 75 additions & 0 deletions src/buy/components/Buy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useEffect, useRef } from 'react';
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';

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

// Handle clicking outside the wallet component to close the dropdown.
useEffect(() => {
const handleClickOutsideComponent = (event: MouseEvent) => {
if (
fundSwapContainerRef.current &&
!fundSwapContainerRef.current.contains(event.target as Node) &&
isDropdownOpen
) {
setIsDropdownOpen(false);
}
};

document.addEventListener('click', handleClickOutsideComponent);
return () =>
document.removeEventListener('click', handleClickOutsideComponent);
}, [isDropdownOpen, setIsDropdownOpen]);

return (
<div
ref={fundSwapContainerRef}
className={cn('relative flex flex-col gap-2', className)}
>
<div className={cn('flex items-center gap-4', className)}>
<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,
projectId,
}: BuyReact) {
return (
<BuyProvider
config={config}
experimental={experimental}
isSponsored={isSponsored}
onError={onError}
onStatus={onStatus}
onSuccess={onSuccess}
toToken={toToken}
fromToken={fromToken}
projectId={projectId}
>
<BuyContent className={className} />
</BuyProvider>
);
}
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 { useCallback } from 'react';
import { isValidAmount } from '../../core/utils/isValidAmount';
import { TextInput } from '../../internal/components/TextInput';
import { cn, pressable } from '../../styles/theme';
import { TokenChip } from '../../token';
import { formatAmount } from '../../swap/utils/formatAmount';
import { useBuyContext } from './BuyProvider';

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

const handleChange = useCallback(
(amount: string) => {
handleAmountChange(amount);
},
[handleAmountChange],
);

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

return (
<div className="flex h-full items-center rounded-lg border px-4">
<TextInput
className={cn(
'mr-2 w-full border-[none] bg-transparent font-display',
'leading-none outline-none',
)}
placeholder="0.0"
delayMs={1000}
value={formatAmount(to.amount)}
setValue={to.setAmount}
disabled={to.loading}
onChange={handleChange}
inputValidator={isValidAmount}
/>
<TokenChip
className={cn(pressable.inverse, 'rounded-md')}
token={to.token}
/>
</div>
);
}
67 changes: 67 additions & 0 deletions src/buy/components/BuyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useCallback, useMemo } from 'react';
import { Spinner } from '../../internal/components/Spinner';
import { checkmarkSvg } from '../../internal/svg/checkmarkSvg';
import {
background,
border,
cn,
color,
pressable,
text,
} from '../../styles/theme';
import { useBuyContext } from './BuyProvider';

export function BuyButton() {
const {
setIsDropdownOpen,
from,
fromETH,
fromUSDC,
to,
lifecycleStatus: { statusName },
} = useBuyContext();
const isLoading =
to?.loading ||
from?.loading ||
fromETH.loading ||
fromUSDC.loading ||
statusName === 'transactionPending' ||
statusName === 'transactionApproved';

const isDisabled = !to?.amount || !to?.token || isLoading;

const handleSubmit = useCallback(() => {
setIsDropdownOpen(true);
}, [setIsDropdownOpen]);

const buttonContent = useMemo(() => {
if (statusName === 'success') {
return checkmarkSvg;
}
return 'Buy';
}, [statusName]);

return (
<button
type="button"
className={cn(
background.primary,
border.radius,
'flex rounded-xl',
'h-12 w-24 items-center justify-center px-4 py-3',
isDisabled && pressable.disabled,
text.headline,
)}
onClick={handleSubmit}
data-testid="ockBuyButton_Button"
>
{isLoading ? (
<Spinner />
) : (
<span className={cn(text.headline, color.inverse)}>
{buttonContent}
</span>
)}
</button>
);
}
95 changes: 95 additions & 0 deletions src/buy/components/BuyDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useCallback, useMemo } from 'react';
import { useAccount } from 'wagmi';
import { getRoundedAmount } from '../../core/utils/getRoundedAmount';
import { ONRAMP_BUY_URL } from '../../fund/constants';
import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize';
import { openPopup } from '../../internal/utils/openPopup';
import { background, cn, color, text } from '../../styles/theme';
import { ONRAMP_PAYMENT_METHODS } from '../constants';
import { BuyOnrampItem } from './BuyOnrampItem';
import { useBuyContext } from './BuyProvider';
import { BuyTokenItem } from './BuyTokenItem';

export function BuyDropdown() {
const { to, fromETH, fromUSDC, from, projectId, startPopupMonitor } =
useBuyContext();
const { address } = useAccount();

const handleOnrampClick = useCallback(
(paymentMethodId: string) => {
return () => {
const assetSymbol = to?.token?.symbol;
let fundAmount = to?.amount;
// funding url requires a leading zero if the amount is less than 1
if (fundAmount?.[0] === '.') {
fundAmount = `0${fundAmount}`;
}
const fundingUrl = `${ONRAMP_BUY_URL}/one-click?appId=${projectId}&addresses={"${address}":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${paymentMethodId}`;
const { height, width } = getFundingPopupSize('md', fundingUrl);
const popupWindow = openPopup({
url: fundingUrl,
height,
width,
target: '_blank',
});

if (popupWindow) {
// Detects when the popup is closed
// to stop loading state
startPopupMonitor(popupWindow);
}
};
},
[address, to, projectId],
);

const formattedAmountUSD = useMemo(() => {
if (!to?.amountUSD || to?.amountUSD === '0') {
return null;
}
const roundedAmount = Number(getRoundedAmount(to?.amountUSD, 2));
return `$${roundedAmount.toFixed(2)}`;
}, [to?.amountUSD]);

const isToETH = to?.token?.symbol === 'ETH';
const isToUSDC = to?.token?.symbol === 'USDC';
const showFromToken =
to?.token?.symbol !== from?.token?.symbol &&
from &&
from?.token?.symbol !== 'ETH' &&
from?.token?.symbol !== 'USDC';

return (
<div
className={cn(
color.foreground,
background.alternate,
'absolute right-0 bottom-0 flex translate-y-[105%] flex-col gap-2',
'min-w-80 rounded p-2',
)}
>
<div className="px-2 pt-2">Buy with</div>
{!isToETH && <BuyTokenItem swapUnit={fromETH} />}
{!isToUSDC && <BuyTokenItem swapUnit={fromUSDC} />}
{showFromToken && <BuyTokenItem swapUnit={from} />}

{ONRAMP_PAYMENT_METHODS.map((method) => {
return (
<BuyOnrampItem
key={method.id}
name={method.name}
description={method.description}
onClick={handleOnrampClick(method.id)}
icon={method.icon}
/>
);
})}

{!!formattedAmountUSD && (
<div
className={cn('flex justify-end', text.legal, color.foregroundMuted)}
>{`${to?.amount} ${to?.token?.name}${formattedAmountUSD}`}</div>
)}
</div>
);
}
18 changes: 18 additions & 0 deletions src/buy/components/BuyMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cn, color } from '../../styles/theme';
import { useBuyContext } from './BuyProvider';

export function BuyMessage() {
const {
lifecycleStatus: { statusName },
} = useBuyContext();

if (statusName !== 'error') {
return null;
}

return (
<div className={cn(color.error, 'text-sm')}>
Something went wrong. Please try again.
</div>
);
}
55 changes: 55 additions & 0 deletions src/buy/components/BuyOnrampItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { appleSvg } from '../../internal/svg/appleSvg';
import { cardSvg } from '../../internal/svg/cardSvg';
import { coinbaseLogoSvg } from '../../internal/svg/coinbaseLogoSvg';
import { cn, color } from '../../styles/theme';
import { useBuyContext } from './BuyProvider';

type OnrampItemReact = {
name: string;
description: string;
onClick: () => void;
svg?: React.ReactNode;
icon: string;
};

const ONRAMP_ICON_MAP: Record<string, React.ReactNode> = {
applePay: appleSvg,
coinbasePay: coinbaseLogoSvg,
creditCard: cardSvg,
};

export function BuyOnrampItem({
name,
description,
onClick,
icon,
}: OnrampItemReact) {
const { setIsDropdownOpen } = useBuyContext();

const handleClick = useCallback(() => {
setIsDropdownOpen(false);
onClick();
}, [onClick, setIsDropdownOpen]);

return (
<button
className={cn(
'flex items-center gap-2 rounded-lg p-2',
'hover:bg-[var(--ock-bg-inverse)]',
)}
onClick={handleClick}
type="button"
>
<div className="flex h-9 w-9 items-center justify-center">
{ONRAMP_ICON_MAP[icon]}
</div>
<div className="flex flex-col items-start">
<div>{name}</div>
<div className={cn('text-xs', color.foregroundMuted)}>
{description}
</div>
</div>
</button>
);
}
Loading

0 comments on commit 6b54db1

Please sign in to comment.