Initial commit with 🏗️ Scaffold-ETH 2 @ 1.0.2

This commit is contained in:
han
2026-01-21 20:45:23 +07:00
commit e7b2a69f2a
156 changed files with 29196 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
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,
isSuccess: isEnsAddressSuccess,
} = 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,
isSuccess: isEnsNameSuccess,
} = 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 ||
isEnsNameSuccess ||
isEnsAddressSuccess ||
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" />
}
/>
);
};

View File

@@ -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>
}
/>
);
};

View 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>
}
/>
);
};

View 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>
}
/>
);
};

View 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>
);
};

View File

@@ -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>
)
}
/>
);
};

View 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";

View 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);