Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 12 additions & 7 deletions packages/nextjs/components/Dashboard/InfoCardContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,20 @@ const InfoCardContainer: React.FC<InfoCardContainerProps> = () => {
<Image src="/icons/actions/rotate-360.svg" alt="Refresh" width={18} height={18} />
</span>
</div>
<div className="flex flex-row gap-2 items-center">
<div className="relative w-10 h-10 flex items-center justify-center shrink-0">
<Image src={avatarSrc} alt="Account" width={40} height={40} className="rounded-[9px]" />
<div className="absolute -bottom-1 -right-1">
<NetworkBadge chainId={chainId} size={16} />
<div className="flex flex-row gap-2 items-center justify-between">
<div className="flex flex-row gap-2 items-center min-w-0">
<div className="relative w-10 h-10 flex items-center justify-center shrink-0">
<Image src={avatarSrc} alt="Account" width={40} height={40} className="rounded-[9px]" />
<div className="absolute -bottom-1 -right-1">
<NetworkBadge chainId={chainId} size={16} />
</div>
</div>
<span className="font-family-repetition text-[32px] text-white leading-none tracking-[-0.01em] max-w-[122px] truncate">
{currentAccount?.name ?? "Default account"}
</span>
</div>
<span className="font-family-repetition text-[32px] text-white leading-none tracking-[-0.01em] max-w-[122px] truncate">
{currentAccount?.name ?? "Default account"}
<span className="text-white text-sm font-medium leading-none shrink-0 self-end">
ver {currentAccount?.contractVersion ?? 1}.0
</span>
</div>
</div>
Expand Down
230 changes: 183 additions & 47 deletions packages/nextjs/components/modals/DepositModal.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
"use client";

import React from "react";
import { isX402SupportedChain } from "@polypay/shared";
import { parseUnits } from "viem";
import { useChainId, useSwitchChain } from "wagmi";
import Image from "next/image";
import ModalContainer from "./ModalContainer";
import { USDC_TOKEN, formatTokenAmount, isX402SupportedChain } from "@polypay/shared";
import { ArrowLeft, Check, X } from "lucide-react";
import { formatUnits, parseUnits } from "viem";
import { useAccount, useChainId, useReadContract, useSwitchChain } from "wagmi";
import { z } from "zod";
import ModalContainer from "~~/components/modals/ModalContainer";
import { Button } from "~~/components/ui/button";
import { useX402Deposit } from "~~/hooks/api/useX402Deposit";
import { useModalApp } from "~~/hooks/app/useModalApp";
import { useZodForm } from "~~/hooks/form";
import type { ModalProps } from "~~/types/modal";
import { notification } from "~~/utils/scaffold-eth/notification";

const ERC20_BALANCE_ABI = [
{
name: "balanceOf",
type: "function",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
},
] as const;

const schema = z.object({
amount: z.string().refine(v => {
const n = Number(v);
Expand All @@ -30,22 +44,40 @@ function txExplorerUrl(chainId: number, txHash: string): string {
}

const DepositModal: React.FC<DepositModalProps> = ({ isOpen, onClose, multisigAddress, multisigChainId }) => {
const { openModal } = useModalApp();
const chainId = useChainId();
const { switchChain } = useSwitchChain();
const { address: walletAddress } = useAccount();
const { mutate, isPending, isSuccess, data, error, reset } = useX402Deposit();
const form = useZodForm({ schema, defaultValues: { amount: "" } });

const wrongChain = typeof multisigChainId === "number" && chainId !== multisigChainId;
const unsupported = !isX402SupportedChain(chainId);

const usdcAddress = USDC_TOKEN.addresses[chainId] as `0x${string}` | undefined;

const { data: usdcBalanceRaw } = useReadContract({
address: usdcAddress,
abi: ERC20_BALANCE_ABI,
functionName: "balanceOf",
args: walletAddress ? [walletAddress] : undefined,
chainId,
query: {
enabled: !!walletAddress && !!usdcAddress && !wrongChain && !unsupported,
},
});

const usdcBalance =
typeof usdcBalanceRaw === "bigint" ? formatTokenAmount(usdcBalanceRaw.toString(), USDC_TOKEN.decimals) : "0";

const handleSubmit = form.handleSubmit((values: FormValues) => {
if (!multisigAddress) {
notification.error("Missing multisig address");
return;
}
mutate({
multisigAddress,
amount: parseUnits(values.amount, 6),
amount: parseUnits(values.amount, USDC_TOKEN.decimals),
});
});

Expand All @@ -55,71 +87,175 @@ const DepositModal: React.FC<DepositModalProps> = ({ isOpen, onClose, multisigAd
onClose();
};

const handleBack = () => {
reset();
form.reset();
onClose();
openModal("receiveMethod", { multisigAddress, multisigChainId });
};

const handleMax = () => {
if (!usdcBalanceRaw || typeof usdcBalanceRaw !== "bigint") return;
const value = formatUnits(usdcBalanceRaw, USDC_TOKEN.decimals);
form.setValue("amount", value, { shouldValidate: true });
};

const handleViewTx = () => {
if (data?.principalTxHash) {
window.open(txExplorerUrl(data.chainId, data.principalTxHash), "_blank", "noopener,noreferrer");
}
};

const amount = form.watch("amount");
const amountError = form.formState.errors.amount?.message;
const canSubmit =
!isPending && !wrongChain && !unsupported && !!amount && Number(amount) > 0 && !amountError && !!multisigAddress;

return (
<ModalContainer
isOpen={isOpen}
onClose={handleClose}
title="Deposit USDC"
isCloseButton
className="bg-white rounded-3xl w-[min(480px,92vw)] px-5 py-5 shadow-modal"
isCloseButton={false}
className="bg-white rounded-3xl w-[min(480px,92vw)] p-0 shadow-modal overflow-hidden"
>
{!isSuccess ? (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 pt-3">
<p className="text-sm text-base-content/70 pr-10">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleBack}
className="p-1 rounded-lg hover:bg-grey-100 cursor-pointer"
aria-label="Back"
>
<ArrowLeft className="w-5 h-5 text-grey-1000" />
</button>
<span className="text-grey-1000 text-base font-semibold">Deposit USDC</span>
</div>
<button
type="button"
onClick={handleClose}
className="w-9 h-9 flex items-center justify-center rounded-lg border border-grey-200 hover:bg-grey-50 cursor-pointer"
aria-label="Close"
>
<X className="w-4 h-4 text-grey-1000" />
</button>
</div>

{/* Description */}
<p className="px-5 text-sm text-grey-600 leading-5">
Deposit USDC to this multisig without paying gas. You will sign one message off-chain.
</p>

{/* Warnings */}
{wrongChain && (
<div className="flex flex-col gap-2 rounded border border-warning/50 bg-warning/10 p-3">
<span className="text-sm text-warning">Your wallet is on a different network than this multisig.</span>
<div className="mx-5 mt-3 flex flex-col gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3">
<span className="text-sm text-amber-800">Your wallet is on a different network than this multisig.</span>
<button
type="button"
className="btn btn-sm btn-warning"
className="self-start text-sm font-medium text-amber-900 underline cursor-pointer"
onClick={() => multisigChainId && switchChain({ chainId: multisigChainId })}
>
Switch network
</button>
</div>
)}

{unsupported && <p className="text-error text-sm">Connect to Base or Base Sepolia to continue.</p>}

<label className="text-sm font-medium">Amount (USDC)</label>
<input
{...form.register("amount")}
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="1"
disabled={isPending || wrongChain || unsupported}
className="w-full h-11 px-4 rounded-full border border-grey-300 bg-white text-base outline-none focus:border-grey-500 disabled:opacity-50"
/>
{form.formState.errors.amount && (
<span className="text-error text-xs">{form.formState.errors.amount.message}</span>
{unsupported && !wrongChain && (
<p className="mx-5 mt-3 text-sm text-red-600">Connect to Base or Base Sepolia to continue.</p>
)}
{error && <p className="text-error text-sm">{(error as Error).message}</p>}

<button type="submit" disabled={isPending || wrongChain || unsupported} className="btn btn-primary w-full">
{isPending ? "Signing / submitting…" : "Deposit"}
</button>
{/* Amount input */}
<div className="flex flex-col items-center gap-3 px-5 py-8">
<div className="flex items-center gap-3">
<Image src="/token/usdc.svg" alt="USDC" width={40} height={40} className="rounded-full" />
<input
{...form.register("amount")}
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="0.00"
disabled={isPending || wrongChain || unsupported}
className="text-4xl font-light text-black placeholder:text-grey-300 bg-transparent outline-none w-[80px] text-center disabled:opacity-50"
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-grey-600">
Balance: <span className="font-medium text-grey-1000">{usdcBalance} USDC</span>
</span>
<button
type="button"
onClick={handleMax}
disabled={isPending || wrongChain || unsupported}
className="px-3 py-1 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold cursor-pointer disabled:opacity-50"
>
Max
</button>
</div>
{amountError && <span className="text-red-600 text-xs">{amountError}</span>}
{error && <p className="text-red-600 text-sm text-center">{(error as Error).message}</p>}
</div>

{/* Footer */}
<div className="flex gap-3 px-5 py-4 border-t border-grey-100">
<Button
type="button"
onClick={handleClose}
disabled={isPending}
className="basis-1/4 h-11 bg-white hover:bg-grey-50 text-grey-1000 border border-grey-200 rounded-xl cursor-pointer"
>
Cancel
</Button>
<Button
type="submit"
disabled={!canSubmit}
className="flex-1 h-11 bg-main-pink hover:bg-pink-550 text-grey-1000 rounded-xl cursor-pointer disabled:opacity-50"
>
{isPending ? "Signing..." : "Deposit"}
</Button>
</div>
</form>
) : (
<div className="flex flex-col gap-3 pt-3">
<p className="text-success font-medium">Deposit submitted</p>
<p className="text-sm">Status: {data?.status}</p>
{data?.principalTxHash && (
<a
href={txExplorerUrl(data.chainId, data.principalTxHash)}
target="_blank"
rel="noreferrer"
className="text-xs link link-primary break-all"
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-end px-5 pt-5 pb-3">
<button
type="button"
onClick={handleClose}
className="w-9 h-9 flex items-center justify-center rounded-lg border border-grey-200 hover:bg-grey-50 cursor-pointer"
aria-label="Close"
>
Tx: {data.principalTxHash}
</a>
)}
<button type="button" className="btn btn-ghost w-full" onClick={handleClose}>
Close
</button>
<X className="w-4 h-4 text-grey-1000" />
</button>
</div>

{/* Success body */}
<div className="flex flex-col items-center gap-4 px-5 py-6">
<div className="w-16 h-16 rounded-full bg-green-500 flex items-center justify-center">
<Check className="w-9 h-9 text-white" strokeWidth={3} />
</div>
<h3 className="text-grey-1000 text-2xl font-semibold uppercase tracking-tight">Deposit successful</h3>
<p className="text-sm text-grey-600 text-center">Your funds are now available in your account.</p>
</div>

{/* Footer */}
<div className="flex gap-3 px-5 py-4 border-t border-grey-100">
<Button
type="button"
onClick={handleViewTx}
disabled={!data?.principalTxHash}
className="flex-1 h-11 bg-grey-1000 hover:bg-grey-950 text-white rounded-xl cursor-pointer disabled:opacity-50"
>
View transaction
</Button>
<Button
type="button"
onClick={handleClose}
className="flex-1 h-11 bg-main-pink hover:bg-pink-550 text-grey-1000 rounded-xl cursor-pointer"
>
Done
</Button>
</div>
</div>
)}
</ModalContainer>
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/components/modals/ModalLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const modals: ModalRegistry = {
questIntro: dynamic(() => import("./QuestIntroModal"), { ssr: false }),
createBatchFromContacts: dynamic(() => import("./CreateBatchFromContactsModal"), { ssr: false }),
depositX402: dynamic(() => import("./DepositModal"), { ssr: false }),
receiveMethod: dynamic(() => import("./ReceiveMethodModal"), { ssr: false }),
};

type ModalInstance = {
Expand Down
Loading
Loading