Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f26883fbd | ||
|
|
98b6198687 | ||
|
|
880148b793 | ||
|
|
ee1bc3eb07 | ||
|
|
e9ab8cb770 | ||
|
|
9c33981b72 | ||
|
|
c645b42684 | ||
|
|
7583074c06 | ||
|
|
fbafb65f5c | ||
|
|
6b286c7e83 | ||
|
|
159ffbfaee | ||
|
|
0c1bb6ccae | ||
|
|
dceb47de8b | ||
|
|
dfbdbd06e5 | ||
|
|
66bbedbe3a | ||
|
|
2fbf105d15 | ||
|
|
afcaa91873 | ||
|
|
51f955352d | ||
|
|
6b2a90ac89 | ||
|
|
cf53184bb1 | ||
|
|
815b9b1ce1 | ||
|
|
ae1baa5105 | ||
|
|
92266b7459 | ||
|
|
80ad84b803 | ||
|
|
633f0cadf1 | ||
|
|
91b0367b23 | ||
|
|
c86e13b522 | ||
|
|
bc42c2fdfb | ||
|
|
cad9c74bef | ||
|
|
6f2f92ddea | ||
|
|
97c9ab7e1c | ||
|
|
b73fa4b16f | ||
|
|
cbfe9ec459 |
@@ -89,6 +89,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "^1.7.4",
|
||||
|
||||
@@ -24,6 +24,7 @@ export type Team = {
|
||||
image?: string;
|
||||
billingPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingStatus: "validPayment" | (string & {}) | null;
|
||||
supportPlan: "pro" | "growth" | "free" | "starter";
|
||||
billingEmail: string | null;
|
||||
growthTrialEligible: false;
|
||||
enabledScopes: EnabledTeamScope[];
|
||||
@@ -47,6 +48,10 @@ export async function getTeamBySlug(slug: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTeamById(id: string) {
|
||||
return getTeamBySlug(id);
|
||||
}
|
||||
|
||||
export async function getTeams() {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { AsteriskIcon, InfoIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
export function FormFieldSetup(props: {
|
||||
htmlFor?: string;
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
errorMessage: React.ReactNode | undefined;
|
||||
children: React.ReactNode;
|
||||
tooltip?: React.ReactNode;
|
||||
|
||||
@@ -27,6 +27,7 @@ export function DatePickerWithRange(props: {
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
labelOverride?: string;
|
||||
popoverAlign?: "start" | "end" | "center";
|
||||
}) {
|
||||
const [screen, setScreen] = React.useState<"from" | "to">("from");
|
||||
const { from, to, setFrom, setTo } = props;
|
||||
@@ -65,7 +66,11 @@ export function DatePickerWithRange(props: {
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Popover */}
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align={props.popoverAlign || "start"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DynamicHeight>
|
||||
<div>
|
||||
{!isValid && (
|
||||
|
||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
|
||||
"right-0 bottom-0 left-0",
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
|
||||
@@ -125,4 +125,6 @@ export const engineKeys = {
|
||||
[...engineKeys.all, engineId, "alerts"] as const,
|
||||
notificationChannels: (engineId: string) =>
|
||||
[...engineKeys.all, engineId, "notificationChannels"] as const,
|
||||
walletCredentials: (instance: string) =>
|
||||
[...engineKeys.all, instance, "walletCredentials"] as const,
|
||||
};
|
||||
|
||||
@@ -93,7 +93,8 @@ type EngineFeature =
|
||||
| "CONTRACT_SUBSCRIPTIONS"
|
||||
| "IP_ALLOWLIST"
|
||||
| "HETEROGENEOUS_WALLET_TYPES"
|
||||
| "SMART_BACKEND_WALLETS";
|
||||
| "SMART_BACKEND_WALLETS"
|
||||
| "WALLET_CREDENTIALS";
|
||||
|
||||
interface EngineSystemHealth {
|
||||
status: string;
|
||||
@@ -860,6 +861,10 @@ export type SetWalletConfigInput =
|
||||
gcpKmsKeyRingId: string;
|
||||
gcpApplicationCredentialEmail: string;
|
||||
gcpApplicationCredentialPrivateKey: string;
|
||||
}
|
||||
| {
|
||||
type: "circle";
|
||||
circleApiKey: string;
|
||||
};
|
||||
|
||||
export function useEngineSetWalletConfig(params: {
|
||||
@@ -869,8 +874,8 @@ export function useEngineSetWalletConfig(params: {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<WalletConfigResponse, void, SetWalletConfigInput>({
|
||||
mutationFn: async (input) => {
|
||||
return useMutation({
|
||||
mutationFn: async (input: SetWalletConfigInput) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}configuration/wallets`, {
|
||||
@@ -884,7 +889,7 @@ export function useEngineSetWalletConfig(params: {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result;
|
||||
return json.result as WalletConfigResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
@@ -894,10 +899,17 @@ export function useEngineSetWalletConfig(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateBackendWalletInput = {
|
||||
type: EngineBackendWalletType;
|
||||
label?: string;
|
||||
};
|
||||
export type CreateBackendWalletInput =
|
||||
| {
|
||||
type: Exclude<EngineBackendWalletType, "circle" | "smart:circle">;
|
||||
label?: string;
|
||||
}
|
||||
| {
|
||||
type: "circle" | "smart:circle";
|
||||
label?: string;
|
||||
credentialId: string;
|
||||
isTestnet: boolean;
|
||||
};
|
||||
|
||||
export function useEngineCreateBackendWallet(params: {
|
||||
instanceUrl: string;
|
||||
@@ -1851,3 +1863,118 @@ export function useEngineDeleteNotificationChannel(engineId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface WalletCredential {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
isDefault: boolean | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CreateWalletCredentialInput {
|
||||
type: "circle";
|
||||
label: string;
|
||||
entitySecret?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export function useEngineWalletCredentials(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { instanceUrl, authToken, page = 1, limit = 100 } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...engineKeys.walletCredentials(instanceUrl), page, limit],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${instanceUrl}wallet-credentials?page=${page}&limit=${limit}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
return (json.result as WalletCredential[]) || [];
|
||||
},
|
||||
enabled: !!instanceUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEngineCreateWalletCredential(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateWalletCredentialInput) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}wallet-credentials`, {
|
||||
method: "POST",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result as WalletCredential;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.walletCredentials(instanceUrl),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface UpdateWalletCredentialInput {
|
||||
label?: string;
|
||||
isDefault?: boolean;
|
||||
entitySecret?: string;
|
||||
}
|
||||
|
||||
export function useEngineUpdateWalletCredential(params: {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { instanceUrl, authToken } = params;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
...input
|
||||
}: UpdateWalletCredentialInput & { id: string }) => {
|
||||
invariant(instanceUrl, "instance is required");
|
||||
|
||||
const res = await fetch(`${instanceUrl}wallet-credentials/${id}`, {
|
||||
method: "PUT",
|
||||
headers: getEngineRequestHeaders(authToken),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.result as WalletCredential;
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: engineKeys.walletCredentials(instanceUrl),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+22
-10
@@ -1,6 +1,10 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchPublishedContractVersions } from "components/contract-components/fetch-contracts-with-versions";
|
||||
import {
|
||||
fetchLatestPublishedContractVersion,
|
||||
fetchPublishedContractVersions,
|
||||
} from "components/contract-components/fetch-contracts-with-versions";
|
||||
import { isAddress } from "thirdweb";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { ModuleMeta } from "./install-module-params";
|
||||
@@ -14,16 +18,24 @@ export async function getModuleInstalledParams(ext: ModuleMeta) {
|
||||
client: getThirdwebClient(),
|
||||
name: ext.publisherAddress,
|
||||
});
|
||||
const allPublishedModules = await fetchPublishedContractVersions(
|
||||
publisherAddress,
|
||||
ext.moduleName,
|
||||
);
|
||||
|
||||
// find the version we want
|
||||
const publishedModule =
|
||||
ext.moduleVersion === "latest"
|
||||
? allPublishedModules[0]
|
||||
: allPublishedModules.find((v) => v.version === ext.moduleVersion);
|
||||
let publishedModule: FetchDeployMetadataResult | undefined = undefined;
|
||||
|
||||
if (ext.moduleVersion === "latest") {
|
||||
publishedModule = await fetchLatestPublishedContractVersion(
|
||||
publisherAddress,
|
||||
ext.moduleName,
|
||||
);
|
||||
} else {
|
||||
const allPublishedModules = await fetchPublishedContractVersions(
|
||||
publisherAddress,
|
||||
ext.moduleName,
|
||||
);
|
||||
|
||||
publishedModule = allPublishedModules.find(
|
||||
(v) => v.version === ext.moduleVersion,
|
||||
);
|
||||
}
|
||||
|
||||
invariant(
|
||||
publishedModule,
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ export function ContractSettingsPageClient(props: {
|
||||
contract={props.contract}
|
||||
functionSelectors={metadataQuery.data.functionSelectors}
|
||||
twAccount={props.twAccount}
|
||||
hasDefaultFeeConfig={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-5
@@ -79,8 +79,9 @@ export function ContractSettingsPage(props: {
|
||||
contract: ThirdwebContract;
|
||||
functionSelectors: string[];
|
||||
twAccount: Account | undefined;
|
||||
hasDefaultFeeConfig: boolean;
|
||||
}) {
|
||||
const { functionSelectors, contract, twAccount } = props;
|
||||
const { functionSelectors, contract, twAccount, hasDefaultFeeConfig } = props;
|
||||
return (
|
||||
<ContractSettingsPageInner
|
||||
contract={contract}
|
||||
@@ -96,10 +97,13 @@ export function ContractSettingsPage(props: {
|
||||
CommonExt.isGetDefaultRoyaltyInfoSupported(functionSelectors),
|
||||
CommonExt.isSetDefaultRoyaltyInfoSupported(functionSelectors),
|
||||
].every(Boolean)}
|
||||
isPlatformFeesSupported={[
|
||||
CommonExt.isGetPlatformFeeInfoSupported(functionSelectors),
|
||||
CommonExt.isSetPlatformFeeInfoSupported(functionSelectors),
|
||||
].every(Boolean)}
|
||||
isPlatformFeesSupported={
|
||||
!hasDefaultFeeConfig &&
|
||||
[
|
||||
CommonExt.isGetPlatformFeeInfoSupported(functionSelectors),
|
||||
CommonExt.isSetPlatformFeeInfoSupported(functionSelectors),
|
||||
].every(Boolean)
|
||||
}
|
||||
twAccount={twAccount}
|
||||
/>
|
||||
);
|
||||
|
||||
+2
-4
@@ -132,9 +132,8 @@ export const SettingsPlatformFees = ({
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
variant="filled"
|
||||
{...form.register("platform_fee_recipient")}
|
||||
isDisabled={!address || sendAndConfirmTx.isPending}
|
||||
disabled={!address || sendAndConfirmTx.isPending}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
@@ -155,7 +154,6 @@ export const SettingsPlatformFees = ({
|
||||
>
|
||||
<FormLabel>Percentage</FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={form.watch("platform_fee_basis_points")}
|
||||
onChange={(value) =>
|
||||
form.setValue("platform_fee_basis_points", value, {
|
||||
@@ -163,7 +161,7 @@ export const SettingsPlatformFees = ({
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
isDisabled={sendAndConfirmTx.isPending}
|
||||
disabled={sendAndConfirmTx.isPending}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
|
||||
+1
-2
@@ -128,10 +128,9 @@ export const SettingsPrimarySale = ({
|
||||
>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
isDisabled={mutation.isPending || !address}
|
||||
disabled={mutation.isPending || !address}
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
variant="filled"
|
||||
{...form.register("primary_sale_recipient")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
|
||||
+1
-3
@@ -144,9 +144,8 @@ export const SettingsRoyalties = ({
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
variant="filled"
|
||||
{...form.register("fee_recipient")}
|
||||
isDisabled={!address}
|
||||
disabled={!address}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
@@ -165,7 +164,6 @@ export const SettingsRoyalties = ({
|
||||
>
|
||||
<FormLabel>Percentage</FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={form.watch("seller_fee_basis_points")}
|
||||
onChange={(value) =>
|
||||
form.setValue("seller_fee_basis_points", value, {
|
||||
|
||||
+10
@@ -1,5 +1,7 @@
|
||||
import { DEFAULT_FEE_RECIPIENT } from "constants/addresses";
|
||||
import { notFound } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getPlatformFeeInfo } from "thirdweb/extensions/common";
|
||||
import { getRawAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
@@ -33,11 +35,19 @@ export default async function Page(props: {
|
||||
getContractPageMetadata(info.contract),
|
||||
]);
|
||||
|
||||
let hasDefaultFeeConfig = true;
|
||||
try {
|
||||
const feeInfo = await getPlatformFeeInfo({ contract });
|
||||
hasDefaultFeeConfig =
|
||||
feeInfo[0].toLowerCase() === DEFAULT_FEE_RECIPIENT.toLowerCase();
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<ContractSettingsPage
|
||||
contract={info.contract}
|
||||
functionSelectors={metadata.functionSelectors}
|
||||
twAccount={account}
|
||||
hasDefaultFeeConfig={hasDefaultFeeConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ContractPublishForm } from "components/contract-components/contract-pub
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { getPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
|
||||
import { getLatestPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
type DirectDeployPageProps = {
|
||||
params: Promise<{
|
||||
@@ -46,18 +46,12 @@ export default async function PublishContractPage(
|
||||
// - get the publish metadata with name+publisher address
|
||||
// - merge the two objects with publishMetadataFromUri taking higher precedence
|
||||
if (!publishMetadataFromUri.version) {
|
||||
const publishedContractVersions =
|
||||
await getPublishedContractsWithPublisherMapping({
|
||||
const publishedContract =
|
||||
await getLatestPublishedContractsWithPublisherMapping({
|
||||
publisher: address,
|
||||
contract_id: publishMetadataFromUri.name,
|
||||
});
|
||||
|
||||
if (!publishedContractVersions) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const publishedContract = publishedContractVersions[0];
|
||||
|
||||
if (publishedContract) {
|
||||
publishMetadata = {
|
||||
...publishedContract,
|
||||
|
||||
@@ -131,3 +131,5 @@ export default async function ExploreCategoryPage(
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
@@ -48,3 +48,5 @@ export default async function ExplorePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
+6
-12
@@ -1,6 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PublishedContractBreadcrumbs } from "./components/breadcrumbs.client";
|
||||
import { getPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
import { getLatestPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
type Params = { publisher: string; contract_id: string };
|
||||
|
||||
@@ -20,16 +19,11 @@ export async function generateMetadata(props: { params: Promise<Params> }) {
|
||||
const params = await props.params;
|
||||
const { publisher, contract_id } = params;
|
||||
|
||||
const publishedContracts = await getPublishedContractsWithPublisherMapping({
|
||||
publisher: publisher,
|
||||
contract_id: contract_id,
|
||||
});
|
||||
|
||||
if (!publishedContracts) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const publishedContract = publishedContracts[0];
|
||||
const publishedContract =
|
||||
await getLatestPublishedContractsWithPublisherMapping({
|
||||
publisher: publisher,
|
||||
contract_id: contract_id,
|
||||
});
|
||||
|
||||
if (!publishedContract) {
|
||||
return {
|
||||
|
||||
+3
-9
@@ -3,7 +3,7 @@ import { format } from "date-fns/format";
|
||||
import { resolveEns } from "lib/ens";
|
||||
import { correctAndUniqueLicenses } from "lib/licenses";
|
||||
import { getSocialProfiles } from "thirdweb/social";
|
||||
import { getPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
import { getLatestPublishedContractsWithPublisherMapping } from "./utils/getPublishedContractsWithPublisherMapping";
|
||||
import { publishedContractOGImageTemplate } from "./utils/publishedContractOGImageTemplate";
|
||||
|
||||
export const runtime = "edge";
|
||||
@@ -22,8 +22,8 @@ export default async function Image(props: {
|
||||
const client = getThirdwebClient();
|
||||
const { publisher, contract_id } = props.params;
|
||||
|
||||
const [publishedContracts, socialProfiles] = await Promise.all([
|
||||
getPublishedContractsWithPublisherMapping({
|
||||
const [publishedContract, socialProfiles] = await Promise.all([
|
||||
getLatestPublishedContractsWithPublisherMapping({
|
||||
publisher: publisher,
|
||||
contract_id: contract_id,
|
||||
}),
|
||||
@@ -42,12 +42,6 @@ export default async function Image(props: {
|
||||
};
|
||||
})();
|
||||
|
||||
if (!publishedContracts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publishedContract = publishedContracts[0];
|
||||
|
||||
if (!publishedContract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+25
-1
@@ -1,5 +1,8 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchPublishedContractVersions } from "components/contract-components/fetch-contracts-with-versions";
|
||||
import {
|
||||
fetchLatestPublishedContractVersion,
|
||||
fetchPublishedContractVersions,
|
||||
} from "components/contract-components/fetch-contracts-with-versions";
|
||||
import { isAddress } from "thirdweb";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
|
||||
@@ -36,3 +39,24 @@ export async function getPublishedContractsWithPublisherMapping(options: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestPublishedContractsWithPublisherMapping(options: {
|
||||
publisher: string;
|
||||
contract_id: string;
|
||||
}) {
|
||||
const { publisher, contract_id } = options;
|
||||
|
||||
try {
|
||||
// resolve ENS
|
||||
const publisherAddress = isAddress(publisher)
|
||||
? publisher
|
||||
: await resolveAddress({
|
||||
client: getThirdwebClient(),
|
||||
name: mapThirdwebPublisher(publisher),
|
||||
});
|
||||
|
||||
return fetchLatestPublishedContractVersion(publisherAddress, contract_id);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { shareLink } from "@/lib/shareLink";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export function DeployActions(props: {
|
||||
publisher: string;
|
||||
contract_id: string;
|
||||
version?: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
const searchparams = useSearchParams();
|
||||
|
||||
const stringifiedSearchParams = searchparams?.toString();
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/${props.publisher}/${props.contract_id}${props.version ? `/${props.version}` : ""}${stringifiedSearchParams ? `?${stringifiedSearchParams}` : ""}`}
|
||||
target="_blank"
|
||||
>
|
||||
Contract
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
shareLink({
|
||||
title: `Deploy ${props.displayName}`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -19,6 +19,7 @@ export function PublishedActions(props: {
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-card"
|
||||
onClick={() => {
|
||||
shareLink({
|
||||
title: `Deploy ${props.displayName}`,
|
||||
@@ -27,7 +28,7 @@ export function PublishedActions(props: {
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
<Button asChild variant="primary" className="gap-2">
|
||||
<Button asChild className="gap-2">
|
||||
<Link
|
||||
href={`/${props.publisher}/${props.contract_id}${props.version ? `/${props.version}` : ""}/deploy${stringifiedSearchParams ? `?${stringifiedSearchParams}` : ""}`}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function DeployContractInfo(props: {
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
{contractImageLink && (
|
||||
<div className="hidden shrink-0 items-center justify-center rounded-xl border border-border p-2 md:flex">
|
||||
<div className="hidden shrink-0 items-center justify-center rounded-xl border border-border bg-card p-2 md:flex">
|
||||
{/*eslint-disable-next-line @next/next/no-img-element*/}
|
||||
<img className="size-12" alt={props.name} src={contractImageLink} />
|
||||
</div>
|
||||
|
||||
+3
-11
@@ -1,4 +1,3 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import {
|
||||
fetchPublishedContractVersion,
|
||||
@@ -7,7 +6,6 @@ import {
|
||||
import { isAddress } from "thirdweb";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import { DeployActions } from "./contract-actions-deploy.client";
|
||||
import { DeployContractHeader } from "./contract-header";
|
||||
import { DeployFormForUri } from "./uri-based-deploy";
|
||||
|
||||
@@ -73,23 +71,17 @@ export async function DeployFormForPublishInfo(props: PublishBasedDeployProps) {
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-8 pb-20">
|
||||
<DeployContractHeader
|
||||
{...props}
|
||||
allVersions={publishedContractVersions}
|
||||
activeVersion={publishedContract}
|
||||
>
|
||||
<DeployActions
|
||||
{...props}
|
||||
displayName={publishedContract.displayName || publishedContract.name}
|
||||
/>
|
||||
</DeployContractHeader>
|
||||
<Separator />
|
||||
/>
|
||||
<DeployFormForUri
|
||||
contractMetadata={contractMetadata}
|
||||
modules={fetchedModules.filter((m) => m !== null)}
|
||||
pathname={`/${props.publisher}/${props.contract_id}${props.version ? `/${props.version}` : ""}/deploy`}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export function DeployContractVersionSelector({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-[180px] bg-transparent hover:bg-accent">
|
||||
<SelectTrigger className="min-w-[180px] bg-card hover:bg-accent">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
+40
-36
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTeamById } from "@/api/team";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
|
||||
type State = {
|
||||
success: boolean;
|
||||
@@ -71,33 +71,37 @@ export async function createTicketAction(
|
||||
_previousState: State,
|
||||
formData: FormData,
|
||||
) {
|
||||
const cookieManager = await cookies();
|
||||
const activeAccount = cookieManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookieManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
: null;
|
||||
if (!activeAccount || !token) {
|
||||
// user is not logged in, make them log in
|
||||
redirect(`/login?next=${encodeURIComponent("/support")}`);
|
||||
}
|
||||
const accountRes = await fetch(`${API_SERVER_URL}/v1/account/me`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (accountRes.status !== 200) {
|
||||
// user is not logged in, make them log in
|
||||
redirect(`/login?next=${encodeURIComponent("/support")}`);
|
||||
const teamId = formData.get("teamId")?.toString();
|
||||
|
||||
if (!teamId) {
|
||||
return {
|
||||
success: false,
|
||||
message: "teamId is required",
|
||||
};
|
||||
}
|
||||
|
||||
const account = (await accountRes.json()) as {
|
||||
data: { name: string; email: string; plan: string; id: string };
|
||||
};
|
||||
const team = await getTeamById(teamId);
|
||||
|
||||
const customerId = isValidPlan(account.data.plan)
|
||||
? planToCustomerId[account.data.plan]
|
||||
: undefined;
|
||||
if (!team) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Team with id "${teamId}" not found`,
|
||||
};
|
||||
}
|
||||
|
||||
const [walletAddress, account] = await Promise.all([
|
||||
getAuthTokenWalletAddress(),
|
||||
getRawAccount(),
|
||||
]);
|
||||
|
||||
if (!walletAddress || !account) {
|
||||
loginRedirect("/support");
|
||||
}
|
||||
|
||||
const customerId = isValidPlan(team.supportPlan)
|
||||
? planToCustomerId[team.supportPlan]
|
||||
: // fallback to "free" tier
|
||||
planToCustomerId.free;
|
||||
|
||||
const product = formData.get("product")?.toString() || "";
|
||||
const problemArea = formData.get("extraInfo_Problem_Area")?.toString() || "";
|
||||
@@ -105,8 +109,8 @@ export async function createTicketAction(
|
||||
const title = prepareEmailTitle(
|
||||
product,
|
||||
problemArea,
|
||||
account.data.email,
|
||||
account.data.name,
|
||||
account.email || "",
|
||||
account.name || "",
|
||||
);
|
||||
|
||||
const keyVal: Record<string, string> = {};
|
||||
@@ -117,10 +121,10 @@ export async function createTicketAction(
|
||||
const markdown = prepareEmailBody({
|
||||
product,
|
||||
markdownInput: keyVal.markdown || "",
|
||||
email: account.data.email,
|
||||
name: account.data.name,
|
||||
email: account.email || "",
|
||||
name: account.name || "",
|
||||
extraInfoInput: keyVal,
|
||||
walletAddress: activeAccount,
|
||||
walletAddress: walletAddress,
|
||||
});
|
||||
|
||||
const content = {
|
||||
@@ -129,9 +133,9 @@ export async function createTicketAction(
|
||||
markdown,
|
||||
status: "open",
|
||||
onBehalfOf: {
|
||||
email: account.data.email,
|
||||
name: account.data.name,
|
||||
id: account.data.id,
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
id: account.id,
|
||||
},
|
||||
customerId,
|
||||
emailInboxId: process.env.UNTHREAD_EMAIL_INBOX_ID,
|
||||
|
||||
+47
-10
@@ -3,6 +3,7 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SupportForm_SelectInput } from "components/help/contact-forms/shared/SupportForm_SelectInput";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { SupportForm_TeamSelection } from "../../../../../components/help/contact-forms/shared/SupportForm_TeamSelection";
|
||||
import { createTicketAction } from "./create-ticket.action";
|
||||
|
||||
const ConnectSupportForm = dynamic(
|
||||
@@ -75,14 +77,46 @@ const productOptions: { label: string; component: ReactElement }[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function CreateTicket() {
|
||||
function ProductAreaSelection(props: {
|
||||
productLabel: string;
|
||||
setProductLabel: (val: string) => void;
|
||||
}) {
|
||||
const { productLabel, setProductLabel } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SupportForm_SelectInput
|
||||
formLabel="What do you need help with?"
|
||||
name="product"
|
||||
options={productOptions.map((o) => o.label)}
|
||||
promptText="Select a product"
|
||||
onValueChange={setProductLabel}
|
||||
value={productLabel}
|
||||
required={true}
|
||||
/>
|
||||
{productOptions.find((o) => o.label === productLabel)?.component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateTicket(props: {
|
||||
teams: {
|
||||
name: string;
|
||||
id: string;
|
||||
}[];
|
||||
}) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(
|
||||
props.teams[0]?.id,
|
||||
);
|
||||
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
|
||||
const [state, formAction] = useActionState(createTicketAction, {
|
||||
message: "",
|
||||
success: false,
|
||||
});
|
||||
|
||||
// needed here
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
@@ -112,16 +146,19 @@ export function CreateTicket() {
|
||||
<div className="h-7" />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<SupportForm_SelectInput
|
||||
formLabel="What do you need help with?"
|
||||
name="product"
|
||||
options={productOptions.map((o) => o.label)}
|
||||
promptText="Select a product"
|
||||
onValueChange={setProductLabel}
|
||||
value={productLabel}
|
||||
required={true}
|
||||
{/* Don't conditionally render this - it has be rendered to submit the input values */}
|
||||
<div className={cn(props.teams.length === 1 && "hidden")}>
|
||||
<SupportForm_TeamSelection
|
||||
selectedTeamId={selectedTeamId}
|
||||
onChange={(teamId) => setSelectedTeamId(teamId)}
|
||||
teams={props.teams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProductAreaSelection
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
/>
|
||||
{productOptions.find((o) => o.label === productLabel)?.component}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getTeams } from "@/api/team";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -7,17 +8,16 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import { CreateTicket } from "./components/create-ticket.client";
|
||||
|
||||
export default async function Page() {
|
||||
const authToken = await getAuthToken();
|
||||
const teams = await getTeams();
|
||||
|
||||
if (!authToken) {
|
||||
return redirect(
|
||||
`/login?next=${encodeURIComponent("/support/create-ticket")}`,
|
||||
);
|
||||
const pagePath = "/support/create-ticket";
|
||||
|
||||
if (!teams || teams.length === 0) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -36,7 +36,12 @@ export default async function Page() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="container max-w-[750px] py-10">
|
||||
<CreateTicket />
|
||||
<CreateTicket
|
||||
teams={teams.map((t) => ({
|
||||
name: t.name,
|
||||
id: t.id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,7 @@ function TailwindTheme(props: { children: React.ReactNode }) {
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
defaultTheme="dark"
|
||||
enableColorScheme={true}
|
||||
>
|
||||
{props.children}
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -14,7 +14,6 @@ import analyticsImage from "../../../../public/assets/landingpage/desktop/analyt
|
||||
import authDesktopImage from "../../../../public/assets/landingpage/desktop/auth.png";
|
||||
import crossPlatformDesktopImage from "../../../../public/assets/landingpage/desktop/cross-platform.png";
|
||||
import enterpriseSecurityImage from "../../../../public/assets/landingpage/desktop/enterprise-security.png";
|
||||
import guestImage from "../../../../public/assets/landingpage/desktop/guest.png";
|
||||
import magicImage from "../../../../public/assets/landingpage/desktop/magic.png";
|
||||
import onboardImage from "../../../../public/assets/landingpage/desktop/onboard.png";
|
||||
import powerfulImage from "../../../../public/assets/landingpage/desktop/powerful.png";
|
||||
@@ -24,7 +23,6 @@ import analyticsMobileImage from "../../../../public/assets/landingpage/mobile/a
|
||||
import authMobileImage from "../../../../public/assets/landingpage/mobile/auth.png";
|
||||
import crossPlatformMobileImage from "../../../../public/assets/landingpage/mobile/cross-platform.png";
|
||||
import enterpriseSecurityMobileImage from "../../../../public/assets/landingpage/mobile/enterprise-security.png";
|
||||
import guestMobileImage from "../../../../public/assets/landingpage/mobile/guest.png";
|
||||
import magicMobileImage from "../../../../public/assets/landingpage/mobile/magic.png";
|
||||
import mobileOnboardImage from "../../../../public/assets/landingpage/mobile/onboard.png";
|
||||
import powerfulMobileImage from "../../../../public/assets/landingpage/mobile/powerful.png";
|
||||
@@ -190,16 +188,6 @@ export default function Page() {
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/how-to/enable-gasless"
|
||||
/>
|
||||
|
||||
<LandingCardWithImage
|
||||
title="Guest mode"
|
||||
description={`Allow anyone to use your app in seconds — with a wallet that's generated automatically when they press "Continue as guest."`}
|
||||
image={guestImage}
|
||||
mobileImage={guestMobileImage}
|
||||
TRACKING_CATEGORY={TRACKING_CATEGORY}
|
||||
href="https://portal.thirdweb.com/references/wallets/latest/LocalWallet"
|
||||
colSpan={1}
|
||||
/>
|
||||
|
||||
<LandingCardWithImage
|
||||
title="Bring your own auth"
|
||||
description="Integrate your authentication system and spin up in-app wallets for your users — new and existing."
|
||||
|
||||
@@ -428,6 +428,7 @@ function EnterEmailOTP(props: {
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
inputMode="text"
|
||||
value={otp}
|
||||
onChange={(v) => {
|
||||
setOtp(v);
|
||||
|
||||
@@ -206,6 +206,7 @@ export const OnboardingConfirmEmail: React.FC<OnboardingConfirmEmailProps> = ({
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
inputMode="text"
|
||||
value={token}
|
||||
onChange={handleChange}
|
||||
disabled={saving}
|
||||
|
||||
+4
@@ -38,6 +38,10 @@ const sidebarLinkMeta: Array<{ pathId: string; label: string }> = [
|
||||
pathId: "alerts",
|
||||
label: "Alerts",
|
||||
},
|
||||
{
|
||||
pathId: "wallet-credentials",
|
||||
label: "Wallet Credentials",
|
||||
},
|
||||
{
|
||||
pathId: "configuration",
|
||||
label: "Configuration",
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import {
|
||||
type EngineInstance,
|
||||
type SetWalletConfigInput,
|
||||
useEngineSetWalletConfig,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CircleConfigProps {
|
||||
instance: EngineInstance;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const CircleConfig: React.FC<CircleConfigProps> = ({
|
||||
instance,
|
||||
authToken,
|
||||
}) => {
|
||||
const { mutate: setCircleConfig, isPending } = useEngineSetWalletConfig({
|
||||
instanceUrl: instance.url,
|
||||
authToken,
|
||||
});
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const defaultValues: SetWalletConfigInput = {
|
||||
type: "circle" as const,
|
||||
circleApiKey: "",
|
||||
};
|
||||
|
||||
const form = useForm<SetWalletConfigInput>({
|
||||
defaultValues,
|
||||
values: defaultValues,
|
||||
resetOptions: {
|
||||
keepDirty: true,
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: SetWalletConfigInput) => {
|
||||
setCircleConfig(data, {
|
||||
onSuccess: () => {
|
||||
toast.success("Configuration set successfully");
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "set-wallet-config",
|
||||
type: "circle",
|
||||
label: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to set configuration", {
|
||||
description: error.message,
|
||||
});
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "set-wallet-config",
|
||||
type: "circle",
|
||||
label: "error",
|
||||
error,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground">
|
||||
Circle wallets require an API Key from your Circle account with
|
||||
sufficient permissions. Created wallets are stored in your AWS
|
||||
account. Configure your Circle API Key to use Circle wallets. Learn
|
||||
more about{" "}
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/engine/features/backend-wallets#circle-wallet"
|
||||
target="_blank"
|
||||
label="learn-more"
|
||||
category="engine"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
how to get an API Key
|
||||
</TrackedLinkTW>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormFieldSetup
|
||||
label="Circle API Key"
|
||||
errorMessage={
|
||||
form.getFieldState("circleApiKey", form.formState).error?.message
|
||||
}
|
||||
htmlFor="circle-api-key"
|
||||
isRequired
|
||||
tooltip={null}
|
||||
>
|
||||
<Input
|
||||
id="circle-api-key"
|
||||
placeholder="TEST_API_KEY:..."
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...form.register("circleApiKey")}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-w-28 gap-2"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+32
-1
@@ -1,9 +1,11 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type EngineInstance,
|
||||
useEngineWalletConfig,
|
||||
useHasEngineFeature,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import {
|
||||
EngineBackendWalletOptions,
|
||||
@@ -13,6 +15,7 @@ import { CircleAlertIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Heading } from "tw-components";
|
||||
import { CircleConfig } from "./circle-config";
|
||||
import { KmsAwsConfig } from "./kms-aws-config";
|
||||
import { KmsGcpConfig } from "./kms-gcp-config";
|
||||
import { LocalConfig } from "./local-config";
|
||||
@@ -33,11 +36,28 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
|
||||
authToken,
|
||||
});
|
||||
|
||||
const { isSupported: isWalletCredentialsSupported } = useHasEngineFeature(
|
||||
instance.url,
|
||||
"WALLET_CREDENTIALS",
|
||||
);
|
||||
|
||||
const filteredWalletOptions = EngineBackendWalletOptions.filter(
|
||||
({ key }) =>
|
||||
// circle wallets were only added with the WALLET_CREDENTIALS feature flag
|
||||
(key !== "circle" || isWalletCredentialsSupported) &&
|
||||
// smart wallets don't need separate configuration
|
||||
!key.startsWith("smart:"),
|
||||
);
|
||||
|
||||
const tabContent: Partial<Record<EngineBackendWalletType, React.ReactNode>> =
|
||||
{
|
||||
local: <LocalConfig />,
|
||||
"aws-kms": <KmsAwsConfig instance={instance} authToken={authToken} />,
|
||||
"gcp-kms": <KmsGcpConfig instance={instance} authToken={authToken} />,
|
||||
// circle wallets were only added with the WALLET_CREDENTIALS feature flag
|
||||
...(isWalletCredentialsSupported && {
|
||||
circle: <CircleConfig instance={instance} authToken={authToken} />,
|
||||
}),
|
||||
} as const;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<EngineBackendWalletType>("local");
|
||||
@@ -62,7 +82,7 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
|
||||
</div>
|
||||
|
||||
<TabButtons
|
||||
tabs={EngineBackendWalletOptions.map(({ key, name }) => ({
|
||||
tabs={filteredWalletOptions.map(({ key, name }) => ({
|
||||
key,
|
||||
name,
|
||||
isActive: activeTab === key,
|
||||
@@ -81,6 +101,17 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
|
||||
tabClassName="font-medium !text-sm"
|
||||
/>
|
||||
|
||||
{!isWalletCredentialsSupported && activeTab === "circle" && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<CircleAlertIcon className="size-4" />
|
||||
<AlertTitle>Update Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
Circle wallet support requires a newer version of Engine. Please
|
||||
update your Engine instance to use this feature.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tabContent[activeTab]}
|
||||
</div>
|
||||
);
|
||||
|
||||
+107
-21
@@ -2,6 +2,7 @@ import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
@@ -102,6 +103,9 @@ export const CreateBackendWalletButton: React.FC<
|
||||
);
|
||||
invariant(selectedOption, "Selected a valid backend wallet type.");
|
||||
|
||||
const isCircleWallet =
|
||||
walletType === "circle" || walletType === "smart:circle";
|
||||
|
||||
// List all wallet types only if Engine is updated to support it.
|
||||
let walletTypeOptions = [selectedOption];
|
||||
if (supportsSmartBackendWallets) {
|
||||
@@ -126,7 +130,7 @@ export const CreateBackendWalletButton: React.FC<
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent
|
||||
className="z-[10001] p-0"
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<Form {...form}>
|
||||
@@ -138,7 +142,7 @@ export const CreateBackendWalletButton: React.FC<
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Wallet type */}
|
||||
<FormFieldSetup
|
||||
label="Wallet Type"
|
||||
@@ -201,25 +205,107 @@ export const CreateBackendWalletButton: React.FC<
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
// Label
|
||||
<FormFieldSetup
|
||||
key="label"
|
||||
label="Label"
|
||||
errorMessage={
|
||||
form.getFieldState("label", form.formState).error
|
||||
?.message
|
||||
}
|
||||
htmlFor="wallet-label"
|
||||
isRequired
|
||||
tooltip={null}
|
||||
>
|
||||
<Input
|
||||
id="wallet-label"
|
||||
type="text"
|
||||
placeholder="A description to identify this backend wallet"
|
||||
{...form.register("label", { required: true })}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
<>
|
||||
{/* Label */}
|
||||
<FormFieldSetup
|
||||
key="label"
|
||||
label="Label"
|
||||
errorMessage={
|
||||
form.getFieldState("label", form.formState).error
|
||||
?.message
|
||||
}
|
||||
htmlFor="wallet-label"
|
||||
isRequired
|
||||
tooltip={null}
|
||||
>
|
||||
<Input
|
||||
id="wallet-label"
|
||||
type="text"
|
||||
placeholder="A description to identify this backend wallet"
|
||||
{...form.register("label", { required: true })}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
{/* Credential ID for Circle wallets */}
|
||||
{isCircleWallet && (
|
||||
<>
|
||||
<FormFieldSetup
|
||||
key="credentialId"
|
||||
label="Credential ID"
|
||||
errorMessage={
|
||||
form.getFieldState("credentialId", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
htmlFor="credential-id"
|
||||
isRequired
|
||||
tooltip={null}
|
||||
>
|
||||
<Input
|
||||
id="credential-id"
|
||||
type="text"
|
||||
placeholder="Enter the Circle credential ID"
|
||||
{...form.register("credentialId", {
|
||||
required: isCircleWallet,
|
||||
})}
|
||||
/>
|
||||
<FormDescription className="py-2">
|
||||
The ID of the Circle credential to use for this
|
||||
wallet. You can find this in the{" "}
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/engine/${instance.id}/wallet-credentials`}
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Wallet Credentials
|
||||
</Link>{" "}
|
||||
section.
|
||||
</FormDescription>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
key="isTestnet"
|
||||
label="Testnet"
|
||||
errorMessage={
|
||||
form.getFieldState("isTestnet", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
htmlFor="is-testnet"
|
||||
tooltip={null}
|
||||
isRequired={false}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="is-testnet"
|
||||
checked={form.watch("isTestnet")}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("isTestnet", !!checked)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="is-testnet"
|
||||
className="text-sm leading-none opacity-70 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Use testnet mode for creating backend wallet
|
||||
</label>
|
||||
</div>
|
||||
<FormDescription className="py-2">
|
||||
If your engine is configured with a testnet API
|
||||
Key for Circle, you can only create testnet
|
||||
wallets. A production API Key cannot be used for
|
||||
testnet transactions, and vice versa.{" "}
|
||||
<Link
|
||||
href="https://developers.circle.com/w3s/sandbox-vs-production"
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+6
-3
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import {
|
||||
type EngineInstance,
|
||||
useEngineBackendWallets,
|
||||
@@ -80,13 +81,15 @@ function BackendWalletsSection(props: {
|
||||
Configuration
|
||||
</Link>{" "}
|
||||
tab, or{" "}
|
||||
<Link
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/features/backend-wallets"
|
||||
target="_blank"
|
||||
className="underline decoration-muted-foreground/50 decoration-dotted underline-offset-4 hover:text-foreground hover:decoration-foreground hover:decoration-solid"
|
||||
label="learn-more"
|
||||
category="engine"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
learn more about backend wallets.
|
||||
</Link>
|
||||
</TrackedLinkTW>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-3
@@ -52,6 +52,7 @@ import Link from "next/link";
|
||||
import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
|
||||
import { toTokens } from "thirdweb";
|
||||
import { FormLabel, LinkButton, Text } from "tw-components";
|
||||
import { normalizeTime } from "../../../../../../../../../../lib/time";
|
||||
import { TransactionTimeline } from "./transaction-timeline";
|
||||
|
||||
export type EngineStatus =
|
||||
@@ -496,9 +497,7 @@ export function TransactionCharts(props: {
|
||||
if (!tx.queuedAt || !tx.status) {
|
||||
continue;
|
||||
}
|
||||
const normalizedDate = new Date(tx.queuedAt);
|
||||
normalizedDate.setHours(0, 0, 0, 0); // normalize time
|
||||
const time = normalizedDate.getTime();
|
||||
const time = normalizeTime(new Date(tx.queuedAt)).getTime();
|
||||
const entry = dayToTxCountMap.get(time) ?? {};
|
||||
entry[tx.status] = (entry[tx.status] ?? 0) + 1;
|
||||
uniqueStatuses.add(tx.status);
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEngineCreateWalletCredential } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CredentialForm } from "./credential-form";
|
||||
import type { CredentialFormData } from "./types";
|
||||
|
||||
interface CreateWalletCredentialButtonProps {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const CreateWalletCredentialButton: React.FC<
|
||||
CreateWalletCredentialButtonProps
|
||||
> = ({ instanceUrl, authToken }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const createCredential = useEngineCreateWalletCredential({
|
||||
instanceUrl,
|
||||
authToken,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: CredentialFormData) => {
|
||||
const promise = createCredential.mutateAsync(data);
|
||||
|
||||
toast.promise(promise, {
|
||||
success: () => {
|
||||
setIsOpen(false);
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "create-wallet-credential",
|
||||
label: "success",
|
||||
type: data.type,
|
||||
});
|
||||
return "Wallet credential created successfully";
|
||||
},
|
||||
error: (error) => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "create-wallet-credential",
|
||||
label: "error",
|
||||
type: data.type,
|
||||
error,
|
||||
});
|
||||
return "Failed to create wallet credential";
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "open-create-wallet-credential",
|
||||
});
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Wallet Credential
|
||||
</Button>
|
||||
|
||||
<CredentialForm
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onSubmit={async (data) => handleSubmit(data as CredentialFormData)}
|
||||
title="Create Wallet Credential"
|
||||
submitButtonText="Create"
|
||||
isPending={createCredential.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { Dialog } from "@radix-ui/react-dialog";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { CircleCredentialFields } from "./credential-type-fields/circle";
|
||||
import {
|
||||
CREDENTIAL_TYPE_OPTIONS,
|
||||
type CredentialFormData,
|
||||
type CredentialType,
|
||||
type CredentialUpdateFormData,
|
||||
} from "./types";
|
||||
|
||||
interface CredentialFormProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (
|
||||
data: CredentialFormData | CredentialUpdateFormData,
|
||||
) => Promise<void>;
|
||||
title: string;
|
||||
submitButtonText: string;
|
||||
isPending: boolean;
|
||||
defaultValues?: Partial<CredentialFormData | CredentialUpdateFormData>;
|
||||
hideTypeSelect?: boolean;
|
||||
isUpdate?: boolean;
|
||||
}
|
||||
|
||||
export const CredentialForm = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
title,
|
||||
submitButtonText,
|
||||
isPending,
|
||||
defaultValues,
|
||||
hideTypeSelect = false,
|
||||
isUpdate = false,
|
||||
}: CredentialFormProps) => {
|
||||
const form = useForm<CredentialFormData | CredentialUpdateFormData>({
|
||||
defaultValues: {
|
||||
type: "circle" as CredentialType,
|
||||
label: "",
|
||||
...defaultValues,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const selectedType = form.watch("type");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
return onSubmit(data);
|
||||
})}
|
||||
>
|
||||
<div className="p-6">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/features/wallet-credentials"
|
||||
target="_blank"
|
||||
label="learn-more"
|
||||
category="engine"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Learn more about wallet credentials
|
||||
</TrackedLinkTW>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Type */}
|
||||
{!hideTypeSelect && (
|
||||
<FormFieldSetup
|
||||
label="Type"
|
||||
errorMessage={
|
||||
form.getFieldState("type", form.formState).error?.message
|
||||
}
|
||||
htmlFor="credential-type"
|
||||
isRequired
|
||||
tooltip={null}
|
||||
>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
form.setValue("type", value as CredentialType)
|
||||
}
|
||||
value={form.watch("type")}
|
||||
>
|
||||
<SelectTrigger className="bg-card">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectGroup>
|
||||
{CREDENTIAL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSetup>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<FormFieldSetup
|
||||
label="Label"
|
||||
errorMessage={
|
||||
form.getFieldState("label", form.formState).error?.message
|
||||
}
|
||||
htmlFor="credential-label"
|
||||
isRequired
|
||||
tooltip={null}
|
||||
>
|
||||
<Input
|
||||
id="credential-label"
|
||||
type="text"
|
||||
placeholder="A description to identify this credential"
|
||||
className="bg-card"
|
||||
{...form.register("label", { required: true })}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
{/* Type-specific fields */}
|
||||
{selectedType === "circle" && (
|
||||
<CircleCredentialFields form={form} isUpdate={isUpdate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4 gap-4 border-border border-t bg-card p-6 lg:gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-w-28 gap-2"
|
||||
disabled={!form.formState.isValid || isPending}
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Link from "next/link";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { CredentialFormData, CredentialUpdateFormData } from "../types";
|
||||
|
||||
interface CircleCredentialFieldsProps {
|
||||
form: UseFormReturn<CredentialFormData | CredentialUpdateFormData>;
|
||||
isUpdate?: boolean;
|
||||
}
|
||||
|
||||
export const CircleCredentialFields: React.FC<CircleCredentialFieldsProps> = ({
|
||||
form,
|
||||
isUpdate = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<FormFieldSetup
|
||||
label="Entity Secret"
|
||||
errorMessage={
|
||||
form.getFieldState("entitySecret", form.formState).error?.message
|
||||
}
|
||||
htmlFor="entity-secret"
|
||||
isRequired={!isUpdate}
|
||||
tooltip={null}
|
||||
helperText={
|
||||
<>
|
||||
Entity Secret is a 32-byte private key designed to secure your
|
||||
Developer-Controlled wallets{" "}
|
||||
<Link
|
||||
href="https://developers.circle.com/w3s/entity-secret-management"
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
Learn more about entity secret management
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="entity-secret"
|
||||
type="password"
|
||||
className="bg-card"
|
||||
placeholder={
|
||||
isUpdate
|
||||
? "Leave empty to keep existing secret"
|
||||
: "Your Circle entity secret"
|
||||
}
|
||||
{...form.register("entitySecret", {
|
||||
required: !isUpdate,
|
||||
pattern: {
|
||||
value: /^([0-9a-fA-F]{64})?$/,
|
||||
message:
|
||||
"Entity secret must be a 32-byte hex string (64 characters)",
|
||||
},
|
||||
setValueAs: (value: string) => (value === "" ? undefined : value),
|
||||
})}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type WalletCredential,
|
||||
useEngineUpdateWalletCredential,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CredentialForm } from "./credential-form";
|
||||
import type { CredentialUpdateFormData } from "./types";
|
||||
|
||||
interface EditWalletCredentialButtonProps {
|
||||
credential: WalletCredential;
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const EditWalletCredentialButton: React.FC<
|
||||
EditWalletCredentialButtonProps
|
||||
> = ({ credential, instanceUrl, authToken }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const trackEvent = useTrack();
|
||||
const updateCredential = useEngineUpdateWalletCredential({
|
||||
instanceUrl,
|
||||
authToken,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: CredentialUpdateFormData) => {
|
||||
const promise = updateCredential.mutateAsync({
|
||||
id: credential.id,
|
||||
...data,
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
success: () => {
|
||||
setIsOpen(false);
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "update-wallet-credential",
|
||||
label: "success",
|
||||
type: data.type,
|
||||
});
|
||||
return "Wallet credential updated successfully";
|
||||
},
|
||||
error: (error) => {
|
||||
console.log(error);
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "update-wallet-credential",
|
||||
label: "error",
|
||||
type: data.type,
|
||||
error,
|
||||
});
|
||||
return "Failed to update wallet credential";
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "engine",
|
||||
action: "open-edit-wallet-credential",
|
||||
});
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className="size-8"
|
||||
>
|
||||
<PencilIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<CredentialForm
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onSubmit={async (data) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
handleSubmit(data as CredentialUpdateFormData).then(resolve);
|
||||
});
|
||||
}}
|
||||
title="Edit Wallet Credential"
|
||||
submitButtonText="Save Changes"
|
||||
isPending={updateCredential.isPending}
|
||||
defaultValues={{
|
||||
type: credential.type as "circle",
|
||||
label: credential.label || "",
|
||||
}}
|
||||
hideTypeSelect
|
||||
isUpdate
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
interface BaseCredentialFormData {
|
||||
type: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type CircleCredentialFormData = BaseCredentialFormData & {
|
||||
type: "circle";
|
||||
entitySecret: string;
|
||||
};
|
||||
|
||||
type CircleCredentialUpdateFormData = BaseCredentialFormData & {
|
||||
type: "circle";
|
||||
entitySecret?: string;
|
||||
};
|
||||
|
||||
export type CredentialFormData = CircleCredentialFormData;
|
||||
export type CredentialUpdateFormData = CircleCredentialUpdateFormData;
|
||||
|
||||
export const CREDENTIAL_TYPE_OPTIONS = [
|
||||
{ value: "circle", label: "Circle" },
|
||||
] as const;
|
||||
|
||||
export type CredentialType = (typeof CREDENTIAL_TYPE_OPTIONS)[number]["value"];
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {} from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { WalletCredential } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { format } from "date-fns";
|
||||
import { EditWalletCredentialButton } from "./edit-wallet-credential-button";
|
||||
|
||||
interface WalletCredentialsTableProps {
|
||||
credentials: WalletCredential[];
|
||||
isPending: boolean;
|
||||
isFetched: boolean;
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const WalletCredentialsTable: React.FC<WalletCredentialsTableProps> = ({
|
||||
credentials,
|
||||
isPending,
|
||||
isFetched,
|
||||
instanceUrl,
|
||||
authToken,
|
||||
}) => {
|
||||
if (isPending && !isFetched) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] grow items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] grow items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">No credentials found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer className="border-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead>Default</TableHead>
|
||||
<TableHead className="w-0" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{credentials.map((credential) => (
|
||||
<TableRow key={credential.id}>
|
||||
<TableCell>
|
||||
<CopyTextButton
|
||||
textToCopy={credential.id}
|
||||
textToShow={`${credential.id.slice(0, 8)}...`}
|
||||
variant="secondary"
|
||||
className="font-mono text-xs"
|
||||
tooltip="Copy credential ID"
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{credential.label || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{credential.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(credential.createdAt), "MMM d, yyyy")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(credential.updatedAt), "MMM d, yyyy")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{credential.isDefault ? (
|
||||
<Badge variant="success">Yes</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">No</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EditWalletCredentialButton
|
||||
credential={credential}
|
||||
instanceUrl={instanceUrl}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
useEngineWalletCredentials,
|
||||
useHasEngineFeature,
|
||||
} from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CreateWalletCredentialButton } from "./create-wallet-credential-button";
|
||||
import { WalletCredentialsTable } from "./wallet-credentials-table";
|
||||
|
||||
interface WalletCredentialsProps {
|
||||
instanceUrl: string;
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export const WalletCredentialsSection: React.FC<WalletCredentialsProps> = ({
|
||||
instanceUrl,
|
||||
authToken,
|
||||
}) => {
|
||||
const {
|
||||
data: credentials = [],
|
||||
isPending,
|
||||
isFetched,
|
||||
} = useEngineWalletCredentials({
|
||||
instanceUrl,
|
||||
authToken,
|
||||
});
|
||||
|
||||
const {
|
||||
isSupported: supportsWalletCredentials,
|
||||
query: { isPending: isFeatureLoading },
|
||||
} = useHasEngineFeature(instanceUrl, "WALLET_CREDENTIALS");
|
||||
|
||||
if (isFeatureLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[500px] grow items-center justify-center rounded-lg border border-border">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!supportsWalletCredentials) {
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-4" />
|
||||
<AlertTitle>Update Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
Wallet credentials support requires a newer version of Engine. Please
|
||||
update your Engine instance to use this feature.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col gap-5 border-border border-b p-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
Wallet Credentials
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Manage your wallet credentials for different providers.{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/features/wallet-credentials"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about wallet credentials.
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-4 border-border max-md:w-full max-md:border-t max-md:pt-6">
|
||||
<CreateWalletCredentialButton
|
||||
instanceUrl={instanceUrl}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WalletCredentialsTable
|
||||
credentials={credentials}
|
||||
isPending={isPending}
|
||||
isFetched={isFetched}
|
||||
instanceUrl={instanceUrl}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { engineInstancePageHandler } from "../../../_utils/getEngineInstancePageMeta";
|
||||
import type { EngineInstancePageProps } from "../types";
|
||||
import { WalletCredentialsSection } from "./components/wallet-credentials";
|
||||
|
||||
export default async function Page(props: EngineInstancePageProps) {
|
||||
const params = await props.params;
|
||||
const { instance, authToken } = await engineInstancePageHandler({
|
||||
engineId: params.engineId,
|
||||
teamSlug: params.team_slug,
|
||||
});
|
||||
|
||||
return (
|
||||
<WalletCredentialsSection
|
||||
instanceUrl={instance.url}
|
||||
authToken={authToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,45 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { NebulaAnalyticsPage } from "../../../[project_slug]/nebula/components/analytics/nebula-analytics-ui";
|
||||
import { NebulaWaitListPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
if (!team) {
|
||||
const [account, authToken, team] = await Promise.all([
|
||||
getValidAccount(),
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
]);
|
||||
|
||||
if (!team || !authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/~/nebula`);
|
||||
}
|
||||
|
||||
// if nebula access is already granted, redirect to nebula web app
|
||||
const hasNebulaAccess = team.enabledScopes.includes("nebula");
|
||||
|
||||
if (hasNebulaAccess) {
|
||||
redirect("https://nebula.thirdweb.com");
|
||||
return (
|
||||
<NebulaAnalyticsPage
|
||||
accountId={account.id}
|
||||
authToken={authToken}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <NebulaWaitListPage team={team} />;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getValidAccount } from "../../../account/settings/getAccount";
|
||||
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
|
||||
@@ -45,9 +45,6 @@ export default async function TeamLayout(props: {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
|
||||
?.onWaitlist;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="bg-card">
|
||||
@@ -59,7 +56,6 @@ export default async function TeamLayout(props: {
|
||||
/>
|
||||
<ProjectTabs
|
||||
layoutPath={`/team/${params.team_slug}/${params.project_slug}`}
|
||||
isOnNebulaWaitList={!!isOnNebulaWaitList}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex grow flex-col">{props.children}</div>
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
export type NebulaAnalyticsDataItem = {
|
||||
date: string;
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
totalSessions: number;
|
||||
totalRequests: number;
|
||||
};
|
||||
|
||||
export const fetchNebulaAnalytics = unstable_cache(
|
||||
async (params: {
|
||||
accountId: string;
|
||||
authToken: string;
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
}) => {
|
||||
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
|
||||
const url = new URL(`${analyticsEndpoint}/v1/nebula/usage`);
|
||||
url.searchParams.set("accountId", params.accountId);
|
||||
url.searchParams.set("from", params.from);
|
||||
url.searchParams.set("to", params.to);
|
||||
url.searchParams.set("interval", params.interval);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
data: resData.data as NebulaAnalyticsDataItem[],
|
||||
};
|
||||
},
|
||||
["nebula-analytics"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useResponsiveSearchParams,
|
||||
useSetResponsiveSearchParams,
|
||||
} from "responsive-rsc";
|
||||
import { DateRangeSelector } from "../../../../../../../components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "../../../../../../../components/analytics/interval-selector";
|
||||
import {
|
||||
getNebulaFiltersFromSearchParams,
|
||||
normalizeTimeISOString,
|
||||
} from "../../../../../../../lib/time";
|
||||
|
||||
export function NebulaAnalyticsFilter() {
|
||||
const responsiveSearchParams = useResponsiveSearchParams();
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
|
||||
const { range, interval } = getNebulaFiltersFromSearchParams({
|
||||
from: responsiveSearchParams.from,
|
||||
to: responsiveSearchParams.to,
|
||||
interval: responsiveSearchParams.interval,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
popoverAlign="end"
|
||||
setRange={(newRange) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
from: normalizeTimeISOString(newRange.from),
|
||||
to: normalizeTimeISOString(newRange.to),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<IntervalSelector
|
||||
intervalType={interval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
interval: newInterval,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { subDays } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import { mobileViewport } from "../../../../../../../stories/utils";
|
||||
import type { NebulaAnalyticsDataItem } from "./fetch-nebula-analytics";
|
||||
import { NebulaAnalyticsDashboardUI } from "./nebula-analytics-ui";
|
||||
|
||||
const meta = {
|
||||
title: "Nebula/Analytics",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
type VariantTab = "30-day" | "7-day" | "pending" | "60-day";
|
||||
|
||||
function Story() {
|
||||
const [tab, setTab] = useState<VariantTab>("60-day");
|
||||
return (
|
||||
<div className="container flex flex-col gap-8 py-10">
|
||||
<div>
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
Story Variants
|
||||
</h2>
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
name: "60 Days",
|
||||
onClick: () => setTab("60-day"),
|
||||
isActive: tab === "60-day",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "30 Days",
|
||||
onClick: () => setTab("30-day"),
|
||||
isActive: tab === "30-day",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "7 Days",
|
||||
onClick: () => setTab("7-day"),
|
||||
isActive: tab === "7-day",
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "Pending",
|
||||
onClick: () => setTab("pending"),
|
||||
isActive: tab === "pending",
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === "60-day" && (
|
||||
<NebulaAnalyticsDashboardUI
|
||||
data={generateRandomNebulaAnalyticsData(60)}
|
||||
isPending={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "30-day" && (
|
||||
<NebulaAnalyticsDashboardUI
|
||||
data={generateRandomNebulaAnalyticsData(30)}
|
||||
isPending={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "7-day" && (
|
||||
<NebulaAnalyticsDashboardUI
|
||||
data={generateRandomNebulaAnalyticsData(7)}
|
||||
isPending={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "pending" && (
|
||||
<NebulaAnalyticsDashboardUI data={[]} isPending={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function generateRandomNebulaAnalyticsData(
|
||||
days: number,
|
||||
): NebulaAnalyticsDataItem[] {
|
||||
return Array.from({ length: days }, (_, i) => ({
|
||||
date: subDays(new Date(), i).toISOString(),
|
||||
totalPromptTokens: randomInt(1000),
|
||||
totalCompletionTokens: randomInt(1000),
|
||||
totalSessions: randomInt(100),
|
||||
totalRequests: randomInt(4000),
|
||||
}));
|
||||
}
|
||||
|
||||
function randomInt(max: number) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ActivityIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
MessageSquareIcon,
|
||||
MessageSquareQuoteIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ResponsiveSearchParamsProvider,
|
||||
ResponsiveSuspense,
|
||||
} from "responsive-rsc";
|
||||
import { normalizeTimeISOString } from "../../../../../../../lib/time";
|
||||
import {
|
||||
type NebulaAnalyticsDataItem,
|
||||
fetchNebulaAnalytics,
|
||||
} from "./fetch-nebula-analytics";
|
||||
import { NebulaAnalyticsFilter } from "./nebula-analytics-filter";
|
||||
import { getNebulaAnalyticsRangeFromSearchParams } from "./utils";
|
||||
|
||||
type ChartData = {
|
||||
time: Date;
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
totalSessions: number;
|
||||
totalRequests: number;
|
||||
};
|
||||
|
||||
export function NebulaAnalyticsPage(props: {
|
||||
searchParams: {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
};
|
||||
accountId: string;
|
||||
authToken: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={props.searchParams}>
|
||||
<header className="border-b">
|
||||
<div className="container flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
|
||||
<div className="flex-1">
|
||||
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
|
||||
Nebula
|
||||
</h1>
|
||||
</div>
|
||||
<NebulaAnalyticsFilter />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container pt-8 pb-20">
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={<NebulaAnalyticsDashboardUI data={[]} isPending={true} />}
|
||||
>
|
||||
<NebulaAnalyticDashboard
|
||||
searchParams={props.searchParams}
|
||||
accountId={props.accountId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function NebulaAnalyticDashboard(props: {
|
||||
accountId: string;
|
||||
authToken: string;
|
||||
searchParams: {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
};
|
||||
}) {
|
||||
const { range, interval } = getNebulaAnalyticsRangeFromSearchParams(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
const res = await fetchNebulaAnalytics({
|
||||
accountId: props.accountId,
|
||||
authToken: props.authToken,
|
||||
from: normalizeTimeISOString(range.from),
|
||||
to: normalizeTimeISOString(range.to),
|
||||
interval,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] grow items-center justify-center rounded-lg border">
|
||||
<div>
|
||||
<p className="mb-2 text-center text-destructive-text">
|
||||
Failed to fetch Nebula analytics
|
||||
</p>
|
||||
<p className="text-muted-foreground">{res.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <NebulaAnalyticsDashboardUI data={res.data} isPending={false} />;
|
||||
}
|
||||
|
||||
export function NebulaAnalyticsDashboardUI(props: {
|
||||
data: NebulaAnalyticsDataItem[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const data = useMemo(() => {
|
||||
const val: {
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
totalSessions: number;
|
||||
totalRequests: number;
|
||||
chartData: ChartData[];
|
||||
} = {
|
||||
totalPromptTokens: 0,
|
||||
totalCompletionTokens: 0,
|
||||
totalSessions: 0,
|
||||
totalRequests: 0,
|
||||
chartData: [],
|
||||
};
|
||||
|
||||
for (const item of props.data) {
|
||||
val.totalPromptTokens += item.totalPromptTokens;
|
||||
val.totalCompletionTokens += item.totalCompletionTokens;
|
||||
val.totalSessions += item.totalSessions;
|
||||
val.totalRequests += item.totalRequests;
|
||||
val.chartData.push({
|
||||
totalPromptTokens: item.totalPromptTokens,
|
||||
totalCompletionTokens: item.totalCompletionTokens,
|
||||
totalSessions: item.totalSessions,
|
||||
totalRequests: item.totalRequests,
|
||||
time: new Date(item.date),
|
||||
});
|
||||
}
|
||||
|
||||
return val;
|
||||
}, [props.data]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
title="Requests sent"
|
||||
value={data.totalRequests}
|
||||
icon={ActivityIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
<StatCard
|
||||
title="Sessions created"
|
||||
value={data.totalSessions}
|
||||
icon={MessageSquareIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
<StatCard
|
||||
title="Prompt tokens received"
|
||||
value={data.totalPromptTokens}
|
||||
icon={MessageCircleQuestionIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
<StatCard
|
||||
title="Response tokens sent"
|
||||
value={data.totalCompletionTokens}
|
||||
icon={MessageSquareQuoteIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<ThirdwebBarChart
|
||||
data={data.chartData}
|
||||
isPending={props.isPending}
|
||||
title="Requests sent"
|
||||
description="Total number of requests sent to the Nebula from users"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
config={{
|
||||
totalRequests: {
|
||||
label: "Total requests",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThirdwebBarChart
|
||||
data={data.chartData}
|
||||
isPending={props.isPending}
|
||||
title="Sessions created"
|
||||
description="Total chat sessions created by users. A session is a conversation thread between a user and the Nebula"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
config={{
|
||||
totalSessions: {
|
||||
label: "Total sessions",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThirdwebBarChart
|
||||
data={data.chartData}
|
||||
isPending={props.isPending}
|
||||
title="Prompt tokens received"
|
||||
description="Total tokens sent from users to Nebula as prompts"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
config={{
|
||||
totalPromptTokens: {
|
||||
label: "Total prompt tokens",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThirdwebBarChart
|
||||
data={data.chartData}
|
||||
isPending={props.isPending}
|
||||
title="Response tokens sent"
|
||||
description="Total tokens sent from Nebula to users as responses"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4.5]"
|
||||
config={{
|
||||
totalCompletionTokens: {
|
||||
label: "Total response tokens",
|
||||
color: "hsl(var(--chart-5))",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard(props: {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h2 className="text-sm lg:text-base">{props.title}</h2>
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<SkeletonContainer
|
||||
className="inline-block"
|
||||
skeletonData={10000}
|
||||
loadedData={props.isPending ? undefined : props.value}
|
||||
render={(v) => (
|
||||
<p className="font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
{v}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { getNebulaFiltersFromSearchParams } from "../../../../../../../lib/time";
|
||||
|
||||
export function getNebulaAnalyticsRangeFromSearchParams(searchParams: {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
}) {
|
||||
return getNebulaFiltersFromSearchParams({
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
interval: searchParams.interval,
|
||||
});
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { redirect } from "next/navigation";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
import { NebulaWaitListPage } from "./components/nebula-waitlist-page";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/nebula`);
|
||||
}
|
||||
|
||||
// if nebula access is already granted, redirect to nebula web app
|
||||
const hasNebulaAccess = team.enabledScopes.includes("nebula");
|
||||
|
||||
if (hasNebulaAccess) {
|
||||
redirect("https://nebula.thirdweb.com");
|
||||
}
|
||||
|
||||
return <NebulaWaitListPage team={team} />;
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import { TabPathLinks } from "@/components/ui/tabs";
|
||||
|
||||
export function ProjectTabs(props: {
|
||||
layoutPath: string;
|
||||
isOnNebulaWaitList: boolean;
|
||||
}) {
|
||||
const { layoutPath, isOnNebulaWaitList } = props;
|
||||
const { layoutPath } = props;
|
||||
|
||||
return (
|
||||
<TabPathLinks
|
||||
@@ -27,14 +26,7 @@ export function ProjectTabs(props: {
|
||||
path: `${layoutPath}/contracts`,
|
||||
name: "Contracts",
|
||||
},
|
||||
...(isOnNebulaWaitList
|
||||
? [
|
||||
{
|
||||
path: `${layoutPath}/nebula`,
|
||||
name: "Nebula",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
path: `${layoutPath}/insight`,
|
||||
name: "Insight",
|
||||
|
||||
@@ -6,18 +6,29 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { format, subDays } from "date-fns";
|
||||
import { differenceInCalendarDays, format, subDays } from "date-fns";
|
||||
import { normalizeTime } from "../../lib/time";
|
||||
|
||||
export function DateRangeSelector(props: {
|
||||
range: Range;
|
||||
setRange: (range: Range) => void;
|
||||
popoverAlign?: "start" | "end" | "center";
|
||||
}) {
|
||||
const { range, setRange } = props;
|
||||
const daysDiff = differenceInCalendarDays(range.to, range.from);
|
||||
const matchingRange =
|
||||
normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime()
|
||||
? durationPresets.find((preset) => preset.days === daysDiff)
|
||||
: undefined;
|
||||
|
||||
const rangeType = matchingRange?.id || range.type;
|
||||
const rangeLabel = matchingRange?.name || range.label;
|
||||
|
||||
return (
|
||||
<DatePickerWithRange
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
popoverAlign={props.popoverAlign}
|
||||
setFrom={(from) =>
|
||||
setRange({
|
||||
from,
|
||||
@@ -35,7 +46,7 @@ export function DateRangeSelector(props: {
|
||||
header={
|
||||
<div className="mb-2 border-border border-b p-4">
|
||||
<Select
|
||||
value={range.type}
|
||||
value={rangeType}
|
||||
onValueChange={(id: DurationId) => {
|
||||
setRange(getLastNDaysRange(id));
|
||||
}}
|
||||
@@ -50,7 +61,7 @@ export function DateRangeSelector(props: {
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{range.type === "custom" && (
|
||||
{rangeType === "custom" && (
|
||||
<SelectItem value="custom">
|
||||
{format(range.from, "LLL dd, y")} -{" "}
|
||||
{format(range.to, "LLL dd, y")}
|
||||
@@ -60,7 +71,7 @@ export function DateRangeSelector(props: {
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
labelOverride={range.label}
|
||||
labelOverride={rangeLabel}
|
||||
className="w-auto bg-card"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,14 @@ export function Fieldset(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="mb-3 font-semibold text-2xl tracking-tight">
|
||||
{props.legend}
|
||||
</legend>
|
||||
|
||||
<div className="rounded-lg border border-border p-4 md:p-6">
|
||||
{props.children}
|
||||
<fieldset className="rounded-lg border border-border bg-card">
|
||||
<div className="border-border border-b p-4 md:px-6 md:py-5">
|
||||
<legend className="font-semibold text-xl tracking-tight">
|
||||
{props.legend}
|
||||
</legend>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:px-6 md:py-6">{props.children}</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
+60
-79
@@ -1,8 +1,8 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FormControl, Input } from "@chakra-ui/react";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { Fieldset } from "./common";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
|
||||
@@ -15,100 +15,81 @@ export const ContractMetadataFieldset: React.FC<
|
||||
> = ({ form }) => {
|
||||
return (
|
||||
<Fieldset legend="Contract Metadata">
|
||||
<div className="flex flex-col gap-6 md:grid md:grid-cols-[270px_1fr]">
|
||||
<div>
|
||||
<FormControl
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
isInvalid={
|
||||
!!form.getFieldState("contractMetadata.image", form.formState)
|
||||
.error
|
||||
<div className="flex flex-col gap-6 md:grid md:grid-cols-[200px_1fr]">
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
form.getFieldState("contractMetadata.image", form.formState).error
|
||||
?.message
|
||||
}
|
||||
label="Image"
|
||||
isRequired={false}
|
||||
>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
value={useImageFileOrUrl(form.watch("contractMetadata.image"))}
|
||||
setValue={(file) =>
|
||||
form.setValue("contractMetadata.image", file, {
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<FormLabel>Image</FormLabel>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
value={useImageFileOrUrl(form.watch("contractMetadata.image"))}
|
||||
setValue={(file) =>
|
||||
form.setValue("contractMetadata.image", file, {
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
className="rounded border-2 border-border border-dotted transition-all duration-200"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("contractMetadata.image", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</div>
|
||||
className="rounded border-border bg-background transition-all duration-200 hover:border-active-border hover:bg-background"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
!!form.getFieldState("contractMetadata.name", form.formState)
|
||||
.error
|
||||
}
|
||||
>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
autoFocus
|
||||
variant="filled"
|
||||
{...form.register("contractMetadata.name")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
{/* name + symbol */}
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-4">
|
||||
<FormFieldSetup
|
||||
className="grow"
|
||||
label="Name"
|
||||
isRequired
|
||||
htmlFor="contractMetadata-name"
|
||||
errorMessage={
|
||||
form.getFieldState("contractMetadata.name", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
>
|
||||
<Input
|
||||
id="contractMetadata-name"
|
||||
{...form.register("contractMetadata.name", {
|
||||
required: "Name is Required",
|
||||
})}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("contractMetadata.symbol", form.formState)
|
||||
.error
|
||||
}
|
||||
>
|
||||
<FormLabel>Symbol</FormLabel>
|
||||
<Input
|
||||
variant="filled"
|
||||
{...form.register("contractMetadata.symbol")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
<FormFieldSetup
|
||||
className="lg:max-w-[300px]"
|
||||
isRequired={false}
|
||||
label="Symbol"
|
||||
htmlFor="contractMetadata-symbol"
|
||||
errorMessage={
|
||||
form.getFieldState("contractMetadata.symbol", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
>
|
||||
<Input
|
||||
{...form.register("contractMetadata.symbol")}
|
||||
id="contractMetadata-symbol"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
<FormFieldSetup
|
||||
className="flex grow flex-col"
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
"contractMetadata.description",
|
||||
form.formState,
|
||||
).error
|
||||
label="Description"
|
||||
isRequired={false}
|
||||
htmlFor="contractMetadata-description"
|
||||
errorMessage={
|
||||
form.getFieldState("contractMetadata.description", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
{...form.register("contractMetadata.description")}
|
||||
className="grow bg-transparent"
|
||||
className="grow"
|
||||
id="contractMetadata-description"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
"contractMetadata.description",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
</div>
|
||||
</Fieldset>
|
||||
|
||||
+49
-47
@@ -1,27 +1,31 @@
|
||||
"use client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Flex,
|
||||
FormControl,
|
||||
} from "@chakra-ui/react";
|
||||
import { Flex, FormControl } from "@chakra-ui/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { verifyContract } from "app/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/ContractSourcesPage";
|
||||
import { NetworkSelectorButton } from "components/selects/NetworkSelectorButton";
|
||||
import { DEFAULT_FEE_BPS, DEFAULT_FEE_RECIPIENT } from "constants/addresses";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { replaceTemplateValues } from "lib/deployment/template-values";
|
||||
import { CircleAlertIcon, ExternalLinkIcon, InfoIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CircleAlertIcon,
|
||||
ExternalLinkIcon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { FormProvider, type UseFormReturn, useForm } from "react-hook-form";
|
||||
@@ -35,7 +39,7 @@ import {
|
||||
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { isZkSyncChain } from "thirdweb/utils";
|
||||
import { FormHelperText, FormLabel, Heading, Text } from "tw-components";
|
||||
import { FormHelperText, FormLabel, Text } from "tw-components";
|
||||
import { useCustomFactoryAbi, useFunctionParamsFromABI } from "../hooks";
|
||||
import { addContractToMultiChainRegistry } from "../utils";
|
||||
import { Fieldset } from "./common";
|
||||
@@ -187,6 +191,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
!isFactoryDeployment &&
|
||||
(metadata?.name.includes("AccountFactory") || false);
|
||||
|
||||
const isMarketplace = metadata?.name.includes("MarketplaceV3") || false;
|
||||
|
||||
const parsedDeployParams = useMemo(
|
||||
() => ({
|
||||
...deployParams.reduce(
|
||||
@@ -387,6 +393,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
paramKey={paramKey}
|
||||
deployParam={deployParam}
|
||||
extraMetadataParam={extraMetadataParam}
|
||||
isRequired
|
||||
inputClassName="bg-background"
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -440,9 +448,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
name: params.contractMetadata?.name || "",
|
||||
contractURI: _contractURI,
|
||||
defaultAdmin: params.deployParams._defaultAdmin as string,
|
||||
platformFeeBps: Number(params.deployParams._platformFeeBps),
|
||||
platformFeeRecipient: params.deployParams
|
||||
._platformFeeRecipient as string,
|
||||
platformFeeBps: DEFAULT_FEE_BPS,
|
||||
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
|
||||
trustedForwarders: params.deployParams._trustedForwarders
|
||||
? JSON.parse(params.deployParams._trustedForwarders as string)
|
||||
: undefined,
|
||||
@@ -457,6 +464,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
payees,
|
||||
shares,
|
||||
_contractURI,
|
||||
_platformFeeBps: DEFAULT_FEE_BPS,
|
||||
_platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
|
||||
};
|
||||
|
||||
const salt = params.deployDeterministic
|
||||
@@ -702,7 +711,9 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPlatformFee && <PlatformFeeFieldset form={form} />}
|
||||
{hasPlatformFee && (
|
||||
<PlatformFeeFieldset isMarketplace={isMarketplace} />
|
||||
)}
|
||||
|
||||
{isSplit && <SplitFieldset form={form} />}
|
||||
|
||||
@@ -729,6 +740,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
paramKey={paramKey}
|
||||
deployParam={deployParam}
|
||||
extraMetadataParam={extraMetadataParam}
|
||||
isRequired
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -755,6 +767,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
paramKey={paramKey}
|
||||
deployParam={deployParam}
|
||||
extraMetadataParam={extraMetadataParam}
|
||||
isRequired
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -795,6 +808,8 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
paramKey={paramKey}
|
||||
deployParam={deployParam}
|
||||
extraMetadataParam={extraMetadataParam}
|
||||
inputClassName="bg-card"
|
||||
isRequired
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -808,25 +823,18 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
)}
|
||||
|
||||
{advancedParams.length > 0 && (
|
||||
<Accordion allowToggle>
|
||||
<AccordionItem borderColor="borderColor" borderBottom="none">
|
||||
<AccordionButton px={0}>
|
||||
<Heading size="subtitle.md" flex="1" textAlign="left">
|
||||
Advanced Configuration
|
||||
</Heading>
|
||||
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
|
||||
<AccordionPanel
|
||||
py={4}
|
||||
px={0}
|
||||
as={Flex}
|
||||
flexDir="column"
|
||||
gap={4}
|
||||
>
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="rounded-lg border border-border bg-card"
|
||||
>
|
||||
<AccordionItem value="advanced" className="border-b-0">
|
||||
<AccordionTrigger className="px-4 font-semibold text-xl tracking-tight lg:px-6">
|
||||
Advanced Configuration
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-5 border-border border-t px-4 pt-6 pb-8 lg:px-6">
|
||||
{advancedParams}
|
||||
</AccordionPanel>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
@@ -841,21 +849,13 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
Select a network to deploy this contract on. We recommend
|
||||
starting with a testnet.{" "}
|
||||
<TrackedLinkTW
|
||||
href="/chainlist"
|
||||
category="deploy"
|
||||
label="chainlist"
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
View all chains
|
||||
</TrackedLinkTW>
|
||||
starting with a testnet
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<NetworkSelectorButton
|
||||
className="bg-background"
|
||||
networksEnabled={
|
||||
metadata?.name === "AccountFactory" ||
|
||||
metadata?.networksForDeployment?.allNetworks ||
|
||||
@@ -866,8 +866,9 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/chainlist" className="gap-2">
|
||||
View all chains <ExternalLinkIcon className="size-4" />
|
||||
<Link href="/chainlist" className="gap-3" target="_blank">
|
||||
View all chains
|
||||
<ExternalLinkIcon className="size-4 bg-background text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -967,13 +968,14 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
</CheckboxWithLabel>
|
||||
|
||||
{/* Deploy */}
|
||||
<div className="flex md:justify-end">
|
||||
<div className="flex border-border border-t pt-6 md:justify-end">
|
||||
<Button
|
||||
disabled={!activeAccount || !walletChain}
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
>
|
||||
Deploy Now
|
||||
<ArrowUpFromLineIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+11
-14
@@ -22,19 +22,17 @@ export function ModularContractDefaultModulesFieldset(props: {
|
||||
isTWPublisher: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{props.modules.map((mod) => {
|
||||
return (
|
||||
<RenderModule
|
||||
key={mod.name}
|
||||
module={mod}
|
||||
isTWPublisher={props.isTWPublisher}
|
||||
form={props.form}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
{props.modules.map((mod) => {
|
||||
return (
|
||||
<RenderModule
|
||||
key={mod.name}
|
||||
module={mod}
|
||||
isTWPublisher={props.isTWPublisher}
|
||||
form={props.form}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +101,6 @@ function RenderModule(props: {
|
||||
solidityType={param.type}
|
||||
// @ts-expect-error - old types, need to update
|
||||
solidityComponents={param.components}
|
||||
variant="filled"
|
||||
{...form.register(formFieldKey)}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Flex, FormControl } from "@chakra-ui/react";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { camelToTitle } from "contract-ui/components/solidity-inputs/helpers";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import {
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Text,
|
||||
} from "tw-components";
|
||||
|
||||
interface ParamProps {
|
||||
paramKey: string;
|
||||
isRequired: boolean;
|
||||
inputClassName?: string;
|
||||
extraMetadataParam?: {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
@@ -25,48 +21,46 @@ export const Param: React.FC<ParamProps> = ({
|
||||
paramKey,
|
||||
extraMetadataParam,
|
||||
deployParam,
|
||||
isRequired,
|
||||
inputClassName,
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
|
||||
if (!deployParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
isRequired
|
||||
<FormFieldSetup
|
||||
isRequired={isRequired}
|
||||
key={paramKey}
|
||||
isInvalid={
|
||||
!!form.getFieldState(`deployParams.${paramKey}`, form.formState).error
|
||||
errorMessage={
|
||||
form.getFieldState(`deployParams.${paramKey}`, form.formState).error
|
||||
?.message
|
||||
}
|
||||
className="relative"
|
||||
label={
|
||||
<span className="inline-flex gap-1 align-baseline">
|
||||
{extraMetadataParam?.displayName
|
||||
? extraMetadataParam.displayName
|
||||
: paramKey === "*"
|
||||
? "Unnamed"
|
||||
: camelToTitle(paramKey)}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
({paramKey})
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
helperText={extraMetadataParam?.description}
|
||||
>
|
||||
<Flex alignItems="center" my={1}>
|
||||
<FormLabel mb={0} flex="1" display="flex">
|
||||
<Flex alignItems="baseline" gap={1}>
|
||||
{extraMetadataParam?.displayName
|
||||
? extraMetadataParam.displayName
|
||||
: paramKey === "*"
|
||||
? "Unnamed"
|
||||
: camelToTitle(paramKey)}
|
||||
<Text size="label.sm">({paramKey})</Text>
|
||||
</Flex>
|
||||
</FormLabel>
|
||||
{deployParam && (
|
||||
<FormHelperText mt={0}>{deployParam.type}</FormHelperText>
|
||||
)}
|
||||
</Flex>
|
||||
{deployParam && (
|
||||
<SolidityInput
|
||||
defaultValue={form.watch(`deployParams.${paramKey}`)}
|
||||
solidityType={deployParam.type}
|
||||
{...form.register(`deployParams.${paramKey}`)}
|
||||
/>
|
||||
)}
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(`deployParams.${paramKey}`, form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
{extraMetadataParam?.description && (
|
||||
<FormHelperText>{extraMetadataParam?.description}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
<span className="absolute top-0.5 right-0 text-muted-foreground text-sm">
|
||||
{deployParam.type}
|
||||
</span>
|
||||
<SolidityInput
|
||||
solidityType={deployParam.type}
|
||||
className={inputClassName}
|
||||
{...form.register(`deployParams.${paramKey}`)}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
+34
-65
@@ -1,76 +1,45 @@
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { BasisPointsInput } from "components/inputs/BasisPointsInput";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import Link from "next/link";
|
||||
import { Fieldset } from "./common";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
|
||||
interface PlatformFeeFieldsetProps {
|
||||
form: CustomContractDeploymentForm;
|
||||
isMarketplace: boolean;
|
||||
}
|
||||
|
||||
export const PlatformFeeFieldset: React.FC<PlatformFeeFieldsetProps> = ({
|
||||
form,
|
||||
}) => {
|
||||
export const PlatformFeeFieldset: React.FC<PlatformFeeFieldsetProps> = (
|
||||
props,
|
||||
) => {
|
||||
return (
|
||||
<Fieldset legend="Platform fees">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
"deployParams._platformFeeRecipient",
|
||||
form.formState,
|
||||
).error
|
||||
}
|
||||
>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
variant="filled"
|
||||
{...form.register("deployParams._platformFeeRecipient")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
"deployParams._platformFeeRecipient",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
For contract with primary sales, get additional fees for all primary
|
||||
sales that happen on this contract. (This is useful if you are
|
||||
deploying this contract for a 3rd party and want to take fees for
|
||||
your service). If this contract is a marketplace, get a percentage
|
||||
of all the secondary sales that happen on your contract.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isRequired
|
||||
maxW={{ base: "100%", md: "150px" }}
|
||||
isInvalid={
|
||||
!!form.getFieldState("deployParams._platformFeeBps", form.formState)
|
||||
.error
|
||||
}
|
||||
>
|
||||
<FormLabel>Percentage</FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={Number(form.watch("deployParams._platformFeeBps"))}
|
||||
onChange={(value) =>
|
||||
form.setValue("deployParams._platformFeeBps", value.toString(), {
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("deployParams._platformFeeBps", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{props.isMarketplace ? (
|
||||
<p className="mb-3 pt-4 text-muted-foreground text-sm italic">
|
||||
A 2.5% platform fee is deducted from each sale to support ongoing
|
||||
platform operations and improvements.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="text-blue-500 underline"
|
||||
href={
|
||||
"https://blog.thirdweb.com/mint-fees-for-contract-deployments-update/"
|
||||
}
|
||||
>
|
||||
Read more.
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-3 pt-4 text-muted-foreground text-sm italic">
|
||||
A 2.5% platform fee is deducted from each primary sale price to
|
||||
support ongoing platform operations and improvements.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="text-blue-500 underline"
|
||||
href={
|
||||
"https://blog.thirdweb.com/mint-fees-for-contract-deployments-update/"
|
||||
}
|
||||
>
|
||||
Read more.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
|
||||
+15
-18
@@ -1,7 +1,6 @@
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { Fieldset } from "./common";
|
||||
|
||||
interface PrimarySaleFieldsetProps {
|
||||
@@ -14,22 +13,20 @@ export const PrimarySaleFieldset: React.FC<PrimarySaleFieldsetProps> = (
|
||||
props,
|
||||
) => {
|
||||
return (
|
||||
<Fieldset legend=" Primary Sales">
|
||||
<FormControl isRequired isInvalid={props.isInvalid}>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
variant="filled"
|
||||
{...props.register}
|
||||
/>
|
||||
|
||||
<FormErrorMessage>{props.errorMessage}</FormErrorMessage>
|
||||
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
The wallet address that should receive the revenue from initial sales
|
||||
of the assets.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<Fieldset legend="Primary Sales">
|
||||
<FormFieldSetup
|
||||
label="Recipient Address"
|
||||
errorMessage={props.errorMessage}
|
||||
isRequired
|
||||
helperText={
|
||||
<>
|
||||
The wallet address that should receive the revenue from initial
|
||||
sales of the assets.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SolidityInput solidityType="address" {...props.register} />
|
||||
</FormFieldSetup>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
+35
-33
@@ -1,8 +1,7 @@
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { BasisPointsInput } from "components/inputs/BasisPointsInput";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { Fieldset } from "./common";
|
||||
|
||||
export function RoyaltyFieldset(props: {
|
||||
@@ -28,53 +27,56 @@ export function RoyaltyFieldset(props: {
|
||||
<Fieldset legend="Royalties">
|
||||
<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>
|
||||
<FormFieldSetup
|
||||
className="grow"
|
||||
isRequired
|
||||
label="Recipient Address"
|
||||
errorMessage={props.royaltyRecipient.errorMessage}
|
||||
helperText={
|
||||
<>
|
||||
The wallet address that should receive the revenue from
|
||||
royalties earned from secondary sales of the assets.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
maxW={{ base: "100%", md: "150px" }}
|
||||
isInvalid={props.royaltyBps.isInvalid}
|
||||
defaultValue="0"
|
||||
label="Percentage"
|
||||
className="shrink-0 md:max-w-[150px]"
|
||||
errorMessage={props.royaltyBps.errorMessage}
|
||||
>
|
||||
<FormLabel>Percentage</FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={Number.isNaN(bpsNumValue) ? 0 : bpsNumValue}
|
||||
onChange={(value) => props.royaltyBps.setValue(value.toString())}
|
||||
defaultValue={0}
|
||||
/>
|
||||
<FormErrorMessage>{props.royaltyBps.errorMessage}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
|
||||
{props.transferValidator && (
|
||||
<FormControl isRequired isInvalid={props.transferValidator.isInvalid}>
|
||||
<FormLabel>Transfer Validator Address</FormLabel>
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
label="Transfer Validator Address"
|
||||
errorMessage={props.transferValidator.errorMessage}
|
||||
helperText={
|
||||
<>
|
||||
The contract address to enforce royalties according to the
|
||||
Creator Token Standard implementation. Passing the zero address
|
||||
disables this validation.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</FormFieldSetup>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
|
||||
+1
-5
@@ -21,11 +21,7 @@ export const SequentialTokenIdFieldset: React.FC<
|
||||
helperText="The starting token ID for the NFT collection."
|
||||
>
|
||||
<FormControl>
|
||||
<SolidityInput
|
||||
solidityType="uint256"
|
||||
variant="filled"
|
||||
{...props.register}
|
||||
/>
|
||||
<SolidityInput solidityType="uint256" {...props.register} />
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
|
||||
+21
-34
@@ -1,12 +1,11 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { BasisPointsInput } from "components/inputs/BasisPointsInput";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { InfoIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { FormErrorMessage, FormLabel } from "tw-components";
|
||||
import { Fieldset } from "./common";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
|
||||
@@ -33,48 +32,42 @@ export const SplitFieldset: React.FC<SplitFieldsetProps> = ({ form }) => {
|
||||
return (
|
||||
<Fieldset legend="Split Settings">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<div key={field.id} className="flex items-end gap-3">
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
<FormFieldSetup
|
||||
className="grow"
|
||||
isRequired={false}
|
||||
label="Recipient Address"
|
||||
errorMessage={
|
||||
form.getFieldState(
|
||||
`recipients.${index}.address`,
|
||||
form.formState,
|
||||
).error
|
||||
).error?.message
|
||||
}
|
||||
>
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<SolidityInput
|
||||
solidityType="address"
|
||||
formContext={form}
|
||||
variant="filled"
|
||||
placeholder={ZERO_ADDRESS}
|
||||
{...form.register(`recipients.${index}.address`)}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
`recipients.${index}.address`,
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormControl
|
||||
{/* */}
|
||||
<FormFieldSetup
|
||||
label="Percentage"
|
||||
className="max-w-[160px]"
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
isRequired={false}
|
||||
errorMessage={
|
||||
form.getFieldState(
|
||||
`recipients.${index}.sharesBps`,
|
||||
form.formState,
|
||||
).error
|
||||
).error?.message
|
||||
}
|
||||
>
|
||||
<FormLabel> Percentage </FormLabel>
|
||||
<BasisPointsInput
|
||||
variant="filled"
|
||||
value={form.watch(`recipients.${index}.sharesBps`)}
|
||||
onChange={(value) =>
|
||||
form.setValue(`recipients.${index}.sharesBps`, value, {
|
||||
@@ -84,18 +77,11 @@ export const SplitFieldset: React.FC<SplitFieldsetProps> = ({ form }) => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
`recipients.${index}.sharesBps`,
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
className="px-3 text-destructive-text"
|
||||
disabled={fields.length === 1 || form.formState.isSubmitting}
|
||||
aria-label="remove row"
|
||||
onClick={() => remove(index)}
|
||||
@@ -112,6 +98,7 @@ export const SplitFieldset: React.FC<SplitFieldsetProps> = ({ form }) => {
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ address: "", sharesBps: 0 })}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
|
||||
+24
-45
@@ -1,6 +1,5 @@
|
||||
import { Flex, FormControl, InputGroup } from "@chakra-ui/react";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "tw-components";
|
||||
import { Fieldset } from "./common";
|
||||
import type {
|
||||
CustomContractDeploymentForm,
|
||||
@@ -23,54 +22,34 @@ export const TrustedForwardersFieldset: React.FC<
|
||||
<>
|
||||
{!isDynamicValue(value) && (
|
||||
<Fieldset legend="Gasless">
|
||||
<FormControl
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
label="Trusted Forwarders"
|
||||
errorMessage={
|
||||
form.getFieldState(
|
||||
"deployParams._trustedForwarders",
|
||||
form.formState,
|
||||
).error
|
||||
).error?.message
|
||||
}
|
||||
helperText={
|
||||
<>
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Trusted forwarder addresses to enable ERC-2771 transactions
|
||||
(i.e. gasless).
|
||||
</span>
|
||||
|
||||
<span className="block text-muted-foreground text-sm">
|
||||
You can provide your own forwarder.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
{/* left */}
|
||||
<div>
|
||||
<FormLabel>Trusted Forwarders</FormLabel>
|
||||
|
||||
<FormHelperText className="!text-sm text-muted-foreground">
|
||||
<span className="mb-1 block text-muted-foreground text-sm">
|
||||
Trusted forwarder addresses to enable ERC-2771 transactions
|
||||
(i.e. gasless).
|
||||
</span>
|
||||
|
||||
<span className="block text-muted-foreground text-sm">
|
||||
You can provide your own forwarder.
|
||||
</span>
|
||||
</FormHelperText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fade-in-0 block animate-in pt-3 duration-400">
|
||||
<InputGroup size="md">
|
||||
<Flex flexDir="column" w="full">
|
||||
<SolidityInput
|
||||
value={value}
|
||||
solidityType="address[]"
|
||||
{...form.register("deployParams._trustedForwarders")}
|
||||
/>
|
||||
</Flex>
|
||||
</InputGroup>
|
||||
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
"deployParams._trustedForwarders",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</div>
|
||||
</FormControl>
|
||||
<SolidityInput
|
||||
value={value}
|
||||
solidityType="address[]"
|
||||
{...form.register("deployParams._trustedForwarders")}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
</Fieldset>
|
||||
)}
|
||||
</>
|
||||
|
||||
+68
-20
@@ -1,5 +1,5 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { isAddress } from "thirdweb";
|
||||
import { type ThirdwebClient, isAddress } from "thirdweb";
|
||||
import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import {
|
||||
@@ -20,22 +20,10 @@ export async function fetchPublishedContractVersions(
|
||||
contractId: string,
|
||||
) {
|
||||
const client = getThirdwebClient();
|
||||
const allVersions = await getPublishedContractVersions({
|
||||
contract: getContractPublisher(client),
|
||||
publisher: isAddress(publisherAddress)
|
||||
? publisherAddress
|
||||
: await resolveAddress({
|
||||
client,
|
||||
name: mapThirdwebPublisher(publisherAddress),
|
||||
}),
|
||||
contractId: contractId,
|
||||
});
|
||||
|
||||
const sortedVersions = allVersions.toSorted((a, b) => {
|
||||
if (a.publishTimestamp === b.publishTimestamp) {
|
||||
return 0;
|
||||
}
|
||||
return a.publishTimestamp > b.publishTimestamp ? -1 : 1;
|
||||
const sortedVersions = await getSortedPublishedContractVersions({
|
||||
publisherAddress,
|
||||
contractId,
|
||||
client,
|
||||
});
|
||||
|
||||
const responses = await Promise.allSettled(
|
||||
@@ -61,21 +49,81 @@ export async function fetchPublishedContractVersions(
|
||||
return uniquePublishedContracts;
|
||||
}
|
||||
|
||||
async function getSortedPublishedContractVersions(params: {
|
||||
publisherAddress: string;
|
||||
contractId: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { publisherAddress, contractId, client } = params;
|
||||
|
||||
const allVersions = await getPublishedContractVersions({
|
||||
contract: getContractPublisher(client),
|
||||
publisher: isAddress(publisherAddress)
|
||||
? publisherAddress
|
||||
: await resolveAddress({
|
||||
client,
|
||||
name: mapThirdwebPublisher(publisherAddress),
|
||||
}),
|
||||
contractId: contractId,
|
||||
});
|
||||
|
||||
const sortedVersions = allVersions.toSorted((a, b) => {
|
||||
if (a.publishTimestamp === b.publishTimestamp) {
|
||||
return 0;
|
||||
}
|
||||
return a.publishTimestamp > b.publishTimestamp ? -1 : 1;
|
||||
});
|
||||
|
||||
return sortedVersions;
|
||||
}
|
||||
|
||||
export async function fetchLatestPublishedContractVersion(
|
||||
publisherAddress: string,
|
||||
contractId: string,
|
||||
) {
|
||||
const client = getThirdwebClient();
|
||||
|
||||
const sortedVersions = await getSortedPublishedContractVersions({
|
||||
publisherAddress,
|
||||
contractId,
|
||||
client,
|
||||
});
|
||||
|
||||
const latestVersion = sortedVersions[0];
|
||||
|
||||
if (!latestVersion) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fetchDeployMetadata({
|
||||
client,
|
||||
uri: latestVersion.publishMetadataUri,
|
||||
}).then((m) => ({ ...m, ...latestVersion }));
|
||||
}
|
||||
|
||||
export async function fetchPublishedContractVersion(
|
||||
publisherAddress: string,
|
||||
contractId: string,
|
||||
version = "latest",
|
||||
) {
|
||||
if (version === "latest") {
|
||||
const latestVersion = await fetchLatestPublishedContractVersion(
|
||||
publisherAddress,
|
||||
contractId,
|
||||
);
|
||||
|
||||
return latestVersion || null;
|
||||
}
|
||||
|
||||
const allVersions = await fetchPublishedContractVersions(
|
||||
publisherAddress,
|
||||
contractId,
|
||||
);
|
||||
|
||||
if (allVersions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (version === "latest") {
|
||||
return allVersions[0];
|
||||
}
|
||||
|
||||
return allVersions.find((v) => v.version === version) || allVersions[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,20 @@ import { replaceIpfsUrl } from "lib/sdk";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
CalendarDaysIcon,
|
||||
ExternalLinkIcon,
|
||||
PencilIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Card, Heading, Link, LinkButton, Text } from "tw-components";
|
||||
import { Card, Heading, LinkButton, Text } from "tw-components";
|
||||
import { Button } from "../../../@/components/ui/button";
|
||||
import { Separator } from "../../../@/components/ui/separator";
|
||||
import type { PublishedContractWithVersion } from "../fetch-contracts-with-versions";
|
||||
import {
|
||||
usePublishedContractEvents,
|
||||
@@ -150,21 +154,22 @@ export const PublishedContract: React.FC<PublishedContractProps> = ({
|
||||
)}
|
||||
</Flex>
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={{ base: 12, md: 3 }}>
|
||||
<Flex flexDir="column" gap={6}>
|
||||
{publishedContract.publisher && (
|
||||
<PublisherHeader wallet={publishedContract.publisher} />
|
||||
)}
|
||||
<Divider />
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Heading as="h4" size="title.sm">
|
||||
Details
|
||||
</Heading>
|
||||
<List as={Flex} flexDir="column" gap={3}>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="font-semibold text-xl tracking-tight">Details</h4>
|
||||
<List as={Flex} flexDir="column" gap={5}>
|
||||
<>
|
||||
{publishedContract.publishTimestamp && (
|
||||
<ListItem>
|
||||
<Flex gap={2} alignItems="flex-start">
|
||||
<Flex gap={3} alignItems="flex-start">
|
||||
<CalendarDaysIcon className="size-5 text-muted-foreground" />
|
||||
<Flex direction="column" gap={1}>
|
||||
<Heading as="h5" size="label.sm">
|
||||
@@ -179,7 +184,7 @@ export const PublishedContract: React.FC<PublishedContractProps> = ({
|
||||
)}
|
||||
{publishedContract?.audit && (
|
||||
<ListItem>
|
||||
<Flex gap={2} alignItems="flex-start">
|
||||
<Flex gap={3} alignItems="flex-start">
|
||||
<ShieldCheckIcon className="size-5 text-green-500" />
|
||||
<Flex direction="column" gap={1}>
|
||||
<Heading as="h5" size="label.sm">
|
||||
@@ -188,15 +193,8 @@ export const PublishedContract: React.FC<PublishedContractProps> = ({
|
||||
<Text size="body.md" lineHeight={1.2}>
|
||||
<Link
|
||||
href={replaceIpfsUrl(publishedContract.audit)}
|
||||
isExternal
|
||||
_dark={{
|
||||
color: "blue.400",
|
||||
_hover: { color: "blue.500" },
|
||||
}}
|
||||
_light={{
|
||||
color: "blue.500",
|
||||
_hover: { color: "blue.500" },
|
||||
}}
|
||||
target="_blank"
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
>
|
||||
View Audit Report
|
||||
</Link>
|
||||
@@ -206,7 +204,7 @@ export const PublishedContract: React.FC<PublishedContractProps> = ({
|
||||
</ListItem>
|
||||
)}
|
||||
<ListItem>
|
||||
<Flex gap={2} alignItems="flex-start">
|
||||
<Flex gap={3} alignItems="flex-start">
|
||||
<BookOpenTextIcon className="size-5 text-muted-foreground" />
|
||||
<Flex direction="column" gap={1}>
|
||||
<Heading as="h5" size="label.sm">
|
||||
@@ -239,21 +237,19 @@ export const PublishedContract: React.FC<PublishedContractProps> = ({
|
||||
) : null}
|
||||
</>
|
||||
</List>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Flex gap={2} alignItems="center">
|
||||
<LinkButton
|
||||
colorScheme="blue"
|
||||
href="https://portal.thirdweb.com/contracts/publish/overview"
|
||||
w="full"
|
||||
variant="ghost"
|
||||
isExternal
|
||||
>
|
||||
Learn more about Publish
|
||||
</LinkButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button asChild variant="outline" className="w-full gap-2 bg-card">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/contracts/publish/overview"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Publish{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
</Flex>
|
||||
</GridItem>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { Flex, Skeleton } from "@chakra-ui/react";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { replaceDeployerAddress } from "lib/publisher-utils";
|
||||
import Link from "next/link";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
AccountAddress,
|
||||
@@ -12,7 +14,6 @@ import {
|
||||
AccountName,
|
||||
AccountProvider,
|
||||
} from "thirdweb/react";
|
||||
import { Heading, Link, LinkButton } from "tw-components";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
import { useEns } from "../hooks";
|
||||
|
||||
@@ -20,90 +21,67 @@ const TRACKING_CATEGORY = "releaser-header";
|
||||
|
||||
interface PublisherHeaderProps {
|
||||
wallet: string;
|
||||
page?: boolean;
|
||||
}
|
||||
export const PublisherHeader: React.FC<PublisherHeaderProps> = ({
|
||||
wallet,
|
||||
page,
|
||||
}) => {
|
||||
export const PublisherHeader: React.FC<PublisherHeaderProps> = ({ wallet }) => {
|
||||
const ensQuery = useEns(wallet);
|
||||
const client = useThirdwebClient();
|
||||
|
||||
const trackEvent = useTrack();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
flexDirection={{ base: "column", md: page ? "column" : "row" }}
|
||||
justifyContent="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Flex direction="column" gap={4} w="full">
|
||||
<Heading as="h4" size="title.sm">
|
||||
Published by
|
||||
</Heading>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<h4 className="font-semibold text-lg tracking-tight">Published by</h4>
|
||||
|
||||
<AccountProvider
|
||||
// passing zero address during loading time to prevent the component from crashing
|
||||
address={ensQuery.data?.address || ZERO_ADDRESS}
|
||||
client={client}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href={replaceDeployerAddress(
|
||||
`/${ensQuery.data?.ensName || wallet}`,
|
||||
)}
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "click",
|
||||
label: "releaser-avatar",
|
||||
})
|
||||
<AccountProvider
|
||||
// passing zero address during loading time to prevent the component from crashing
|
||||
address={ensQuery.data?.address || ZERO_ADDRESS}
|
||||
client={client}
|
||||
>
|
||||
<div className="relative flex items-center gap-3">
|
||||
<AccountAvatar
|
||||
fallbackComponent={
|
||||
<AccountBlobbie className="size-10 rounded-full" />
|
||||
}
|
||||
loadingComponent={<Skeleton className="size-14 rounded-full" />}
|
||||
className="size-10 rounded-full border border-border border-solid object-cover"
|
||||
/>
|
||||
|
||||
<Link
|
||||
className="before:absolute before:inset-0 hover:underline"
|
||||
target="_blank"
|
||||
href={replaceDeployerAddress(
|
||||
`/${ensQuery.data?.ensName || wallet}`,
|
||||
)}
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "click",
|
||||
label: "releaser-name",
|
||||
})
|
||||
}
|
||||
>
|
||||
<AccountName
|
||||
className="font-medium"
|
||||
fallbackComponent={
|
||||
// When social profile API support other TLDs as well - we can remove this condition
|
||||
ensQuery.data?.ensName ? (
|
||||
<span> {ensQuery.data?.ensName} </span>
|
||||
) : (
|
||||
<AccountAddress
|
||||
formatFn={(addr) =>
|
||||
shortenIfAddress(replaceDeployerAddress(addr))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<AccountAvatar
|
||||
fallbackComponent={
|
||||
<AccountBlobbie className="size-14 rounded-full" />
|
||||
}
|
||||
loadingComponent={<Skeleton className="size-14 rounded-full" />}
|
||||
className="size-14 rounded-full object-cover"
|
||||
/>
|
||||
</Link>
|
||||
loadingComponent={<Skeleton className="h-8 w-40" />}
|
||||
formatFn={(name) => replaceDeployerAddress(name)}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</AccountProvider>
|
||||
|
||||
<Link
|
||||
href={replaceDeployerAddress(
|
||||
`/${ensQuery.data?.ensName || wallet}`,
|
||||
)}
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: TRACKING_CATEGORY,
|
||||
action: "click",
|
||||
label: "releaser-name",
|
||||
})
|
||||
}
|
||||
>
|
||||
<AccountName
|
||||
fallbackComponent={
|
||||
// When social profile API support other TLDs as well - we can remove this condition
|
||||
ensQuery.data?.ensName ? (
|
||||
<span> {ensQuery.data?.ensName} </span>
|
||||
) : (
|
||||
<AccountAddress
|
||||
formatFn={(addr) =>
|
||||
shortenIfAddress(replaceDeployerAddress(addr))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
loadingComponent={<Skeleton className="h-8 w-40" />}
|
||||
formatFn={(name) => replaceDeployerAddress(name)}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</AccountProvider>
|
||||
|
||||
<LinkButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button variant="outline" asChild className="bg-card">
|
||||
<Link
|
||||
href={replaceDeployerAddress(`/${wallet}`)}
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
@@ -114,8 +92,8 @@ export const PublisherHeader: React.FC<PublisherHeaderProps> = ({
|
||||
}
|
||||
>
|
||||
View all contracts
|
||||
</LinkButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -286,7 +286,14 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
|
||||
}) => {
|
||||
// TODO: clean this up
|
||||
const functionsWithExtension = useMemo(() => {
|
||||
const allFunctions = fnsOrEvents.filter((f) => f.type === "function");
|
||||
const allFunctions = fnsOrEvents
|
||||
.filter((f) => f.type === "function")
|
||||
.filter(
|
||||
(f) =>
|
||||
f.name !== "setPlatformFeeInfo" &&
|
||||
f.name !== "setFlatPlatformFeeInfo" &&
|
||||
f.name !== "setPlatformFeeType",
|
||||
);
|
||||
const results: ExtensionFunctions[] = [];
|
||||
results.push({
|
||||
extension: "",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { moduleToBase64 } from "app/(dashboard)/published-contract/utils/module-
|
||||
import { replaceDeployerAddress } from "lib/publisher-utils";
|
||||
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ClientOnly } from "../../ClientOnly/ClientOnly";
|
||||
import { fetchPublishedContractVersion } from "../../contract-components/fetch-contracts-with-versions";
|
||||
import { ContractPublisher } from "../publisher";
|
||||
|
||||
@@ -109,25 +110,6 @@ export async function ContractCard({
|
||||
"relative flex min-h-[220px] flex-col rounded-lg border border-border bg-card p-4 hover:border-active-border"
|
||||
}
|
||||
>
|
||||
<TrackedLinkTW
|
||||
className="absolute inset-0 z-0 cursor-pointer"
|
||||
href={getContractUrl({
|
||||
publisher,
|
||||
contractId,
|
||||
version,
|
||||
modules,
|
||||
titleOverride,
|
||||
})}
|
||||
category="contract_card"
|
||||
label={contractId}
|
||||
trackingProps={{
|
||||
publisher,
|
||||
contractId,
|
||||
version,
|
||||
...(tracking || {}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Audited + Version + Tags */}
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -167,11 +149,30 @@ export async function ContractCard({
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
{(
|
||||
titleOverride ||
|
||||
publishedContractResult.displayName ||
|
||||
publishedContractResult.name
|
||||
).replace("[Beta]", "")}
|
||||
<TrackedLinkTW
|
||||
className="cursor-pointer before:absolute before:inset-0 before:z-0"
|
||||
href={getContractUrl({
|
||||
publisher,
|
||||
contractId,
|
||||
version,
|
||||
modules,
|
||||
titleOverride,
|
||||
})}
|
||||
category="contract_card"
|
||||
label={contractId}
|
||||
trackingProps={{
|
||||
publisher,
|
||||
contractId,
|
||||
version,
|
||||
...(tracking || {}),
|
||||
}}
|
||||
>
|
||||
{(
|
||||
titleOverride ||
|
||||
publishedContractResult.displayName ||
|
||||
publishedContractResult.name
|
||||
).replace("[Beta]", "")}
|
||||
</TrackedLinkTW>
|
||||
</h3>
|
||||
|
||||
{/* Desc */}
|
||||
@@ -198,7 +199,11 @@ export async function ContractCard({
|
||||
)}
|
||||
>
|
||||
{publishedContractResult.publisher && (
|
||||
<ContractPublisher addressOrEns={publishedContractResult.publisher} />
|
||||
<ClientOnly ssr={<Skeleton className="size-5 rounded-full" />}>
|
||||
<ContractPublisher
|
||||
addressOrEns={publishedContractResult.publisher}
|
||||
/>
|
||||
</ClientOnly>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
formLabel: string;
|
||||
name: string;
|
||||
required: boolean;
|
||||
value: string;
|
||||
value: string | undefined;
|
||||
onValueChange: (value: string) => void;
|
||||
};
|
||||
|
||||
@@ -22,14 +22,6 @@ export const SupportForm_SelectInput = (props: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
hidden
|
||||
value={props.value}
|
||||
name={name}
|
||||
onChange={(e) => props.onValueChange(e.target.value)}
|
||||
required={required}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor={name} className="relative">
|
||||
{formLabel}
|
||||
@@ -41,6 +33,8 @@ export const SupportForm_SelectInput = (props: Props) => {
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
name={name}
|
||||
required={required}
|
||||
value={props.value}
|
||||
onValueChange={(val) => {
|
||||
props.onValueChange(val);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type MinimalTeam = {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
teams: MinimalTeam[];
|
||||
selectedTeamId: string | undefined;
|
||||
onChange: (teamId: string) => void;
|
||||
};
|
||||
|
||||
export const SupportForm_TeamSelection = (props: Props) => {
|
||||
const selectedTeamName = props.teams.find(
|
||||
(t) => t.id === props.selectedTeamId,
|
||||
)?.name;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor="team" className="relative">
|
||||
Select Team
|
||||
<span className="-top-1.5 -right-2 absolute text-destructive">•</span>
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
name="teamId"
|
||||
value={props.selectedTeamId}
|
||||
onValueChange={(selectedId) => {
|
||||
props.onChange(selectedId);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="team">
|
||||
<SelectValue placeholder="Select a Team">
|
||||
{selectedTeamName}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.teams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,10 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import {
|
||||
Input,
|
||||
InputGroup,
|
||||
type InputProps,
|
||||
InputRightAddon,
|
||||
} from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PercentIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type InputProps = React.ComponentProps<typeof Input>;
|
||||
|
||||
interface BasisPointsInputProps
|
||||
extends Omit<InputProps, "onChange" | "value" | "onBlur" | "max" | "min"> {
|
||||
value?: number;
|
||||
@@ -44,9 +42,11 @@ export const BasisPointsInput: React.FC<BasisPointsInputProps> = ({
|
||||
}, [stringValue]);
|
||||
|
||||
return (
|
||||
<InputGroup {...restInputProps}>
|
||||
<div className="flex overflow-hidden rounded-lg border border-border ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
<Input
|
||||
className="rounded-r-none border-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
value={stringValue}
|
||||
{...restInputProps}
|
||||
onChange={(e) => setStringValue(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
@@ -61,7 +61,9 @@ export const BasisPointsInput: React.FC<BasisPointsInputProps> = ({
|
||||
}}
|
||||
maxLength={5}
|
||||
/>
|
||||
<InputRightAddon>%</InputRightAddon>
|
||||
</InputGroup>
|
||||
<div className="flex items-center justify-center border-border border-l bg-card px-3 font-medium">
|
||||
<PercentIcon className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { popularChains } from "@3rdweb-sdk/react/components/popularChains";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { useActiveChainAsDashboardChain } from "lib/v5-adapter";
|
||||
@@ -28,6 +29,7 @@ interface NetworkSelectorButtonProps {
|
||||
networksEnabled?: number[];
|
||||
isDisabled?: boolean;
|
||||
onSwitchChain?: (chain: StoredChain) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NetworkSelectorButton: React.FC<NetworkSelectorButtonProps> = ({
|
||||
@@ -35,6 +37,7 @@ export const NetworkSelectorButton: React.FC<NetworkSelectorButtonProps> = ({
|
||||
networksEnabled,
|
||||
isDisabled,
|
||||
onSwitchChain,
|
||||
className,
|
||||
}) => {
|
||||
const client = useThirdwebClient();
|
||||
const { idToChain, allChains } = useAllChainsData();
|
||||
@@ -142,7 +145,7 @@ export const NetworkSelectorButton: React.FC<NetworkSelectorButtonProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isDisabled || !wallet}
|
||||
className="w-full justify-start gap-2 text-left"
|
||||
className={cn("w-full justify-start gap-2 text-left", className)}
|
||||
onClick={() => {
|
||||
networkSwitcherModal.open({
|
||||
theme: getSDKTheme(theme === "light" ? "light" : "dark"),
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export const THIRDWEB_DEPLOYER_ADDRESS =
|
||||
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024";
|
||||
|
||||
export const DEFAULT_FEE_RECIPIENT =
|
||||
"0x1af20c6b23373350ad464700b5965ce4b0d2ad94";
|
||||
|
||||
export const DEFAULT_FEE_BPS = 250;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Flex, Input, Spinner } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Box, Spinner } from "@chakra-ui/react";
|
||||
import { useEns } from "components/contract-components/hooks";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { isAddress, isValidENSName } from "thirdweb/utils";
|
||||
import { FormHelperText } from "tw-components";
|
||||
import type { SolidityInputProps } from ".";
|
||||
import { validateAddress } from "./helpers";
|
||||
|
||||
@@ -138,12 +138,12 @@ export const SolidityAddressInput: React.FC<SolidityInputProps> = ({
|
||||
/>
|
||||
|
||||
{!hasError && (helperTextLeft || helperTextRight) && (
|
||||
<FormHelperText>
|
||||
<Flex gap={1} align="center">
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<div className="items-center gap-1">
|
||||
{helperTextLeft}
|
||||
{helperTextRight}
|
||||
</Flex>
|
||||
</FormHelperText>
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Input } from "@chakra-ui/react";
|
||||
import type { SolidityInputWithTypeProps } from ".";
|
||||
import { validateBytes } from "./helpers";
|
||||
import type { SolidityInputWithTypeProps } from "./index";
|
||||
|
||||
export const SolidityBytesInput: React.FC<SolidityInputWithTypeProps> = ({
|
||||
formContext: form,
|
||||
solidityType,
|
||||
...inputProps
|
||||
}) => {
|
||||
const { name, ...restOfInputProps } = inputProps;
|
||||
// discard size prop to fix type
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { name, size, ...restOfInputProps } = inputProps;
|
||||
const inputName = name as string;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input, type InputProps } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { forwardRef } from "react";
|
||||
import { type UseFormReturn, useFormContext } from "react-hook-form";
|
||||
import { SolidityAddressInput } from "./address-input";
|
||||
@@ -9,6 +9,8 @@ import { SolidityRawInput } from "./raw-input";
|
||||
import { SolidityStringInput } from "./string-input";
|
||||
import { SolidityTupleInput } from "./tuple-input";
|
||||
|
||||
type InputProps = React.ComponentProps<typeof Input>;
|
||||
|
||||
export interface SolidityInputProps extends InputProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
formContext: UseFormReturn<any, any>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Input, InputGroup, InputRightElement } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputRightElement } from "@chakra-ui/react";
|
||||
import { useCallback } from "react";
|
||||
import { toWei } from "thirdweb";
|
||||
import { Button } from "tw-components";
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Textarea, type TextareaProps } from "@chakra-ui/react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SolidityInputWithTypeProps } from ".";
|
||||
import { formatHint, validateSolidityInput } from "./helpers";
|
||||
|
||||
type TextareaProps = React.ComponentProps<typeof Textarea>;
|
||||
|
||||
export const SolidityRawInput: React.FC<SolidityInputWithTypeProps> = ({
|
||||
formContext: form,
|
||||
solidityType,
|
||||
@@ -49,9 +52,9 @@ export const SolidityRawInput: React.FC<SolidityInputWithTypeProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Textarea
|
||||
fontFamily="mono"
|
||||
placeholder={solidityType}
|
||||
{...(restOfInputProps as TextareaProps)}
|
||||
className={cn("font-mono", restOfInputProps.className)}
|
||||
value={form.watch(inputName)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardStorageUpload } from "@3rdweb-sdk/react/hooks/useDashboardStorageUpload";
|
||||
import { Box, Input, InputGroup, InputRightElement } from "@chakra-ui/react";
|
||||
import { Box, InputGroup, InputRightElement } from "@chakra-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { PINNED_FILES_QUERY_KEY_ROOT } from "app/team/[team_slug]/(team)/~/usage/storage/your-files";
|
||||
import { IpfsUploadButton } from "components/ipfs-upload/button";
|
||||
@@ -35,8 +36,8 @@ export const SolidityStringInput: React.FC<SolidityInputWithTypeProps> = ({
|
||||
<InputGroup display="flex">
|
||||
<Input
|
||||
placeholder="string"
|
||||
isDisabled={storageUpload.isPending}
|
||||
pr={{ base: "90px", md: "160px" }}
|
||||
disabled={storageUpload.isPending}
|
||||
className="pr-[90px] md:pr-[160px]"
|
||||
{...restOfInputProps}
|
||||
value={form.watch(inputName)}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Flex, Textarea, type TextareaProps } from "@chakra-ui/react";
|
||||
import { FormHelperText } from "tw-components";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SolidityInputWithTypeProps } from ".";
|
||||
import { formatHint } from "./helpers";
|
||||
|
||||
type TextareaProps = React.ComponentProps<typeof Textarea>;
|
||||
|
||||
export const SolidityTupleInput: React.FC<SolidityInputWithTypeProps> = ({
|
||||
formContext: form,
|
||||
solidityType,
|
||||
@@ -50,18 +52,18 @@ export const SolidityTupleInput: React.FC<SolidityInputWithTypeProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<div>
|
||||
<Textarea
|
||||
fontFamily="mono"
|
||||
placeholder={solidityType}
|
||||
{...(restOfInputProps as TextareaProps)}
|
||||
value={form.watch(inputName)}
|
||||
className={cn("font-mono", restOfInputProps.className)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<FormHelperText>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
Input should be passed in JSON format - Ex:{" "}
|
||||
{formatHint(solidityType, solidityComponents)}
|
||||
</FormHelperText>
|
||||
</Flex>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -242,7 +242,7 @@ const WALLETS_SNIPPETS = [
|
||||
description: "Deploy accounts for your users",
|
||||
iconUrl:
|
||||
"ipfs://QmeAJVqn17aDNQhjEU3kcWVZCFBrfta8LzaDGkS8Egdiyk/smart-wallet.svg",
|
||||
link: "https://portal.thirdweb.com/references/wallets/latest/SmartWallet",
|
||||
link: "https://portal.thirdweb.com/references/typescript/v5/smartWallet",
|
||||
supportedLanguages: {
|
||||
javascript: `import { defineChain } from "thirdweb";
|
||||
import { inAppWallet, smartWallet } from "thirdweb/wallets";
|
||||
|
||||
@@ -2,9 +2,11 @@ export type EngineBackendWalletType =
|
||||
| "local"
|
||||
| "aws-kms"
|
||||
| "gcp-kms"
|
||||
| "circle"
|
||||
| "smart:local"
|
||||
| "smart:aws-kms"
|
||||
| "smart:gcp-kms";
|
||||
| "smart:gcp-kms"
|
||||
| "smart:circle";
|
||||
|
||||
export const EngineBackendWalletOptions: {
|
||||
key: EngineBackendWalletType;
|
||||
@@ -13,7 +15,9 @@ export const EngineBackendWalletOptions: {
|
||||
{ key: "local", name: "Local" },
|
||||
{ key: "aws-kms", name: "AWS KMS" },
|
||||
{ key: "gcp-kms", name: "Google Cloud KMS" },
|
||||
{ key: "circle", name: "Circle" },
|
||||
{ key: "smart:local", name: "Smart (Local)" },
|
||||
{ key: "smart:aws-kms", name: "Smart (AWS KMS)" },
|
||||
{ key: "smart:gcp-kms", name: "Smart (Google Cloud KMS)" },
|
||||
{ key: "smart:circle", name: "Smart Circle" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { differenceInCalendarDays } from "date-fns";
|
||||
import {
|
||||
type Range,
|
||||
getLastNDaysRange,
|
||||
} from "../components/analytics/date-range-selector";
|
||||
|
||||
export function normalizeTime(date: Date) {
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(1, 0, 0, 0);
|
||||
return newDate;
|
||||
}
|
||||
|
||||
export function getNebulaFiltersFromSearchParams(params: {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
}) {
|
||||
const fromStr = params.from;
|
||||
const toStr = params.to;
|
||||
const defaultRange = getLastNDaysRange("last-30");
|
||||
|
||||
const range: Range =
|
||||
fromStr && toStr && typeof fromStr === "string" && typeof toStr === "string"
|
||||
? {
|
||||
from: normalizeTime(new Date(fromStr)),
|
||||
to: normalizeTime(new Date(toStr)),
|
||||
type: "custom",
|
||||
}
|
||||
: {
|
||||
from: normalizeTime(defaultRange.from),
|
||||
to: normalizeTime(defaultRange.to),
|
||||
type: defaultRange.type,
|
||||
};
|
||||
|
||||
const defaultInterval =
|
||||
differenceInCalendarDays(range.to, range.from) > 30
|
||||
? "week"
|
||||
: ("day" as const);
|
||||
|
||||
return {
|
||||
range,
|
||||
interval:
|
||||
params.interval === "day"
|
||||
? ("day" as const)
|
||||
: params.interval === "week"
|
||||
? ("week" as const)
|
||||
: defaultInterval,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTimeISOString(date: Date) {
|
||||
return normalizeTime(date).toISOString();
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
const team: Team = {
|
||||
id: `team-${id}-id`,
|
||||
billingPlan: billingPlan,
|
||||
supportPlan: billingPlan,
|
||||
billingStatus: "validPayment",
|
||||
name: `Team ${id}`,
|
||||
slug: `team-${id}`,
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# thirdweb-login
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`98b6198`](https://github.com/thirdweb-dev/js/commit/98b6198687b11022033fb08410407f0ccb09cd1c)]:
|
||||
- thirdweb@5.88.4
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`ee1bc3e`](https://github.com/thirdweb-dev/js/commit/ee1bc3eb076a189fa806dd6008bf2f97e0bd052f)]:
|
||||
- thirdweb@5.88.3
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`2fbf105`](https://github.com/thirdweb-dev/js/commit/2fbf105d156ee551eec29e545fa08c43fd649051), [`97c9ab7`](https://github.com/thirdweb-dev/js/commit/97c9ab7e1caa8b3e006ecc7f64b54b392ba83eda), [`6b2a90a`](https://github.com/thirdweb-dev/js/commit/6b2a90ac89fd8c1bb8b784fb67e2a1a1a0e99816), [`fbafb65`](https://github.com/thirdweb-dev/js/commit/fbafb65f5c8669efd23028dd365982274cc06034), [`66bbedb`](https://github.com/thirdweb-dev/js/commit/66bbedbe3afef8b3eb6902e47391bd914f18bcca), [`159ffbf`](https://github.com/thirdweb-dev/js/commit/159ffbfaeed19dc6e37c19ad502b45a6a9a80669), [`6b286c7`](https://github.com/thirdweb-dev/js/commit/6b286c7e83dbba4beb6eeedc6ec24d9d3617f0cd)]:
|
||||
- thirdweb@5.88.2
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "thirdweb-login",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
@@ -80,15 +80,13 @@ function AnyAuth() {
|
||||
limits on customizations and auth methods.
|
||||
</p>
|
||||
<CodeExample
|
||||
preview={
|
||||
<div className="w-1/2">
|
||||
<CustomLoginForm />
|
||||
</div>
|
||||
}
|
||||
preview={<CustomLoginForm />}
|
||||
code={`import { useState } from "react";
|
||||
import { useConnect } from "thirdweb/react";
|
||||
import { inAppWallet, preAuthenticate } from "thirdweb/wallets/in-app";
|
||||
|
||||
const wallet = inAppWallet();
|
||||
|
||||
export function CustomLoginUi() {
|
||||
const { connect, isConnecting, error } = useConnect();
|
||||
|
||||
@@ -101,10 +99,9 @@ export function CustomLoginUi() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogin = async (email: string, verificationCode: string) => {
|
||||
const loginWithEmail = async (email: string, verificationCode: string) => {
|
||||
// verify email with verificationCode and connect
|
||||
connect(async () => {
|
||||
const wallet = inAppWallet();
|
||||
await wallet.connect({
|
||||
client,
|
||||
strategy: "email",
|
||||
@@ -114,6 +111,17 @@ export function CustomLoginUi() {
|
||||
return wallet;
|
||||
});
|
||||
};
|
||||
|
||||
const loginWithGoogle = async () => {
|
||||
// connect with google
|
||||
connect(async () => {
|
||||
await wallet.connect({
|
||||
client,
|
||||
strategy: "google",
|
||||
});
|
||||
return wallet;
|
||||
});
|
||||
};
|
||||
}
|
||||
`}
|
||||
lang="tsx"
|
||||
|
||||
@@ -27,9 +27,26 @@ export function CustomLoginForm() {
|
||||
const wallet = inAppWallet();
|
||||
await wallet.connect({
|
||||
strategy: "email",
|
||||
client: THIRDWEB_CLIENT,
|
||||
email,
|
||||
verificationCode,
|
||||
client: THIRDWEB_CLIENT,
|
||||
});
|
||||
return wallet;
|
||||
});
|
||||
};
|
||||
|
||||
const loginWithGoogle = async () => {
|
||||
connect(async () => {
|
||||
const wallet = inAppWallet({
|
||||
auth: {
|
||||
options: ["google"],
|
||||
mode: "redirect",
|
||||
redirectUrl: `${window.location.origin}/connect/in-app-wallet`,
|
||||
},
|
||||
});
|
||||
await wallet.connect({
|
||||
strategy: "google",
|
||||
client: THIRDWEB_CLIENT,
|
||||
});
|
||||
return wallet;
|
||||
});
|
||||
@@ -41,7 +58,7 @@ export function CustomLoginForm() {
|
||||
|
||||
if (screen === "login") {
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex w-full flex-col space-y-4">
|
||||
<label htmlFor="email" className="font-medium text-sm">
|
||||
Email Address
|
||||
</label>
|
||||
@@ -62,6 +79,14 @@ export function CustomLoginForm() {
|
||||
>
|
||||
{isConnecting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
<p className="text-center text-sm text-white">Or</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loginWithGoogle}
|
||||
className="rounded-lg bg-blue-500 px-4 py-2 text-white transition-colors enabled:hover:bg-blue-600"
|
||||
>
|
||||
Login with Google
|
||||
</button>
|
||||
{error && <p className="max-w-[300px] text-red-500">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -998,6 +998,8 @@ const v4ToV5Redirects = {
|
||||
"/storage-sdk/v2/:path*": "/typescript/v5/storage",
|
||||
"/unity/v4": "/unity/v5",
|
||||
"/unity/v4/:path*": "/unity/v5",
|
||||
"/references/wallets": "/typescript/v5/supported-wallets",
|
||||
"/references/wallets/:path*": "/typescript/v5/supported-wallets",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
@@ -58,7 +58,7 @@ This only needs to be done once per device.
|
||||
|
||||
When you login with the dashboard, a number of things happen:
|
||||
|
||||
- A [local wallet](/references/wallets/v2/LocalWallet) is created and stored in your device's local storage.
|
||||
- A local wallet is created and stored in your device's local storage.
|
||||
- An auth token is generated for this local wallet.
|
||||
- The auth token is linked to your wallet when you sign the message.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Smart accounts rely on code instead of private keys to secure and recover wallet
|
||||
|
||||
**A smart account is a type of Web3 wallet powered by smart contracts.**
|
||||
|
||||
This smart account is unlocked by a 'key' - a personal account. This key can be anything from a [MetaMask](/references/wallets/v2/MetaMaskWallet) wallet or even a [In-App Wallet](/connect/in-app-wallet/overview) and is used as a way to 'sign in' to the wallet.
|
||||
This smart account is unlocked by a 'key' - a personal account. This key can be anything from a [MetaMask wallet](/typescript/v5/supported-wallets/io.metamask) or even an [In-App Wallet](/connect/in-app-wallet/overview) and is used as a way to 'sign in' to the wallet.
|
||||
|
||||
## Benefits of Smart Accounts
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SDKCard,
|
||||
Callout,
|
||||
ArticleIconCard,
|
||||
DocImage
|
||||
} from "@doc";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import SupportedChains from "../_images/supported-chains.png";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "thirdweb Connect",
|
||||
@@ -102,6 +104,8 @@ Get started with Connect in your preferred language.
|
||||
|
||||
Connect is supported on every EVM compatible chain. To view the full list, visit [thirdweb chainlist](https://thirdweb.com/chainlist).
|
||||
|
||||
<DocImage src={SupportedChains} />
|
||||
|
||||
## Starter Kits & demos
|
||||
|
||||
View all available starter kits, demos and templates on Github.
|
||||
@@ -109,5 +113,4 @@ View all available starter kits, demos and templates on Github.
|
||||
<GithubTemplateCard
|
||||
title="View all templates"
|
||||
href="https://github.com/thirdweb-example"
|
||||
tag="Starter"
|
||||
/>
|
||||
|
||||
@@ -74,7 +74,11 @@ const { mutate: sendTransaction } = useSendTransaction({
|
||||
|
||||
</Tabs>
|
||||
|
||||
You can use the following information when using any provider in test mode:
|
||||
For Coinbase onramp, you can turn on "Mocked mode" when launching the Onramp experience.
|
||||
|
||||
At the bottom of the Onramp modal where it says "Secured by Coinbase", click 10 times on the word "Secured." This will navigate you to Onramp's Debug Menu. There, navigate to the Actions tab click on the toggle for "Enable Mocked Buy and Send." You can now exit the Debug Menu and proceed with mock buys and sends. Remember to click on the "Enable Mocked Buy and Send" toggle once you are done to enable actual buys and sends.
|
||||
|
||||
For other providers, you can use the following information when using any provider in test mode:
|
||||
|
||||
| Provider | Card Type | Name | Number | Expiration Date | CVV | Other Info |
|
||||
| -------- | ------------ | ----------- | ------------------- | --------------- | ------------ | ---------------------------------------------------------------- |
|
||||
|
||||
@@ -12,7 +12,7 @@ export const metadata = createMetadata({
|
||||
|
||||
# Deploy
|
||||
|
||||
Deploy is a tool to deploy any contract to any EVM compatible chain through one simple command and an easy-to-use interface. Deploy reduces the complexities of deploying a contract by eliminating steps such as hardcoding private keys, managing RPC URLs, or running scripts to deploy your contracts.
|
||||
Deployment tools allow you to deploy any contract to any EVM compatible chain through one simple command and an easy-to-use interface. Deploy reduces the complexities of deploying a contract by eliminating steps such as hardcoding private keys, managing RPC URLs, or running scripts to deploy your contracts.
|
||||
|
||||
Simply run the following command at the root of your contract directory:
|
||||
|
||||
@@ -31,24 +31,14 @@ npx thirdweb deploy
|
||||
description="Contracts are automatically verified on Sourcify"
|
||||
iconUrl="/icons/feature-cards/verified.svg"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Deploy to any EVM"
|
||||
description="Deploy to over 2,000+ available EVM chains"
|
||||
iconUrl="/icons/feature-cards/evm.svg"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Increased security"
|
||||
description="Avoid exporting private keys with client-side deployment"
|
||||
iconUrl="/icons/feature-cards/security.svg"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Streamlined deployment"
|
||||
description="Avoid writing scripts, setting up RPCs, copying ABIs, etc."
|
||||
iconUrl="/icons/feature-cards/streamlined-deployment.svg"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Support for frameworks"
|
||||
description="Works with contracts built with Hardhat and Foundry"
|
||||
description="Works with any contract built with Hardhat and Foundry"
|
||||
iconUrl="/icons/feature-cards/frameworks.svg"
|
||||
/>
|
||||
<FeatureCard
|
||||
@@ -58,7 +48,7 @@ npx thirdweb deploy
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Dashboard"
|
||||
description="Unlock a management dashboard"
|
||||
description="Unlock a management dashboard to interact with contracts and view analytics"
|
||||
iconUrl="/icons/feature-cards/dashboard.svg"
|
||||
/>
|
||||
<FeatureCard
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GithubTemplateCard, ExpandableGrid, createMetadata, OpenSourceCard } from "@doc";
|
||||
import { GithubTemplateCard, ExpandableGrid, createMetadata, OpenSourceCard, DocImage, ArticleIconCard, Grid } from "@doc";
|
||||
import SupportedChains from "../_images/supported-chains.png";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "thirdweb Contracts",
|
||||
@@ -8,13 +10,31 @@ export const metadata = createMetadata({
|
||||
|
||||
# What are Contracts?
|
||||
|
||||
Contracts is an end-to-end development suite which provides tools to develop, deploy, and manage smart contracts
|
||||
Contracts is an end-to-end development suite which provides tools to develop, deploy, and manage smart contracts.
|
||||
|
||||
### Features
|
||||
<Grid>
|
||||
|
||||
- [Explore (Contract Library)](/contracts/explore/overview): Audited and expansive library of contracts deployable with one-click
|
||||
- [Modular Contracts](/contracts/modular-contracts/overview): Framework to build highly customizable and secure smart contracts
|
||||
- [Deployment Tool](/contracts/deploy/overview): Client-side deploy tool to securely deploy any contract to any EVM compatible chain
|
||||
<ArticleIconCard
|
||||
title="Deployment Tool"
|
||||
icon={ExternalLink}
|
||||
href="/contracts/deploy/overview"
|
||||
description="Securely deploy any smart contract."
|
||||
/>
|
||||
<ArticleIconCard
|
||||
title="Explore"
|
||||
icon={ExternalLink}
|
||||
href="/contracts/explore/overview"
|
||||
description="Audited and expansive library of contracts."
|
||||
/>
|
||||
|
||||
<ArticleIconCard
|
||||
title="Modular Contracts"
|
||||
icon={ExternalLink}
|
||||
href="/contracts/explore/overview"
|
||||
description="Build customizable smart contracts."
|
||||
/>
|
||||
|
||||
</Grid>
|
||||
|
||||
<OpenSourceCard title="Contracts" href="https://github.com/thirdweb-dev/contracts" />
|
||||
|
||||
@@ -91,3 +111,9 @@ Contracts is an end-to-end development suite which provides tools to develop, de
|
||||
/>
|
||||
|
||||
</ExpandableGrid>
|
||||
|
||||
## Supported Chains
|
||||
Contracts is supported on every EVM compatible chain. To view the full list, visit [thirdweb chainlist](https://thirdweb.com/chainlist?service=contracts).
|
||||
|
||||
<DocImage src={SupportedChains} />
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user