diff --git a/.changeset/poor-shoes-hear.md b/.changeset/poor-shoes-hear.md new file mode 100644 index 000000000..2278b80e5 --- /dev/null +++ b/.changeset/poor-shoes-hear.md @@ -0,0 +1,5 @@ +--- +"hub": patch +--- + +feat: Add UI and hooks for pre-deposit functionality diff --git a/apps/hub/src/app/_components/hub-layout.tsx b/apps/hub/src/app/_components/hub-layout.tsx index 0da89edee..382cf1a98 100644 --- a/apps/hub/src/app/_components/hub-layout.tsx +++ b/apps/hub/src/app/_components/hub-layout.tsx @@ -1,8 +1,12 @@ 'use client' import { useState } from 'react' +import { ToastContainer } from '@status-im/components' import { Divider, Footer } from '@status-im/status-network/components' +import { type Vault, VAULTS } from '~constants/index' + +import { PreDepositModal } from './pre-deposit-modal' import { Sidebar } from './sidebar' import { TopBar } from './top-bar' @@ -12,6 +16,11 @@ interface HubLayoutProps { export function HubLayout({ children }: HubLayoutProps) { const [sidebarOpen, setSidebarOpen] = useState(false) + const [selectedVault, setSelectedVault] = useState(null) + + const handleDepositClick = () => { + setSelectedVault(VAULTS[0]) + } return (
@@ -22,7 +31,11 @@ export function HubLayout({ children }: HubLayoutProps) {
{/* Sidebar */} - setSidebarOpen(false)} /> + setSidebarOpen(false)} + onDepositClick={handleDepositClick} + /> {/* Main Content */}
{children}
@@ -30,17 +43,27 @@ export function HubLayout({ children }: HubLayoutProps) {
-
+
-
+
+ + {selectedVault && ( + !open && setSelectedVault(null)} + vault={selectedVault} + setActiveVault={setSelectedVault} + vaults={VAULTS} + /> + )}
) } diff --git a/apps/hub/src/app/_components/icons/index.ts b/apps/hub/src/app/_components/icons/index.ts index fbde49ee3..55084a8de 100644 --- a/apps/hub/src/app/_components/icons/index.ts +++ b/apps/hub/src/app/_components/icons/index.ts @@ -20,5 +20,6 @@ export { default as SNTIcon } from './snt-icon' export { default as StakeIcon } from './stake-icon' export { default as SubmitAppIcon } from './submit-app-icon' export { default as SwapIcon } from './swap-icon' +export { default as TokenIcon } from './token-icon' export { default as TwitterIcon } from './twitter-icon' export { default as VaultIcon } from './vault-icon' diff --git a/apps/hub/src/app/_components/icons/token-icon.tsx b/apps/hub/src/app/_components/icons/token-icon.tsx new file mode 100644 index 000000000..3f7d87288 --- /dev/null +++ b/apps/hub/src/app/_components/icons/token-icon.tsx @@ -0,0 +1,19 @@ +import Image from 'next/image' + +export type TokenIconType = { + token: string +} + +export default function TokenIcon({ token }: TokenIconType) { + return ( +
+ {token} +
+ ) +} diff --git a/apps/hub/src/app/_components/link-item.tsx b/apps/hub/src/app/_components/link-item.tsx index 7bbc49fb3..964a37b81 100644 --- a/apps/hub/src/app/_components/link-item.tsx +++ b/apps/hub/src/app/_components/link-item.tsx @@ -9,6 +9,7 @@ type LinkItemProps = { icon: React.ComponentType<{ className?: string }> href: string tag?: string + onClick?: () => void } const LinkItem = (props: LinkItemProps) => { @@ -25,10 +26,18 @@ const LinkItem = (props: LinkItemProps) => { const isExternal = href.startsWith('http') const isActive = isActiveRoute(href) + const handleClick = (e: React.MouseEvent) => { + if (props.onClick) { + e.preventDefault() + props.onClick() + } + } + return (
  • void + vault: Vault + vaults: Vault[] + setActiveVault: Dispatch> +} + +const depositFormSchema = z.object({ + amount: z.string().min(1, 'Amount is required'), +}) + +type FormValues = z.infer + +type DepositAction = + | 'idle' + | 'approve' + | 'deposit' + | 'invalid' + | 'exceeds-max' + | 'below-min' + +const inputContainerStyles = cva({ + base: 'rounded-16 border bg-white-100 px-4 py-3 transition-colors', + variants: { + state: { + default: 'border-neutral-20', + error: 'border-danger-50', + }, + }, + defaultVariants: { + state: 'default', + }, +}) + +const dividerStyles = cva({ + base: '-mx-4 my-3 h-px w-[calc(100%+32px)] transition-colors', + variants: { + state: { + default: 'bg-neutral-10', + error: 'bg-danger-50', + }, + }, + defaultVariants: { + state: 'default', + }, +}) + +const PreDepositModal = ({ + open, + onOpenChange, + vault, + vaults, + setActiveVault, +}: PreDepositModalProps) => { + const { address, isConnected } = useAccount() + // TODO: Replace with useExhangeRate({ token: vault.token.symbol }) before prod + const { data: exchangeRate } = useExchangeRate() + + const { mutate: approveToken, isPending: isApproving } = useApproveToken() + const { mutate: preDeposit, isPending: isDepositing } = usePreDepositVault() + + const form = useForm({ + resolver: zodResolver(depositFormSchema), + mode: 'onChange', + defaultValues: { + amount: '', + }, + }) + + // TODO: I've seen a bunch of useBalance around this is deprecated in wagmi in favor of readContract on the ERC20 etc. + const { data: balance } = useBalance({ + address, + token: vault?.token.address, + query: { + enabled: isConnected && open && !!vault, + }, + }) + + const { data: maxDeposit } = useMaxPreDepositValue({ + vault, + }) + + const { data: minDeposit } = useMinPreDepositValue({ + vault, + }) + + const amountValue = useWatch({ + control: form.control, + name: 'amount', + defaultValue: '', + }) + + const { data: currentAllowance, refetch } = useReadContract({ + address: vault?.token.address, + abi: vault?.token.abi as typeof allowanceAbi, + functionName: 'allowance', + args: address && vault ? [address, vault.address] : undefined, + query: { + enabled: isConnected && open && !!vault && !!address, + }, + }) + + const amountInUSD = useMemo(() => { + const amountInputNumber = parseFloat(amountValue || '0') + const calculatedUSD = amountInputNumber * (exchangeRate?.price ?? 0) + + return match({ exchangeRate, amountInputNumber, calculatedUSD }) + .with({ exchangeRate: P.nullish }, () => 0) + .with({ amountInputNumber: P.when(n => isNaN(n) || n <= 0) }, () => 0) + .with({ calculatedUSD: P.when(n => !isFinite(n)) }, () => null) + .with({ calculatedUSD: P.when(n => n > 1_000_000_000_000) }, () => null) + .otherwise(({ calculatedUSD }) => calculatedUSD) + }, [amountValue, exchangeRate]) + + const depositAction = useMemo(() => { + if (!vault || !balance || !amountValue || amountValue.trim() === '') { + return 'idle' + } + + let amountWei: bigint + try { + amountWei = parseUnits(amountValue, vault.token.decimals) + } catch { + return 'idle' + } + + if (amountWei <= 0n) return 'idle' + + const allowance: bigint = currentAllowance ?? 0n + + return match({ + amountWei, + balance: balance.value, + allowance, + maxDeposit, + minDeposit, + }) + .returnType() + .with({ amountWei: P.when(amt => amt > balance.value) }, () => 'invalid') + .with( + { + maxDeposit: P.not(P.nullish), + amountWei: P.when(amt => maxDeposit && amt > maxDeposit), + }, + () => 'exceeds-max' + ) + .with( + { + minDeposit: P.not(P.nullish), + amountWei: P.when(amt => minDeposit && amt < minDeposit), + }, + () => 'below-min' + ) + .with({ amountWei: P.when(amt => amt > allowance) }, () => 'approve') + .otherwise(() => 'deposit') + }, [amountValue, balance, vault, currentAllowance, maxDeposit, minDeposit]) + + if (!vault) return null + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + form.reset() + } + onOpenChange(nextOpen) + } + + const handleSubmit = async (data: FormValues) => { + if (!vault || !address) return + + const deposit = () => { + preDeposit( + { amount: data.amount, vault }, + { onSuccess: () => onOpenChange(false) } + ) + } + + const approve = () => { + approveToken( + { + token: vault.token, + amount: data.amount, + spenderAddress: vault.address, + }, + { + async onSuccess() { + await refetch() + deposit() + }, + } + ) + } + + try { + match(depositAction) + .with('approve', approve) + .with('deposit', deposit) + .otherwise(() => {}) + } catch (error) { + console.error('Error during deposit:', error) + form.setError('amount', { + type: 'manual', + message: 'Failed to process deposit', + }) + } + } + + const isPending = isApproving || isDepositing + + const formattedBalance = formatTokenAmount( + balance?.value ?? 0n, + vault.token.symbol, + { + tokenDecimals: vault.token.decimals, + includeSymbol: true, + } + ) + + const formattedMaxDeposit = formatTokenAmount( + maxDeposit ?? 0n, + vault.token.symbol, + { + tokenDecimals: vault.token.decimals, + includeSymbol: true, + } + ) + + const formattedMinDeposit = formatTokenAmount( + minDeposit ?? 0n, + vault.token.symbol, + { + tokenDecimals: vault.token.decimals, + includeSymbol: true, + } + ) + + const inputState = match(depositAction) + .with( + P.union('invalid', 'exceeds-max', 'below-min'), + () => 'error' as const + ) + .otherwise(() => 'default' as const) + + const errorMessage = match(depositAction) + .with('invalid', () => `Insufficient balance. Maximum: ${formattedBalance}`) + .with( + 'exceeds-max', + () => `Exceeds vault limit. Maximum: ${formattedMaxDeposit}` + ) + .with( + 'below-min', + () => `Below minimum deposit. Minimum: ${formattedMinDeposit}` + ) + .otherwise(() => form.formState.errors.amount?.message) + + return ( + + + + +
    + + + + +
    + +
    + + Deposit funds + +
    +
    + + +
    + Deposit funds for yield and rewards +
    +
    +
    + +
    +
    + {/* Vault info */} +
    +
    + Select token +
    + + + + + {vaults.map(v => ( + setActiveVault(v)} + icon={v.icon} + /> + ))} + + +
    + + {/* Amount input */} +
    + +
    +
    + +
    + + + l{vault.token.symbol} + +
    +
    +
    +
    + + {match(amountInUSD) + .with(null, () => '—') + .otherwise(usd => formatCurrency(usd))} + + +
    +
    + {errorMessage && ( +

    {errorMessage}

    + )} +
    + + {/* Rewards */} +
    +

    + Rewards +

    +
    +
    + + + + {vault.apy} APY +
    +
    + + + + + {vault.rewards.join(', ')} + +
    +
    +
    + + {/* Actions */} +
    + +
    +
    + +
    + + + + ) +} + +export { PreDepositModal } +export type { Vault } diff --git a/apps/hub/src/app/_components/sidebar.tsx b/apps/hub/src/app/_components/sidebar.tsx index 09366e943..4c5dbffce 100644 --- a/apps/hub/src/app/_components/sidebar.tsx +++ b/apps/hub/src/app/_components/sidebar.tsx @@ -2,7 +2,7 @@ import { BridgeIcon, - // DepositIcon, + DepositIcon, DiscoverIcon, DocsIcon, ExplorerIcon, @@ -16,13 +16,13 @@ import { LinkItem } from './link-item' const NAV_LINKS = [ { id: 'dashboard', label: 'Home', icon: HomeIcon, href: '/dashboard' }, - // { - // id: 'deposit', - // label: 'Deposit', - // icon: DepositIcon, - // href: '/deposit', - // tag: 'Mainnet', - // }, + { + id: 'deposit', + label: 'Deposit', + icon: DepositIcon, + href: '#', + tag: 'Mainnet', + }, { id: 'discover', label: 'Discover', icon: DiscoverIcon, href: '/discover' }, { id: 'stake', label: 'Stake', icon: StakeIcon, href: '/stake' }, { id: 'karma', label: 'Karma', icon: KarmaIcon, href: '/karma' }, @@ -67,10 +67,11 @@ const OTHER_LINKS = [ type Props = { isOpen: boolean onClose: () => void + onDepositClick?: () => void } const Sidebar = (props: Props) => { - const { isOpen, onClose } = props + const { isOpen, onClose, onDepositClick } = props return ( <> @@ -95,9 +96,13 @@ const Sidebar = (props: Props) => { {/* Main Navigation */}