[Add] Support for modular contracts in explore and deploy pages (#4288)
<!-- start pr-codex -->
## PR-Codex overview
This PR updates contract components and SDK with default modules support.
### Detailed summary
- Added `defaultModules` support in SDK and contract components
- Updated contract card to display modules
- Added transfer validator field in royalty fieldset
- Improved contract publishing information display
> The following files were skipped due to too many changes: `apps/dashboard/src/components/contract-components/shared/published-by.tsx`, `apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx`, `apps/dashboard/src/components/explore/contract-card/index.tsx`, `apps/dashboard/src/components/pages/publish.tsx`, `apps/dashboard/src/data/explore.ts`
> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
<!-- end pr-codex -->
This commit is contained in:
+38
-2
@@ -24,14 +24,19 @@ import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { replaceTemplateValues } from "lib/deployment/template-values";
|
||||
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import { FormProvider, type UseFormReturn, useForm } from "react-hook-form";
|
||||
import { FiHelpCircle } from "react-icons/fi";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { computePublishedContractAddress } from "thirdweb/deploys";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { encodeAbiParameters } from "thirdweb/utils";
|
||||
import invariant from "tiny-invariant";
|
||||
import { FormHelperText, FormLabel, Heading, Text } from "tw-components";
|
||||
import { thirdwebClient } from "../../../@/constants/client";
|
||||
import { useV5DashboardChain } from "../../../lib/v5-adapter";
|
||||
import {
|
||||
useContractEnabledExtensions,
|
||||
useContractFullPublishMetadata,
|
||||
@@ -77,6 +82,11 @@ export type CustomContractDeploymentFormData = {
|
||||
saltForCreate2: string;
|
||||
signerAsSalt: boolean;
|
||||
deployParams: Record<string, string>;
|
||||
defaultModules: {
|
||||
moduleName: string;
|
||||
moduleVersion: string;
|
||||
publisherAddress: string;
|
||||
}[];
|
||||
modularContractDefaultModulesInstallParams: Record<string, string>[];
|
||||
contractMetadata?: {
|
||||
name: string;
|
||||
@@ -120,6 +130,8 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
const connectedWallet = ensQuery.data?.address || walletAddress;
|
||||
const trackEvent = useTrack();
|
||||
const activeAccount = useActiveAccount();
|
||||
const searchParams = useSearchParams();
|
||||
const chain = useV5DashboardChain(selectedChain);
|
||||
|
||||
const compilerMetadata = useContractPublishMetadataFromURI(ipfsHash);
|
||||
const fullPublishMetadata = useContractFullPublishMetadata(ipfsHash);
|
||||
@@ -160,7 +172,14 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
fullPublishMetadata.data?.deployType === "customFactory";
|
||||
|
||||
const isModular = fullPublishMetadata.data?.routerType === "modular";
|
||||
const defaultModules = fullPublishMetadata.data?.defaultModules;
|
||||
|
||||
const defaultModules = (searchParams?.getAll("module") || [])
|
||||
.map((m) => JSON.parse(atob(m)))
|
||||
.map((m) => ({
|
||||
publisherAddress: m.publisher,
|
||||
moduleName: m.moduleId,
|
||||
moduleVersion: m.version || "latest",
|
||||
}));
|
||||
|
||||
const modularContractDefaultModulesInstallParams =
|
||||
useModularContractsDefaultModulesInstallParams({
|
||||
@@ -218,6 +237,7 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
signerAsSalt: true,
|
||||
deployParams: parsedDeployParams,
|
||||
recipients: [{ address: connectedWallet || "", sharesBps: 10000 }],
|
||||
defaultModules,
|
||||
// set default values for modular contract modules with custom components
|
||||
modularContractDefaultModulesInstallParams:
|
||||
(activeAccount &&
|
||||
@@ -229,6 +249,8 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
// set connected wallet address as default "royaltyRecipient"
|
||||
if (showRoyaltyFieldset(paramNames)) {
|
||||
returnVal.royaltyRecipient = activeAccount.address;
|
||||
returnVal.royaltyBps = "0";
|
||||
returnVal.transferValidator = ZERO_ADDRESS;
|
||||
}
|
||||
|
||||
// set connected wallet address as default "primarySaleRecipient"
|
||||
@@ -247,6 +269,7 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
modularContractDefaultModulesInstallParams.data,
|
||||
isTWPublisher,
|
||||
activeAccount,
|
||||
defaultModules,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -395,7 +418,7 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
id="custom-contract-form"
|
||||
as="form"
|
||||
onSubmit={form.handleSubmit(async (formData) => {
|
||||
if (!selectedChain) {
|
||||
if (!selectedChain || !chain) {
|
||||
return;
|
||||
}
|
||||
const deployData = {
|
||||
@@ -436,7 +459,20 @@ const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
},
|
||||
);
|
||||
|
||||
const moduleInstallationAddresses: string[] = await Promise.all(
|
||||
modularContractDefaultModulesInstallParams.data.map((d) =>
|
||||
computePublishedContractAddress({
|
||||
chain,
|
||||
client: thirdwebClient,
|
||||
contractId: d.moduleName,
|
||||
// modules never have consturctor params so this is fine!
|
||||
constructorParams: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
deployParams._moduleInstallData = JSON.stringify(moduleInstallData);
|
||||
deployParams._modules = JSON.stringify(moduleInstallationAddresses);
|
||||
}
|
||||
|
||||
deploy.mutate(
|
||||
|
||||
+15
-2
@@ -177,6 +177,9 @@ function RenderRoyaltyFieldset(props: {
|
||||
const royaltyBpsPath =
|
||||
`modularContractDefaultModulesInstallParams.${ext.moduleIndex}.royaltyBps` as const;
|
||||
|
||||
const transferValidatorPath =
|
||||
`modularContractDefaultModulesInstallParams.${ext.moduleIndex}.transferValidator` as const;
|
||||
|
||||
return (
|
||||
<RoyaltyFieldset
|
||||
royaltyRecipient={{
|
||||
@@ -198,15 +201,25 @@ function RenderRoyaltyFieldset(props: {
|
||||
errorMessage: form.getFieldState(royaltyBpsPath, form.formState).error
|
||||
?.message,
|
||||
}}
|
||||
transferValidator={{
|
||||
isInvalid: !!form.getFieldState(transferValidatorPath, form.formState)
|
||||
.error,
|
||||
register: form.register(transferValidatorPath, {
|
||||
required: "Required",
|
||||
}),
|
||||
errorMessage: form.getFieldState(transferValidatorPath, form.formState)
|
||||
.error?.message,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function showRoyaltyFieldset(paramNames: string[]) {
|
||||
return (
|
||||
paramNames.length === 2 &&
|
||||
paramNames.length === 3 &&
|
||||
paramNames.includes("royaltyRecipient") &&
|
||||
paramNames.includes("royaltyBps")
|
||||
paramNames.includes("royaltyBps") &&
|
||||
paramNames.includes("transferValidator")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+55
-30
@@ -17,40 +17,65 @@ export function RoyaltyFieldset(props: {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
};
|
||||
transferValidator?: {
|
||||
isInvalid: boolean;
|
||||
register: UseFormRegisterReturn;
|
||||
errorMessage: string | undefined;
|
||||
};
|
||||
}) {
|
||||
const bpsNumValue = Number.parseInt(props.royaltyBps.value);
|
||||
return (
|
||||
<Fieldset legend="Royalties">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormControl isRequired isInvalid={props.royaltyRecipient.isInvalid}>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
variant="filled"
|
||||
{...props.royaltyRecipient.register}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{props.royaltyRecipient.errorMessage}
|
||||
</FormErrorMessage>
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
The wallet address that should receive the revenue from royalties
|
||||
earned from secondary sales of the assets.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isRequired
|
||||
maxW={{ base: "100%", md: "150px" }}
|
||||
isInvalid={props.royaltyBps.isInvalid}
|
||||
defaultValue="0"
|
||||
>
|
||||
<FormLabel>Percentage</FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={Number.isNaN(bpsNumValue) ? 0 : bpsNumValue}
|
||||
onChange={(value) => props.royaltyBps.setValue(value.toString())}
|
||||
/>
|
||||
<FormErrorMessage>{props.royaltyBps.errorMessage}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormControl isRequired isInvalid={props.royaltyRecipient.isInvalid}>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
variant="filled"
|
||||
{...props.royaltyRecipient.register}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{props.royaltyRecipient.errorMessage}
|
||||
</FormErrorMessage>
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
The wallet address that should receive the revenue from royalties
|
||||
earned from secondary sales of the assets.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isRequired
|
||||
maxW={{ base: "100%", md: "150px" }}
|
||||
isInvalid={props.royaltyBps.isInvalid}
|
||||
defaultValue="0"
|
||||
>
|
||||
<FormLabel>Percentage</FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={Number.isNaN(bpsNumValue) ? 0 : bpsNumValue}
|
||||
onChange={(value) => props.royaltyBps.setValue(value.toString())}
|
||||
/>
|
||||
<FormErrorMessage>{props.royaltyBps.errorMessage}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</div>
|
||||
{props.transferValidator && (
|
||||
<FormControl isRequired isInvalid={props.transferValidator.isInvalid}>
|
||||
<FormLabel>Transfer Validator Address</FormLabel>
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
variant="filled"
|
||||
{...props.transferValidator.register}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{props.transferValidator.errorMessage}
|
||||
</FormErrorMessage>
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
The contract address to enforce royalties according to the Creator
|
||||
Token Standard implementation. Passing the zero address disables
|
||||
this validation.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
|
||||
@@ -840,6 +840,7 @@ export function useCustomContractDeployMutation(options: {
|
||||
Object.values(data.deployParams),
|
||||
{
|
||||
forceDirectDeploy,
|
||||
defaultModules: data.defaultModules,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEVMContractInfo } from "@3rdweb-sdk/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useEns,
|
||||
usePublishedContractsFromDeploy,
|
||||
@@ -6,16 +7,21 @@ import {
|
||||
import { ContractCard } from "components/explore/contract-card";
|
||||
import { THIRDWEB_DEPLOYER_ADDRESS } from "constants/addresses";
|
||||
import { useMemo } from "react";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { polygon } from "thirdweb/chains";
|
||||
import { getBytecode, getContract } from "thirdweb/contract";
|
||||
import { getPublishedUriFromCompilerUri } from "thirdweb/extensions/thirdweb";
|
||||
import { getInstalledModules } from "thirdweb/modules";
|
||||
import { useActiveAccount, useReadContract } from "thirdweb/react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { extractIPFSUri } from "thirdweb/utils";
|
||||
|
||||
interface PublishedByProps {
|
||||
contractAddress: string;
|
||||
contract: ThirdwebContract;
|
||||
}
|
||||
|
||||
export const PublishedBy: React.FC<PublishedByProps> = ({
|
||||
contractAddress,
|
||||
}) => {
|
||||
const contractEnsQuery = useEns(contractAddress);
|
||||
export const PublishedBy: React.FC<PublishedByProps> = ({ contract }) => {
|
||||
const contractEnsQuery = useEns(contract.address);
|
||||
const activeNetworkInfo = useEVMContractInfo();
|
||||
|
||||
const publishedContractsFromDeploy = usePublishedContractsFromDeploy(
|
||||
@@ -49,6 +55,78 @@ export const PublishedBy: React.FC<PublishedByProps> = ({
|
||||
const publisherAddress =
|
||||
publisherEnsQuery.data?.ensName || publisherEnsQuery.data?.address;
|
||||
|
||||
const installedModules = useReadContract(getInstalledModules, {
|
||||
contract,
|
||||
queryOptions: {
|
||||
enabled: publishedContractToShow?.routerType === "modular" && !!contract,
|
||||
},
|
||||
});
|
||||
|
||||
// this handles all the logic for modules in the published contract card
|
||||
const installedModulesQuery = useQuery({
|
||||
queryKey: [
|
||||
"published-by-modules",
|
||||
contract,
|
||||
installedModules.data?.map((m) => m.implementation),
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!installedModules.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const moduleContracts = installedModules.data.map((module) => {
|
||||
return getContract({ ...contract, address: module.implementation });
|
||||
});
|
||||
const metadataUris = await Promise.allSettled(
|
||||
moduleContracts.map(async (c) => {
|
||||
const byteCode = await getBytecode(c);
|
||||
const ipfsUri = extractIPFSUri(byteCode);
|
||||
|
||||
if (!ipfsUri) {
|
||||
throw new Error("No IPFS URI found in bytecode");
|
||||
}
|
||||
let uris = await getPublishedUriFromCompilerUri({
|
||||
contract: {
|
||||
chain: polygon,
|
||||
client: contract.client,
|
||||
address: "0xf5b896Ddb5146D5dA77efF4efBb3Eae36E300808",
|
||||
},
|
||||
compilerMetadataUri: ipfsUri,
|
||||
}).catch((e) => {
|
||||
console.error("Error fetching published URI", e);
|
||||
return [];
|
||||
});
|
||||
|
||||
uris = uris.filter((uri) => uri.length > 0);
|
||||
if (uris.length === 0) {
|
||||
throw new Error("No published URI found");
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
uris.map(async (uri) => {
|
||||
const content = await download({
|
||||
uri,
|
||||
client: contract.client,
|
||||
});
|
||||
return JSON.parse(await content.text());
|
||||
}),
|
||||
);
|
||||
|
||||
return results
|
||||
.filter((r) => r.status === "fulfilled")
|
||||
.map((r) => r.value);
|
||||
}),
|
||||
);
|
||||
|
||||
const filtered = metadataUris
|
||||
.filter((m) => m.status === "fulfilled")
|
||||
.map((m) => m.value);
|
||||
|
||||
return filtered.map((m) => m[0]);
|
||||
},
|
||||
enabled: !!installedModules.data?.length,
|
||||
});
|
||||
|
||||
if (!publishedContractToShow || !publisherAddress) {
|
||||
return null;
|
||||
}
|
||||
@@ -61,6 +139,11 @@ export const PublishedBy: React.FC<PublishedByProps> = ({
|
||||
isBeta={(publishedContractToShow.displayName || "")
|
||||
.toLowerCase()
|
||||
.includes("beta")}
|
||||
modules={installedModulesQuery.data?.map((m) => ({
|
||||
publisher: m.publisher,
|
||||
moduleId: m.name,
|
||||
version: m.version,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getDashboardChainRpc } from "lib/rpc";
|
||||
import { getThirdwebSDK, replaceIpfsUrl } from "lib/sdk";
|
||||
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { polygon } from "thirdweb/chains";
|
||||
import invariant from "tiny-invariant";
|
||||
import { ContractPublisher, replaceDeployerAddress } from "../publisher";
|
||||
@@ -21,43 +20,84 @@ import { ContractPublisher, replaceDeployerAddress } from "../publisher";
|
||||
interface ContractCardProps {
|
||||
publisher: string;
|
||||
contractId: string;
|
||||
titleOverride?: string;
|
||||
descriptionOverride?: string;
|
||||
version?: string;
|
||||
tracking?: {
|
||||
source: string;
|
||||
itemIndex: `${number}`;
|
||||
};
|
||||
isBeta: boolean | undefined;
|
||||
// for modular contracts to show the modules in the card
|
||||
// publisher and moduleId are required
|
||||
// version is optional
|
||||
// if version is not provided, it will default to "latest"
|
||||
modules?: {
|
||||
publisher: string;
|
||||
moduleId: string;
|
||||
version?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function getContractUrl(
|
||||
{
|
||||
version,
|
||||
publisher,
|
||||
contractId,
|
||||
titleOverride,
|
||||
modules = [],
|
||||
}: {
|
||||
publisher: string;
|
||||
contractId: string;
|
||||
version?: string;
|
||||
titleOverride?: string;
|
||||
modules?: {
|
||||
publisher: string;
|
||||
moduleId: string;
|
||||
version?: string;
|
||||
}[];
|
||||
},
|
||||
isDeploy?: true,
|
||||
) {
|
||||
let pathName = "";
|
||||
if (version !== "latest") {
|
||||
pathName = `/${publisher}/${contractId}/${version}`;
|
||||
} else {
|
||||
pathName = `/${publisher}/${contractId}`;
|
||||
}
|
||||
if (isDeploy) {
|
||||
pathName += "/deploy";
|
||||
}
|
||||
const moudleUrl = new URLSearchParams();
|
||||
|
||||
for (const m of modules) {
|
||||
moudleUrl.append("module", btoa(JSON.stringify(m)));
|
||||
}
|
||||
|
||||
if (titleOverride) {
|
||||
moudleUrl.append("displayName", titleOverride);
|
||||
}
|
||||
|
||||
pathName += `?${moudleUrl.toString()}`;
|
||||
return replaceDeployerAddress(pathName);
|
||||
}
|
||||
|
||||
export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
publisher,
|
||||
contractId,
|
||||
titleOverride,
|
||||
descriptionOverride,
|
||||
version = "latest",
|
||||
tracking,
|
||||
modules = [],
|
||||
isBeta,
|
||||
}) => {
|
||||
const publishedContractResult = usePublishedContract(
|
||||
`${publisher}/${contractId}/${version}`,
|
||||
);
|
||||
|
||||
const isNewContract = useMemo(() => {
|
||||
const newContracts = ["thirdweb.eth/AccountFactory"];
|
||||
return newContracts.includes(`${publisher}/${contractId}`);
|
||||
}, [publisher, contractId]);
|
||||
|
||||
const showSkeleton = publishedContractResult.isLoading;
|
||||
|
||||
const href = useMemo(() => {
|
||||
let h: string;
|
||||
if (version !== "latest") {
|
||||
h = `/${publisher}/${contractId}/${version}`;
|
||||
} else {
|
||||
h = `/${publisher}/${contractId}`;
|
||||
}
|
||||
|
||||
return replaceDeployerAddress(h);
|
||||
}, [contractId, publisher, version]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
@@ -67,7 +107,13 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
>
|
||||
<TrackedLinkTW
|
||||
className="absolute inset-0 z-0 cursor-pointer"
|
||||
href={href}
|
||||
href={getContractUrl({
|
||||
publisher,
|
||||
contractId,
|
||||
version,
|
||||
modules,
|
||||
titleOverride,
|
||||
})}
|
||||
category="contract_card"
|
||||
label={contractId}
|
||||
trackingProps={{
|
||||
@@ -115,8 +161,6 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
<Badge className="border-[#a21caf] dark:border-[#86198f] text-white dark:bg-[linear-gradient(154deg,#4a044e,#2e1065)] bg-[linear-gradient(154deg,#d946ef,#9333ea)] py-[3px] px-[8px]">
|
||||
Beta
|
||||
</Badge>
|
||||
) : isNewContract ? (
|
||||
<Badge variant="outline">New</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -126,6 +170,7 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
className="inline-block"
|
||||
skeletonData="Edition Drop"
|
||||
loadedData={
|
||||
titleOverride ||
|
||||
publishedContractResult.data?.displayName ||
|
||||
publishedContractResult.data?.name
|
||||
}
|
||||
@@ -140,7 +185,7 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
|
||||
{publishedContractResult.data ? (
|
||||
<p className="text-sm text-muted-foreground leading-5 mt-1">
|
||||
{publishedContractResult.data?.description}
|
||||
{descriptionOverride || publishedContractResult.data?.description}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-1">
|
||||
@@ -149,8 +194,24 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
<Skeleton className="h-4 w-[60%]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-3 relative z-1 flex gap-2 justify-between">
|
||||
{modules.length ? (
|
||||
<div className="mt-auto pt-3 flex flex-row gap-1 flex-wrap">
|
||||
{modules.slice(0, 2).map((m) => (
|
||||
<Badge variant="outline" key={m.publisher + m.moduleId + m.version}>
|
||||
{m.moduleId.split("ERC")[0]}
|
||||
</Badge>
|
||||
))}
|
||||
{modules.length > 2 ? (
|
||||
<Badge variant="outline">+{modules.length - 2}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"pt-3 relative z-1 flex gap-2 justify-between",
|
||||
!modules?.length && "mt-auto",
|
||||
)}
|
||||
>
|
||||
<ContractPublisher
|
||||
addressOrEns={publishedContractResult.data?.publisher}
|
||||
showSkeleton={showSkeleton}
|
||||
@@ -163,7 +224,17 @@ export const ContractCard: React.FC<ContractCardProps> = ({
|
||||
className="gap-1.5 py-1.5 px-2.5 text-xs h-auto relative z-10"
|
||||
asChild
|
||||
>
|
||||
<Link href={`${href}/deploy`}>
|
||||
<Link
|
||||
href={getContractUrl(
|
||||
{
|
||||
publisher,
|
||||
contractId,
|
||||
version,
|
||||
modules,
|
||||
},
|
||||
true,
|
||||
)}
|
||||
>
|
||||
<RocketIcon className="size-3" />
|
||||
Deploy
|
||||
</Link>
|
||||
|
||||
@@ -48,17 +48,39 @@ export const ContractRow: React.FC<ContractRowProps> = ({ category }) => {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 z-0 relative">
|
||||
{category.contracts.slice(0, 6).map((publishedContractId, idx) => {
|
||||
const [publisher, contractId] = publishedContractId.split("/");
|
||||
const publisher: string = Array.isArray(publishedContractId)
|
||||
? publishedContractId[0].split("/")[0]
|
||||
: publishedContractId.split("/")[0];
|
||||
const contractId: string = Array.isArray(publishedContractId)
|
||||
? publishedContractId[0].split("/")[1]
|
||||
: publishedContractId.split("/")[1];
|
||||
const modules = Array.isArray(publishedContractId)
|
||||
? publishedContractId[1]
|
||||
: undefined;
|
||||
const overrides = Array.isArray(publishedContractId)
|
||||
? publishedContractId[2]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ContractCard
|
||||
key={publishedContractId}
|
||||
key={publisher + contractId + overrides?.title}
|
||||
publisher={publisher}
|
||||
contractId={contractId}
|
||||
tracking={{
|
||||
source: category.id,
|
||||
itemIndex: `${idx}`,
|
||||
}}
|
||||
titleOverride={overrides?.title}
|
||||
descriptionOverride={overrides?.description}
|
||||
isBeta={category.isBeta}
|
||||
modules={
|
||||
modules?.length
|
||||
? modules.map((m) => ({
|
||||
publisher: m.split("/")[0],
|
||||
moduleId: m.split("/")[1],
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { replaceIpfsUrl } from "lib/sdk";
|
||||
import { ChevronsRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import { ContractDeployForm } from "../contract-components/contract-deploy-form";
|
||||
|
||||
@@ -19,7 +18,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Sidebar } from "../../@/components/blocks/Sidebar";
|
||||
import { Badge } from "../../@/components/ui/badge";
|
||||
import { useDashboardRouter } from "../../@/lib/DashboardRouter";
|
||||
import { shareLink } from "../../@/lib/shareLink";
|
||||
|
||||
export interface PublishWithVersionPageProps {
|
||||
@@ -44,7 +46,18 @@ export const PublishWithVersionPage: React.FC<PublishWithVersionPageProps> = ({
|
||||
const availableVersions = allVersions.data?.map(({ version: v }) => v) || [];
|
||||
const version = _version || availableVersions[0];
|
||||
const trackEvent = useTrack();
|
||||
const router = useRouter();
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchparams = useSearchParams();
|
||||
const modules = (searchparams?.getAll("module") || [])
|
||||
.map((m) => JSON.parse(atob(m)))
|
||||
.map((m) => ({
|
||||
publisherAddress: m.publisher,
|
||||
moduleName: m.moduleId,
|
||||
moduleVersion: m.version || "latest",
|
||||
}));
|
||||
|
||||
const stringifiedSearchParams = searchparams?.toString();
|
||||
|
||||
const publishedContract = useMemo(() => {
|
||||
return (
|
||||
@@ -78,9 +91,14 @@ export const PublishWithVersionPage: React.FC<PublishWithVersionPageProps> = ({
|
||||
: `/${author}/${contractName}/${val}`;
|
||||
|
||||
if (isDeploy) {
|
||||
router.push(`${pathName}/deploy`);
|
||||
router.push(
|
||||
`${pathName}/deploy${stringifiedSearchParams ? `?${stringifiedSearchParams}` : ""}`,
|
||||
);
|
||||
} else {
|
||||
router.push(pathName);
|
||||
router.push(
|
||||
pathName +
|
||||
(stringifiedSearchParams ? `?${stringifiedSearchParams}` : ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -104,28 +122,45 @@ export const PublishWithVersionPage: React.FC<PublishWithVersionPageProps> = ({
|
||||
);
|
||||
|
||||
const contractInfo = (
|
||||
<div className="flex gap-4 items-center flex-1">
|
||||
{publishedContract?.logo && (
|
||||
<div className="rounded-xl p-2 border border-border shrink-0 items-center justify-center hidden md:flex">
|
||||
<img
|
||||
className="size-12"
|
||||
alt={publishedContract.name}
|
||||
src={replaceIpfsUrl(publishedContract.logo)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-4 items-center flex-1">
|
||||
{publishedContract?.logo && (
|
||||
<div className="rounded-xl p-2 border border-border shrink-0 items-center justify-center hidden md:flex">
|
||||
<img
|
||||
className="size-12"
|
||||
alt={publishedContract.name}
|
||||
src={replaceIpfsUrl(publishedContract.logo)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||
{contractNameDisplay}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{publishedContract?.description}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||
{contractNameDisplay}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{publishedContract?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const modulesList = modules.length ? (
|
||||
<>
|
||||
<div className="mt-auto flex flex-row gap-2 flex-wrap">
|
||||
{modules.map((m) => (
|
||||
<Badge
|
||||
variant="default"
|
||||
key={m.publisherAddress + m.moduleName + m.moduleVersion}
|
||||
>
|
||||
{m.moduleName.split("ERC")[0]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (isDeploy) {
|
||||
const showLoading = !deployContractId || !publishedContract;
|
||||
return (
|
||||
@@ -154,7 +189,7 @@ export const PublishWithVersionPage: React.FC<PublishWithVersionPageProps> = ({
|
||||
{/* Right */}
|
||||
<div className="flex-1">
|
||||
{/* header */}
|
||||
<div className="py-6 border-b border-border md:py-12">
|
||||
<div className="py-6 flex flex-col gap-4 border-b border-border md:py-12">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-6 md:items-center">
|
||||
{contractInfo}
|
||||
<div className="flex flex-col-reverse md:flex-row gap-3">
|
||||
@@ -162,7 +197,7 @@ export const PublishWithVersionPage: React.FC<PublishWithVersionPageProps> = ({
|
||||
<div className="flex gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`${router.asPath.replace("/deploy", "")}`}
|
||||
href={`${pathname?.replace("/deploy", "") + (stringifiedSearchParams ? `?${stringifiedSearchParams}` : "")}`}
|
||||
target="_blank"
|
||||
>
|
||||
Contract
|
||||
@@ -182,6 +217,7 @@ export const PublishWithVersionPage: React.FC<PublishWithVersionPageProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{modulesList}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -205,19 +241,22 @@ export const PublishWithVersionPage: React.FC<PublishWithVersionPageProps> = ({
|
||||
return (
|
||||
<Flex direction="column" gap={{ base: 6, md: 10 }}>
|
||||
{/* Header */}
|
||||
<div className="py-2 md:pt-8 md:pb-0">
|
||||
<div className="py-2 flex flex-col gap-4 md:pt-8 md:pb-0">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-6">
|
||||
{contractInfo}
|
||||
<div className="flex gap-3">
|
||||
{versionSelector}
|
||||
<Button asChild variant="primary" className="gap-2">
|
||||
<Link href={`${router.asPath}/deploy`}>
|
||||
<Link
|
||||
href={`${pathname}/deploy${stringifiedSearchParams ? `?${stringifiedSearchParams}` : ""}`}
|
||||
>
|
||||
Deploy Now
|
||||
<ChevronsRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{modulesList}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const ContractOverviewPage: React.FC<ContractOverviewPageProps> = ({
|
||||
<BuildYourApp trackingCategory={TRACKING_CATEGORY} />
|
||||
</GridItem>
|
||||
<GridItem colSpan={{ xl: 3 }} as={Flex} direction="column" gap={6}>
|
||||
<PublishedBy contractAddress={contract.address} />
|
||||
<PublishedBy contract={contract} />
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,15 @@ export interface ExploreCategory {
|
||||
displayName?: string;
|
||||
description: string;
|
||||
learnMore?: string;
|
||||
contracts: Readonly<PublishedContractID[]>;
|
||||
contracts:
|
||||
| Array<PublishedContractID>
|
||||
| Array<
|
||||
[
|
||||
PublishedContractID,
|
||||
Array<PublishedContractID>,
|
||||
{ title: string; description: string },
|
||||
]
|
||||
>;
|
||||
showInExplore?: boolean;
|
||||
isBeta?: boolean;
|
||||
}
|
||||
@@ -27,7 +35,8 @@ const POPULAR = {
|
||||
"thirdweb.eth/NFTStake",
|
||||
"unlock-protocol.eth/PublicLock",
|
||||
],
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const NFTS = {
|
||||
id: "nft",
|
||||
name: "NFT",
|
||||
@@ -47,7 +56,7 @@ const NFTS = {
|
||||
"thirdweb.eth/Multiwrap",
|
||||
"kronickatz.eth/ERC721NESDrop",
|
||||
],
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const GOVERNANCE = {
|
||||
id: "daos-governance",
|
||||
@@ -58,7 +67,7 @@ const GOVERNANCE = {
|
||||
"thirdweb.eth/TokenERC20",
|
||||
"thirdweb.eth/Split",
|
||||
],
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const DROPS = {
|
||||
id: "drops",
|
||||
@@ -70,7 +79,7 @@ const DROPS = {
|
||||
"thirdweb.eth/DropERC1155",
|
||||
"thirdweb.eth/DropERC20",
|
||||
],
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const MARKETS = {
|
||||
id: "marketplace",
|
||||
@@ -82,7 +91,109 @@ const MARKETS = {
|
||||
"thirdweb.eth/TokenERC20",
|
||||
"thirdweb.eth/Split",
|
||||
],
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const MODULAR_CONTRACTS = {
|
||||
id: "modular-contracts",
|
||||
name: "modular",
|
||||
displayName: "Modular Contracts (Beta)",
|
||||
description:
|
||||
"Collection of highly customizable and upgradeable smart contracts built with the modular contracts framework.",
|
||||
isBeta: true,
|
||||
contracts: [
|
||||
// erc721 drop
|
||||
[
|
||||
"thirdweb.eth/ERC721CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC721",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC721",
|
||||
"deployer.thirdweb.eth/RoyaltyERC721",
|
||||
],
|
||||
{
|
||||
title: "Modular NFT Drop",
|
||||
description: "ERC721 NFTs that anyone can mint.",
|
||||
},
|
||||
],
|
||||
// erc721 token
|
||||
[
|
||||
"thirdweb.eth/ERC721CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/MintableERC721",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC721",
|
||||
"deployer.thirdweb.eth/TransferableERC721",
|
||||
],
|
||||
{
|
||||
title: "Modular NFT Collection",
|
||||
description: "ERC721 NFTs that only owners can mint.",
|
||||
},
|
||||
],
|
||||
// open edition 721
|
||||
[
|
||||
"thirdweb.eth/ERC721CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC721",
|
||||
"deployer.thirdweb.eth/OpenEditionMetadataERC721",
|
||||
"deployer.thirdweb.eth/RoyaltyERC721",
|
||||
],
|
||||
{
|
||||
title: "Modular Open Edition",
|
||||
description: "ERC721 NFTs with identical metadata.",
|
||||
},
|
||||
],
|
||||
// erc1155 drop
|
||||
[
|
||||
"thirdweb.eth/ERC1155CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC1155",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC1155",
|
||||
"deployer.thirdweb.eth/RoyaltyERC1155",
|
||||
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
|
||||
],
|
||||
{
|
||||
title: "Modular Edition Drop",
|
||||
description: "ERC1155 NFTs that others can mint.",
|
||||
},
|
||||
],
|
||||
// erc1155 token
|
||||
[
|
||||
"thirdweb.eth/ERC1155CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/MintableERC1155",
|
||||
"deployer.thirdweb.eth/BatchMetadataERC1155",
|
||||
"deployer.thirdweb.eth/TransferableERC1155",
|
||||
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
|
||||
],
|
||||
{
|
||||
title: "Modular Edition",
|
||||
description: "ERC1155 NFTs that only owners can mint.",
|
||||
},
|
||||
],
|
||||
// erc20 drop
|
||||
[
|
||||
"thirdweb.eth/ERC20CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/ClaimableERC20",
|
||||
"deployer.thirdweb.eth/TransferableERC20",
|
||||
],
|
||||
{
|
||||
title: "Modular Token Drop",
|
||||
description: "ERC20 Tokens that others can mint.",
|
||||
},
|
||||
],
|
||||
// erc20 token
|
||||
[
|
||||
"thirdweb.eth/ERC20CoreInitializable",
|
||||
[
|
||||
"deployer.thirdweb.eth/MintableERC20",
|
||||
"deployer.thirdweb.eth/TransferableERC20",
|
||||
],
|
||||
{
|
||||
title: "Modular Token",
|
||||
description: "ERC20 Tokens that only owners can mint.",
|
||||
},
|
||||
],
|
||||
],
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const AIRDROP = {
|
||||
id: "airdrop",
|
||||
@@ -91,7 +202,7 @@ const AIRDROP = {
|
||||
description:
|
||||
"Efficiently transfer large numbers of on-chain assets to a large number of recipients.",
|
||||
contracts: ["thirdweb.eth/Airdrop"],
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const GAMING = {
|
||||
id: "gaming",
|
||||
@@ -109,7 +220,7 @@ const GAMING = {
|
||||
"thirdweb.eth/NFTStake",
|
||||
],
|
||||
showInExplore: false,
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const LOYALTY = {
|
||||
id: "loyalty",
|
||||
@@ -123,7 +234,7 @@ const LOYALTY = {
|
||||
"thirdweb.eth/TokenERC20",
|
||||
],
|
||||
showInExplore: false,
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const COMMERCE = {
|
||||
id: "commerce",
|
||||
@@ -138,7 +249,7 @@ const COMMERCE = {
|
||||
"thirdweb.eth/Split",
|
||||
],
|
||||
showInExplore: false,
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const STAKING = {
|
||||
id: "staking",
|
||||
@@ -151,7 +262,7 @@ const STAKING = {
|
||||
"thirdweb.eth/TokenStake",
|
||||
],
|
||||
showInExplore: true,
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const SMART_WALLET = {
|
||||
id: "smart-wallet",
|
||||
@@ -166,10 +277,11 @@ const SMART_WALLET = {
|
||||
"thirdweb.eth/ManagedAccountFactory",
|
||||
],
|
||||
showInExplore: true,
|
||||
} as const;
|
||||
} satisfies ExploreCategory;
|
||||
|
||||
const CATEGORIES = {
|
||||
const CATEGORIES: Record<string, ExploreCategory> = {
|
||||
[POPULAR.id]: POPULAR,
|
||||
[MODULAR_CONTRACTS.id]: MODULAR_CONTRACTS,
|
||||
[NFTS.id]: NFTS,
|
||||
[MARKETS.id]: MARKETS,
|
||||
[DROPS.id]: DROPS,
|
||||
@@ -180,9 +292,9 @@ const CATEGORIES = {
|
||||
[COMMERCE.id]: COMMERCE,
|
||||
[STAKING.id]: STAKING,
|
||||
[GOVERNANCE.id]: GOVERNANCE,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export function getCategory(id: string): ExploreCategory | null {
|
||||
export function getCategory(id: string) {
|
||||
if (isExploreCategory(id)) {
|
||||
return CATEGORIES[id];
|
||||
}
|
||||
@@ -195,8 +307,8 @@ function isExploreCategory(category: string): category is ExploreCategoryName {
|
||||
return category in CATEGORIES;
|
||||
}
|
||||
|
||||
export const EXPLORE_PAGE_DATA = Object.values(CATEGORIES).filter(
|
||||
(v) => (v as ExploreCategory).showInExplore !== false,
|
||||
export const EXPLORE_PAGE_DATA = Object.values(CATEGORIES).filter((v) =>
|
||||
"showInExplore" in v ? v.showInExplore !== false : true,
|
||||
);
|
||||
|
||||
export const ALL_CATEGORIES = Object.values(CATEGORIES).map((v) => v.id);
|
||||
@@ -208,23 +320,13 @@ export function prefetchCategory(
|
||||
return Promise.allSettled(
|
||||
category.contracts.map((contract) =>
|
||||
queryClient.fetchQuery(
|
||||
publishedContractQuery(`${contract}/latest`, queryClient),
|
||||
publishedContractQuery(
|
||||
Array.isArray(contract)
|
||||
? `${contract[0]}/latest`
|
||||
: `${contract}/latest`,
|
||||
queryClient,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllExplorePublishedContracts() {
|
||||
const all = EXPLORE_PAGE_DATA.flatMap((category) => category.contracts);
|
||||
return [...new Set(all)];
|
||||
}
|
||||
|
||||
export function getAllExplorePublishers() {
|
||||
return [
|
||||
...new Set(
|
||||
getAllExplorePublishedContracts().map(
|
||||
(contract) => contract.split("/")[0],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
InferGetStaticPropsType,
|
||||
} from "next";
|
||||
import { NextSeo } from "next-seo";
|
||||
// import dynamic from "next/dynamic";
|
||||
import { PageId } from "page-id";
|
||||
import { FiChevronLeft } from "react-icons/fi";
|
||||
import { Heading, Link, Text } from "tw-components";
|
||||
@@ -93,17 +92,38 @@ const ExploreCategoryPage: ThirdwebNextPage = (
|
||||
</Flex>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} gap={5}>
|
||||
{props.category.contracts.map((publishedContractId, idx) => {
|
||||
const [publisher, contractId] = publishedContractId.split("/");
|
||||
const publisher: string = Array.isArray(publishedContractId)
|
||||
? publishedContractId[0].split("/")[0]
|
||||
: publishedContractId.split("/")[0];
|
||||
const contractId: string = Array.isArray(publishedContractId)
|
||||
? publishedContractId[0].split("/")[1]
|
||||
: publishedContractId.split("/")[1];
|
||||
const modules = Array.isArray(publishedContractId)
|
||||
? publishedContractId[1]
|
||||
: undefined;
|
||||
const overrides = Array.isArray(publishedContractId)
|
||||
? publishedContractId[2]
|
||||
: undefined;
|
||||
return (
|
||||
<ContractCard
|
||||
key={publishedContractId}
|
||||
key={publisher + contractId + overrides?.title}
|
||||
publisher={publisher}
|
||||
contractId={contractId}
|
||||
titleOverride={overrides?.title}
|
||||
descriptionOverride={overrides?.description}
|
||||
tracking={{
|
||||
source: props.category.id,
|
||||
itemIndex: `${idx}`,
|
||||
}}
|
||||
isBeta={props.category.isBeta}
|
||||
modules={
|
||||
modules?.length
|
||||
? modules.map((m) => ({
|
||||
publisher: m.split("/")[0],
|
||||
moduleId: m.split("/")[1],
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user