diff --git a/packages/nextjs/components/Dashboard/InfoCardContainer.tsx b/packages/nextjs/components/Dashboard/InfoCardContainer.tsx index b557d8c9..af3ed133 100644 --- a/packages/nextjs/components/Dashboard/InfoCardContainer.tsx +++ b/packages/nextjs/components/Dashboard/InfoCardContainer.tsx @@ -49,15 +49,20 @@ const InfoCardContainer: React.FC = () => { Refresh -
-
- Account -
- +
+
+
+ Account +
+ +
+ + {currentAccount?.name ?? "Default account"} +
- - {currentAccount?.name ?? "Default account"} + + ver {currentAccount?.contractVersion ?? 1}.0
diff --git a/packages/nextjs/components/modals/DepositModal.tsx b/packages/nextjs/components/modals/DepositModal.tsx index 66aedb72..a5eb5c71 100644 --- a/packages/nextjs/components/modals/DepositModal.tsx +++ b/packages/nextjs/components/modals/DepositModal.tsx @@ -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); @@ -30,14 +44,32 @@ function txExplorerUrl(chainId: number, txHash: string): string { } const DepositModal: React.FC = ({ 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"); @@ -45,7 +77,7 @@ const DepositModal: React.FC = ({ isOpen, onClose, multisigAd } mutate({ multisigAddress, - amount: parseUnits(values.amount, 6), + amount: parseUnits(values.amount, USDC_TOKEN.decimals), }); }); @@ -55,71 +87,175 @@ const DepositModal: React.FC = ({ 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 ( {!isSuccess ? ( -
-

+ + {/* Header */} +

+
+ + Deposit USDC +
+ +
+ + {/* Description */} +

Deposit USDC to this multisig without paying gas. You will sign one message off-chain.

+ {/* Warnings */} {wrongChain && ( -
- Your wallet is on a different network than this multisig. +
+ Your wallet is on a different network than this multisig.
)} - - {unsupported &&

Connect to Base or Base Sepolia to continue.

} - - - - {form.formState.errors.amount && ( - {form.formState.errors.amount.message} + {unsupported && !wrongChain && ( +

Connect to Base or Base Sepolia to continue.

)} - {error &&

{(error as Error).message}

} - + {/* Amount input */} +
+
+ USDC + +
+
+ + Balance: {usdcBalance} USDC + + +
+ {amountError && {amountError}} + {error &&

{(error as Error).message}

} +
+ + {/* Footer */} +
+ + +
) : ( -
-

Deposit submitted

-

Status: {data?.status}

- {data?.principalTxHash && ( - + {/* Header */} + + + {/* Success body */} +
+
+ +
+

Deposit successful

+

Your funds are now available in your account.

+
+ + {/* Footer */} +
+ + +
)} diff --git a/packages/nextjs/components/modals/ModalLayout.tsx b/packages/nextjs/components/modals/ModalLayout.tsx index b37bbbb3..ae2f4434 100644 --- a/packages/nextjs/components/modals/ModalLayout.tsx +++ b/packages/nextjs/components/modals/ModalLayout.tsx @@ -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 = { diff --git a/packages/nextjs/components/modals/PortFolioModal.tsx b/packages/nextjs/components/modals/PortFolioModal.tsx index e5ef8f1c..21765075 100644 --- a/packages/nextjs/components/modals/PortFolioModal.tsx +++ b/packages/nextjs/components/modals/PortFolioModal.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import { Button } from "../ui/button"; import { Sheet, SheetClose, SheetContent, SheetTitle, SheetTrigger } from "../ui/sheet"; import { ResolvedToken, isX402SupportedChain } from "@polypay/shared"; -import { ArrowDownToLine, Eye, EyeOff, MoveDown, MoveUp, X } from "lucide-react"; +import { Eye, EyeOff, MoveDown, MoveUp, X } from "lucide-react"; import { Address } from "viem"; import NetworkBadge from "~~/components/Common/NetworkBadge"; import { useMetaMultiSigWallet } from "~~/hooks"; @@ -142,14 +142,24 @@ export const PortfolioModal: React.FC = ({ children }) => { {/* Action Buttons */} {(() => { - const showDeposit = + const x402Enabled = process.env.NEXT_PUBLIC_FEATURE_X402_DEPOSIT === "true" && isX402SupportedChain(chainId); - const btnPad = showDeposit ? "px-2" : "px-6"; - const txtSize = showDeposit ? "text-sm" : "text-base"; - const iconSize = showDeposit ? "h-4 w-4" : "h-5 w-5"; - const btnClass = `flex-1 min-w-0 h-icon-btn ${btnPad} py-2 gap-1 bg-[rgba(248,248,248,0.13)] hover:bg-[rgba(248,248,248,0.25)] rounded-xl border border-[rgba(255,255,255,0.25)] cursor-pointer`; - const labelClass = `text-grey-50 ${txtSize} font-normal leading-[19px]`; - const iconClass = `${iconSize} text-grey-50 shrink-0`; + const btnClass = + "flex-1 min-w-0 h-icon-btn px-6 py-2 gap-1 bg-[rgba(248,248,248,0.13)] hover:bg-[rgba(248,248,248,0.25)] rounded-xl border border-[rgba(255,255,255,0.25)] cursor-pointer"; + const labelClass = "text-grey-50 text-base font-normal leading-[19px]"; + const iconClass = "h-5 w-5 text-grey-50 shrink-0"; + const handleReceive = () => { + // On Base (with x402 flag on), show method selector first. + // On Horizen (or flag off), open QR directly as before. + if (x402Enabled) { + openModal("receiveMethod", { + multisigAddress: metaMultiSigWallet?.address as `0x${string}`, + multisigChainId: chainId, + }); + } else { + openModal("qrAddressReceiver", { address: metaMultiSigWallet?.address as Address }); + } + }; return (
@@ -158,31 +168,10 @@ export const PortfolioModal: React.FC = ({ children }) => { Transfer - - {showDeposit && ( - - - - )}
); })()} diff --git a/packages/nextjs/components/modals/ReceiveMethodModal.tsx b/packages/nextjs/components/modals/ReceiveMethodModal.tsx new file mode 100644 index 00000000..abc09459 --- /dev/null +++ b/packages/nextjs/components/modals/ReceiveMethodModal.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import ModalContainer from "./ModalContainer"; +import { useModalApp } from "~~/hooks/app/useModalApp"; +import type { ModalProps } from "~~/types/modal"; + +export interface ReceiveMethodModalProps extends ModalProps { + multisigAddress?: `0x${string}`; + multisigChainId?: number; +} + +const ReceiveMethodModal: React.FC = ({ + isOpen, + onClose, + multisigAddress, + multisigChainId, +}) => { + const { openModal } = useModalApp(); + + const handleQR = () => { + onClose(); + openModal("qrAddressReceiver", { address: multisigAddress }); + }; + + const handleFromWallet = () => { + onClose(); + openModal("depositX402", { multisigAddress, multisigChainId }); + }; + + return ( + +
+ {/* Option 1 — QR */} + + + {/* Option 2 — From Wallet (USDC only) */} + +
+
+ ); +}; + +export default ReceiveMethodModal; diff --git a/packages/nextjs/components/ui/dialog.tsx b/packages/nextjs/components/ui/dialog.tsx index 035f904c..5de353c5 100644 --- a/packages/nextjs/components/ui/dialog.tsx +++ b/packages/nextjs/components/ui/dialog.tsx @@ -58,7 +58,7 @@ function DialogContent({ {showCloseButton && ( diff --git a/packages/nextjs/public/icons/receive-method/agent.svg b/packages/nextjs/public/icons/receive-method/agent.svg new file mode 100644 index 00000000..1624706e --- /dev/null +++ b/packages/nextjs/public/icons/receive-method/agent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nextjs/public/icons/receive-method/qr.svg b/packages/nextjs/public/icons/receive-method/qr.svg new file mode 100644 index 00000000..d29a1b4b --- /dev/null +++ b/packages/nextjs/public/icons/receive-method/qr.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/nextjs/public/icons/receive-method/wallet.svg b/packages/nextjs/public/icons/receive-method/wallet.svg new file mode 100644 index 00000000..f31bd0a6 --- /dev/null +++ b/packages/nextjs/public/icons/receive-method/wallet.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/nextjs/types/modal.ts b/packages/nextjs/types/modal.ts index c741cf96..9e254448 100644 --- a/packages/nextjs/types/modal.ts +++ b/packages/nextjs/types/modal.ts @@ -32,4 +32,5 @@ export type ModalName = | "claimReward" | "questIntro" | "createBatchFromContacts" - | "depositX402"; + | "depositX402" + | "receiveMethod";