remove claimconditions legacy (#4590)

## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
The focus of this PR is to refactor claim conditions components and improve error handling.

### Detailed summary
- Refactored components related to claim conditions
- Improved error handling in `errorParser.tsx`
- Updated transaction button component
- Enhanced ERC20 amount conversion logic
- Streamlined wallet confirmation logic

> The following files were skipped due to too many changes: `apps/dashboard/src/contract-ui/tabs/claim-conditions/components/snapshot-upload.tsx`, `apps/dashboard/src/contract-ui/tabs/claim-conditions/components/claim-conditions-form/hooks.ts`, `apps/dashboard/src/contract-ui/tabs/claim-conditions/components/claim-conditions-form/index.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
jnsdls
2024-09-15 03:49:04 +00:00
parent ebe4975aa3
commit 390aedcc8a
15 changed files with 503 additions and 368 deletions
@@ -35,7 +35,6 @@ interface TransactionButtonProps extends Omit<ButtonProps, "leftIcon"> {
isLoading: boolean; isLoading: boolean;
isGasless?: boolean; isGasless?: boolean;
upsellTestnet?: boolean; upsellTestnet?: boolean;
onChainSelect?: (chainId: number) => void;
} }
function useWalletRequiresExternalConfirmation() { function useWalletRequiresExternalConfirmation() {
@@ -55,7 +54,7 @@ export const TransactionButton: React.FC<TransactionButtonProps> = ({
colorScheme, colorScheme,
variant, variant,
isGasless, isGasless,
onChainSelect,
...restButtonProps ...restButtonProps
}) => { }) => {
const activeWallet = useActiveWallet(); const activeWallet = useActiveWallet();
@@ -93,7 +92,6 @@ export const TransactionButton: React.FC<TransactionButtonProps> = ({
> >
<PopoverTrigger> <PopoverTrigger>
<ButtonComponent <ButtonComponent
onChainSelect={onChainSelect}
borderRadius="md" borderRadius="md"
position="relative" position="relative"
role="group" role="group"
@@ -17,10 +17,9 @@ export const ClaimPriceInput = () => {
field, field,
isColumn, isColumn,
claimConditionType, claimConditionType,
isClaimPhaseV1,
} = useClaimConditionsFormContext(); } = useClaimConditionsFormContext();
if (!isClaimPhaseV1 && claimConditionType === "creator") { if (claimConditionType === "creator") {
return null; return null;
} }
@@ -18,7 +18,6 @@ export const ClaimerSelection = () => {
field, field,
dropType, dropType,
isErc20, isErc20,
isClaimPhaseV1,
setOpenSnapshotIndex: setOpenIndex, setOpenSnapshotIndex: setOpenIndex,
isAdmin, isAdmin,
isColumn, isColumn,
@@ -31,14 +30,10 @@ export const ClaimerSelection = () => {
if (val === "any") { if (val === "any") {
form.setValue(`phases.${phaseIndex}.snapshot`, undefined); form.setValue(`phases.${phaseIndex}.snapshot`, undefined);
} else { } else {
if (val === "specific" && !isClaimPhaseV1) { if (val === "specific") {
form.setValue(`phases.${phaseIndex}.maxClaimablePerWallet`, 0); form.setValue(`phases.${phaseIndex}.maxClaimablePerWallet`, 0);
} }
if ( if (val === "overrides" && field.maxClaimablePerWallet !== 1) {
val === "overrides" &&
!isClaimPhaseV1 &&
field.maxClaimablePerWallet !== 1
) {
form.setValue(`phases.${phaseIndex}.maxClaimablePerWallet`, 1); form.setValue(`phases.${phaseIndex}.maxClaimablePerWallet`, 1);
} }
form.setValue(`phases.${phaseIndex}.snapshot`, []); form.setValue(`phases.${phaseIndex}.snapshot`, []);
@@ -50,39 +45,31 @@ export const ClaimerSelection = () => {
const disabledSnapshotButton = isAdmin && formDisabled; const disabledSnapshotButton = isAdmin && formDisabled;
if (isClaimPhaseV1) { if (dropType === "specific") {
helperText = helperText = (
"Snapshot spots are one-time-use! Once a wallet has claimed the drop, it cannot claim again, even if it did not claim the entire amount assigned to it in the snapshot."; <>
<b>Only</b> wallets on the <b>allowlist</b> can claim.
</>
);
} else if (dropType === "any") {
helperText = (
<>
<b>Anyone</b> can claim based on the rules defined in this phase.
(&quot;Public Mint&quot;)
</>
);
} else { } else {
if (dropType === "specific") { helperText = (
helperText = ( <>
<> <b>Anyone</b> can claim based on the rules defined in this phase.
<b>Only</b> wallets on the <b>allowlist</b> can claim. <br />
</> <b>Wallets in the snapshot</b> can claim with special rules defined in
); the snapshot.
} else if (dropType === "any") { </>
helperText = ( );
<>
<b>Anyone</b> can claim based on the rules defined in this phase.
(&quot;Public Mint&quot;)
</>
);
} else {
helperText = (
<>
<b>Anyone</b> can claim based on the rules defined in this phase.
<br />
<b>Wallets in the snapshot</b> can claim with special rules defined in
the snapshot.
</>
);
}
} }
if ( if (claimConditionType === "public" || claimConditionType === "creator") {
!isClaimPhaseV1 &&
(claimConditionType === "public" || claimConditionType === "creator")
) {
return null; return null;
} }
@@ -113,9 +100,7 @@ export const ClaimerSelection = () => {
onChange={handleClaimerChange} onChange={handleClaimerChange}
> >
<option value="any">Any wallet</option> <option value="any">Any wallet</option>
{!isClaimPhaseV1 ? ( <option value="overrides">Any wallet (with overrides)</option>
<option value="overrides">Any wallet (with overrides)</option>
) : null}
<option value="specific">Only specific wallets</option> <option value="specific">Only specific wallets</option>
</Select> </Select>
)} )}
@@ -10,7 +10,6 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
const { const {
formDisabled, formDisabled,
isErc20, isErc20,
isClaimPhaseV1,
form, form,
tokenDecimals, tokenDecimals,
field, field,
@@ -26,9 +25,7 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
return ( return (
<CustomFormControl <CustomFormControl
disabled={formDisabled} disabled={formDisabled}
label={`How many ${isErc20 ? "tokens" : "NFTs"} can be claimed per ${ label={`How many ${isErc20 ? "tokens" : "NFTs"} can be claimed per wallet?`}
isClaimPhaseV1 ? "transaction" : "wallet"
}?`}
error={ error={
form.getFieldState( form.getFieldState(
`phases.${phaseIndex}.maxClaimablePerWallet`, `phases.${phaseIndex}.maxClaimablePerWallet`,
@@ -37,14 +34,10 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
} }
helperText={ helperText={
<> <>
{!isClaimPhaseV1 ? ( This value applies for <strong>all</strong> wallets
<> {claimConditionType !== "public"
This value applies for <strong>all</strong> wallets ? ", and can be overridden for specific wallets in the snapshot. "
{claimConditionType !== "public" : ". "}
? ", and can be overridden for specific wallets in the snapshot. "
: ". "}
</>
) : null}
Limits are set per wallets and not per user, sophisticated actors Limits are set per wallets and not per user, sophisticated actors
could get around wallet restrictions.{" "} could get around wallet restrictions.{" "}
<Link <Link
@@ -61,9 +54,7 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
<QuantityInputWithUnlimited <QuantityInputWithUnlimited
isRequired isRequired
decimals={tokenDecimals} decimals={tokenDecimals}
isDisabled={ isDisabled={dropType === "specific" || formDisabled}
(!isClaimPhaseV1 && dropType === "specific") || formDisabled
}
value={field?.maxClaimablePerWallet?.toString() || "0"} value={field?.maxClaimablePerWallet?.toString() || "0"}
onChange={(value) => onChange={(value) =>
form.setValue( form.setValue(
@@ -14,10 +14,9 @@ export const MaxClaimableSupplyInput: React.FC = () => {
tokenDecimals, tokenDecimals,
field, field,
claimConditionType, claimConditionType,
isClaimPhaseV1,
} = useClaimConditionsFormContext(); } = useClaimConditionsFormContext();
if (!isClaimPhaseV1 && claimConditionType === "creator") { if (claimConditionType === "creator") {
return null; return null;
} }
@@ -6,15 +6,11 @@ import { useClaimConditionsFormContext } from "../index";
* Allows users to edit the name for the claim phase. * Allows users to edit the name for the claim phase.
*/ */
export const PhaseNameInput: React.FC = () => { export const PhaseNameInput: React.FC = () => {
const { phaseIndex, formDisabled, isClaimPhaseV1, form, field } = const { phaseIndex, formDisabled, form, field } =
useClaimConditionsFormContext(); useClaimConditionsFormContext();
const inputPlaceholder = `Phase ${phaseIndex + 1}`; const inputPlaceholder = `Phase ${phaseIndex + 1}`;
const inputValue = field.metadata?.name; const inputValue = field.metadata?.name;
if (isClaimPhaseV1) {
return null;
}
return ( return (
<CustomFormControl <CustomFormControl
disabled={formDisabled} disabled={formDisabled}
@@ -0,0 +1,237 @@
import type {
ClaimCondition as LegacyClaimCondition,
ClaimConditionInputSchema as LegacyClaimConditionInputSchema,
} from "@thirdweb-dev/sdk";
import {
type BaseTransactionOptions,
type ThirdwebClient,
toTokens,
} from "thirdweb";
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
import { getContractMetadata } from "thirdweb/extensions/common";
import * as ERC20Ext from "thirdweb/extensions/erc20";
import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { download } from "thirdweb/storage";
import { maxUint256 } from "thirdweb/utils";
import type { z } from "zod";
type SnapshotEntry = {
address: string;
maxClaimable: string;
price?: string | undefined;
currencyAddress?: string | undefined;
};
export type CombinedClaimCondition = Prettify<
Omit<
LegacyClaimCondition,
"price" | "waitInSeconds" | "availableSupply" | "currencyMetadata"
> & {
price: bigint;
currencyMetadata: Omit<
LegacyClaimCondition["currencyMetadata"],
"value"
> & {
value: bigint;
};
}
>;
type Options =
| {
type: "erc20";
}
| {
type: "erc721";
}
| {
type: "erc1155";
tokenId: bigint;
};
export async function getClaimPhasesInLegacyFormat(
options: BaseTransactionOptions<Options>,
): Promise<CombinedClaimCondition[]> {
const conditions = await (async () => {
switch (options.type) {
case "erc20":
return ERC20Ext.getClaimConditions(options);
case "erc721":
return ERC721Ext.getClaimConditions(options);
case "erc1155":
return ERC1155Ext.getClaimConditions(options);
}
})();
return Promise.all(
conditions.map(async (condition) => {
const [currencyMetadata, metadata, contractMetadata] = await Promise.all([
await ERC20Ext.getCurrencyMetadata({
contract: { ...options.contract, address: condition.currency },
}).then((m) => ({
...m,
displayValue: toTokens(condition.pricePerToken, m.decimals),
value: condition.pricePerToken,
})),
download({
uri: condition.metadata,
client: options.contract.client,
}).then((r) => r.json()),
getContractMetadata(options),
]);
const snapshot = await fetchSnapshot(
condition.merkleRoot,
contractMetadata.merkle,
options.contract.client,
);
return {
...condition,
startTime: new Date(Number(condition.startTimestamp * 1000n)),
currencyAddress: condition.currency,
price: condition.pricePerToken,
maxClaimableSupply: toUnlimited(condition.maxClaimableSupply),
currencyMetadata,
currentMintSupply: (
condition.maxClaimableSupply - condition.supplyClaimed
).toString(),
maxClaimablePerWallet: toUnlimited(condition.quantityLimitPerWallet),
merkleRootHash: condition.merkleRoot,
metadata,
snapshot,
} satisfies CombinedClaimCondition;
}),
);
}
type PhaseInput = z.input<typeof LegacyClaimConditionInputSchema>;
export function setClaimPhasesTx(
baseOptions: BaseTransactionOptions<Options>,
rawPhases: PhaseInput[],
) {
const phases = rawPhases.map((phase) => {
return {
startTime: toDate(phase.startTime),
maxClaimableSupply: toBigInt(phase.maxClaimableSupply),
maxClaimablePerWallet: toBigInt(phase.maxClaimablePerWallet),
merkleRootHash: phase.merkleRootHash as string | undefined,
overrideList: phase.snapshot?.length
? snapshotToOverrides(phase.snapshot)
: undefined,
price: phase.price?.toString(),
currencyAddress: phase.currencyAddress,
metadata: phase.metadata,
} satisfies ERC20Ext.SetClaimConditionsParams["phases"][0];
});
switch (baseOptions.type) {
case "erc20":
return ERC20Ext.setClaimConditions({
contract: baseOptions.contract,
phases,
});
case "erc721":
return ERC721Ext.setClaimConditions({
contract: baseOptions.contract,
phases,
});
case "erc1155":
return ERC1155Ext.setClaimConditions({
contract: baseOptions.contract,
phases,
tokenId: baseOptions.tokenId,
});
}
}
// HELPERS
function snapshotToOverrides(snapshot: PhaseInput["snapshot"]) {
if (!snapshot) {
return [];
}
return snapshot.map((entry) => {
if (typeof entry === "string") {
return {
address: entry,
} satisfies OverrideEntry;
}
return {
address: entry.address,
maxClaimable: entry.maxClaimable?.toString(),
price: entry.price?.toString(),
currencyAddress: entry.currencyAddress,
} satisfies OverrideEntry;
});
}
function toDate(timestamp: number | Date | undefined) {
if (timestamp === undefined) {
return undefined;
}
if (timestamp instanceof Date) {
return timestamp;
}
return new Date(timestamp);
}
function toBigInt(value: string | number | undefined) {
if (value === undefined) {
return undefined;
}
if (value === "unlimited") {
return maxUint256;
}
return BigInt(value);
}
function toUnlimited(value: bigint) {
return value === maxUint256 ? "unlimited" : value.toString();
}
async function fetchSnapshot(
merkleRoot: string,
merkleMetadata: Record<string, string> | undefined,
client: ThirdwebClient,
): Promise<SnapshotEntry[] | null> {
if (!merkleMetadata) {
return null;
}
try {
const snapshotUri = merkleMetadata[merkleRoot];
if (snapshotUri) {
const raw = await download({
uri: snapshotUri,
client: client,
}).then((r) => r.json());
if (raw.isShardedMerkleTree && raw.merkleRoot === merkleRoot) {
return download({
uri: raw.originalEntriesUri,
client: client,
})
.then((r) => r.json())
.catch(() => null);
}
if (merkleRoot === raw.merkleRoot) {
return raw.claims.map(
(claim: {
address: string;
maxClaimable: bigint;
price: bigint;
currencyAddress: string;
}) => ({
address: claim.address,
maxClaimable: claim.maxClaimable,
price: claim.price,
currencyAddress: claim.currencyAddress,
}),
);
}
}
return null;
} catch (e) {
console.error("failed to fetch snapshot", e);
return null;
}
}
@@ -13,11 +13,6 @@ import {
MenuList, MenuList,
Spinner, Spinner,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import {
type DropContract,
useClaimConditions,
useSetClaimConditions,
} from "@thirdweb-dev/react";
import { import {
type ClaimConditionInput, type ClaimConditionInput,
ClaimConditionInputSchema, ClaimConditionInputSchema,
@@ -42,12 +37,17 @@ import {
ZERO_ADDRESS, ZERO_ADDRESS,
} from "thirdweb"; } from "thirdweb";
import { decimals } from "thirdweb/extensions/erc20"; import { decimals } from "thirdweb/extensions/erc20";
import { useActiveAccount, useReadContract } from "thirdweb/react"; import {
useActiveAccount,
useReadContract,
useSendAndConfirmTransaction,
} from "thirdweb/react";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { Button, Heading, MenuItem, Text } from "tw-components"; import { Button, Heading, MenuItem, Text } from "tw-components";
import * as z from "zod"; import * as z from "zod";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { ResetClaimEligibility } from "../reset-claim-eligibility"; import { ResetClaimEligibility } from "../reset-claim-eligibility";
import { getClaimPhasesInLegacyFormat, setClaimPhasesTx } from "./hooks";
import { ClaimConditionsPhase } from "./phase"; import { ClaimConditionsPhase } from "./phase";
type ClaimConditionDashboardInput = ClaimConditionInput & { type ClaimConditionDashboardInput = ClaimConditionInput & {
@@ -59,7 +59,6 @@ const DEFAULT_PHASE: ClaimConditionDashboardInput = {
startTime: new Date(), startTime: new Date(),
maxClaimableSupply: "unlimited", maxClaimableSupply: "unlimited",
maxClaimablePerWallet: "unlimited", maxClaimablePerWallet: "unlimited",
waitInSeconds: "0",
price: "0", price: "0",
currencyAddress: NATIVE_TOKEN_ADDRESS, currencyAddress: NATIVE_TOKEN_ADDRESS,
snapshot: undefined, snapshot: undefined,
@@ -153,7 +152,6 @@ interface ClaimsConditionFormContextData {
phaseIndex: number; phaseIndex: number;
formDisabled: boolean; formDisabled: boolean;
tokenDecimals: number; tokenDecimals: number;
isClaimPhaseV1: boolean;
isMultiPhase: boolean; isMultiPhase: boolean;
isActive: boolean; isActive: boolean;
dropType: DropType; dropType: DropType;
@@ -177,14 +175,10 @@ export function useClaimConditionsFormContext() {
return data; return data;
} }
// TODO: refactor, this is no longer supported
const isClaimPhaseV1 = false;
interface ClaimConditionsFormProps { interface ClaimConditionsFormProps {
contract: DropContract; contract: ThirdwebContract;
tokenId?: string; tokenId?: string;
isColumn?: true; isColumn?: true;
contractV5: ThirdwebContract;
isErc20: boolean; isErc20: boolean;
isMultiPhase: boolean; isMultiPhase: boolean;
} }
@@ -193,26 +187,39 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
contract, contract,
tokenId, tokenId,
isColumn, isColumn,
contractV5,
isErc20, isErc20,
isMultiPhase, isMultiPhase,
}) => { }) => {
// assume 1155 if we have a tokenId
const isErc1155 = tokenId !== undefined;
// if neither 1155 or 20 then it's 721
const isErc721 = !isErc20 && !isErc1155;
const walletAddress = useActiveAccount()?.address; const walletAddress = useActiveAccount()?.address;
const trackEvent = useTrack(); const trackEvent = useTrack();
const [resetFlag, setResetFlag] = useState(false);
const isAdmin = useIsAdmin(contractV5);
const [openSnapshotIndex, setOpenSnapshotIndex] = useState(-1);
const setClaimConditionsQuery = useSetClaimConditions(contract, tokenId);
const tokenDecimals = useReadContract(decimals, { contract: contractV5 }); const isAdmin = useIsAdmin(contract);
const [openSnapshotIndex, setOpenSnapshotIndex] = useState(-1);
const sendTx = useSendAndConfirmTransaction();
const tokenDecimals = useReadContract(decimals, {
contract,
queryOptions: {
enabled: isErc20,
},
});
const tokenDecimalsData = tokenDecimals.data ?? 0; const tokenDecimalsData = tokenDecimals.data ?? 0;
const saveClaimPhaseNotification = useTxNotifications( const saveClaimPhaseNotification = useTxNotifications(
"Saved claim phases", "Saved claim phases",
"Failed to save claim phases", "Failed to save claim phases",
); );
const claimConditionsQuery = useClaimConditions(contract, tokenId, { const claimConditionsQuery = useReadContract(getClaimPhasesInLegacyFormat, {
withAllowList: true, contract,
...(isErc20
? { type: "erc20" }
: isErc721
? { type: "erc721" }
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
}); });
const transformedQueryData = useMemo(() => { const transformedQueryData = useMemo(() => {
@@ -227,7 +234,6 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
}, },
currencyAddress: phase.currencyAddress?.toLowerCase() || "0", currencyAddress: phase.currencyAddress?.toLowerCase() || "0",
maxClaimablePerWallet: phase.maxClaimablePerWallet?.toString() || "0", maxClaimablePerWallet: phase.maxClaimablePerWallet?.toString() || "0",
waitInSeconds: phase.waitInSeconds?.toString() || "0",
startTime: new Date(phase.startTime), startTime: new Date(phase.startTime),
snapshot: phase.snapshot?.map( snapshot: phase.snapshot?.map(
({ address, maxClaimable, price, currencyAddress }) => ({ ({ address, maxClaimable, price, currencyAddress }) => ({
@@ -249,8 +255,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
); );
}, [claimConditionsQuery.data, isMultiPhase]); }, [claimConditionsQuery.data, isMultiPhase]);
const isFetchingData = const isFetchingData = claimConditionsQuery.isFetching || sendTx.isPending;
claimConditionsQuery.isFetching || setClaimConditionsQuery.isLoading;
const canEditForm = isAdmin && !isFetchingData; const canEditForm = isAdmin && !isFetchingData;
@@ -346,10 +351,18 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
}); });
try { try {
await setClaimConditionsQuery.mutateAsync({ const tx = setClaimPhasesTx(
phases: d.phases as ClaimConditionInput[], {
reset: resetFlag, contract,
}); ...(isErc20
? { type: "erc20" }
: isErc721
? { type: "erc721" }
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
},
d.phases,
);
await sendTx.mutateAsync(tx);
trackEvent({ trackEvent({
category, category,
action: "set-claim-conditions", action: "set-claim-conditions",
@@ -365,6 +378,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
form.setValue("phases", newPhases); form.setValue("phases", newPhases);
} catch (error) { } catch (error) {
console.error(error);
trackEvent({ trackEvent({
category, category,
action: "set-claim-conditions", action: "set-claim-conditions",
@@ -448,8 +462,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
)} )}
{controlledFields.map((field, index) => { {controlledFields.map((field, index) => {
const dropType: DropType = field.snapshot const dropType: DropType = field.snapshot
? isClaimPhaseV1 || ? field.maxClaimablePerWallet?.toString() === "0"
field.maxClaimablePerWallet?.toString() === "0"
? "specific" ? "specific"
: "overrides" : "overrides"
: "any"; : "any";
@@ -478,7 +491,6 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
<Fragment key={`snapshot_${field.id}_${index}`}> <Fragment key={`snapshot_${field.id}_${index}`}>
<SnapshotUpload <SnapshotUpload
dropType={dropType} dropType={dropType}
isV1ClaimCondition={isClaimPhaseV1}
isOpen={openSnapshotIndex === index} isOpen={openSnapshotIndex === index}
onClose={() => setOpenSnapshotIndex(-1)} onClose={() => setOpenSnapshotIndex(-1)}
value={snapshotValue} value={snapshotValue}
@@ -496,7 +508,6 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
formDisabled: !canEditForm, formDisabled: !canEditForm,
isErc20, isErc20,
tokenDecimals: tokenDecimalsData, tokenDecimals: tokenDecimalsData,
isClaimPhaseV1,
dropType, dropType,
setOpenSnapshotIndex, setOpenSnapshotIndex,
isAdmin, isAdmin,
@@ -507,14 +518,11 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
}} }}
> >
<ClaimConditionsPhase <ClaimConditionsPhase
contract={contractV5} contract={contract}
onRemove={() => { onRemove={() => {
removePhase(index); removePhase(index);
if (!isMultiPhase) {
setResetFlag(true);
}
}} }}
isLoading={setClaimConditionsQuery.isLoading} isLoading={sendTx.isPending}
/> />
</ClaimsConditionFormContext.Provider> </ClaimsConditionFormContext.Provider>
</Fragment> </Fragment>
@@ -545,115 +553,92 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
gap={2} gap={2}
> >
<Flex gap={2}> <Flex gap={2}>
{contractV5 && ( <AdminOnly contract={contract}>
<AdminOnly contract={contractV5}> <Menu>
{!isClaimPhaseV1 ? ( <MenuButton
<> as={Button}
<Menu> size="sm"
<MenuButton colorScheme="primary"
as={Button} variant={phases?.length > 0 ? "outline" : "solid"}
size="sm" borderRadius="md"
colorScheme="primary" leftIcon={<Icon as={FiPlus} />}
variant={phases?.length > 0 ? "outline" : "solid"} isDisabled={
borderRadius="md" sendTx.isPending || (!isMultiPhase && phases?.length > 0)
leftIcon={<Icon as={FiPlus} />} }
isDisabled={ >
setClaimConditionsQuery.isLoading || Add {isMultiPhase ? "Phase" : "Claim Conditions"}
(!isMultiPhase && phases?.length > 0) </MenuButton>
} <MenuList
> borderRadius="lg"
Add {isMultiPhase ? "Phase" : "Claim Conditions"} overflow="hidden"
</MenuButton> zIndex="overlay"
<MenuList >
borderRadius="lg" {Object.keys(ClaimConditionTypeData).map((key) => {
overflow="hidden" const type = key as ClaimConditionType;
zIndex="overlay"
>
{Object.keys(ClaimConditionTypeData).map((key) => {
const type = key as ClaimConditionType;
if (type === "custom") { if (type === "custom") {
return null; return null;
} }
return (
<MenuItem
key={type}
onClick={() => {
addPhase(type);
// TODO: Automatically start editing the new phase after adding it
}}
>
<div className="flex items-center gap-1">
{ClaimConditionTypeData[type].name}
<TooltipBox
content={
<Text size="body.md">
{ClaimConditionTypeData[type].description}
</Text>
}
/>
</div>
</MenuItem>
);
})}
</MenuList>
</Menu>
</AdminOnly>
return (
<MenuItem
key={type}
onClick={() => {
addPhase(type);
// TODO: Automatically start editing the new phase after adding it
}}
>
<div className="flex items-center gap-1">
{ClaimConditionTypeData[type].name}
<TooltipBox
content={
<Text size="body.md">
{
ClaimConditionTypeData[type]
.description
}
</Text>
}
/>
</div>
</MenuItem>
);
})}
</MenuList>
</Menu>
</>
) : (
<Button
size="sm"
colorScheme="primary"
variant="solid"
borderRadius="md"
leftIcon={<Icon as={FiPlus} />}
onClick={() => addPhase("custom")}
isDisabled={setClaimConditionsQuery.isLoading}
>
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
</Button>
)}
</AdminOnly>
)}
{controlledFields.some((field) => field.fromSdk) && ( {controlledFields.some((field) => field.fromSdk) && (
<ResetClaimEligibility <ResetClaimEligibility
isErc20={isErc20} isErc20={isErc20}
contract={contractV5} contract={contract}
tokenId={tokenId} tokenId={tokenId}
/> />
)} )}
</Flex> </Flex>
<Flex> <Flex>
{contractV5 && ( <AdminOnly contract={contract} fallback={<Box pb={5} />}>
<AdminOnly contract={contractV5} fallback={<Box pb={5} />}> <Flex justifyContent="center" alignItems="center" gap={3}>
<Flex justifyContent="center" alignItems="center" gap={3}> {(hasRemovedPhases || hasAddedPhases) && (
{(hasRemovedPhases || hasAddedPhases) && ( <Text color="red.500" fontWeight="bold">
<Text color="red.500" fontWeight="bold"> You have unsaved changes
You have unsaved changes </Text>
</Text> )}
)} {controlledFields.length > 0 ||
{controlledFields.length > 0 || hasRemovedPhases ||
hasRemovedPhases || !isMultiPhase ? (
!isMultiPhase ? ( <TransactionButton
<TransactionButton colorScheme="primary"
colorScheme="primary" transactionCount={1}
transactionCount={1} isDisabled={claimConditionsQuery.isLoading}
isDisabled={claimConditionsQuery.isLoading} type="submit"
type="submit" isLoading={sendTx.isPending}
isLoading={setClaimConditionsQuery.isLoading} loadingText="Saving..."
loadingText="Saving..." size="md"
size="md" >
> Save Phases
Save Phases </TransactionButton>
</TransactionButton> ) : null}
) : null} </Flex>
</Flex> </AdminOnly>
</AdminOnly>
)}
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>
@@ -30,7 +30,6 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
form, form,
field, field,
isErc20, isErc20,
isClaimPhaseV1,
isAdmin, isAdmin,
claimConditionType, claimConditionType,
isActive, isActive,
@@ -82,26 +81,15 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
<Flex flexDir="column" gap={2} mt={{ base: 4, md: 0 }}> <Flex flexDir="column" gap={2} mt={{ base: 4, md: 0 }}>
<Flex gap={3} alignItems="center"> <Flex gap={3} alignItems="center">
<Heading> <Heading>{ClaimConditionTypeData[claimConditionType].name}</Heading>
{isClaimPhaseV1
? isMultiPhase
? `Claim Phase ${phaseIndex + 1}`
: "Claim Conditions"
: ClaimConditionTypeData[claimConditionType].name}
</Heading>
{isActive && ( {isActive && (
<Badge colorScheme="green" borderRadius="lg" p={1.5}> <Badge colorScheme="green" borderRadius="lg" p={1.5}>
Currently active Currently active
</Badge> </Badge>
)} )}
</Flex> </Flex>
{isClaimPhaseV1 ? (
"" <Text>{ClaimConditionTypeData[claimConditionType].description}</Text>
) : (
<Text>
{ClaimConditionTypeData[claimConditionType].description}
</Text>
)}
</Flex> </Flex>
{!field.isEditing ? ( {!field.isEditing ? (
@@ -1,5 +1,4 @@
import { Flex, Stack } from "@chakra-ui/react"; import { Flex, Stack } from "@chakra-ui/react";
import { useContract } from "@thirdweb-dev/react";
import type { ThirdwebContract } from "thirdweb"; import type { ThirdwebContract } from "thirdweb";
import { Heading, Text } from "tw-components"; import { Heading, Text } from "tw-components";
import { ClaimConditionsForm } from "./claim-conditions-form/index"; import { ClaimConditionsForm } from "./claim-conditions-form/index";
@@ -16,8 +15,6 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
isColumn, isColumn,
isERC20, isERC20,
}) => { }) => {
const contractQuery = useContract(contract.address);
return ( return (
<Stack spacing={8}> <Stack spacing={8}>
<Flex p={0} position="relative"> <Flex p={0} position="relative">
@@ -39,17 +36,14 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
</Flex> </Flex>
{/* Set Claim Conditions */} {/* Set Claim Conditions */}
{contractQuery.contract && ( <ClaimConditionsForm
<ClaimConditionsForm isErc20={isERC20}
isErc20={isERC20} contract={contract}
contract={contractQuery.contract} tokenId={tokenId}
tokenId={tokenId} isColumn={isColumn}
isColumn={isColumn} // always multi phase!
contractV5={contract} isMultiPhase={true}
// always multi phase! />
isMultiPhase={true}
/>
)}
</Flex> </Flex>
</Flex> </Flex>
</Stack> </Stack>
@@ -87,9 +87,6 @@ export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
return ( return (
<AdminOnly contract={contract} fallback={<div className="pb-5" />}> <AdminOnly contract={contract} fallback={<div className="pb-5" />}>
<TransactionButton <TransactionButton
colorScheme="secondary"
bg="bgBlack"
color="bgWhite"
transactionCount={1} transactionCount={1}
type="button" type="button"
isLoading={sendTxMutation.isPending} isLoading={sendTxMutation.isPending}
@@ -97,7 +94,7 @@ export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
loadingText="Resetting..." loadingText="Resetting..."
size="sm" size="sm"
> >
Reset Eligibility{" "} Reset Eligibility
<ToolTipLabel <ToolTipLabel
label={ label={
<> <>
@@ -27,7 +27,7 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Logo } from "components/logo"; import { Logo } from "components/logo";
import Papa from "papaparse"; import Papa from "papaparse";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { type DropzoneOptions, useDropzone } from "react-dropzone"; import { type DropzoneOptions, useDropzone } from "react-dropzone";
import { BsFillCloudUploadFill } from "react-icons/bs"; import { BsFillCloudUploadFill } from "react-icons/bs";
import { FiDownload } from "react-icons/fi"; import { FiDownload } from "react-icons/fi";
@@ -64,7 +64,6 @@ interface SnapshotUploadProps {
}[] }[]
| null; | null;
dropType: "specific" | "any" | "overrides"; dropType: "specific" | "any" | "overrides";
isV1ClaimCondition: boolean;
isDisabled: boolean; isDisabled: boolean;
} }
@@ -74,7 +73,6 @@ export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
onClose, onClose,
value, value,
dropType, dropType,
isV1ClaimCondition,
isDisabled, isDisabled,
}) => { }) => {
const [validSnapshot, setValidSnapshot] = useState<SnapshotAddressInput[]>( const [validSnapshot, setValidSnapshot] = useState<SnapshotAddressInput[]>(
@@ -224,11 +222,7 @@ export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
</Flex> </Flex>
{validSnapshot.length > 0 ? ( {validSnapshot.length > 0 ? (
<SnapshotTable <SnapshotTable portalRef={paginationPortalRef} data={snapshotData} />
portalRef={paginationPortalRef}
data={snapshotData}
isV1ClaimCondition={isV1ClaimCondition}
/>
) : ( ) : (
<Flex flexGrow={1} align="center" overflow="auto"> <Flex flexGrow={1} align="center" overflow="auto">
<Container maxW="container.page"> <Container maxW="container.page">
@@ -275,33 +269,7 @@ export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
<Flex gap={2} flexDir="column"> <Flex gap={2} flexDir="column">
<Heading size="label.md">Requirements</Heading> <Heading size="label.md">Requirements</Heading>
<UnorderedList spacing={1}> <UnorderedList spacing={1}>
{isV1ClaimCondition ? ( {dropType === "specific" ? (
<>
<Text as={ListItem}>
Files <em>must</em> contain one .csv file with a list
of addresses.
<br />
<Link download color="blue.500" href="/snapshot.csv">
<Icon as={FiDownload} /> Example snapshot
</Link>
</Text>
<Text as={ListItem}>
You may optionally add a <Code>maxClaimable</Code>{" "}
column to specify how many NFTs can be claimed by that
specific address per transaction, if not specified,
the default value is the one you have set on your
claim phase.
<br />
<Link
download
color="blue.500"
href="/snapshot-with-maxclaimable.csv"
>
<Icon as={FiDownload} /> Example snapshot
</Link>
</Text>
</>
) : dropType === "specific" ? (
<> <>
<Text as={ListItem}> <Text as={ListItem}>
Files <em>must</em> contain one .csv file with a list Files <em>must</em> contain one .csv file with a list
@@ -384,13 +352,10 @@ export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
Repeated addresses will be removed and only the first Repeated addresses will be removed and only the first
found will be kept. found will be kept.
</Text> </Text>
{!isV1ClaimCondition && ( <Text as={ListItem}>
<Text as={ListItem}> The limit you set is for the maximum amount of NFTs a
The limit you set is for the maximum amount of NFTs a wallet can claim, not how many they can receive in total.
wallet can claim, not how many they can receive in </Text>
total.
</Text>
)}
</UnorderedList> </UnorderedList>
</Flex> </Flex>
</Flex> </Flex>
@@ -459,81 +424,67 @@ export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
interface SnapshotTableProps { interface SnapshotTableProps {
data: SnapshotAddressInput[]; data: SnapshotAddressInput[];
portalRef: React.RefObject<HTMLDivElement>; portalRef: React.RefObject<HTMLDivElement>;
isV1ClaimCondition: boolean;
} }
const SnapshotTable: React.FC<SnapshotTableProps> = ({ const SnapshotTableColumns = [
data, {
portalRef, Header: "Address",
isV1ClaimCondition, accessor: ({ address, isValid }) => {
}) => { if (isValid) {
const columns = useMemo(() => { return address;
let cols = [ }
{ return (
Header: "Address", <Flex>
accessor: ({ address, isValid }) => { <Tooltip
if (isValid) { label={
return address; address.startsWith("0x")
} ? "Address is not valid"
return ( : "Address couldn't be resolved"
<Flex> }
<Tooltip >
label={ <Stack direction="row" align="center">
address.startsWith("0x") <Icon as={IoAlertCircleOutline} color="red.500" boxSize={5} />
? "Address is not valid" <Text fontWeight="bold" color="red.500" cursor="default">
: "Address couldn't be resolved" {address}
} </Text>
> </Stack>
<Stack direction="row" align="center"> </Tooltip>
<Icon as={IoAlertCircleOutline} color="red.500" boxSize={5} /> </Flex>
<Text fontWeight="bold" color="red.500" cursor="default"> );
{address} },
</Text> },
</Stack> {
</Tooltip> Header: "Max claimable",
</Flex> accessor: ({ maxClaimable }) => {
); return maxClaimable === "0" || !maxClaimable
}, ? "Default"
}, : maxClaimable === "unlimited"
{ ? "Unlimited"
Header: "Max claimable", : maxClaimable;
accessor: ({ maxClaimable }) => { },
return maxClaimable === "0" || !maxClaimable },
? "Default" {
: maxClaimable === "unlimited" Header: "Price",
? "Unlimited" accessor: ({ price }) => {
: maxClaimable; return price === "0"
}, ? "Free"
}, : !price || price === "unlimited"
] as Column<SnapshotAddressInput>[]; ? "Default"
: price;
if (!isV1ClaimCondition) { },
cols = cols.concat([ },
{ {
Header: "Price", Header: "Currency Address",
accessor: ({ price }) => { accessor: ({ currencyAddress }) => {
return price === "0" return currencyAddress === "0x0000000000000000000000000000000000000000" ||
? "Free" !currencyAddress
: !price || price === "unlimited" ? "Default"
? "Default" : currencyAddress;
: price; },
}, },
}, ] as Column<SnapshotAddressInput>[];
{
Header: "Currency Address",
accessor: ({ currencyAddress }) => {
return currencyAddress ===
"0x0000000000000000000000000000000000000000" || !currencyAddress
? "Default"
: currencyAddress;
},
},
]);
}
return cols;
}, [isV1ClaimCondition]);
const SnapshotTable: React.FC<SnapshotTableProps> = ({ data, portalRef }) => {
const { const {
getTableProps, getTableProps,
getTableBodyProps, getTableBodyProps,
@@ -555,7 +506,7 @@ const SnapshotTable: React.FC<SnapshotTableProps> = ({
state: { pageIndex, pageSize }, state: { pageIndex, pageSize },
} = useTable( } = useTable(
{ {
columns, columns: SnapshotTableColumns,
data, data,
initialState: { initialState: {
pageSize: 50, pageSize: 50,
@@ -18,7 +18,6 @@ export const NFTRevealButton: React.FC<NFTRevealButtonProps> = ({
const batchesQuery = useReadContract(getBatchesToReveal, { const batchesQuery = useReadContract(getBatchesToReveal, {
contract, contract,
}); });
console.log("*** batches", batchesQuery);
return batchesQuery.data?.length ? ( return batchesQuery.data?.length ? (
<MinterOnly contract={contract}> <MinterOnly contract={contract}>
<Drawer <Drawer
+10
View File
@@ -54,6 +54,16 @@ function parseError(error: unknown): string | JSX.Element {
return error.message; return error.message;
} }
// handle rpc errors
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string"
) {
return error.message;
}
// everything that falls through here should be logged and sent to posthog // everything that falls through here should be logged and sent to posthog
console.error("unknown error", error); console.error("unknown error", error);
posthog.capture("unknown_error", { error }); posthog.capture("unknown_error", { error });
@@ -22,24 +22,30 @@ export async function convertErc20Amount(
} & AmountOrAmountInWei } & AmountOrAmountInWei
>, >,
): Promise<bigint> { ): Promise<bigint> {
if ("amount" in options) { try {
// for native token, we know decimals are 18 if ("amount" in options) {
if (!options.erc20Address || isNativeTokenAddress(options.erc20Address)) { // for native token, we know decimals are 18
return toUnits(options.amount.toString(), 18); if (!options.erc20Address || isNativeTokenAddress(options.erc20Address)) {
return toUnits(options.amount.toString(), 18);
}
// otherwise get the decimals of the currency
const currencyContract = getContract({
client: options.client,
chain: options.chain,
address: options.erc20Address,
});
const { decimals } = await import(
"../../extensions/erc20/read/decimals.js"
);
const currencyDecimals = await decimals({
contract: currencyContract,
});
return toUnits(options.amount.toString(), currencyDecimals);
} }
// otherwise get the decimals of the currency return options.amountInWei;
const currencyContract = getContract({ } catch (e) {
client: options.client, throw new Error(
chain: options.chain, `Failed to convert ERC20 amount for token: ${options.erc20Address}: ${e}`,
address: options.erc20Address,
});
const { decimals } = await import(
"../../extensions/erc20/read/decimals.js"
); );
const currencyDecimals = await decimals({
contract: currencyContract,
});
return toUnits(options.amount.toString(), currencyDecimals);
} }
return options.amountInWei;
} }