Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
5f26883fbd Version Packages (#6247)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-12 19:38:31 -08:00
Joaquim VergesandGitHub 98b6198687 [SDK] fix: Prevent popup when using auth redirect mode (#6246) 2025-02-12 19:31:09 -08:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
880148b793 Version Packages (#6241)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-12 14:34:11 -08:00
Joaquim VergesandGitHub ee1bc3eb07 [SDK] fix: show token + dollar values for wallet balances (#6240) 2025-02-12 14:20:16 -08:00
MananTank e9ab8cb770 [TOOL-3398] Dashboard: Fix DialogOverlay and DialogContent missing default shadcn z-index (#6239) 2025-02-12 21:08:03 +00:00
MananTank 9c33981b72 [TOOL-3396] Dashboard: Fix inputMode for OTP input, Use text instead of default numeric (#6238) 2025-02-12 11:43:10 +00:00
Joaquim VergesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
c645b42684 Version Packages (#6208)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-12 01:36:43 -08:00
Joaquim VergesandGitHub 7583074c06 Update page.mdx (#6235)
Signed-off-by: Joaquim Verges <[email protected]>
2025-02-12 01:30:39 -08:00
Joaquim VergesandGitHub fbafb65f5c [SDK] fix: Polish PayEmbed UI and balance display (#6237) 2025-02-12 01:30:20 -08:00
Joaquim VergesandGitHub 6b286c7e83 [SDK] fix: Disable amount editing for non-wallet funding modes (#6236) 2025-02-12 01:23:28 -08:00
joaquim-verges 159ffbfaee [SDK] feat: Add verifyClaim export and canClaim function (#6234)
Fixes TOOL-3381

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on exposing the `canClaim` extension function for ERC20, ERC721, and ERC1155 drops, allowing for better claim verification and improving the overall functionality of the drops system.

### Detailed summary
- Added `canClaim` function to `erc20`, `erc721`, and `erc1155` extensions.
- Updated tests to include `canClaim` functionality.
- Modified JSON ABI files to include `verifyClaim` function.
- Adjusted gas values in tests.
- Enhanced error handling in `extract-error.ts`.

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

<!-- end pr-codex -->
2025-02-12 07:56:01 +00:00
kumaryash90 0c1bb6ccae [Dashboard] Deploy config updates (#6159)
TOOL-3389

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a default fee configuration for contract deployments, updates the handling of platform fees, and improves the UI components related to fee management, ensuring that default values are used consistently throughout the application.

### Detailed summary
- Added `DEFAULT_FEE_RECIPIENT` and `DEFAULT_FEE_BPS` to `addresses.ts`.
- Introduced `hasDefaultFeeConfig` in `ContractSettingsPage`.
- Filtered out specific functions related to platform fees in `contract-function.tsx`.
- Integrated platform fee logic in `CustomContractForm`.
- Updated `PlatformFeeFieldset` to reflect marketplace conditions.

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

<!-- end pr-codex -->
2025-02-11 22:00:08 +00:00
Shubham ParkhiandGitHub dceb47de8b [SDK/Dashboard/Portal] Feature/Fix: fix typo in docs (#6229)
Signed-off-by: Shubham Parkhi <[email protected]>
2025-02-10 21:19:02 -08:00
Jonas DanielsandGitHub dfbdbd06e5 use supportPlan instead of billingPlan for support tickets (#6228) 2025-02-10 21:17:24 -08:00
66bbedbe3a [SDK] Feature: add treasure and treasure topaz (#6191)
Co-authored-by: Joaquim Verges <[email protected]>
2025-02-10 19:42:15 -08:00
Joaquim VergesandGitHub 2fbf105d15 [SDK] fix: Disable quote retries and show fiat values (#6210) 2025-02-10 19:41:42 -08:00
FirekeeperandGitHub afcaa91873 Docs remove legacy enc key arg (#6220) 2025-02-10 19:41:08 -08:00
Manan TankandGitHub 51f955352d [TOOL-3377] Portal: Remove v4 SDK links, setup missing redirects (#6222) 2025-02-10 19:40:48 -08:00
Joaquim VergesandGitHub 6b2a90ac89 [SDK] feat: Add session key update handling and expose utility (#6217) 2025-02-10 19:40:06 -08:00
MananTank cf53184bb1 [TOOL-3379] Dashboard: Add team selector in support form (#6227) 2025-02-10 22:21:18 +00:00
MananTank 815b9b1ce1 [NEB-92] Dashboard: Add Nebula Analytics dashboard (#6198)
<!-- start pr-codex -->

## PR-Codex overview
This PR primarily focuses on enhancing the `Nebula` analytics functionality by adding new components and improving data handling for analytics. It introduces new utilities for managing date ranges and search parameters, along with updates to the UI for better user interaction.

### Detailed summary
- Added `getNebulaAnalyticsRangeFromSearchParams` function in `utils.ts`.
- Updated `ProjectTabs` to remove `isOnNebulaWaitList` prop.
- Introduced `normalizeTime` function in `time.ts` for date normalization.
- Created `NebulaAnalyticsFilter` component for filtering analytics data.
- Implemented `NebulaAnalyticsPage` and `NebulaAnalyticDashboard` for displaying analytics.
- Enhanced `DateRangeSelector` with popover alignment options.
- Added `NebulaAnalyticsDashboardUI` for rendering analytics data and charts.
- Integrated `responsive-rsc` for responsive search parameters.
- Updated `package.json` to include `responsive-rsc` dependency.
- Removed calls to `getTeamNebulaWaitList` in `layout.tsx` and associated logic.

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

<!-- end pr-codex -->
2025-02-10 20:05:31 +00:00
Maxim EvtushandGitHub ae1baa5105 fix: typos in documentation files (#6215)
Signed-off-by: Maxim Evtush <[email protected]>
2025-02-10 10:40:32 -08:00
saminacodes 92266b7459 [Portal] Cosmetic Changes (#6218)
CORE-0000

---

## PR-Codex overview
This PR focuses on updating content and structure across various pages in the `apps/portal` application, including the addition of new features, improved descriptions, and adjustments to the sidebar navigation.

### Detailed summary
- Deleted `supported-chains.png` and `context-filters.png`.
- Added new content for `faqs`, `troubleshoot`, and `Nebula` guides.
- Updated the sidebar with new links and icons.
- Changed import paths for images.
- Enhanced descriptions in `Deploy`, `Contracts`, and `Insight` pages.
- Added a new section for "Supported Chains" in `Contracts` and `Insight` pages.

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

<!-- end pr-codex -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the documentation and content for the `thirdweb` platform, particularly for the `Insight` and `Nebula` sections, while also restructuring some sidebar links and enhancing the user interface with new images.

### Detailed summary
- Deleted `supported-chains` image and `page.mdx` files.
- Added new content for `Insight FAQs` and `Troubleshoot Guide`.
- Updated `Nebula` description and added a support link.
- Changed sidebar items for `Insight` and `Contracts`.
- Introduced new features and layout in `Insight` and `Contracts` pages.
- Added support for displaying `Supported Chains` images in relevant sections.

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

<!-- end pr-codex -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updates to the `insight` and `nebula` sections of the application, including the addition of new content, restructuring of sidebar navigation, and changes to image imports. It also enhances the user interface with improved documentation and guidance.

### Detailed summary
- Deleted `apps/portal/src/app/insight/supported-chains/page.mdx`.
- Added new content for `Insight FAQs`, `Insight Troubleshoot Guide`, and `Nebula Troubleshoot Guide`.
- Updated sidebar navigation in `apps/portal/src/app/insight/sidebar.tsx`.
- Changed the name and href for several sidebar items.
- Reorganized `Supported Chains` references and added a new image import in multiple files.
- Enhanced content descriptions in `apps/portal/src/app/contracts/deploy/overview/page.mdx`.
- Added new features and video tutorials in `apps/portal/src/app/insight/page.mdx`.
- Updated layout and structure of `Contracts` section in `apps/portal/src/app/contracts/page.mdx`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
2025-02-10 17:19:27 +00:00
MananTank 80ad84b803 [TOOL-3376] Dashboard: Fix 404 on publish contract page (#6221) 2025-02-10 17:01:59 +00:00
saminacodes 633f0cadf1 [Portal] Nebula documentation update (#6201)
CORE-0000

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the documentation for `thirdweb Nebula`, adding detailed metadata, explanations of new features, and clarifying existing concepts. It includes updates to various pages, improving user understanding of plugins, sessions, context filters, and response handling.

### Detailed summary
- Added `metadata` exports to multiple `.mdx` files.
- Enhanced FAQ entries with links and additional context.
- Updated `sidebar` to include new key concepts.
- Added sections on `Chat & Execute endpoints`, `Execute Configuration`, and `Context Filters`.
- Introduced new content on `Response Handling` and streaming vs non-streaming responses.
- Clarified `Sessions` management and benefits.
- Replaced `Stack` with `Grid` in the `page.mdx` and updated links.
- Improved descriptions and examples for API usage and configurations.

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

<!-- end pr-codex -->
2025-02-10 16:00:45 +00:00
Phillip HoandGitHub 91b0367b23 [service-utils] fix auth cache for cf workers (#6219) 2025-02-10 23:51:58 +08:00
MananTank c86e13b522 [TOOL-3366] Dashboard: Contract Deployment UI improvements, Reduce Chakra Usage (#6211)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refining the UI components and improving the overall structure of the contract deployment forms. It introduces new styles, modifies existing components, and enhances accessibility.

### Detailed summary
- Updated `ThemeProvider` to enable color scheme.
- Changed class names for various components to improve styling.
- Simplified props for `SolidityInput` components.
- Replaced `FormControl` with `FormFieldSetup` for better structure.
- Enhanced accessibility by modifying error messages and labels.
- Streamlined imports and removed unnecessary components.
- Improved the layout and styling of various fieldsets and buttons.
- Added new utility functions for better handling of form states.

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

<!-- end pr-codex -->
2025-02-09 21:35:45 +05:30
MananTank bc42c2fdfb [TOOL-3368] Dashboard: Improve page load perf of explore page and other few others (#6214)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on updating the contract fetching logic in the `dashboard` application to improve the retrieval of the latest published contracts. It replaces older functions with new ones that specifically fetch the latest contract versions.

### Detailed summary
- Added `export const dynamic = "force-static";` to `page.tsx` files.
- Replaced `getPublishedContractsWithPublisherMapping` with `getLatestPublishedContractsWithPublisherMapping` in multiple files.
- Introduced `getLatestPublishedContractsWithPublisherMapping` function to fetch the latest contract version.
- Updated `fetchPublishedContractVersions` to use `getSortedPublishedContractVersions`.
- Created `fetchLatestPublishedContractVersion` for fetching the latest version details.
- Modified `ContractCard` to use `ClientOnly` for rendering the `ContractPublisher`.

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

<!-- end pr-codex -->
2025-02-09 15:33:14 +00:00
Phillip HoandGitHub cad9c74bef [service-utils] Omit team_id for client usageV2 events (#6213) 2025-02-09 17:59:56 +08:00
Phillip HoandGitHub 6f2f92ddea [service-utils]H elper to call client usageV2 reporting endpoint (#6212) 2025-02-09 17:26:08 +08:00
Joaquim VergesandGitHub 97c9ab7e1c [SDK] feat: Batch approvals and swaps for smart wallets (#6209) 2025-02-08 03:04:11 -08:00
d4mr b73fa4b16f [dashboard-engine] updates for circle and wallet credentials (#6202)
closes TOOL-3351

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces support for managing Circle wallet credentials in the thirdweb Engine, enhancing the wallet management features by allowing users to create, update, and configure Circle wallet credentials.

### Detailed summary
- Added `Wallet Credentials` section in the sidebar.
- Introduced `Circle` wallet option in backend wallet configurations.
- Created new components for managing Circle wallet credentials.
- Implemented credential creation and update functionalities.
- Updated UI components to support Circle wallet management.
- Enhanced documentation for Circle wallet setup and usage.

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

<!-- end pr-codex -->
2025-02-08 09:45:46 +00:00
Phillip HoandGitHub cbfe9ec459 [service-utils] Allow sending client-side usageV2 events (#6207) 2025-02-08 13:07:57 +08:00
189 changed files with 4656 additions and 1374 deletions
+1
View File
@@ -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",
+5
View File
@@ -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),
});
},
});
}
@@ -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,
@@ -25,6 +25,7 @@ export function ContractSettingsPageClient(props: {
contract={props.contract}
functionSelectors={metadataQuery.data.functionSelectors}
twAccount={props.twAccount}
hasDefaultFeeConfig={true}
/>
);
}
@@ -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}
/>
);
@@ -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>
{
@@ -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>
@@ -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, {
@@ -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";
@@ -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,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;
}
@@ -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;
}
}
@@ -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>
);
}
@@ -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>
@@ -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>
);
}
@@ -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>
@@ -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,
@@ -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}
@@ -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",
@@ -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>
);
};
@@ -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>
);
@@ -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>
@@ -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>
@@ -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);
@@ -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}
/>
</>
);
};
@@ -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>
);
};
@@ -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>
</>
);
};
@@ -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
/>
</>
);
};
@@ -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"];
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
@@ -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
},
);
@@ -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>
);
}
@@ -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);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
@@ -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>
@@ -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>
);
};
@@ -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>
);
@@ -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>
);
};
@@ -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>
@@ -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>
);
@@ -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" />
@@ -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>
)}
</>
@@ -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";
+5 -1
View File
@@ -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" },
];
+53
View File
@@ -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();
}
+1
View File
@@ -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}`,
+21
View File
@@ -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 -1
View File
@@ -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>
);
+2
View File
@@ -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

+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
+32 -6
View File
@@ -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