Initial commit with 🏗️ Scaffold-ETH 2 @ 1.0.5
This commit is contained in:
187
packages/nextjs/components/scaffold-eth/Address/Address.tsx
Normal file
187
packages/nextjs/components/scaffold-eth/Address/Address.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { AddressCopyIcon } from "./AddressCopyIcon";
|
||||
import { AddressLinkWrapper } from "./AddressLinkWrapper";
|
||||
import { Address as AddressType, getAddress, isAddress } from "viem";
|
||||
import { normalize } from "viem/ens";
|
||||
import { useEnsAvatar, useEnsName } from "wagmi";
|
||||
import { BlockieAvatar } from "~~/components/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
|
||||
|
||||
const textSizeMap = {
|
||||
"3xs": "text-[10px]",
|
||||
"2xs": "text-[11px]",
|
||||
xs: "text-xs",
|
||||
sm: "text-sm",
|
||||
base: "text-base",
|
||||
lg: "text-lg",
|
||||
xl: "text-xl",
|
||||
"2xl": "text-2xl",
|
||||
"3xl": "text-3xl",
|
||||
"4xl": "text-4xl",
|
||||
} as const;
|
||||
|
||||
const blockieSizeMap = {
|
||||
"3xs": 4,
|
||||
"2xs": 5,
|
||||
xs: 6,
|
||||
sm: 7,
|
||||
base: 8,
|
||||
lg: 9,
|
||||
xl: 10,
|
||||
"2xl": 12,
|
||||
"3xl": 15,
|
||||
"4xl": 17,
|
||||
"5xl": 19,
|
||||
"6xl": 21,
|
||||
"7xl": 23,
|
||||
} as const;
|
||||
|
||||
const copyIconSizeMap = {
|
||||
"3xs": "h-2.5 w-2.5",
|
||||
"2xs": "h-3 w-3",
|
||||
xs: "h-3.5 w-3.5",
|
||||
sm: "h-4 w-4",
|
||||
base: "h-[18px] w-[18px]",
|
||||
lg: "h-5 w-5",
|
||||
xl: "h-[22px] w-[22px]",
|
||||
"2xl": "h-6 w-6",
|
||||
"3xl": "h-[26px] w-[26px]",
|
||||
"4xl": "h-7 w-7",
|
||||
} as const;
|
||||
|
||||
type SizeMap = typeof textSizeMap | typeof blockieSizeMap;
|
||||
|
||||
const getNextSize = <T extends SizeMap>(sizeMap: T, currentSize: keyof T, step = 1): keyof T => {
|
||||
const sizes = Object.keys(sizeMap) as Array<keyof T>;
|
||||
const currentIndex = sizes.indexOf(currentSize);
|
||||
const nextIndex = Math.min(currentIndex + step, sizes.length - 1);
|
||||
return sizes[nextIndex];
|
||||
};
|
||||
|
||||
const getPrevSize = <T extends SizeMap>(sizeMap: T, currentSize: keyof T, step = 1): keyof T => {
|
||||
const sizes = Object.keys(sizeMap) as Array<keyof T>;
|
||||
const currentIndex = sizes.indexOf(currentSize);
|
||||
const prevIndex = Math.max(currentIndex - step, 0);
|
||||
return sizes[prevIndex];
|
||||
};
|
||||
|
||||
type AddressProps = {
|
||||
address?: AddressType;
|
||||
disableAddressLink?: boolean;
|
||||
format?: "short" | "long";
|
||||
size?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
|
||||
onlyEnsOrAddress?: boolean;
|
||||
};
|
||||
|
||||
export const Address = ({
|
||||
address,
|
||||
disableAddressLink,
|
||||
format,
|
||||
size = "base",
|
||||
onlyEnsOrAddress = false,
|
||||
}: AddressProps) => {
|
||||
const checkSumAddress = address ? getAddress(address) : undefined;
|
||||
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
const { data: ens, isLoading: isEnsNameLoading } = useEnsName({
|
||||
address: checkSumAddress,
|
||||
chainId: 1,
|
||||
query: {
|
||||
enabled: isAddress(checkSumAddress ?? ""),
|
||||
},
|
||||
});
|
||||
const { data: ensAvatar } = useEnsAvatar({
|
||||
name: ens ? normalize(ens) : undefined,
|
||||
chainId: 1,
|
||||
query: {
|
||||
enabled: Boolean(ens),
|
||||
gcTime: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
const shortAddress = checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4);
|
||||
const displayAddress = format === "long" ? checkSumAddress : shortAddress;
|
||||
const displayEnsOrAddress = ens || displayAddress;
|
||||
|
||||
const showSkeleton = !checkSumAddress || (!onlyEnsOrAddress && (ens || isEnsNameLoading));
|
||||
|
||||
const addressSize = showSkeleton && !onlyEnsOrAddress ? getPrevSize(textSizeMap, size, 2) : size;
|
||||
const ensSize = getNextSize(textSizeMap, addressSize);
|
||||
const blockieSize = showSkeleton && !onlyEnsOrAddress ? getNextSize(blockieSizeMap, addressSize, 4) : addressSize;
|
||||
|
||||
if (!checkSumAddress) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="shrink-0 skeleton rounded-full"
|
||||
style={{
|
||||
width: (blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"],
|
||||
height: (blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"],
|
||||
}}
|
||||
></div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
{!onlyEnsOrAddress && (
|
||||
<div className={`ml-1.5 skeleton rounded-lg font-bold ${textSizeMap[ensSize]}`}>
|
||||
<span className="invisible">0x1234...56789</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`ml-1.5 skeleton rounded-lg ${textSizeMap[addressSize]}`}>
|
||||
<span className="invisible">0x1234...56789</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAddress(checkSumAddress)) {
|
||||
return <span className="text-error">Wrong address</span>;
|
||||
}
|
||||
|
||||
const blockExplorerAddressLink = getBlockExplorerAddressLink(targetNetwork, checkSumAddress);
|
||||
|
||||
return (
|
||||
<div className="flex items-center shrink-0">
|
||||
<div className="shrink-0">
|
||||
<BlockieAvatar
|
||||
address={checkSumAddress}
|
||||
ensImage={ensAvatar}
|
||||
size={(blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{showSkeleton &&
|
||||
(isEnsNameLoading ? (
|
||||
<div className={`ml-1.5 skeleton rounded-lg font-bold ${textSizeMap[ensSize]}`}>
|
||||
<span className="invisible">{shortAddress}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className={`ml-1.5 ${textSizeMap[ensSize]} font-bold`}>
|
||||
<AddressLinkWrapper
|
||||
disableAddressLink={disableAddressLink}
|
||||
blockExplorerAddressLink={blockExplorerAddressLink}
|
||||
>
|
||||
{ens}
|
||||
</AddressLinkWrapper>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex">
|
||||
<span className={`ml-1.5 ${textSizeMap[addressSize]} font-normal`}>
|
||||
<AddressLinkWrapper
|
||||
disableAddressLink={disableAddressLink}
|
||||
blockExplorerAddressLink={blockExplorerAddressLink}
|
||||
>
|
||||
{onlyEnsOrAddress ? displayEnsOrAddress : displayAddress}
|
||||
</AddressLinkWrapper>
|
||||
</span>
|
||||
<AddressCopyIcon
|
||||
className={`ml-1 ${copyIconSizeMap[addressSize]} cursor-pointer`}
|
||||
address={checkSumAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
|
||||
|
||||
export const AddressCopyIcon = ({ className, address }: { className?: string; address: string }) => {
|
||||
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
copyAddressToClipboard(address);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isAddressCopiedToClipboard ? (
|
||||
<CheckCircleIcon className={className} aria-hidden="true" />
|
||||
) : (
|
||||
<DocumentDuplicateIcon className={className} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||
|
||||
type AddressLinkWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
disableAddressLink?: boolean;
|
||||
blockExplorerAddressLink: string;
|
||||
};
|
||||
|
||||
export const AddressLinkWrapper = ({
|
||||
children,
|
||||
disableAddressLink,
|
||||
blockExplorerAddressLink,
|
||||
}: AddressLinkWrapperProps) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
return disableAddressLink ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<Link
|
||||
href={blockExplorerAddressLink}
|
||||
target={targetNetwork.id === hardhat.id ? undefined : "_blank"}
|
||||
rel={targetNetwork.id === hardhat.id ? undefined : "noopener noreferrer"}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
75
packages/nextjs/components/scaffold-eth/Balance.tsx
Normal file
75
packages/nextjs/components/scaffold-eth/Balance.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { Address, formatEther } from "viem";
|
||||
import { useDisplayUsdMode } from "~~/hooks/scaffold-eth/useDisplayUsdMode";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
|
||||
import { useGlobalState } from "~~/services/store/store";
|
||||
|
||||
type BalanceProps = {
|
||||
address?: Address;
|
||||
className?: string;
|
||||
usdMode?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display (ETH & USD) balance of an ETH address.
|
||||
*/
|
||||
export const Balance = ({ address, className = "", usdMode }: BalanceProps) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
|
||||
const isNativeCurrencyPriceFetching = useGlobalState(state => state.nativeCurrency.isFetching);
|
||||
|
||||
const {
|
||||
data: balance,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useWatchBalance({
|
||||
address,
|
||||
});
|
||||
|
||||
const { displayUsdMode, toggleDisplayUsdMode } = useDisplayUsdMode({ defaultUsdMode: usdMode });
|
||||
|
||||
if (!address || isLoading || balance === null || (isNativeCurrencyPriceFetching && nativeCurrencyPrice === 0)) {
|
||||
return (
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="rounded-md bg-slate-300 h-6 w-6"></div>
|
||||
<div className="flex items-center space-y-6">
|
||||
<div className="h-2 w-28 bg-slate-300 rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="border-2 border-base-content/30 rounded-md px-2 flex flex-col items-center max-w-fit cursor-pointer">
|
||||
<div className="text-warning">Error</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedBalance = balance ? Number(formatEther(balance.value)) : 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-sm btn-ghost flex flex-col font-normal items-center hover:bg-transparent ${className}`}
|
||||
onClick={toggleDisplayUsdMode}
|
||||
type="button"
|
||||
>
|
||||
<div className="w-full flex items-center justify-center">
|
||||
{displayUsdMode ? (
|
||||
<>
|
||||
<span className="text-[0.8em] font-bold mr-1">$</span>
|
||||
<span>{(formattedBalance * nativeCurrencyPrice).toFixed(2)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{formattedBalance.toFixed(4)}</span>
|
||||
<span className="text-[0.8em] font-bold ml-1">{targetNetwork.nativeCurrency.symbol}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { AvatarComponent } from "@rainbow-me/rainbowkit";
|
||||
import { blo } from "blo";
|
||||
|
||||
// Custom Avatar for RainbowKit
|
||||
export const BlockieAvatar: AvatarComponent = ({ address, ensImage, size }) => (
|
||||
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={ensImage || blo(address as `0x${string}`)}
|
||||
width={size}
|
||||
height={size}
|
||||
alt={`${address} avatar`}
|
||||
/>
|
||||
);
|
||||
129
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal file
129
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Address as AddressType, createWalletClient, http, parseEther } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useAccount } from "wagmi";
|
||||
import { BanknotesIcon } from "@heroicons/react/24/outline";
|
||||
import { Address, AddressInput, Balance, EtherInput } from "~~/components/scaffold-eth";
|
||||
import { useTransactor } from "~~/hooks/scaffold-eth";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
// Account index to use from generated hardhat accounts.
|
||||
const FAUCET_ACCOUNT_INDEX = 0;
|
||||
|
||||
const localWalletClient = createWalletClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Faucet modal which lets you send ETH to any address.
|
||||
*/
|
||||
export const Faucet = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputAddress, setInputAddress] = useState<AddressType>();
|
||||
const [faucetAddress, setFaucetAddress] = useState<AddressType>();
|
||||
const [sendValue, setSendValue] = useState("");
|
||||
|
||||
const { chain: ConnectedChain } = useAccount();
|
||||
|
||||
const faucetTxn = useTransactor(localWalletClient);
|
||||
|
||||
useEffect(() => {
|
||||
const getFaucetAddress = async () => {
|
||||
try {
|
||||
const accounts = await localWalletClient.getAddresses();
|
||||
setFaucetAddress(accounts[FAUCET_ACCOUNT_INDEX]);
|
||||
} catch (error) {
|
||||
notification.error(
|
||||
<>
|
||||
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
|
||||
<p className="m-0">
|
||||
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
|
||||
</p>
|
||||
<p className="mt-1 break-normal">
|
||||
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
|
||||
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
|
||||
</p>
|
||||
</>,
|
||||
);
|
||||
console.error("⚡️ ~ file: Faucet.tsx:getFaucetAddress ~ error", error);
|
||||
}
|
||||
};
|
||||
getFaucetAddress();
|
||||
}, []);
|
||||
|
||||
const sendETH = async () => {
|
||||
if (!faucetAddress || !inputAddress) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await faucetTxn({
|
||||
to: inputAddress,
|
||||
value: parseEther(sendValue as `${number}`),
|
||||
account: faucetAddress,
|
||||
});
|
||||
setLoading(false);
|
||||
setInputAddress(undefined);
|
||||
setSendValue("");
|
||||
} catch (error) {
|
||||
console.error("⚡️ ~ file: Faucet.tsx:sendETH ~ error", error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render only on local chain
|
||||
if (ConnectedChain?.id !== hardhat.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="faucet-modal" className="btn btn-primary btn-sm font-normal gap-1">
|
||||
<BanknotesIcon className="h-4 w-4" />
|
||||
<span>Faucet</span>
|
||||
</label>
|
||||
<input type="checkbox" id="faucet-modal" className="modal-toggle" />
|
||||
<label htmlFor="faucet-modal" className="modal cursor-pointer">
|
||||
<label className="modal-box relative">
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
<h3 className="text-xl font-bold mb-3">Local Faucet</h3>
|
||||
<label htmlFor="faucet-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex space-x-4">
|
||||
<div>
|
||||
<span className="text-sm font-bold">From:</span>
|
||||
<Address address={faucetAddress} onlyEnsOrAddress />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-bold pl-3">Available:</span>
|
||||
<Balance address={faucetAddress} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<AddressInput
|
||||
placeholder="Destination Address"
|
||||
value={inputAddress ?? ""}
|
||||
onChange={value => setInputAddress(value as AddressType)}
|
||||
/>
|
||||
<EtherInput placeholder="Amount to send" value={sendValue} onChange={value => setSendValue(value)} />
|
||||
<button className="h-10 btn btn-primary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
|
||||
{!loading ? (
|
||||
<BanknotesIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
)}
|
||||
<span>Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createWalletClient, http, parseEther } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useAccount } from "wagmi";
|
||||
import { BanknotesIcon } from "@heroicons/react/24/outline";
|
||||
import { useTransactor } from "~~/hooks/scaffold-eth";
|
||||
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
|
||||
|
||||
// Number of ETH faucet sends to an address
|
||||
const NUM_OF_ETH = "1";
|
||||
const FAUCET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||
|
||||
const localWalletClient = createWalletClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
/**
|
||||
* FaucetButton button which lets you grab eth.
|
||||
*/
|
||||
export const FaucetButton = () => {
|
||||
const { address, chain: ConnectedChain } = useAccount();
|
||||
|
||||
const { data: balance } = useWatchBalance({ address });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const faucetTxn = useTransactor(localWalletClient);
|
||||
|
||||
const sendETH = async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await faucetTxn({
|
||||
account: FAUCET_ADDRESS,
|
||||
to: address,
|
||||
value: parseEther(NUM_OF_ETH),
|
||||
});
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("⚡️ ~ file: FaucetButton.tsx:sendETH ~ error", error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render only on local chain
|
||||
if (ConnectedChain?.id !== hardhat.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isBalanceZero = balance && balance.value === 0n;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
!isBalanceZero
|
||||
? "ml-1"
|
||||
: "ml-1 tooltip tooltip-bottom tooltip-primary tooltip-open font-bold before:left-auto before:transform-none before:content-[attr(data-tip)] before:-translate-x-2/5"
|
||||
}
|
||||
data-tip="Grab funds from faucet"
|
||||
>
|
||||
<button className="btn btn-secondary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
|
||||
{!loading ? (
|
||||
<BanknotesIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
112
packages/nextjs/components/scaffold-eth/Input/AddressInput.tsx
Normal file
112
packages/nextjs/components/scaffold-eth/Input/AddressInput.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { blo } from "blo";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
import { Address, isAddress } from "viem";
|
||||
import { normalize } from "viem/ens";
|
||||
import { useEnsAddress, useEnsAvatar, useEnsName } from "wagmi";
|
||||
import { CommonInputProps, InputBase, isENS } from "~~/components/scaffold-eth";
|
||||
|
||||
/**
|
||||
* Address input with ENS name resolution
|
||||
*/
|
||||
export const AddressInput = ({ value, name, placeholder, onChange, disabled }: CommonInputProps<Address | string>) => {
|
||||
// Debounce the input to keep clean RPC calls when resolving ENS names
|
||||
// If the input is an address, we don't need to debounce it
|
||||
const [_debouncedValue] = useDebounceValue(value, 500);
|
||||
const debouncedValue = isAddress(value) ? value : _debouncedValue;
|
||||
const isDebouncedValueLive = debouncedValue === value;
|
||||
|
||||
// If the user changes the input after an ENS name is already resolved, we want to remove the stale result
|
||||
const settledValue = isDebouncedValueLive ? debouncedValue : undefined;
|
||||
|
||||
const {
|
||||
data: ensAddress,
|
||||
isLoading: isEnsAddressLoading,
|
||||
isError: isEnsAddressError,
|
||||
} = useEnsAddress({
|
||||
name: settledValue,
|
||||
chainId: 1,
|
||||
query: {
|
||||
gcTime: 30_000,
|
||||
enabled: isDebouncedValueLive && isENS(debouncedValue),
|
||||
},
|
||||
});
|
||||
|
||||
const [enteredEnsName, setEnteredEnsName] = useState<string>();
|
||||
const {
|
||||
data: ensName,
|
||||
isLoading: isEnsNameLoading,
|
||||
isError: isEnsNameError,
|
||||
} = useEnsName({
|
||||
address: settledValue as Address,
|
||||
chainId: 1,
|
||||
query: {
|
||||
enabled: isAddress(debouncedValue),
|
||||
gcTime: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: ensAvatar, isLoading: isEnsAvatarLoading } = useEnsAvatar({
|
||||
name: ensName ? normalize(ensName) : undefined,
|
||||
chainId: 1,
|
||||
query: {
|
||||
enabled: Boolean(ensName),
|
||||
gcTime: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
// ens => address
|
||||
useEffect(() => {
|
||||
if (!ensAddress) return;
|
||||
|
||||
// ENS resolved successfully
|
||||
setEnteredEnsName(debouncedValue);
|
||||
onChange(ensAddress);
|
||||
}, [ensAddress, onChange, debouncedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setEnteredEnsName(undefined);
|
||||
}, [value]);
|
||||
|
||||
const reFocus = isEnsAddressError || isEnsNameError || ensName === null || ensAddress === null;
|
||||
|
||||
return (
|
||||
<InputBase<Address>
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
error={ensAddress === null}
|
||||
value={value as Address}
|
||||
onChange={onChange}
|
||||
disabled={isEnsAddressLoading || isEnsNameLoading || disabled}
|
||||
reFocus={reFocus}
|
||||
prefix={
|
||||
ensName ? (
|
||||
<div className="flex bg-base-300 rounded-l-full items-center">
|
||||
{isEnsAvatarLoading && <div className="skeleton bg-base-200 w-[35px] h-[35px] rounded-full shrink-0"></div>}
|
||||
{ensAvatar ? (
|
||||
<span className="w-[35px]">
|
||||
{
|
||||
// eslint-disable-next-line
|
||||
<img className="w-full rounded-full" src={ensAvatar} alt={`${ensAddress} avatar`} />
|
||||
}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-accent px-2">{enteredEnsName ?? ensName}</span>
|
||||
</div>
|
||||
) : (
|
||||
(isEnsNameLoading || isEnsAddressLoading) && (
|
||||
<div className="flex bg-base-300 rounded-l-full items-center gap-2 pr-2">
|
||||
<div className="skeleton bg-base-200 w-[35px] h-[35px] rounded-full shrink-0"></div>
|
||||
<div className="skeleton bg-base-200 h-3 w-20"></div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
suffix={
|
||||
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
value && <img alt="" className="rounded-full!" src={blo(value as `0x${string}`)} width="35" height="35" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback } from "react";
|
||||
import { hexToString, isHex, stringToHex } from "viem";
|
||||
import { CommonInputProps, InputBase } from "~~/components/scaffold-eth";
|
||||
|
||||
export const Bytes32Input = ({ value, onChange, name, placeholder, disabled }: CommonInputProps) => {
|
||||
const convertStringToBytes32 = useCallback(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onChange(isHex(value) ? hexToString(value, { size: 32 }) : stringToHex(value, { size: 32 }));
|
||||
}, [onChange, value]);
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
suffix={
|
||||
<button
|
||||
className="self-center cursor-pointer text-xl font-semibold px-4 text-accent"
|
||||
onClick={convertStringToBytes32}
|
||||
type="button"
|
||||
>
|
||||
#
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
28
packages/nextjs/components/scaffold-eth/Input/BytesInput.tsx
Normal file
28
packages/nextjs/components/scaffold-eth/Input/BytesInput.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import { bytesToString, isHex, toBytes, toHex } from "viem";
|
||||
import { CommonInputProps, InputBase } from "~~/components/scaffold-eth";
|
||||
|
||||
export const BytesInput = ({ value, onChange, name, placeholder, disabled }: CommonInputProps) => {
|
||||
const convertStringToBytes = useCallback(() => {
|
||||
onChange(isHex(value) ? bytesToString(toBytes(value)) : toHex(toBytes(value)));
|
||||
}, [onChange, value]);
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
suffix={
|
||||
<button
|
||||
className="self-center cursor-pointer text-xl font-semibold px-4 text-accent"
|
||||
onClick={convertStringToBytes}
|
||||
type="button"
|
||||
>
|
||||
#
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
128
packages/nextjs/components/scaffold-eth/Input/EtherInput.tsx
Normal file
128
packages/nextjs/components/scaffold-eth/Input/EtherInput.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowsRightLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { CommonInputProps, InputBase, SIGNED_NUMBER_REGEX } from "~~/components/scaffold-eth";
|
||||
import { useDisplayUsdMode } from "~~/hooks/scaffold-eth/useDisplayUsdMode";
|
||||
import { useGlobalState } from "~~/services/store/store";
|
||||
|
||||
const MAX_DECIMALS_USD = 2;
|
||||
|
||||
function etherValueToDisplayValue(usdMode: boolean, etherValue: string, nativeCurrencyPrice: number) {
|
||||
if (usdMode && nativeCurrencyPrice) {
|
||||
const parsedEthValue = parseFloat(etherValue);
|
||||
if (Number.isNaN(parsedEthValue)) {
|
||||
return etherValue;
|
||||
} else {
|
||||
// We need to round the value rather than use toFixed,
|
||||
// since otherwise a user would not be able to modify the decimal value
|
||||
return (
|
||||
Math.round(parsedEthValue * nativeCurrencyPrice * 10 ** MAX_DECIMALS_USD) /
|
||||
10 ** MAX_DECIMALS_USD
|
||||
).toString();
|
||||
}
|
||||
} else {
|
||||
return etherValue;
|
||||
}
|
||||
}
|
||||
|
||||
function displayValueToEtherValue(usdMode: boolean, displayValue: string, nativeCurrencyPrice: number) {
|
||||
if (usdMode && nativeCurrencyPrice) {
|
||||
const parsedDisplayValue = parseFloat(displayValue);
|
||||
if (Number.isNaN(parsedDisplayValue)) {
|
||||
// Invalid number.
|
||||
return displayValue;
|
||||
} else {
|
||||
// Compute the ETH value if a valid number.
|
||||
return (parsedDisplayValue / nativeCurrencyPrice).toString();
|
||||
}
|
||||
} else {
|
||||
return displayValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for ETH amount with USD conversion.
|
||||
*
|
||||
* onChange will always be called with the value in ETH
|
||||
*/
|
||||
export const EtherInput = ({
|
||||
value,
|
||||
name,
|
||||
placeholder,
|
||||
onChange,
|
||||
disabled,
|
||||
usdMode,
|
||||
}: CommonInputProps & { usdMode?: boolean }) => {
|
||||
const [transitoryDisplayValue, setTransitoryDisplayValue] = useState<string>();
|
||||
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
|
||||
const isNativeCurrencyPriceFetching = useGlobalState(state => state.nativeCurrency.isFetching);
|
||||
|
||||
const { displayUsdMode, toggleDisplayUsdMode } = useDisplayUsdMode({ defaultUsdMode: usdMode });
|
||||
|
||||
// The displayValue is derived from the ether value that is controlled outside of the component
|
||||
// In usdMode, it is converted to its usd value, in regular mode it is unaltered
|
||||
const displayValue = useMemo(() => {
|
||||
const newDisplayValue = etherValueToDisplayValue(displayUsdMode, value, nativeCurrencyPrice || 0);
|
||||
if (transitoryDisplayValue && parseFloat(newDisplayValue) === parseFloat(transitoryDisplayValue)) {
|
||||
return transitoryDisplayValue;
|
||||
}
|
||||
// Clear any transitory display values that might be set
|
||||
setTransitoryDisplayValue(undefined);
|
||||
return newDisplayValue;
|
||||
}, [nativeCurrencyPrice, transitoryDisplayValue, displayUsdMode, value]);
|
||||
|
||||
const handleChangeNumber = (newValue: string) => {
|
||||
if (newValue && !SIGNED_NUMBER_REGEX.test(newValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Following condition is a fix to prevent usdMode from experiencing different display values
|
||||
// than what the user entered. This can happen due to floating point rounding errors that are introduced in the back and forth conversion
|
||||
if (displayUsdMode) {
|
||||
const decimals = newValue.split(".")[1];
|
||||
if (decimals && decimals.length > MAX_DECIMALS_USD) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Since the display value is a derived state (calculated from the ether value), usdMode would not allow introducing a decimal point.
|
||||
// This condition handles a transitory state for a display value with a trailing decimal sign
|
||||
if (newValue.endsWith(".") || newValue.endsWith(".0")) {
|
||||
setTransitoryDisplayValue(newValue);
|
||||
} else {
|
||||
setTransitoryDisplayValue(undefined);
|
||||
}
|
||||
|
||||
const newEthValue = displayValueToEtherValue(displayUsdMode, newValue, nativeCurrencyPrice || 0);
|
||||
onChange(newEthValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
name={name}
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChangeNumber}
|
||||
disabled={disabled}
|
||||
prefix={<span className="pl-4 -mr-2 text-accent self-center">{displayUsdMode ? "$" : "Ξ"}</span>}
|
||||
suffix={
|
||||
<div
|
||||
className={`${
|
||||
nativeCurrencyPrice > 0
|
||||
? ""
|
||||
: "tooltip tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none"
|
||||
}`}
|
||||
data-tip={isNativeCurrencyPriceFetching ? "Fetching price" : "Unable to fetch price"}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary h-[2.2rem] min-h-[2.2rem]"
|
||||
onClick={toggleDisplayUsdMode}
|
||||
disabled={!displayUsdMode && !nativeCurrencyPrice}
|
||||
type="button"
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-3 w-3 cursor-pointer" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
66
packages/nextjs/components/scaffold-eth/Input/InputBase.tsx
Normal file
66
packages/nextjs/components/scaffold-eth/Input/InputBase.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ChangeEvent, FocusEvent, ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { CommonInputProps } from "~~/components/scaffold-eth";
|
||||
|
||||
type InputBaseProps<T> = CommonInputProps<T> & {
|
||||
error?: boolean;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
reFocus?: boolean;
|
||||
};
|
||||
|
||||
export const InputBase = <T extends { toString: () => string } | undefined = string>({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
disabled,
|
||||
prefix,
|
||||
suffix,
|
||||
reFocus,
|
||||
}: InputBaseProps<T>) => {
|
||||
const inputReft = useRef<HTMLInputElement>(null);
|
||||
|
||||
let modifier = "";
|
||||
if (error) {
|
||||
modifier = "border-error";
|
||||
} else if (disabled) {
|
||||
modifier = "border-disabled bg-base-300";
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value as unknown as T);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Runs only when reFocus prop is passed, useful for setting the cursor
|
||||
// at the end of the input. Example AddressInput
|
||||
const onFocus = (e: FocusEvent<HTMLInputElement, Element>) => {
|
||||
if (reFocus !== undefined) {
|
||||
e.currentTarget.setSelectionRange(e.currentTarget.value.length, e.currentTarget.value.length);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (reFocus !== undefined && reFocus === true) inputReft.current?.focus();
|
||||
}, [reFocus]);
|
||||
|
||||
return (
|
||||
<div className={`flex border-2 border-base-300 bg-base-200 rounded-full text-accent ${modifier}`}>
|
||||
{prefix}
|
||||
<input
|
||||
className="input input-ghost focus-within:border-transparent focus:outline-hidden focus:bg-transparent h-[2.2rem] min-h-[2.2rem] px-4 border w-full font-medium placeholder:text-accent/70 text-base-content/70 focus:text-base-content/70"
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={value?.toString()}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
ref={inputReft}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { parseEther } from "viem";
|
||||
import { CommonInputProps, InputBase, IntegerVariant, isValidInteger } from "~~/components/scaffold-eth";
|
||||
|
||||
type IntegerInputProps = CommonInputProps<string> & {
|
||||
variant?: IntegerVariant;
|
||||
disableMultiplyBy1e18?: boolean;
|
||||
};
|
||||
|
||||
export const IntegerInput = ({
|
||||
value,
|
||||
onChange,
|
||||
name,
|
||||
placeholder,
|
||||
disabled,
|
||||
variant = IntegerVariant.UINT256,
|
||||
disableMultiplyBy1e18 = false,
|
||||
}: IntegerInputProps) => {
|
||||
const [inputError, setInputError] = useState(false);
|
||||
const multiplyBy1e18 = useCallback(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
return onChange(parseEther(value).toString());
|
||||
}, [onChange, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidInteger(variant, value)) {
|
||||
setInputError(false);
|
||||
} else {
|
||||
setInputError(true);
|
||||
}
|
||||
}, [value, variant]);
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
error={inputError}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
suffix={
|
||||
!inputError &&
|
||||
!disableMultiplyBy1e18 && (
|
||||
<div
|
||||
className="space-x-4 flex tooltip tooltip-top tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none"
|
||||
data-tip="Multiply by 1e18 (wei)"
|
||||
>
|
||||
<button
|
||||
className={`${disabled ? "cursor-not-allowed" : "cursor-pointer"} font-semibold px-4 text-accent`}
|
||||
onClick={multiplyBy1e18}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
∗
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
9
packages/nextjs/components/scaffold-eth/Input/index.ts
Normal file
9
packages/nextjs/components/scaffold-eth/Input/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export * from "./AddressInput";
|
||||
export * from "./Bytes32Input";
|
||||
export * from "./BytesInput";
|
||||
export * from "./EtherInput";
|
||||
export * from "./InputBase";
|
||||
export * from "./IntegerInput";
|
||||
export * from "./utils";
|
||||
109
packages/nextjs/components/scaffold-eth/Input/utils.ts
Normal file
109
packages/nextjs/components/scaffold-eth/Input/utils.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export type CommonInputProps<T = string> = {
|
||||
value: T;
|
||||
onChange: (newValue: T) => void;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export enum IntegerVariant {
|
||||
UINT8 = "uint8",
|
||||
UINT16 = "uint16",
|
||||
UINT24 = "uint24",
|
||||
UINT32 = "uint32",
|
||||
UINT40 = "uint40",
|
||||
UINT48 = "uint48",
|
||||
UINT56 = "uint56",
|
||||
UINT64 = "uint64",
|
||||
UINT72 = "uint72",
|
||||
UINT80 = "uint80",
|
||||
UINT88 = "uint88",
|
||||
UINT96 = "uint96",
|
||||
UINT104 = "uint104",
|
||||
UINT112 = "uint112",
|
||||
UINT120 = "uint120",
|
||||
UINT128 = "uint128",
|
||||
UINT136 = "uint136",
|
||||
UINT144 = "uint144",
|
||||
UINT152 = "uint152",
|
||||
UINT160 = "uint160",
|
||||
UINT168 = "uint168",
|
||||
UINT176 = "uint176",
|
||||
UINT184 = "uint184",
|
||||
UINT192 = "uint192",
|
||||
UINT200 = "uint200",
|
||||
UINT208 = "uint208",
|
||||
UINT216 = "uint216",
|
||||
UINT224 = "uint224",
|
||||
UINT232 = "uint232",
|
||||
UINT240 = "uint240",
|
||||
UINT248 = "uint248",
|
||||
UINT256 = "uint256",
|
||||
INT8 = "int8",
|
||||
INT16 = "int16",
|
||||
INT24 = "int24",
|
||||
INT32 = "int32",
|
||||
INT40 = "int40",
|
||||
INT48 = "int48",
|
||||
INT56 = "int56",
|
||||
INT64 = "int64",
|
||||
INT72 = "int72",
|
||||
INT80 = "int80",
|
||||
INT88 = "int88",
|
||||
INT96 = "int96",
|
||||
INT104 = "int104",
|
||||
INT112 = "int112",
|
||||
INT120 = "int120",
|
||||
INT128 = "int128",
|
||||
INT136 = "int136",
|
||||
INT144 = "int144",
|
||||
INT152 = "int152",
|
||||
INT160 = "int160",
|
||||
INT168 = "int168",
|
||||
INT176 = "int176",
|
||||
INT184 = "int184",
|
||||
INT192 = "int192",
|
||||
INT200 = "int200",
|
||||
INT208 = "int208",
|
||||
INT216 = "int216",
|
||||
INT224 = "int224",
|
||||
INT232 = "int232",
|
||||
INT240 = "int240",
|
||||
INT248 = "int248",
|
||||
INT256 = "int256",
|
||||
}
|
||||
|
||||
export const SIGNED_NUMBER_REGEX = /^-?\d+\.?\d*$/;
|
||||
export const UNSIGNED_NUMBER_REGEX = /^\.?\d+\.?\d*$/;
|
||||
|
||||
export const isValidInteger = (dataType: IntegerVariant, value: string) => {
|
||||
const isSigned = dataType.startsWith("i");
|
||||
const bitcount = Number(dataType.substring(isSigned ? 3 : 4));
|
||||
|
||||
let valueAsBigInt;
|
||||
try {
|
||||
valueAsBigInt = BigInt(value);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {}
|
||||
if (typeof valueAsBigInt !== "bigint") {
|
||||
if (!value || typeof value !== "string") {
|
||||
return true;
|
||||
}
|
||||
return isSigned ? SIGNED_NUMBER_REGEX.test(value) || value === "-" : UNSIGNED_NUMBER_REGEX.test(value);
|
||||
} else if (!isSigned && valueAsBigInt < 0) {
|
||||
return false;
|
||||
}
|
||||
const hexString = valueAsBigInt.toString(16);
|
||||
const significantHexDigits = hexString.match(/.*x0*(.*)$/)?.[1] ?? "";
|
||||
if (
|
||||
significantHexDigits.length * 4 > bitcount ||
|
||||
(isSigned && significantHexDigits.length * 4 === bitcount && parseInt(significantHexDigits.slice(-1)?.[0], 16) < 8)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Treat any dot-separated string as a potential ENS name
|
||||
const ensRegex = /.+\..+/;
|
||||
export const isENS = (address = "") => ensRegex.test(address);
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { NetworkOptions } from "./NetworkOptions";
|
||||
import { getAddress } from "viem";
|
||||
import { Address } from "viem";
|
||||
import { useAccount, useDisconnect } from "wagmi";
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EyeIcon,
|
||||
QrCodeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BlockieAvatar, isENS } from "~~/components/scaffold-eth";
|
||||
import { useCopyToClipboard, useOutsideClick } from "~~/hooks/scaffold-eth";
|
||||
import { getTargetNetworks } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BURNER_WALLET_ID = "burnerWallet";
|
||||
|
||||
const allowedNetworks = getTargetNetworks();
|
||||
|
||||
type AddressInfoDropdownProps = {
|
||||
address: Address;
|
||||
blockExplorerAddressLink: string | undefined;
|
||||
displayName: string;
|
||||
ensAvatar?: string;
|
||||
};
|
||||
|
||||
export const AddressInfoDropdown = ({
|
||||
address,
|
||||
ensAvatar,
|
||||
displayName,
|
||||
blockExplorerAddressLink,
|
||||
}: AddressInfoDropdownProps) => {
|
||||
const { disconnect } = useDisconnect();
|
||||
const { connector } = useAccount();
|
||||
const checkSumAddress = getAddress(address);
|
||||
|
||||
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
const [selectingNetwork, setSelectingNetwork] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
const closeDropdown = () => {
|
||||
setSelectingNetwork(false);
|
||||
dropdownRef.current?.removeAttribute("open");
|
||||
};
|
||||
|
||||
useOutsideClick(dropdownRef, closeDropdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<details ref={dropdownRef} className="dropdown dropdown-end leading-3">
|
||||
<summary className="btn btn-secondary btn-sm pl-0 pr-2 shadow-md dropdown-toggle gap-0 h-auto!">
|
||||
<BlockieAvatar address={checkSumAddress} size={30} ensImage={ensAvatar} />
|
||||
<span className="ml-2 mr-1">
|
||||
{isENS(displayName) ? displayName : checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4)}
|
||||
</span>
|
||||
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
</summary>
|
||||
<ul className="dropdown-content menu z-2 p-2 mt-2 shadow-center shadow-accent bg-base-200 rounded-box gap-1">
|
||||
<NetworkOptions hidden={!selectingNetwork} />
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<div
|
||||
className="h-8 btn-sm rounded-xl! flex gap-3 py-3 cursor-pointer"
|
||||
onClick={() => copyAddressToClipboard(checkSumAddress)}
|
||||
>
|
||||
{isAddressCopiedToClipboard ? (
|
||||
<>
|
||||
<CheckCircleIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DocumentDuplicateIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">Copy address</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<label htmlFor="qrcode-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3">
|
||||
<QrCodeIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span className="whitespace-nowrap">View QR Code</span>
|
||||
</label>
|
||||
</li>
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button className="h-8 btn-sm rounded-xl! flex gap-3 py-3" type="button">
|
||||
<ArrowTopRightOnSquareIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<a
|
||||
target="_blank"
|
||||
href={blockExplorerAddressLink}
|
||||
rel="noopener noreferrer"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
View on Block Explorer
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
{allowedNetworks.length > 1 ? (
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button
|
||||
className="h-8 btn-sm rounded-xl! flex gap-3 py-3"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectingNetwork(true);
|
||||
}}
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Switch Network</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{connector?.id === BURNER_WALLET_ID ? (
|
||||
<li>
|
||||
<label htmlFor="reveal-burner-pk-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3 text-error">
|
||||
<EyeIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span>Reveal Private Key</span>
|
||||
</label>
|
||||
</li>
|
||||
) : null}
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button
|
||||
className="menu-item text-error h-8 btn-sm rounded-xl! flex gap-3 py-3"
|
||||
type="button"
|
||||
onClick={() => disconnect()}
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Disconnect</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { Address as AddressType } from "viem";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
|
||||
type AddressQRCodeModalProps = {
|
||||
address: AddressType;
|
||||
modalId: string;
|
||||
};
|
||||
|
||||
export const AddressQRCodeModal = ({ address, modalId }: AddressQRCodeModalProps) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<input type="checkbox" id={`${modalId}`} className="modal-toggle" />
|
||||
<label htmlFor={`${modalId}`} className="modal cursor-pointer">
|
||||
<label className="modal-box relative">
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
<label htmlFor={`${modalId}`} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</label>
|
||||
<div className="space-y-3 py-6">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<QRCodeSVG value={address} size={256} />
|
||||
<Address address={address} format="long" disableAddressLink onlyEnsOrAddress />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useAccount, useSwitchChain } from "wagmi";
|
||||
import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { getNetworkColor } from "~~/hooks/scaffold-eth";
|
||||
import { getTargetNetworks } from "~~/utils/scaffold-eth";
|
||||
|
||||
const allowedNetworks = getTargetNetworks();
|
||||
|
||||
type NetworkOptionsProps = {
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export const NetworkOptions = ({ hidden = false }: NetworkOptionsProps) => {
|
||||
const { switchChain } = useSwitchChain();
|
||||
const { chain } = useAccount();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
{allowedNetworks
|
||||
.filter(allowedNetwork => allowedNetwork.id !== chain?.id)
|
||||
.map(allowedNetwork => (
|
||||
<li key={allowedNetwork.id} className={hidden ? "hidden" : ""}>
|
||||
<button
|
||||
className="menu-item btn-sm rounded-xl! flex gap-3 py-3 whitespace-nowrap"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
switchChain?.({ chainId: allowedNetwork.id });
|
||||
}}
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span>
|
||||
Switch to{" "}
|
||||
<span
|
||||
style={{
|
||||
color: getNetworkColor(allowedNetwork, isDarkMode),
|
||||
}}
|
||||
>
|
||||
{allowedNetwork.name}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useRef } from "react";
|
||||
import { rainbowkitBurnerWallet } from "burner-connector";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/outline";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth";
|
||||
import { getParsedError, notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BURNER_WALLET_PK_KEY = "burnerWallet.pk";
|
||||
|
||||
export const RevealBurnerPKModal = () => {
|
||||
const { copyToClipboard, isCopiedToClipboard } = useCopyToClipboard();
|
||||
const modalCheckboxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCopyPK = async () => {
|
||||
try {
|
||||
const storage = rainbowkitBurnerWallet.useSessionStorage ? sessionStorage : localStorage;
|
||||
const burnerPK = storage?.getItem(BURNER_WALLET_PK_KEY);
|
||||
if (!burnerPK) throw new Error("Burner wallet private key not found");
|
||||
await copyToClipboard(burnerPK);
|
||||
notification.success("Burner wallet private key copied to clipboard");
|
||||
} catch (e) {
|
||||
const parsedError = getParsedError(e);
|
||||
notification.error(parsedError);
|
||||
if (modalCheckboxRef.current) modalCheckboxRef.current.checked = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<input type="checkbox" id="reveal-burner-pk-modal" className="modal-toggle" ref={modalCheckboxRef} />
|
||||
<label htmlFor="reveal-burner-pk-modal" className="modal cursor-pointer">
|
||||
<label className="modal-box relative">
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
<label htmlFor="reveal-burner-pk-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</label>
|
||||
<div>
|
||||
<p className="text-lg font-semibold m-0 p-0">Copy Burner Wallet Private Key</p>
|
||||
<div role="alert" className="alert alert-warning mt-4">
|
||||
<ShieldExclamationIcon className="h-6 w-6" />
|
||||
<span className="font-semibold">
|
||||
Burner wallets are intended for local development only and are not safe for storing real funds.
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
Your Private Key provides <strong>full access</strong> to your entire wallet and funds. This is
|
||||
currently stored <strong>temporarily</strong> in your browser.
|
||||
</p>
|
||||
<button className="btn btn-outline btn-error" onClick={handleCopyPK} disabled={isCopiedToClipboard}>
|
||||
Copy Private Key To Clipboard
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NetworkOptions } from "./NetworkOptions";
|
||||
import { useDisconnect } from "wagmi";
|
||||
import { ArrowLeftOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const WrongNetworkDropdown = () => {
|
||||
const { disconnect } = useDisconnect();
|
||||
|
||||
return (
|
||||
<div className="dropdown dropdown-end mr-2">
|
||||
<label tabIndex={0} className="btn btn-error btn-sm dropdown-toggle gap-1">
|
||||
<span>Wrong network</span>
|
||||
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content menu p-2 mt-1 shadow-center shadow-accent bg-base-200 rounded-box gap-1"
|
||||
>
|
||||
<NetworkOptions />
|
||||
<li>
|
||||
<button
|
||||
className="menu-item text-error btn-sm rounded-xl! flex gap-3 py-3"
|
||||
type="button"
|
||||
onClick={() => disconnect()}
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
// @refresh reset
|
||||
import { Balance } from "../Balance";
|
||||
import { AddressInfoDropdown } from "./AddressInfoDropdown";
|
||||
import { AddressQRCodeModal } from "./AddressQRCodeModal";
|
||||
import { RevealBurnerPKModal } from "./RevealBurnerPKModal";
|
||||
import { WrongNetworkDropdown } from "./WrongNetworkDropdown";
|
||||
import { ConnectButton } from "@rainbow-me/rainbowkit";
|
||||
import { Address } from "viem";
|
||||
import { useNetworkColor } from "~~/hooks/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
|
||||
|
||||
/**
|
||||
* Custom Wagmi Connect Button (watch balance + custom design)
|
||||
*/
|
||||
export const RainbowKitCustomConnectButton = () => {
|
||||
const networkColor = useNetworkColor();
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
return (
|
||||
<ConnectButton.Custom>
|
||||
{({ account, chain, openConnectModal, mounted }) => {
|
||||
const connected = mounted && account && chain;
|
||||
const blockExplorerAddressLink = account
|
||||
? getBlockExplorerAddressLink(targetNetwork, account.address)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(() => {
|
||||
if (!connected) {
|
||||
return (
|
||||
<button className="btn btn-primary btn-sm" onClick={openConnectModal} type="button">
|
||||
Connect Wallet
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (chain.unsupported || chain.id !== targetNetwork.id) {
|
||||
return <WrongNetworkDropdown />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center mr-1">
|
||||
<Balance address={account.address as Address} className="min-h-0 h-auto" />
|
||||
<span className="text-xs" style={{ color: networkColor }}>
|
||||
{chain.name}
|
||||
</span>
|
||||
</div>
|
||||
<AddressInfoDropdown
|
||||
address={account.address as Address}
|
||||
displayName={account.displayName}
|
||||
ensAvatar={account.ensAvatar}
|
||||
blockExplorerAddressLink={blockExplorerAddressLink}
|
||||
/>
|
||||
<AddressQRCodeModal address={account.address as Address} modalId="qrcode-modal" />
|
||||
<RevealBurnerPKModal />
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</ConnectButton.Custom>
|
||||
);
|
||||
};
|
||||
7
packages/nextjs/components/scaffold-eth/index.tsx
Normal file
7
packages/nextjs/components/scaffold-eth/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./Address/Address";
|
||||
export * from "./Balance";
|
||||
export * from "./BlockieAvatar";
|
||||
export * from "./Faucet";
|
||||
export * from "./FaucetButton";
|
||||
export * from "./Input";
|
||||
export * from "./RainbowKitCustomConnectButton";
|
||||
Reference in New Issue
Block a user