Compare commits

..
Author SHA1 Message Date
Jonas DanielsGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
9e46b5b94e Version Packages (#4121)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-18 12:37:18 +12:00
gregfromstl e0dcc64256 Fix connect button border in firefox (#4160)
Corrects an issue with the border visibility in firefox by explicitly specifying the div height

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on fixing the connect button border issue on Firefox browser.

### Detailed summary
- Added `height: "50px"` to the button style in `Details.tsx` for proper display on Firefox.

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

<!-- end pr-codex -->
2024-08-18 00:03:46 +00:00
gregfromstl f0d6e343ee Adds EIP-1271 Signature Validation Fallback (#4156)
This PR EIP-1271 fallback for smart contract validation since not all chains support the deployless call used in 6792 flow.

<!-- start pr-codex -->

---

## PR-Codex overview
The focus of this PR is to fix smart contract wallet signature validation on older chains.

### Detailed summary
- Updated the prompt text for signing in on the wallet UI
- Added a fallback to EIP1271 validation for chains not supporting eth_call simulation
- Introduced a new function `verifyEip1271Signature` for EIP1271 signature validation

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

<!-- end pr-codex -->
2024-08-17 23:43:45 +00:00
gregfromstl cf4443a9eb Improve Blobbie Algo (#4159)
![image](https://github.com/user-attachments/assets/cfc34112-3b94-4125-8aba-4a065d7c9ec2)
![image](https://github.com/user-attachments/assets/9daf26b0-fde8-4a77-bebf-a77f94d444f2)
![image](https://github.com/user-attachments/assets/91c4ceb1-c3c0-4be6-a654-d233b699495a)
![image](https://github.com/user-attachments/assets/7a298ad2-7fa4-4967-a7ea-7e9ec2fcf7be)

<!-- start pr-codex -->

---

## PR-Codex overview
The focus of this PR is to improve the Blobbie algorithm for generating unique gradient avatars based on provided addresses.

### Detailed summary
- Updated color calculation logic for better color pairing
- Removed unused `nearestColor` function
- Refactored color and position calculations for Blobbie avatar
- Adjusted ellipse positions and sizes for better visual representation

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

<!-- end pr-codex -->
2024-08-17 23:12:15 +00:00
gregfromstl 355795a2d4 Fix text wrapping on connect button (#4157)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on fixing text wrapping on the Connect Button in the `Details.tsx` file.

### Detailed summary
- Added `whiteSpace: "nowrap"` to prevent text wrapping
- Updated styles for better button display

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

<!-- end pr-codex -->
2024-08-17 19:37:43 +00:00
kien-ngo 5fda794569 [SDK] Add more VoteERC20 extensions (for Dashboard) (#4139)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR exposes ERC20Vote extensions and adds more Vote contract extensions.

### Detailed summary
- Exposed ERC20Vote extensions
- Added more Vote contract extensions
- Updated types for vote and proposal states
- Implemented functions for proposal existence, execution, and voting counts

> The following files were skipped due to too many changes: `packages/thirdweb/src/extensions/vote/read/getAll.ts`

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

<!-- end pr-codex -->
2024-08-17 12:28:02 +00:00
kien-ngo 93b3bad783 [SDK] Expose thirdweb extensions (for Dashboard) (#4149)
<!-- start pr-codex -->

## PR-Codex overview
The focus of this PR is to export contracts related to Publisher, Multichain Registry, and Contract Factory functionalities.

### Detailed summary
- Added Publisher contract exports for reading and writing operations
- Added Multichain Registry exports for reading and writing operations
- Added Contract Factory export for deploying proxies

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

<!-- end pr-codex -->
2024-08-17 12:02:02 +00:00
Joaquim VergesandGitHub f5952621ab feat(wallet): handle eth_sendTransaction when doing zksync direct deploys (#4153) 2024-08-17 23:52:10 +12:00
kien-ngo 1d9f4e5e3e [Dashboard] MismatchButton to v5 (#4152)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview

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

<!-- end pr-codex -->
2024-08-17 10:01:56 +00:00
gregfromstl 2e99057f90 feat(wallet-ui): initial app (#3478)
1. Created a new `.env.example` file specifying the `NEXT_PUBLIC_ROOT_DOMAIN` variable.
2. Updated `.gitignore` to include `.env*.local` instead of `.env*`.
3. Modified the `Layout` component in `[ecosystem]/layout.tsx` to accept `children` and `params` properties and display the ecosystem and children elements.
4. Updated the `Page` component in `[ecosystem]/page.tsx` to return a simple `div` with the text `Page`.
5. Added a new `middleware.ts` file to handle dynamic routing based on the request host, matching all paths except for specific routes (e.g., `/api`, `/_next`, `/_static`). Rewrites the URL to point to either the root application or the appropriate ecosystem dynamic route.

---

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on adding ecosystem-related features and improving UI components.

### Detailed summary
- Added `Ecosystem` and token types
- Updated `biome.json` schema
- Implemented revalidate function
- Added NFT loading and card components
- Updated environment variables
- Improved layout and metadata handling
- Added new UI components like `Skeleton`, `Label`, and `Input`
- Enhanced client and ecosystem API integration

> The following files were skipped due to too many changes: `apps/wallet-ui/src/lib/chains.ts`, `apps/wallet-ui/src/components/ui/sonner.tsx`, `apps/wallet-ui/src/components/ConnectButton.tsx`, `apps/wallet-ui/src/app/[ecosystem]/login/page.tsx`, `apps/wallet-ui/src/app/[ecosystem]/(ui)/layout.tsx`, `apps/wallet-ui/src/components/ui/badge.tsx`, `apps/wallet-ui/src/components/ui/popover.tsx`, `apps/wallet-ui/src/middleware.ts`, `apps/wallet-ui/src/lib/assets/erc20.ts`, `apps/wallet-ui/src/components/ui/button.tsx`, `apps/wallet-ui/src/components/NftCard.tsx`, `apps/wallet-ui/src/components/NftGallery.tsx`, `apps/wallet-ui/src/lib/auth.ts`, `apps/wallet-ui/src/lib/assets/erc721.ts`, `apps/wallet-ui/src/app/[ecosystem]/(ui)/wallet/[address]/layout.tsx`, `apps/wallet-ui/src/util/simplehash.ts`, `apps/wallet-ui/src/components/ChainCombobox.tsx`, `apps/wallet-ui/src/components/TransactionToastProvider.tsx`, `apps/wallet-ui/src/app/globals.css`, `apps/wallet-ui/src/components/ChainIcon.tsx`, `apps/wallet-ui/src/components/ui/dialog.tsx`, `apps/wallet-ui/src/components/ui/form.tsx`, `apps/wallet-ui/src/components/ui/command.tsx`, `apps/wallet-ui/src/components/NftModal.tsx`, `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2024-08-17 08:08:48 +00:00
gregfromstl 13764f1abb Connect UI Profile Image Improvements (#4126)
<!-- start pr-codex -->

## PR-Codex overview
This PR focuses on cosmetic improvements to the Connect UI, introduces Blobbie component for unique avatars, and updates button styles and sizes.

### Detailed summary
- Increased `minWidth` of `TransactionButton` to 165px
- Updated `defaultButtonTitle` in `en.ts`
- Added `Blobbie` component for unique avatars
- Updated button styles and sizes in `ConnectButton.tsx`
- Added `shortenAddress` import in `wallet.ts`
- Updated styles in `Details.tsx` and `DetailsModal`

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

<!-- end pr-codex -->
2024-08-17 08:03:28 +00:00
iuwqyir 997c3945a7 export usageEventSchema and UsageEvent (#4147)
## Problem solved

Almost the same as https://github.com/thirdweb-dev/js/pull/4122. Made a new PR, because hadn't setup commit signing for graphite, so it failed to merge

Exports the Zod schema for usage events. This way it can be used for validate the message before sending it to the usage tracker queue.
Moved it under core because it is not tied to worker logic and can be used outside workers.
Also exported `UsageEvent` and `usageEventSchema` under node, because I think core is not exported

<!-- start pr-codex -->

---

## PR-Codex overview
The focus of this PR is to export `usageEventSchema` for Cloudflare worker and define `UsageEvent` schema for node.

### Detailed summary
- Exported `usageEventSchema` for Cloudflare worker and defined `UsageEvent` schema for node
- Added detailed schema for usage events with various optional fields

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

<!-- end pr-codex -->
2024-08-16 21:15:34 +00:00
MananTank 2bf2e1eec4 Update Engine page header (#4125)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces UI improvements to the Engine page layout, including new components, styling updates, and a modal for updating the Engine version.

### Detailed summary
- Added `CopyTextButton`, `Button`, and `Separator` components
- Updated styling and structure of the Engine page layout
- Implemented a modal for updating the Engine version
- Added new icons and components for UI enhancements

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

<!-- end pr-codex -->
2024-08-16 20:37:12 +00:00
MananTank 9b3725a56e Migrate Engine list UI to shadcn/tailwind (#4123)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances UI components in the dashboard app.

### Detailed summary
- Updated tooltip styling in `tooltip.tsx`.
- Refactored dropdown menu in `dropdown-menu.tsx`.
- Improved table UI with dropdown menus in `TWTable.tsx`.
- Implemented modals for editing and removing engine instances in `engine-instances-table.tsx`.

> The following files were skipped due to too many changes: `apps/dashboard/src/components/engine/engine-instances-table.tsx`

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

<!-- end pr-codex -->
2024-08-16 20:37:11 +00:00
MananTank beafe43f46 Move Engine import from Modal to /dashboard/engine/import (#4119)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
The focus of this PR is to refactor the Engine sidebar component and improve error handling in Engine pages.

### Detailed summary
- Refactored `EngineSidebar` component
- Improved error handling with `PageLoading` and `EngineErrorPage`
- Added import functionality for Engine instances

> The following files were skipped due to too many changes: `apps/dashboard/src/pages/dashboard/engine/import.tsx`

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

<!-- end pr-codex -->
2024-08-16 20:37:11 +00:00
Winston YeoandGitHub 214a74c36a chore: add authentication method to usage tracking (#4146) 2024-08-16 20:17:57 +00:00
MananTank d0e3c4defc Fix hydration error in markdown renderer + property and ts cleanup (#4141)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR refactors the `MarkdownRenderer` component in `published-contract`.

### Detailed summary
- Refactored heading components (`h1`, `h2`, `h3`, `h4`, `h5`, `h6`) to remove explicit `any` type
- Refactored `a`, `code`, `p`, `table`, `th`, `td`, `ul`, `ol`, `li` components to use cleaned props without `node`

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

<!-- end pr-codex -->
2024-08-16 18:30:57 +00:00
MananTank 0cdf34ea98 Move Engine pages from client Router to Pages Router (#4113)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR upgrades Engine navigation links, adds new pages for Engine management, and refactors list styling.

### Detailed summary
- Updated Engine navigation links
- Added new pages for Engine management
- Refactored list styling in components

> The following files were skipped due to too many changes: `apps/dashboard/src/components/engine/engine-instances-table.tsx`, `apps/dashboard/src/pages/dashboard/engine/create.tsx`, `apps/dashboard/src/components/engine/EnginePageLayout.tsx`, `apps/dashboard/src/components/engine/engine-list.tsx`

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

<!-- end pr-codex -->
2024-08-16 15:45:43 +00:00
MananTank b1632a3df1 Add force-dynamic declaration for pages using cookies - fix sentry errors (#4140)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
The focus of this PR is to add a new export `dynamic = "force-dynamic"` in three files due to the usage of `cookies()`.

### Detailed summary
- Added `export const dynamic = "force-dynamic"` in `page.tsx` under `dashboard/connect/pay`
- Added `export const dynamic = "force-dynamic"` in `page.tsx` under `dashboard/connect/ecosystem`
- Added `export const dynamic = "force-dynamic"` in `layout.tsx` under `dashboard/connect/ecosystem/[slug]/(active)`

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

<!-- end pr-codex -->
2024-08-16 15:34:13 +00:00
jnsdls d3f43a757b [Optimize] Add CI optimization step using Graphite action (#4115)
tl;dr: https://graphite.dev/docs/stacking-and-ci#reducing-ci-runs-for-github-actions

<!-- start pr-codex -->

---

## PR-Codex overview
This PR optimizes the CI workflow by adding a step to skip unnecessary processes based on a condition.

### Detailed summary
- Added `optimize_ci` job with skip condition
- Updated `build`, `lint`, `test`, `e2e`, and `size` jobs to depend on `optimize_ci` and skip if condition is met

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

<!-- end pr-codex -->
2024-08-15 21:22:24 +00:00
gregfromstl 825352476c fix: throw when no encryption key (#4120)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing security by throwing an error when no encryption key is provided for custom authentication.

### Detailed summary
- Added error throwing when encryption key is missing for custom JWT authentication
- Added error throwing when encryption key is missing for custom auth endpoint authentication

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

<!-- end pr-codex -->
2024-08-15 21:17:20 +00:00
arcoraven 8caa095a67 fix: update transaction drawer labels (#4118)
<!-- start pr-codex -->

## PR-Codex overview
The focus of this PR is to enhance transaction details display in the dashboard by adding support for effective gas price calculation and showing additional information like account address and block number.

### Detailed summary
- Added `effectiveGasPrice` field to calculate transaction fee
- Displayed account address and linked to explorer
- Differentiated recipient address and contract address
- Showed transaction value in tokens and symbol
- Linked block number to explorer

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

<!-- end pr-codex -->
2024-08-15 19:22:51 +00:00
jnsdls 997e1197e9 analytics overhaul (#4107)
<!-- start pr-codex -->

## PR-Codex overview
This PR enhances blockchain analytics in the dashboard. It introduces support for analytics on specific chains and updates chart components for better visualization.

### Detailed summary
- Added `CHAINSAW_API_KEY` environment variable for contract analytics
- Updated chart components for better visualization
- Improved analytics support for specific chains
- Refactored chart styling for better UI/UX

> The following files were skipped due to too many changes: `apps/dashboard/src/contract-ui/tabs/overview/components/Analytics.tsx`, `apps/dashboard/src/contract-ui/tabs/analytics/page.tsx`

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

<!-- end pr-codex -->
2024-08-15 15:29:50 +00:00
Jonas DanielsandGitHub c9ac25fe3f Version Packages (#4114) 2024-08-15 07:38:54 -07:00
Wyatt MufsonandGitHub 79260c8cd0 Add new auth fields for redirect (#4099) 2024-08-15 07:30:59 -07:00
MananTank cf8badbc46 Move Network Configuration Form+Modal to shadcn/tailwind (#4105)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates various UI components for consistency and usability.

### Detailed summary
- Updated styling for `ClaimConditionsForm`, `CustomChainRenderer`, `ToolTipLabel`, `RpcInput`, `ChainIdInput`, `TooltipBox`, `ConfigureNetworkModal`, `ResetClaimEligibility`, `NetworkIdInput`, `IconUpload`, and `ConfigureNetworks`.
- Replaced `TooltipBox` with `ToolTipLabel` and `CircleHelpIcon`.
- Added `Input` from `@/components/ui/input`.
- Removed unnecessary imports.
- Replaced `Modal` components with `Dialog` and `DialogContent`.
- Used `toast` for error notifications.
- Updated button styling and functionality.

> The following files were skipped due to too many changes: `apps/dashboard/src/components/configure-networks/ConfigureNetworks.tsx`, `apps/dashboard/src/pages/[chain_id]/[...paths].tsx`, `apps/dashboard/src/components/configure-networks/ConfigureNetworkForm.tsx`

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

<!-- end pr-codex -->
2024-08-15 05:59:21 +00:00
MananTank 09e4d80b89 Show Top Progress Bar on navigation initiated using useRouter hook (#4097)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates Next.js navigation usage in the dashboard app to `useDashboardRouter` from `@/lib/DashboardRouter`.

### Detailed summary
- Replaces `useRouter` with `useDashboardRouter` in multiple files
- Adds `DashboardRouterTopProgressBar` component import in layout files
- Introduces `useDashboardRouter` import in various components
- Defines `createStore` function in `@/lib/reactive.ts`

> The following files were skipped due to too many changes: `apps/dashboard/src/@/lib/DashboardRouter.tsx`

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

<!-- end pr-codex -->
2024-08-15 05:48:22 +00:00
kien-ngo 1edd6c7e4c embed-setup.tsx to v5 (#4108)
## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
The focus of this PR is to enhance the Contract Embed feature by integrating with `thirdwebClient` and `useV5DashboardChain`.

### Detailed summary
- Added `thirdwebClient` and `useV5DashboardChain` for contract setup
- Updated `contract` type to `ThirdwebContract`
- Refactored `buildIframeSrc` function to use `ThirdwebContract` properties

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

<!-- end pr-codex -->
2024-08-15 05:37:15 +00:00
kien-ngo 588f19639f [Dashboard] ContractMetadata settings to v5 (#4100)
<!-- start pr-codex -->

## PR-Codex overview
This PR updates the SettingsMetadata component in the contract UI to handle contract data more efficiently.

### Detailed summary
- Refactored the SettingsMetadata component to use ThirdwebContract type
- Updated schema imports and data handling
- Removed unnecessary dependencies and functions

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

<!-- end pr-codex -->
2024-08-15 05:26:34 +00:00
178 changed files with 6457 additions and 2545 deletions
+22 -1
View File
@@ -19,7 +19,21 @@ env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
jobs:
optimize_ci:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check_skip.outputs.skip }}
steps:
- name: Optimize CI
id: check_skip
uses: withgraphite/graphite-ci-action@main
with:
graphite_token: ${{ secrets.GRAPHITE_OMTIMIZE_TOKEN }}
build:
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
runs-on: ubuntu-latest
name: Build Packages
steps:
@@ -33,6 +47,8 @@ jobs:
run: pnpm build:packages
lint:
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
timeout-minutes: 15
name: Lint Packages
runs-on: ubuntu-latest
@@ -51,6 +67,8 @@ jobs:
- run: pnpm lint
test:
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
timeout-minutes: 15
name: Unit Tests
runs-on: ubuntu-latest
@@ -77,6 +95,8 @@ jobs:
verbose: true
e2e:
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
timeout-minutes: 15
name: E2E Tests
runs-on: ubuntu-latest
@@ -144,8 +164,9 @@ jobs:
fi
size:
needs: optimize_ci
# only run on pull requests
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && needs.optimize_ci.outputs.skip == 'false'
timeout-minutes: 15
name: "Size"
runs-on: ubuntu-latest
+19
View File
@@ -27,7 +27,20 @@ env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
jobs:
optimize_ci:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check_skip.outputs.skip }}
steps:
- name: Optimize CI
id: check_skip
uses: withgraphite/graphite-ci-action@main
with:
graphite_token: ${{ secrets.GRAPHITE_OMTIMIZE_TOKEN }}
build:
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
runs-on: ubuntu-latest
name: Build Packages
steps:
@@ -41,6 +54,8 @@ jobs:
run: pnpm build:legacy_packages
lint:
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
timeout-minutes: 15
name: Lint Packages
runs-on: ubuntu-latest
@@ -54,6 +69,8 @@ jobs:
- run: pnpm lint:legacy
test:
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
timeout-minutes: 15
name: Unit Tests
runs-on: ubuntu-latest
@@ -74,6 +91,8 @@ jobs:
verbose: true
e2e:
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
timeout-minutes: 15
name: E2E Tests
runs-on: ubuntu-latest
+3
View File
@@ -40,6 +40,9 @@ NEXT_PUBLIC_STRIPE_KEY=
NEXT_PUBLIC_STRIPE_PAYMENT_METHOD_CFG_ID=
# Needed for contract analytics / blockchain data information
CHAINSAW_API_KEY=
#
# Private (server)
#
+6
View File
@@ -80,6 +80,12 @@ module.exports = {
message:
"Import from `@chakra-ui/react` instead of `@chakra-ui/menu`.",
},
{
name: "next/navigation",
importNames: ["useRouter"],
message:
'Use `import { useDashboardRouter } from "@/lib/DashboardRouter";` instead',
},
],
},
],
@@ -0,0 +1,109 @@
"use client";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { useMemo } from "react";
export type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
// metadata
title: string;
description?: string;
// chart config
config: TConfig;
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
showLegend?: boolean;
// chart className
chartClassName?: string;
};
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
props: ThirdwebAreaChartProps<TConfig>,
) {
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
return (
<Card>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
{props.description && (
<CardDescription>{props.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<ChartContainer config={props.config} className={props.chartClassName}>
<AreaChart
accessibilityLayer
data={props.data}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<defs>
{configKeys.map((key) => (
<linearGradient
key={key}
id={`fill_${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={`var(--color-${key})`}
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor={`var(--color-${key})`}
stopOpacity={0.1}
/>
</linearGradient>
))}
</defs>
{configKeys.map((key) => (
<Area
key={key}
dataKey={key}
type="natural"
fill={`url(#fill_${key})`}
fillOpacity={0.4}
stroke={`var(--color-${key})`}
stackId="a"
/>
))}
{props.showLegend && (
<ChartLegend content={<ChartLegendContent />} />
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,91 @@
"use client";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { useMemo } from "react";
export type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
// metadata
title: string;
description?: string;
// chart config
config: TConfig;
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
showLegend?: boolean;
variant?: "stacked" | "grouped";
// chart className
chartClassName?: string;
};
export function ThirdwebBarChart<TConfig extends ChartConfig>(
props: ThirdwebBarChartProps<TConfig>,
) {
const configKeys = useMemo(() => Object.keys(props.config), [props.config]);
// if there are more than 4 keys then we should stack them by default
const variant =
props.variant || configKeys.length > 4 ? "stacked" : "grouped";
return (
<Card>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
{props.description && (
<CardDescription>{props.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<ChartContainer config={props.config} className={props.chartClassName}>
<BarChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
{props.showLegend && (
<ChartLegend content={<ChartLegendContent />} />
)}
{configKeys.map((key, idx) => (
<Bar
key={key}
dataKey={key}
// if stacked then they should all be the same stackId
// if grouped then they should all be unique stackId (so the key works great)
stackId={variant === "stacked" ? "a" : key}
fill={`var(--color-${key})`}
// if stacked then we need to figure out the radius based on the index in the array
// if grouped then we can just use the same radius for all
radius={
variant === "stacked"
? idx === 0
? [0, 0, 4, 4]
: idx === configKeys.length - 1
? [4, 4, 0, 0]
: [0, 0, 0, 0]
: [4, 4, 4, 4]
}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,25 @@
.unorderedList,
.orderedList {
@apply pl-5 leading-relaxed list-none text-secondary-foreground;
}
.unorderedList > li {
@apply pb-1 pl-2 list-disc;
}
.orderedList > li {
@apply pb-1 pl-2 list-decimal;
}
.unorderedList > li::marker {
@apply text-muted-foreground !m-0 text-left;
}
.orderedList > li::marker {
@apply text-muted-foreground text-left;
}
.unorderedList > li:hover::marker,
.orderedList > li:hover::marker {
@apply text-foreground;
}
@@ -0,0 +1,9 @@
import styles from "./List.module.css";
export function UnorderedList(props: { children?: React.ReactNode }) {
return <ul className={styles.unorderedList}>{props.children}</ul>;
}
export function OrderedList(props: { children?: React.ReactNode }) {
return <ul className={styles.orderedList}>{props.children}</ul>;
}
+1 -1
View File
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
+1 -1
View File
@@ -283,7 +283,7 @@ const ChartLegendContent = React.forwardRef<
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
"flex items-center justify-center gap-4 overflow-x-auto",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
@@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
@@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
@@ -31,7 +31,7 @@ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export function ToolTipLabel(props: {
children: React.ReactNode;
label: string | undefined;
label: React.ReactNode;
rightIcon?: React.ReactNode;
leftIcon?: React.ReactNode;
}) {
@@ -47,7 +47,7 @@ export function ToolTipLabel(props: {
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="max-w-[300px] leading-relaxed"
className="max-w-[400px] leading-relaxed whitespace-normal"
>
<div className="p-2 text-sm flex items-center gap-1.5">
{props.leftIcon}
@@ -0,0 +1,139 @@
"use client";
// eslint-disable-next-line no-restricted-imports
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
Suspense,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { createStore } from "./reactive";
// Using useDashboardRouter instead of useRouter gives us a nice progress bar on top of the page when navigating using router.push or router.replace
// using a store instead of context to avoid triggering re-renders on root component
export const LoadingRouteHref = createStore<string | undefined>(undefined);
export function useDashboardRouter() {
const router = useRouter();
return useMemo(() => {
return {
...router,
replace(href: string, options?: { scroll?: boolean }) {
LoadingRouteHref.setValue(href);
router.replace(href, options);
},
push(href: string, options?: { scroll?: boolean }) {
LoadingRouteHref.setValue(href);
router.push(href, options);
},
};
}, [router]);
}
function useRouterLoadingStatus() {
const loadingHref = useSyncExternalStore(
LoadingRouteHref.subscribe,
LoadingRouteHref.getValue,
LoadingRouteHref.getValue,
);
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParamsStr = searchParams?.toString();
const routerHref = pathname + (searchParamsStr ? `?${searchParamsStr}` : "");
const isLoading = loadingHref && loadingHref !== routerHref;
// reset loading on route load
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!isLoading) {
LoadingRouteHref.setValue(undefined);
}
}, [isLoading]);
return isLoading;
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Render this on root
function DashboardRouterTopProgressBarInner() {
const isLoading = useRouterLoadingStatus();
const [progress, setProgress] = useState(0);
const progressStartedRef = useRef(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!isLoading) {
setProgress(0);
progressStartedRef.current = false;
return;
}
if (progressStartedRef.current) {
return;
}
let isMounted = true;
progressStartedRef.current = true;
async function updateProgressBar(progress: number, delay: number) {
if (!isMounted) {
if (!isLoading) {
setProgress(100);
}
return;
}
setProgress(progress);
await wait(delay);
// increase progress by 10% of the remaining progress
// increase the delay by 5% to slow down number of updates for slower progress
updateProgressBar(progress + (100 - progress) / 10, delay * 1.05);
}
async function startEffect() {
// if the loading state remains for atleast 500ms start the progress bar
await wait(500);
if (isMounted) {
updateProgressBar(0, 100);
}
}
startEffect();
return () => {
isMounted = false;
};
}, [isLoading]);
const width = isLoading ? progress : 100;
return (
<span
className="block fixed top-0 h-[2px] bg-blue-500"
style={{
opacity: isLoading ? "100" : "0",
width: `${width}%`,
transition: width === 0 ? "none" : "width 0.2s ease, opacity 0.3s ease",
zIndex: "100000000",
pointerEvents: "none",
}}
/>
);
}
// need to wrap with suspense because of useSearchParams usage
export function DashboardRouterTopProgressBar() {
return (
<Suspense fallback={null}>
<DashboardRouterTopProgressBarInner />
</Suspense>
);
}
+47
View File
@@ -0,0 +1,47 @@
export type Store<T> = {
getValue(): T;
setValue(newValue: T): void;
subscribe(listener: () => void): () => void;
};
/**
* Create a reactive value store
* @param initialValue - The initial value to store
* @example
* ```ts
* const store = createStore(0);
* ```
* @returns A store object
* @internal
*/
export function createStore<T>(initialValue: T): Store<T> {
type Listener = () => void;
const listeners = new Set<Listener>();
let value = initialValue;
const notify = () => {
for (const listener of listeners) {
listener();
}
};
return {
getValue() {
return value;
},
setValue(newValue: T) {
if (newValue === value) {
return;
}
value = newValue;
notify();
},
subscribe(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
+11 -8
View File
@@ -94,19 +94,22 @@
/* shadcn charts */
@layer base {
:root {
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
/* below is ai generated based on the initial */
--chart-6: 200 65% 50%;
--chart-7: 120 70% 45%;
--chart-8: 45 75% 55%;
--chart-9: 260 60% 60%;
--chart-10: 0 70% 50%;
--chart-11: 180 60% 50%;
--chart-12: 100 65% 55%;
--chart-13: 360 70% 55%;
--chart-14: 240 65% 50%;
--chart-15: 80 60% 50%;
}
}
@@ -333,6 +333,7 @@ export type Transaction = {
gasPrice?: string | null;
maxFeePerGas?: string | null;
maxPriorityFeePerGas?: string | null;
effectiveGasPrice?: string | null;
transactionType?: number | null;
transactionHash?: string | null;
queuedAt?: string | null;
@@ -1,7 +1,8 @@
import { isLoginRequired } from "@/constants/auth";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useQuery } from "@tanstack/react-query";
import type { EnsureLoginResponse } from "app/api/auth/ensure-login/route";
import { usePathname, useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useRef } from "react";
import {
useActiveAccount,
@@ -20,7 +21,7 @@ export function useLoggedInUser(): {
isLoggedIn: boolean;
user: { address: string; jwt?: string } | null;
} {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const connectedAddress = useActiveAccount()?.address;
const connectionStatus = useActiveWalletConnectionStatus();
@@ -10,8 +10,9 @@ import {
} from "@/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { ChevronDownIcon, Filter, XIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react";
import { products } from "../../../components/server/products";
@@ -73,7 +74,7 @@ type FilterResetButtonProps = {
const FilterResetButton: React.FC<
PropsWithChildren<FilterResetButtonProps>
> = ({ filters, children }) => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const mutableSearchParams = useMemo(() => {
@@ -124,7 +125,7 @@ type ChainTypeFilterProps = { sectionOnly?: boolean };
export const ChainTypeFilter: React.FC<ChainTypeFilterProps> = ({
sectionOnly,
}) => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -228,7 +229,7 @@ type ChainOptionsFilterProps = { sectionOnly?: boolean };
export const ChainOptionsFilter: React.FC<ChainOptionsFilterProps> = ({
sectionOnly,
}) => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const mutableSearchParams = useMemo(() => {
@@ -332,7 +333,7 @@ type ChainServiceFilterProps = { sectionOnly?: boolean };
export const ChainServiceFilter: React.FC<ChainServiceFilterProps> = ({
sectionOnly,
}) => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const mutableSearchParams = useMemo(() => {
@@ -1,7 +1,8 @@
"use client";
import { PaginationButtons } from "@/components/pagination-buttons";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
type ChainlistPaginationProps = {
@@ -15,7 +16,7 @@ export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const router = useDashboardRouter();
const createPageURL = useCallback(
(pageNumber: number) => {
@@ -2,8 +2,9 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { SearchIcon, XCircleIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { useRef } from "react";
import { useDebouncedCallback } from "use-debounce";
@@ -15,7 +16,7 @@ function cleanUrl(url: string) {
}
export const SearchInput: React.FC = () => {
const router = useRouter();
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -1,8 +1,9 @@
"use client";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { Grid2X2Icon, ListIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
type hainListViewProps = {
@@ -12,7 +13,7 @@ type hainListViewProps = {
export const ChainListView: React.FC<hainListViewProps> = ({ activeView }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const router = useDashboardRouter();
const createPageURL = useCallback(
(view: "grid" | "table") => {
@@ -51,3 +51,6 @@ export default async function Layout({
</div>
);
}
// because cookies() is used
export const dynamic = "force-dynamic";
@@ -15,11 +15,11 @@ import { ImageUpload } from "@/components/ui/image-upload";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -49,7 +49,7 @@ export function CreateEcosystemForm() {
>();
const { data: billingAccountInfo } = useAccount();
const router = useRouter();
const router = useDashboardRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -113,3 +113,6 @@ export default async function Page() {
</main>
);
}
// because cookies() is used
export const dynamic = "force-dynamic";
@@ -79,3 +79,6 @@ export default async function DashboardConnectPayPage() {
</div>
);
}
// because cookies() is used
export const dynamic = "force-dynamic";
@@ -6,10 +6,11 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { usePathname, useSearchParams } from "next/navigation";
export function TablePagination() {
const router = useRouter();
const router = useDashboardRouter();
const path = usePathname();
const searchParams = useSearchParams();
const page = Number(searchParams?.get("page") || 1);
@@ -1,7 +1,8 @@
"use client";
import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { ChevronDown } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import type { SortBy, TimeRange } from "../../../../lib/search";
export type SortingHeaderProps = {
@@ -13,7 +14,7 @@ export type SortingHeaderProps = {
};
export function SortingHeader(props: SortingHeaderProps) {
const router = useRouter();
const router = useDashboardRouter();
const path = usePathname();
const enableSorting = !!props.searchParams;
const { timeRange, page, sortBy } = props.searchParams || {};
@@ -1,10 +1,11 @@
"use client";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { TimeRange } from "lib/search";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
export function TimeRangeSwitcher() {
const router = useRouter();
const router = useDashboardRouter();
const path = usePathname();
const searchParams = useSearchParams();
const currentSort = searchParams?.get("sortBy") || "transactionCount";
+2
View File
@@ -1,5 +1,6 @@
import "@/styles/globals.css";
import { Toaster } from "@/components/ui/sonner";
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import type { Metadata } from "next";
import PlausibleProvider from "next-plausible";
@@ -70,6 +71,7 @@ export default function RootLayout({
<PostHogPageView />
<AppRouterProviders>{children}</AppRouterProviders>
<Toaster richColors />
<DashboardRouterTopProgressBar />
<NextTopLoader
color="hsl(var(--primary))"
height={2}
@@ -129,13 +129,11 @@ export const BarChart = <
tickFormatter={(payload) =>
index.format
? index.format(payload)
: index.type === "date"
? new Date(payload).toLocaleDateString(undefined, {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})
: payload
: new Date(payload).toLocaleDateString(undefined, {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})
}
style={{
fontSize: "12px",
@@ -25,7 +25,7 @@ import {
} from "@chakra-ui/react";
import { AiOutlineWarning } from "@react-icons/all-files/ai/AiOutlineWarning";
import { useQuery } from "@tanstack/react-query";
import { useSDK, useSDKChainId } from "@thirdweb-dev/react";
import { useSDKChainId } from "@thirdweb-dev/react";
import { FaucetButton } from "app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton";
import { GiftIcon } from "app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/icons/GiftIcon";
import type {
@@ -33,6 +33,7 @@ import type {
ChainServices,
} from "app/(dashboard)/(chain)/types/chain";
import { getSDKTheme } from "app/components/sdk-component-theme";
import { LOCAL_NODE_PKEY } from "constants/misc";
import { useTrack } from "hooks/analytics/useTrack";
import { useSupportedChain } from "hooks/chains/configureChains";
import { ExternalLinkIcon } from "lucide-react";
@@ -41,6 +42,7 @@ import Link from "next/link";
import { forwardRef, useCallback, useMemo, useRef, useState } from "react";
import { VscDebugDisconnect } from "react-icons/vsc";
import { toast } from "sonner";
import { prepareTransaction, sendTransaction, toWei } from "thirdweb";
import { type Chain, type ChainMetadata, localhost } from "thirdweb/chains";
import {
PayEmbed,
@@ -51,6 +53,7 @@ import {
useSwitchActiveWalletChain,
useWalletBalance,
} from "thirdweb/react";
import { privateKeyToAccount } from "thirdweb/wallets";
import { Button, type ButtonProps, Card, Heading, Text } from "tw-components";
import { THIRDWEB_API_HOST } from "../../constants/urls";
import { useV5DashboardChain } from "../../lib/v5-adapter";
@@ -485,16 +488,30 @@ const MismatchNotice: React.FC<{
};
const GetLocalHostTestnetFunds: React.FC = () => {
const sdk = useSDK();
const address = useActiveAccount()?.address;
const requestFunds = async () => {
if (sdk) {
try {
await sdk.wallet.requestFunds(10);
toast.success("Successfully got funds from localhost");
} catch (e) {
toast.error("Failed to get funds from localhost");
}
if (!address) {
return toast.error("No active account detected");
}
const faucet = privateKeyToAccount({
privateKey: LOCAL_NODE_PKEY,
client: thirdwebClient,
});
const transaction = prepareTransaction({
to: address,
chain: localhost,
client: thirdwebClient,
value: toWei("10"),
});
const promise = sendTransaction({
account: faucet,
transaction,
});
toast.promise(promise, {
error: "Failed to get funds from localhost",
success: "Successfully got funds from localhost",
loading: "Requesting funds",
});
};
return (
@@ -1,13 +1,11 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Alert,
AlertIcon,
Flex,
FormControl,
Input,
Radio,
RadioGroup,
SimpleGrid,
Stack,
} from "@chakra-ui/react";
import { ChainIcon } from "components/icons/ChainIcon";
import type { StoredChain } from "contexts/configured-chains";
@@ -16,7 +14,7 @@ import { useSupportedChainsNameRecord } from "hooks/chains/configureChains";
import { useRemoveChainModification } from "hooks/chains/useModifyChain";
import { getDashboardChainRpc } from "lib/rpc";
import { useForm } from "react-hook-form";
import { Button, FormErrorMessage, FormLabel, Text } from "tw-components";
import { FormErrorMessage, FormLabel } from "tw-components";
import { ChainIdInput } from "./Form/ChainIdInput";
import { ConfirmationPopover } from "./Form/ConfirmationPopover";
import { IconUpload } from "./Form/IconUpload";
@@ -217,11 +215,7 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
const submitBtn = (
<Button
background="bgBlack"
color="bgWhite"
_hover={{
background: "bgBlack",
}}
variant="primary"
type={disableSubmit ? "submit" : overwritingChain ? "button" : "submit"}
disabled={disableSubmit}
>
@@ -243,9 +237,6 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
<Input
autoComplete="off"
placeholder="e.g. My Network"
_placeholder={{
fontWeight: 500,
}}
type="text"
onChange={(e) => {
const value = e.target.value;
@@ -272,9 +263,9 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
{/* Slug URL */}
<NetworkIDInput form={form} disabled={!isFullyEditable} />
<Flex direction="column" gap={6} mt={6}>
<div className="flex flex-col gap-6 mt-6">
{/* Chain ID + Currency Symbol */}
<SimpleGrid columns={{ md: 2, base: 1 }} gap={4}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ChainIdInput form={form} disabled={!isFullyEditable} />
{/* Currency Symbol */}
@@ -284,33 +275,29 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
disabled={!isFullyEditable}
placeholder="e.g. ETH"
autoComplete="off"
_placeholder={{
fontWeight: 500,
}}
type="text"
{...form.register("currencySymbol", { required: true })}
/>
</FormControl>
</SimpleGrid>
</div>
<SimpleGrid columns={{ md: 2, base: 1 }} gap={4}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Testnet / Mainnet */}
<FormControl>
<FormLabel display="flex">
<FormLabel className="!flex gap-1 items-center">
Network type
<TooltipBox
content={
<>
<Text color="heading" display="inline-block" mb={2}>
{" "}
<span className="mb-2 block">
The network type indicates whether it is intended for
production or testing.
</Text>
</span>
<Text color="heading">
<span className="block">
It{`'`}s only used for displaying network type on the
dashboard and does not affect functionality.
</Text>
</span>
</>
}
/>
@@ -324,10 +311,10 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
}}
value={form.watch("type")}
>
<Stack direction="row" gap={4} mt={3}>
<div className="flex gap-4 mt-3">
<Radio value="mainnet">Mainnet</Radio>
<Radio value="testnet">Testnet</Radio>
</Stack>
</div>
</RadioGroup>
</FormControl>
@@ -335,19 +322,19 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
<FormControl isInvalid={!!form.formState.errors.icon}>
<FormLabel>Icon</FormLabel>
<Flex gap={3} alignItems="center">
<div className="flex gap-1 items-center">
<ChainIcon size={24} ipfsSrc={form.watch("icon")} />
<IconUpload
onUpload={(uri) => {
form.setValue("icon", uri, { shouldDirty: true });
}}
/>
</Flex>
</div>
</FormControl>
</SimpleGrid>
</div>
{editingChain?.status === "deprecated" && (
<SimpleGrid columns={{ md: 2, base: 1 }} gap={4}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Active / Deprecated */}
<FormControl>
<FormLabel display="flex">Network status</FormLabel>
@@ -360,13 +347,13 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
}}
value={form.watch("status")}
>
<Stack direction="row" gap={4} mt={3}>
<div className="flex mt-3 gap-4">
<Radio value="active">Live</Radio>
<Radio value="deprecated">Deprecated</Radio>
</Stack>
</div>
</RadioGroup>
</FormControl>
</SimpleGrid>
</div>
)}
{/* RPC URL */}
@@ -382,11 +369,7 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
)}
{/* Buttons */}
<Flex
gap={4}
direction={{ base: "column", md: "row" }}
justifyContent={{ base: "center", md: "flex-end" }}
>
<div className="flex gap-4 flex-col md:flex-row justify-end">
{/* Add / Update Button */}
{overwritingChain && !disableSubmit ? (
<ConfirmationPopover
@@ -417,8 +400,8 @@ export const ConfigureNetworkForm: React.FC<NetworkConfigFormProps> = ({
Reset
</Button>
)}
</Flex>
</Flex>
</div>
</div>
</form>
);
};
@@ -1,10 +1,4 @@
import {
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalOverlay,
} from "@chakra-ui/react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { StoredChain } from "contexts/configured-chains";
import { useSetEditChain } from "hooks/networkConfigModal";
import { ConfigureNetworks } from "./ConfigureNetworks";
@@ -24,31 +18,26 @@ export const ConfigureNetworkModal: React.FC<AddNetworkModalProps> = (
};
return (
<Modal isOpen={true} onClose={onModalClose} isCentered>
<ModalOverlay />
<ModalContent
borderRadius="xl"
overflow="hidden"
w="500px"
mt={{ base: 8, md: 16 }}
mb={{ base: 0, md: 16 }}
maxW={{ base: "100vw", md: "calc(100vw - 40px)" }}
<Dialog
open={true}
onOpenChange={(v) => {
if (!v) {
onModalClose();
}
}}
>
<DialogContent
className="p-0 z-[10001] max-w-[480px]"
dialogOverlayClassName="z-[10000]"
>
<ModalCloseButton />
<ModalBody
p={0}
_dark={{ background: "backgroundBody" }}
_light={{ background: "white" }}
>
<ConfigureNetworks
onNetworkAdded={(chain) => {
props.onNetworkAdded?.(chain);
onModalClose();
}}
onNetworkConfigured={onModalClose}
/>
</ModalBody>
</ModalContent>
</Modal>
<ConfigureNetworks
onNetworkAdded={(chain) => {
props.onNetworkAdded?.(chain);
onModalClose();
}}
onNetworkConfigured={onModalClose}
/>
</DialogContent>
</Dialog>
);
};
@@ -1,10 +1,9 @@
import { Box, useToast } from "@chakra-ui/react";
import type { StoredChain } from "contexts/configured-chains";
import { useTrack } from "hooks/analytics/useTrack";
import { useAddRecentlyUsedChainId } from "hooks/chains/recentlyUsedChains";
import { useModifyChain } from "hooks/chains/useModifyChain";
import { useEditChain } from "hooks/networkConfigModal";
import { Heading } from "tw-components";
import { toast } from "sonner";
import { ConfigureNetworkForm } from "./ConfigureNetworkForm";
function useChainConfigTrack() {
@@ -31,17 +30,6 @@ export const ConfigureNetworks: React.FC<ConfigureNetworksProps> = (props) => {
const modifyChain = useModifyChain();
const editChain = useEditChain();
const toast = useToast();
const successToast = (message: string) => {
toast({
title: message,
status: "success",
duration: 3000,
isClosable: true,
});
};
const handleSubmit = (chain: StoredChain) => {
modifyChain(chain);
addRecentlyUsedChainId(chain.chainId);
@@ -52,44 +40,29 @@ export const ConfigureNetworks: React.FC<ConfigureNetworksProps> = (props) => {
}
trackChainConfig("add", chain);
successToast("Network Added Successfully");
toast.success("Network Added Successfully");
} else {
if (props.onNetworkConfigured) {
props.onNetworkConfigured(chain);
}
trackChainConfig("update", chain);
successToast("Network Updated Successfully");
toast.success("Network Updated Successfully");
}
};
return (
<Box
p={{ base: 4, md: 8 }}
minH="600px"
borderTop={{ md: "none", base: "1px solid" }}
marginTop={{ base: 6, md: 0 }}
borderColor="inputBg"
>
<Heading
as={"h3"}
size="label.xl"
mb={8}
display="flex"
gap={2}
alignItems="center"
>
<div className="p-6">
<h3 className="text-2xl font-semibold tracking-tight mb-5">
{editChain ? "Edit Network" : "Add Custom Network"}
</Heading>
</h3>
{/* Modify the given chain */}
{editChain && (
<Box mt={9}>
<ConfigureNetworkForm
editingChain={editChain}
onSubmit={handleSubmit}
/>
</Box>
<ConfigureNetworkForm
editingChain={editChain}
onSubmit={handleSubmit}
/>
)}
{/* Custom chain */}
@@ -100,6 +73,6 @@ export const ConfigureNetworks: React.FC<ConfigureNetworksProps> = (props) => {
onSubmit={handleSubmit}
/>
)}
</Box>
</div>
);
};
@@ -1,11 +1,11 @@
import { FormControl, Input } from "@chakra-ui/react";
import { Input } from "@/components/ui/input";
import { FormControl } from "@chakra-ui/react";
import type { UseFormReturn } from "react-hook-form";
import { FormLabel } from "tw-components";
import type { NetworkConfigFormData } from "../ConfigureNetworkForm";
export const ChainIdInput: React.FC<{
// biome-ignore lint/suspicious/noExplicitAny: FIXME
form: UseFormReturn<NetworkConfigFormData, any>;
form: UseFormReturn<NetworkConfigFormData>;
disabled: boolean;
}> = ({ form, disabled }) => {
return (
@@ -18,9 +18,6 @@ export const ChainIdInput: React.FC<{
disabled={disabled}
placeholder="e.g. 152"
autoComplete="off"
_placeholder={{
fontWeight: 500,
}}
onKeyDown={(e) => {
// prevent typing e, +, -
if (e.key === "e" || e.key === "+" || e.key === "-") {
@@ -1,23 +1,22 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useDashboardStorageUpload } from "@3rdweb-sdk/react/hooks/useDashboardStorageUpload";
import { Icon } from "@chakra-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { FileInput } from "components/shared/FileInput";
import { PINNED_FILES_QUERY_KEY_ROOT } from "components/storage/your-files";
import { useErrorHandler } from "contexts/error-handler";
import { FiUpload } from "react-icons/fi";
import { Button } from "tw-components";
import { toast } from "sonner";
export const IconUpload: React.FC<{ onUpload: (url: string) => void }> = ({
onUpload,
}) => {
const errorHandler = useErrorHandler();
const storageUpload = useDashboardStorageUpload();
const queryClient = useQueryClient();
const handleIconUpload = (file: File) => {
// if file size is larger than 5000kB, show error
if (file.size > 5000 * 1024) {
errorHandler.onError("Icon image can not be larger than 5MB");
toast.error("Icon image can not be larger than 5MB");
return;
}
@@ -28,21 +27,20 @@ export const IconUpload: React.FC<{ onUpload: (url: string) => void }> = ({
queryClient.invalidateQueries([PINNED_FILES_QUERY_KEY_ROOT]);
},
onError(error) {
errorHandler.onError(error, "Failed to upload file");
console.error(error);
toast.error("Failed to upload file");
},
});
};
return (
<FileInput setValue={handleIconUpload} accept={{ "image/*": [] }}>
<Button
bg="inputBg"
size="sm"
variant="solid"
aria-label="Upload to IPFS"
rightIcon={<Icon as={FiUpload} />}
isLoading={storageUpload.isLoading}
>
<Button variant="ghost" className="gap-2 px-2" size="sm">
{storageUpload.isLoading ? (
<Spinner className="size-4" />
) : (
<FiUpload className="size-4" />
)}
Upload Icon
</Button>
</FileInput>
@@ -1,19 +1,13 @@
import { FormControl, Input } from "@chakra-ui/react";
import { Input } from "@/components/ui/input";
import { FormControl } from "@chakra-ui/react";
import { useAllChainsData } from "hooks/chains/allChains";
import type { UseFormReturn } from "react-hook-form";
import {
CodeBlock,
FormErrorMessage,
FormLabel,
Heading,
Text,
} from "tw-components";
import { FormErrorMessage, FormLabel } from "tw-components";
import type { NetworkConfigFormData } from "../ConfigureNetworkForm";
import { TooltipBox } from "./TooltipBox";
export const NetworkIDInput: React.FC<{
// biome-ignore lint/suspicious/noExplicitAny: FIXME
form: UseFormReturn<NetworkConfigFormData, any>;
form: UseFormReturn<NetworkConfigFormData>;
disabled?: boolean;
}> = ({ form, disabled }) => {
const slug = form.watch("slug");
@@ -25,23 +19,16 @@ export const NetworkIDInput: React.FC<{
mt={6}
isInvalid={form.formState.errors.slug?.type === "taken"}
>
<FormLabel display="flex">
<FormLabel className="!flex items-center gap-1">
Network ID
<TooltipBox
content={
<>
<Text color="heading" mb={4}>
Network ID is used to identify the network in the URL{" "}
</Text>
<Heading fontSize="14px" mb={3}>
Example
</Heading>
<CodeBlock
fontSize="14px"
code="thirdweb.com/<network-id>/<contract-address>"
language="bash"
canCopy={false}
/>
Network ID is used to identify the network in the URL
<p className="font-semibold mt-2 mb-1">Example</p>
<p className="text-link-foreground text-xs">
{"thirdweb.com/<network-id>/<contract-address>"}
</p>
</>
}
/>
@@ -50,9 +37,6 @@ export const NetworkIDInput: React.FC<{
disabled={disabled}
autoComplete="off"
placeholder="e.g. ethereum"
_placeholder={{
fontWeight: 500,
}}
onKeyDown={(e) => {
// only allow alphanumeric characters and dashes
if (!/^[a-z0-9-]*$/i.test(e.key)) {
@@ -1,4 +1,5 @@
import { Alert, AlertIcon, FormControl, Input } from "@chakra-ui/react";
import { Input } from "@/components/ui/input";
import { Alert, AlertIcon, FormControl } from "@chakra-ui/react";
import type { UseFormReturn } from "react-hook-form";
import { IoWarning } from "react-icons/io5";
import { FormErrorMessage, FormLabel } from "tw-components";
@@ -28,15 +29,7 @@ export const RpcInput: React.FC<{
isDisabled={form.watch("status") !== "active"}
>
<FormLabel>RPC URL</FormLabel>
<Input
autoComplete="off"
placeholder="https://"
type="url"
_placeholder={{
fontWeight: 500,
}}
{...reg}
/>
<Input autoComplete="off" placeholder="https://" type="url" {...reg} />
<FormErrorMessage fontSize="12px">Invalid RPC URL</FormErrorMessage>
@@ -1,34 +1,12 @@
import { Flex, Icon, Tooltip } from "@chakra-ui/react";
import { BsQuestionCircle } from "react-icons/bs";
import { Card } from "tw-components";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { CircleHelpIcon } from "lucide-react";
export const TooltipBox: React.FC<{
content: React.ReactNode;
iconColor?: string;
}> = ({ content, iconColor = "accent.600" }) => {
}> = ({ content }) => {
return (
<Tooltip
placement="top-start"
borderRadius="md"
bg="transparent"
boxShadow="none"
p={4}
minW={{ md: "450px" }}
label={
<Card py={2} px={4} bgColor="backgroundCardHighlight">
{content}
</Card>
}
>
<Flex alignItems="center" justifyContent="center">
<Icon
ml={2}
mr={1}
as={BsQuestionCircle}
color={iconColor}
boxSize={4}
/>
</Flex>
</Tooltip>
<ToolTipLabel label={<div>{content}</div>}>
<CircleHelpIcon className="size-4 text-muted-foreground" />
</ToolTipLabel>
);
};
@@ -750,6 +750,20 @@ export function useCustomContractDeployMutation(options: {
message,
);
}
case "eth_sendTransaction": {
const tx = params?.[0];
if (!tx) {
throw new Error("No transaction provided");
}
return signer.sendTransaction(tx);
}
case "personal_sign": {
const data = params?.[0];
if (!data) {
throw new Error("Nothing to sign");
}
return signer.signMessage(data);
}
default: {
return (signer as providers.JsonRpcSigner)?.provider?.send(
method,
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
Box,
type BoxProps,
@@ -35,8 +34,7 @@ export const MarkdownRenderer: React.FC<
{...restProps}
remarkPlugins={[remarkGfm]}
components={{
// biome-ignore lint/suspicious/noExplicitAny: FIXME
h1: (props: any) => (
h1: (props) => (
<Heading
as="h2"
size="title.lg"
@@ -44,11 +42,11 @@ export const MarkdownRenderer: React.FC<
borderBottomColor="borderColor"
{...commonHeadingProps}
mb={4}
{...props}
{...cleanedProps(props)}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
h2: (props: any) => (
h2: (props) => (
<Heading
as="h3"
size="title.md"
@@ -57,51 +55,51 @@ export const MarkdownRenderer: React.FC<
{...commonHeadingProps}
mt={8}
mb={4}
{...props}
{...cleanedProps(props)}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
h3: (props: any) => (
h3: (props) => (
<Heading
as="h4"
size="title.sm"
{...commonHeadingProps}
{...props}
{...cleanedProps(props)}
mt={4}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
h4: (props: any) => (
h4: (props) => (
<Heading
as="h5"
size="subtitle.md"
{...commonHeadingProps}
{...props}
{...cleanedProps(props)}
mt={4}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
h5: (props: any) => (
h5: (props) => (
<Heading
as="h6"
size="subtitle.sm"
{...commonHeadingProps}
{...props}
{...cleanedProps(props)}
mt={4}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
h6: (props: any) => (
h6: (props) => (
<Heading
as="p"
size="label.md"
{...commonHeadingProps}
{...props}
{...cleanedProps(props)}
mt={4}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
a: (props: any) => (
a: (props) => (
<Link
_dark={{
color: "blue.400",
@@ -116,13 +114,12 @@ export const MarkdownRenderer: React.FC<
},
}}
isExternal
{...props}
{...cleanedProps(props)}
mt={4}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
code: ({ inline, ...props }: any) => {
code: ({ ...props }) => {
if (props?.className) {
const language = props.className.replace("language-", "");
return (
@@ -130,7 +127,7 @@ export const MarkdownRenderer: React.FC<
code={onlyText(props.children).trim()}
language={language}
mb={4}
{...props}
{...cleanedProps(props)}
/>
);
}
@@ -145,16 +142,21 @@ export const MarkdownRenderer: React.FC<
_dark={{ bg: "whiteAlpha.100" }}
borderRadius="md"
fontFamily="mono"
{...props}
{...cleanedProps(props)}
/>
);
},
// biome-ignore lint/suspicious/noExplicitAny: FIXME
p: (props: any) => (
<Text size="body.md" mb={4} {...props} lineHeight={1.5} />
p: (props) => (
<Text
size="body.md"
mb={4}
{...cleanedProps(props)}
lineHeight={1.5}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
table: (props: any) => (
table: (props) => (
<Box
maxW="100%"
overflowX="auto"
@@ -164,53 +166,44 @@ export const MarkdownRenderer: React.FC<
mb={4}
borderTopRadius="lg"
>
<Table {...props} />
<Table {...cleanedProps(props)} />
</Box>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
th: ({ children: c, ...props }: any) => (
<Th
// biome-ignore lint/suspicious/noExplicitAny: FIXME
{...(props as unknown as any)}
textAlign="left!important"
border="none"
>
<Text as="label" size="label.sm" color="faded">
th: ({ children: c, ...props }) => (
<Th {...cleanedProps(props)} textAlign="left" border="none">
<Text as="span" size="label.sm" color="faded">
{c}
</Text>
</Th>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
td: (props: any) => (
td: (props) => (
<Td
// biome-ignore lint/suspicious/noExplicitAny: FIXME
{...(props as unknown as any)}
{...cleanedProps(props)}
borderColor="borderColor"
textAlign="left!important"
textAlign="left"
borderBottomWidth={"inherit"}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
thead: (props: any) => <Thead {...props} />,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
tbody: (props: any) => <Tbody {...props} />,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
tr: (props: any) => (
thead: (props) => <Thead {...cleanedProps(props)} />,
tbody: (props) => <Tbody {...cleanedProps(props)} />,
tr: (props) => (
<Tr
{...props}
{...cleanedProps(props)}
transition="all 0.1s"
borderBottomWidth={1}
_last={{ borderBottomWidth: 0 }}
/>
),
// biome-ignore lint/suspicious/noExplicitAny: FIXME
ul: ({ ordered, ...props }: any) => <UnorderedList {...props} mb={4} />,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
ol: ({ ordered, ...props }: any) => <OrderedList {...props} mb={4} />,
// biome-ignore lint/suspicious/noExplicitAny: FIXME
li: ({ children: c, ordered, ...props }: any) => (
<ListItem {...props}>
<Text>{c}</Text>
ul: (props) => {
return <UnorderedList {...cleanedProps(props)} mb={4} />;
},
ol: (props) => <OrderedList {...cleanedProps(props)} mb={4} />,
li: ({ children: c, ...props }) => (
<ListItem {...cleanedProps(props)}>
{/* hydration error - can not render p > p, so use span instead - remove extra span when we move to tailwind */}
<Text as="span">{c}</Text>
</ListItem>
),
}}
@@ -219,3 +212,11 @@ export const MarkdownRenderer: React.FC<
</ChakraReactMarkdown>
);
};
function cleanedProps<T extends object & { node?: unknown }>(
props: T,
): Omit<T, "node"> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { node, ...rest } = props;
return rest;
}
@@ -0,0 +1,42 @@
import { useRouter } from "next/router";
import type { EngineInstance } from "../../@3rdweb-sdk/react/hooks/useEngine";
import { PageId } from "../../page-id";
import type { ThirdwebNextPage } from "../../utils/types";
import { AppLayout } from "../app-layouts/app";
import {
type ActivePage,
EnginePageLayout,
PageLoading,
} from "./EnginePageLayout";
export function createEnginePage(
activePage: ActivePage,
PageContent: React.FC<{ instance: EngineInstance }>,
): ThirdwebNextPage {
const Page: ThirdwebNextPage = () => {
const router = useRouter();
const engineId = router.query.engineId;
if (typeof engineId !== "string") {
return <PageLoading />;
}
return (
<EnginePageLayout
activePage={activePage}
engineId={engineId}
content={PageContent}
/>
);
};
Page.pageId = PageId.EngineManage;
Page.getLayout = (page, props) => (
<AppLayout {...props} hasSidebar={true}>
{page}
</AppLayout>
);
return Page;
}
@@ -0,0 +1,331 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { ArrowLeftIcon, CircleAlertIcon } from "lucide-react";
import Link from "next/link";
import { useCallback, useMemo } from "react";
import invariant from "tiny-invariant";
import { CopyTextButton } from "../../@/components/ui/CopyTextButton";
import { Button } from "../../@/components/ui/button";
import { Separator } from "../../@/components/ui/separator";
import { useDashboardRouter } from "../../@/lib/DashboardRouter";
import {
type EngineInstance,
useEngineInstances,
} from "../../@3rdweb-sdk/react/hooks/useEngine";
import { SidebarNav } from "../../core-ui/sidebar/nav";
import type { Route } from "../../core-ui/sidebar/types";
import { useTrack } from "../../hooks/analytics/useTrack";
import { EngineVersionBadge } from "./badges/version";
import { useHasEnginePermission } from "./useHasEnginePermission";
export type ActivePage =
| "overview"
| "explorer"
| "relayers"
| "contract-subscriptions"
| "admins"
| "access-tokens"
| "webhooks"
| "configuration"
| "metrics";
const NEXT_PUBLIC_DEMO_ENGINE_URL = process.env.NEXT_PUBLIC_DEMO_ENGINE_URL;
export function EnginePageLayout(props: {
engineId: string;
activePage: ActivePage;
content: React.FC<{ instance: EngineInstance }>;
}) {
const { data } = useAccount();
if (props.engineId === "sandbox" && data) {
invariant(
NEXT_PUBLIC_DEMO_ENGINE_URL,
"missing NEXT_PUBLIC_DEMO_ENGINE_URL",
);
const sandboxEngine: EngineInstance = {
id: "sandbox",
url: NEXT_PUBLIC_DEMO_ENGINE_URL,
name: "Demo Engine",
status: "active",
lastAccessedAt: new Date().toISOString(),
accountId: data?.id,
};
return (
<RenderInstance
instance={sandboxEngine}
activePage={props.activePage}
content={props.content}
/>
);
}
return <QueryAndRenderInstance {...props} />;
}
function QueryAndRenderInstance(props: {
activePage: ActivePage;
engineId: string;
content: React.FC<{ instance: EngineInstance }>;
}) {
const instancesQuery = useEngineInstances();
const instance = instancesQuery.data?.find((x) => x.id === props.engineId);
if (instancesQuery.isLoading) {
return <PageLoading />;
}
if (!instance) {
return <EngineErrorPage>Engine Instance Not Found</EngineErrorPage>;
}
return (
<EnsurePermissionAndRenderInstance
instance={instance}
activePage={props.activePage}
content={props.content}
/>
);
}
function EnsurePermissionAndRenderInstance(props: {
activePage: ActivePage;
content: React.FC<{ instance: EngineInstance }>;
instance: EngineInstance;
}) {
const permissionQuery = useHasEnginePermission({
instanceUrl: props.instance.url,
});
if (permissionQuery.isLoading) {
return <PageLoading />;
}
if (permissionQuery.error instanceof Error) {
if (permissionQuery.error.message.includes("Failed to fetch")) {
return (
<EngineErrorPage>
<p>Unable to connect to Engine</p>
<p>Ensure that your Engine is publicly accessible</p>
</EngineErrorPage>
);
}
return (
<EngineErrorPage>
<p>There was an unexpected error reaching your Engine instance</p>
<p>Try again or contact us if this issue persists.</p>
</EngineErrorPage>
);
}
if (
permissionQuery.data &&
permissionQuery.data.hasPermission === false &&
permissionQuery.data.reason === "Unauthorized"
) {
return (
<div>
You are not an admin for this Engine instance. Contact the owner to add
your wallet as an admin
</div>
);
}
return (
<RenderInstance
instance={props.instance}
activePage={props.activePage}
content={props.content}
/>
);
}
function RenderInstance(props: {
instance: EngineInstance;
activePage: ActivePage;
content: React.FC<{ instance: EngineInstance }>;
}) {
const { instance, activePage } = props;
const router = useDashboardRouter();
const trackEvent = useTrack();
const handleClick = useCallback(
(id: ActivePage) => {
trackEvent({
category: "engine",
action: "navigate-tab",
label: id,
url: instance.url,
});
if (id === "overview") {
router.push(`/dashboard/engine/${instance.id}`);
} else {
router.push(`/dashboard/engine/${instance.id}/${id}`);
}
},
[instance.url, instance.id, trackEvent, router],
);
const links = useMemo(
() =>
[
{
path: `/dashboard/engine/${instance.id}`,
title: "Overview",
name: "overview",
onClick: () => handleClick("overview"),
},
{
path: `/dashboard/engine/${instance.id}/explorer`,
title: "Explorer",
name: "explorer",
onClick: () => handleClick("explorer"),
},
{
path: `/dashboard/engine/${instance.id}/relayers`,
title: "Relayers",
name: "relayers",
onClick: () => handleClick("relayers"),
},
{
path: `/dashboard/engine/${instance.id}/contract-subscriptions`,
title: "Contract Subscriptions",
name: "contract-subscriptions",
onClick: () => handleClick("contract-subscriptions"),
},
{
path: `/dashboard/engine/${instance.id}/admins`,
title: "Admins",
name: "admins",
onClick: () => handleClick("admins"),
},
{
path: `/dashboard/engine/${instance.id}/access-tokens`,
title: "Access Tokens",
name: "access-tokens",
onClick: () => handleClick("access-tokens"),
},
{
path: `/dashboard/engine/${instance.id}/webhooks`,
title: "Webhooks",
name: "webhooks",
onClick: () => handleClick("webhooks"),
},
{
path: `/dashboard/engine/${instance.id}/configuration`,
title: "Configuration",
name: "configuration",
onClick: () => handleClick("configuration"),
},
{
path: `/dashboard/engine/${instance.id}/metrics`,
title: "Metrics",
name: "metrics",
onClick: () => handleClick("metrics"),
},
] satisfies Route[],
[handleClick, instance.id],
);
return (
<>
<SidebarNav links={links} activePage={activePage} title="Engine" />
<div className="flex">
<Button
variant="ghost"
className="px-2 py-1 -translate-x-2 flex items-center gap-2 text-muted-foreground hover:text-foreground h-auto"
>
<Link
href="/dashboard/engine"
aria-label="Go Back"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<ArrowLeftIcon className="size-4" /> Back
</Link>
</Button>
</div>
<div className="h-5" />
<div className="flex flex-col lg:flex-row lg:items-center gap-4 lg:justify-between">
<div>
<h1 className="text-3xl md:text-5xl font-semibold tracking-tighter">
{instance.name}
</h1>
<div className="h-1" />
<div className="flex gap-3 items-center">
{!instance.name.startsWith("https://") && (
<CopyTextButton
copyIconPosition="right"
textToShow={instance.url}
textToCopy={instance.url}
tooltip="Copy Engine URL"
variant="ghost"
className="px-2 py-1 -translate-x-2 h-auto text-muted-foreground"
/>
)}
</div>
</div>
<EngineVersionBadge instance={instance} />
</div>
<div className="h-5" />
<Separator />
<div className="h-10 " />
<props.content instance={instance} />
</>
);
}
function EngineErrorPage(props: {
children: React.ReactNode;
}) {
return (
<div>
<Link
href="/dashboard/engine"
className="gap-2 flex items-center text-muted-foreground hover:text-foreground"
>
<ArrowLeftIcon className="size-5" />
Back
</Link>
<div className="border rounded-lg border-border mt-5">
<MessageContainer>
<div className="flex flex-col gap-4 items-center">
<CircleAlertIcon className="size-16 text-destructive-text" />
<p className="text-secondary-foreground text-center">
{props.children}
</p>
</div>
</MessageContainer>
</div>
</div>
);
}
export function MessageContainer(props: {
children: React.ReactNode;
}) {
return (
<div className="h-[300px] lg:h-[400px] flex items-center justify-center">
{props.children}
</div>
);
}
export function PageLoading() {
return (
<MessageContainer>
<Spinner className="size-10" />
</MessageContainer>
);
}
@@ -0,0 +1,26 @@
import { SidebarNav } from "../../core-ui/sidebar/nav";
export function EngineSidebar() {
return (
<SidebarNav
title="Engine"
links={[
{
name: "overview",
path: "/dashboard/engine",
title: "Overview",
},
{
name: "create",
path: "/dashboard/engine/create",
title: "Create",
},
{
name: "import",
path: "/dashboard/engine/import",
title: "Import",
},
]}
/>
);
}
@@ -1,27 +1,25 @@
import { Button } from "@/components/ui/button";
import { ToolTipLabel } from "@/components/ui/tooltip";
import {
type EngineInstance,
useEngineLatestVersion,
useEngineSystemHealth,
useEngineUpdateVersion,
} from "@3rdweb-sdk/react/hooks/useEngine";
import { CircleArrowDownIcon, CloudDownloadIcon } from "lucide-react";
import { useState } from "react";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import {
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
Tag,
TagLabel,
TagLeftIcon,
Tooltip,
type UseDisclosureReturn,
useDisclosure,
} from "@chakra-ui/react";
import { useTxNotifications } from "hooks/useTxNotifications";
import { FaArrowCircleUp } from "react-icons/fa";
import { Button, Text, TrackedLink } from "tw-components";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { toast } from "sonner";
export const EngineVersionBadge = ({
instance,
@@ -30,7 +28,7 @@ export const EngineVersionBadge = ({
}) => {
const healthQuery = useEngineSystemHealth(instance.url);
const latestVersionQuery = useEngineLatestVersion();
const disclosure = useDisclosure();
const [isModalOpen, setModalOpen] = useState(false);
const current = healthQuery.data?.engineVersion ?? "...";
const latest = latestVersionQuery.data ?? "...";
@@ -38,112 +36,141 @@ export const EngineVersionBadge = ({
if (!isStale) {
return (
<Tag size="sm">
<TagLabel fontSize="small">{current}</TagLabel>
</Tag>
<ToolTipLabel label="Latest Version">
<Button variant="outline" asChild className="hover:bg-transparent">
<div>{current}</div>
</Button>
</ToolTipLabel>
);
}
return (
<>
<Tooltip label="Update to the latest version">
<Button variant="unstyled">
<Tag
onClick={disclosure.onOpen}
size="sm"
colorScheme="blue"
variant="outline"
>
<TagLeftIcon as={FaArrowCircleUp} boxSize={3} />
<TagLabel fontSize="small">{current}</TagLabel>
</Tag>
</Button>
</Tooltip>
<ToolTipLabel
label="New version is available"
leftIcon={
<CircleArrowDownIcon className="size-4 text-link-foreground" />
}
>
<Button
variant="outline"
className="relative"
onClick={() => setModalOpen(true)}
>
{current}
{disclosure.isOpen && (
<UpdateVersionModal
disclosure={disclosure}
latest={latest ?? ""}
instance={instance}
/>
)}
{/* Notification Dot */}
<span className="absolute -top-1 -right-1">
<PulseDot />
</span>
</Button>
</ToolTipLabel>
<UpdateVersionModal
open={isModalOpen}
onOpenChange={setModalOpen}
latest={latest ?? ""}
instance={instance}
/>
</>
);
};
const UpdateVersionModal = ({
disclosure,
latest,
instance,
}: {
disclosure: UseDisclosureReturn;
const UpdateVersionModal = (props: {
open: boolean;
onOpenChange: (open: boolean) => void;
latest: string;
instance: EngineInstance;
}) => {
const { mutate } = useEngineUpdateVersion();
const { onSuccess, onError } = useTxNotifications(
"Submitted a request to update your Engine instance. Please allow 1-2 business days for this process.",
"Unexpected error updating your Engine instance.",
);
const { open, onOpenChange, latest, instance } = props;
const updateEngine = useEngineUpdateVersion();
if (!instance.cloudDeployedAt) {
// For self-hosted, show a prompt to the Github release page.
return (
<Modal isOpen={disclosure.isOpen} onClose={disclosure.onClose}>
<ModalContent>
<ModalCloseButton />
<ModalHeader>Update your self-hosted Engine to {latest}</ModalHeader>
<ModalBody>
<Text>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] max-w-[400px]"
dialogOverlayClassName="z-[10000]"
>
<DialogHeader>
<DialogTitle className="text-2xl font-semibold tracking-tight pr-4 mb-6">
Update your self-hosted Engine to {latest}
</DialogTitle>
<DialogDescription>
View the changelog in the{" "}
<TrackedLink
<TrackedLinkTW
href="https://github.com/thirdweb-dev/engine/releases"
category="engine"
label="clicked-engine-releases"
isExternal
color="blue.500"
target="_blank"
className="text-link-foreground hover:text-foreground"
>
Engine Github repository
</TrackedLink>
.
</Text>
</ModalBody>
<ModalFooter as={Flex} gap={3}>
<Button type="button" onClick={disclosure.onClose} variant="ghost">
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</TrackedLinkTW>
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
const onClick = () => {
const onClick = async () => {
try {
mutate({ engineId: instance.id });
onSuccess();
} catch (e) {
onError(e);
const promise = updateEngine.mutateAsync({ engineId: instance.id });
toast.promise(promise, {
success:
"Submitted a request to update your Engine instance. Please allow 1-2 business days for this process.",
error: "Unexpected error updating your Engine instance.",
});
await promise;
} finally {
onOpenChange(false);
}
disclosure.onClose();
};
return (
<Modal isOpen={disclosure.isOpen} onClose={disclosure.onClose}>
<ModalContent>
<ModalCloseButton />
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="z-[10001] max-w-[400px]"
dialogOverlayClassName="z-[10000]"
>
<DialogHeader>
<DialogTitle>Update Engine to {latest}?</DialogTitle>
</DialogHeader>
<ModalHeader>Update Engine to {latest}?</ModalHeader>
<ModalFooter as={Flex} gap={3}>
<Button type="button" onClick={disclosure.onClose} variant="ghost">
<DialogFooter className="mt-5">
<Button
type="button"
onClick={() => onOpenChange(false)}
variant="outline"
>
Close
</Button>
<Button type="submit" onClick={onClick} colorScheme="blue">
<Button
type="submit"
onClick={onClick}
variant="primary"
className="gap-2"
>
{updateEngine.isLoading ? (
<Spinner className="size-4" />
) : (
<CloudDownloadIcon className="size-4" />
)}
Update
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
function PulseDot() {
return (
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2 bg-primary" />
</span>
);
}
@@ -1,257 +0,0 @@
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import type { EngineTier } from "@3rdweb-sdk/react/hooks/useEngine";
import {
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
SimpleGrid,
type UseDisclosureReturn,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { ChakraNextImage } from "components/Image";
import { OnboardingModal } from "components/onboarding/Modal";
import { THIRDWEB_API_HOST } from "constants/urls";
import { useTrack } from "hooks/analytics/useTrack";
import { useSingleQueryParam } from "hooks/useQueryParam";
import { useRouter } from "next/router";
import { useState } from "react";
import { Button, Heading, Text } from "tw-components";
import { LazyOnboardingBilling } from "../onboarding/LazyOnboardingBilling";
import { EngineTierCard, MONTHLY_PRICE_USD } from "./tier-card";
interface CreateEngineInstanceButtonProps {
ctaText: string;
refetch: () => void;
}
export const CreateEngineInstanceButton = ({
ctaText,
refetch,
}: CreateEngineInstanceButtonProps) => {
const showModalOnLoad = useSingleQueryParam("requestCloudHosted");
const disclosure = useDisclosure({
defaultIsOpen: showModalOnLoad !== undefined,
});
const paymentDisclosure = useDisclosure();
const trackEvent = useTrack();
const toast = useToast();
const accountQuery = useAccount();
const [engineTier, setEngineTier] = useState<EngineTier>("STARTER");
const addCloudHostedEngine = async (tier?: EngineTier) => {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier: tier ?? engineTier,
});
try {
const res = await fetch(
`${THIRDWEB_API_HOST}/v1/engine/add-cloud-hosted`,
{
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify({
tier: tier ?? engineTier,
}),
},
);
if (!res.ok) {
if (res.status === 409) {
toast({
status: "warning",
description:
"There is a pending Engine deployment. Please contact [email protected] if this takes longer than 2 hours.",
});
return;
}
const json = await res.json();
const error =
json.error?.message ??
"Unexpected error. Please try again or visit https://thirdweb.com/support.";
throw error;
}
toast({
status: "success",
description: "Thank you! Your Engine deployment will begin shortly.",
});
// Refresh the engine list to show a pending cloud-hosted Engine instance.
refetch();
} catch (e) {
toast({
status: "error",
description: `${e}`,
});
}
};
return (
<>
<Button
onClick={() => {
trackEvent({
category: "engine",
action: "click",
label: "add-engine-instance",
});
disclosure.onOpen();
}}
colorScheme="blue"
px={6}
>
{ctaText}
</Button>
{disclosure.isOpen && (
<CreateCloudHostedEngineModal
disclosure={disclosure}
onClickAddToPlan={async (tier: EngineTier) => {
setEngineTier(tier);
disclosure.onClose();
if (accountQuery.data?.status === AccountStatus.ValidPayment) {
await addCloudHostedEngine(tier);
} else {
trackEvent({
category: "engine",
action: "click",
label: "clicked-add-payment-method",
});
// Switch the Cloud-hosted Engine modal with the Add Payment modal.
paymentDisclosure.onOpen();
}
}}
/>
)}
<OnboardingModal
isOpen={paymentDisclosure.isOpen}
onClose={paymentDisclosure.onClose}
>
<LazyOnboardingBilling
onSave={async () => {
await addCloudHostedEngine();
paymentDisclosure.onClose();
}}
onCancel={paymentDisclosure.onClose}
/>
</OnboardingModal>
</>
);
};
const CreateCloudHostedEngineModal = ({
disclosure,
onClickAddToPlan,
}: {
disclosure: UseDisclosureReturn;
onClickAddToPlan: (tier: EngineTier) => void;
}) => {
const trackEvent = useTrack();
const router = useRouter();
const [modalState, setModalState] = useState<
"selectTier" | "confirmStandard" | "confirmPremium"
>("selectTier");
if (modalState === "confirmStandard" || modalState === "confirmPremium") {
const tier: EngineTier =
modalState === "confirmStandard" ? "STARTER" : "PREMIUM";
return (
<Modal
isOpen={disclosure.isOpen}
onClose={disclosure.onClose}
isCentered
size="lg"
>
<ModalOverlay />
<ModalContent p={4} pt={8}>
<ModalCloseButton />
<ModalBody as={Flex} flexDir="column" gap={6}>
<ChakraNextImage
alt="Engine hero image"
src={require("../../../public/assets/engine/empty-state-header.png")}
w="fit-content"
/>
<Heading size="title.sm">
Are you sure you want to deploy a{" "}
{tier === "STARTER" ? "Standard" : "Premium"} Engine?
</Heading>
<Text>
You will be charged ${MONTHLY_PRICE_USD[tier]} per month for the
subscription.
</Text>
</ModalBody>
<ModalFooter as={Flex} alignItems="flex-end" gap={3}>
<Button onClick={() => setModalState("selectTier")} variant="ghost">
Back
</Button>
<Button onClick={() => onClickAddToPlan(tier)} colorScheme="blue">
Deploy now
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
return (
<Modal
isOpen={disclosure.isOpen}
onClose={disclosure.onClose}
isCentered
size="6xl"
>
<ModalOverlay />
<ModalContent p={4} pb={8}>
<ModalCloseButton />
<ModalHeader>
<Heading size="title.sm">Choose an Engine deployment</Heading>
</ModalHeader>
<ModalBody as={Flex} flexDir="column" gap={4}>
<Text>
Host Engine on thirdweb with no setup or maintenance required.
</Text>
<SimpleGrid columns={{ base: 1, lg: 3 }} gap={6}>
<EngineTierCard
tier="STARTER"
onClick={() => setModalState("confirmStandard")}
/>
<EngineTierCard
tier="PREMIUM"
isPrimaryCta
onClick={() => setModalState("confirmPremium")}
/>
<EngineTierCard
tier="ENTERPRISE"
previousTier="Premium Engine"
onClick={() => {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier: "ENTERPRISE",
});
router.push("/contact-us");
}}
/>
</SimpleGrid>
</ModalBody>
</ModalContent>
</Modal>
);
};
@@ -1,3 +1,13 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ToolTipLabel } from "@/components/ui/tooltip";
import {
type EditEngineInstanceInput,
type EngineInstance,
@@ -7,52 +17,38 @@ import {
useEngineRemoveCloudHosted,
useEngineRemoveFromDashboard,
} from "@3rdweb-sdk/react/hooks/useEngine";
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
import {
Alert,
AlertDescription,
AlertTitle,
Flex,
FormControl,
HStack,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
Stack,
Textarea,
Tooltip,
type UseDisclosureReturn,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useMutation } from "@tanstack/react-query";
import { FormControl, Radio, RadioGroup, Stack } from "@chakra-ui/react";
import { DialogDescription } from "@radix-ui/react-dialog";
import { createColumnHelper } from "@tanstack/react-table";
import { TWTable } from "components/shared/TWTable";
import { useTrack } from "hooks/analytics/useTrack";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
} from "react";
CircleAlertIcon,
SendIcon,
Trash2Icon,
TriangleAlertIcon,
} from "lucide-react";
import Link from "next/link";
import { type ReactNode, useState } from "react";
import { useForm } from "react-hook-form";
import { BiPencil } from "react-icons/bi";
import { FiArrowRight, FiTrash } from "react-icons/fi";
import { useActiveAccount } from "thirdweb/react";
import { Badge, Button, FormLabel, Heading, Text } from "tw-components";
import { FiTrash } from "react-icons/fi";
import { toast } from "sonner";
import { FormLabel } from "tw-components";
import {
Alert,
AlertDescription,
AlertTitle,
} from "../../@/components/ui/alert";
import { Button } from "../../@/components/ui/button";
import { Input } from "../../@/components/ui/input";
import { Textarea } from "../../@/components/ui/textarea";
interface EngineInstancesTableProps {
instances: EngineInstance[];
isLoading: boolean;
isFetched: boolean;
refetch: ReturnType<typeof useEngineInstances>["refetch"];
setConnectedInstance: Dispatch<SetStateAction<EngineInstance | undefined>>;
}
export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
@@ -60,86 +56,15 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
isLoading,
isFetched,
refetch,
setConnectedInstance,
}) => {
const editDisclosure = useDisclosure();
const removeDisclosure = useDisclosure();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false);
const trackEvent = useTrack();
const token = useLoggedInUser().user?.jwt ?? null;
const address = useActiveAccount()?.address;
const toast = useToast();
const [instanceToUpdate, setInstanceToUpdate] = useState<
EngineInstance | undefined
>();
const { mutate: connectToInstance } = useMutation({
mutationFn: async (instance: EngineInstance) => {
// Make an authed request.
// If it fails to fetch, the server is unreachable.
// If it returns a 401, the user is not a valid admin.
const res = await fetch(`${instance.url}auth/permissions/get-all`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (res.ok) {
return instance;
}
if (res.status === 401) {
throw new Error("Unauthorized");
}
throw new Error(`Unexpected status code ${res.status}`);
},
onSuccess: (instance) => {
trackEvent({
category: "engine",
action: "connect",
label: "success",
});
setConnectedInstance(instance);
},
onError: (error, instance) => {
const e = error as Error;
if (e?.message === "Failed to fetch") {
trackEvent({
category: "engine",
action: "connect",
label: "unreachable",
});
toast({
status: "error",
description: `Unable to connect to ${instance.url}. Ensure that your Engine is publicly accessible.`,
duration: null,
isClosable: true,
});
} else if (e?.message.indexOf("Unauthorized") > -1) {
trackEvent({
category: "engine",
action: "connect",
label: "unauthorized",
});
toast({
status: "error",
title: "Unauthorized",
description: `You are not an admin for this Engine instance. Contact the owner to add your wallet as an admin: ${address}`,
duration: null,
isClosable: true,
});
} else {
toast({
status: "error",
description:
"There was an unexpected error reaching your Engine instance. Try again or contact us if this issue persists.",
duration: null,
isClosable: true,
});
}
},
});
const columnHelper = createColumnHelper<EngineInstance>();
const columns = [
columnHelper.accessor("id", {
@@ -150,83 +75,62 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
let badge: ReactNode | undefined;
if (status === "requested") {
badge = (
<Tooltip label="Deployment will begin shortly.">
<Badge
borderRadius="full"
size="label.sm"
variant="subtle"
px={3}
py={1.5}
colorScheme="yellow"
>
Pending
</Badge>
</Tooltip>
<ToolTipLabel label="Deployment will begin shortly.">
<div>
<Badge variant="outline" className="gap-1.5">
<Spinner className="size-3" />
Pending
</Badge>
</div>
</ToolTipLabel>
);
} else if (status === "deploying") {
badge = (
<Tooltip label="This step may take up to 30 minutes.">
<Badge
borderRadius="full"
size="label.sm"
variant="subtle"
px={3}
py={1.5}
colorScheme="green"
>
Deploying
</Badge>
</Tooltip>
<ToolTipLabel label="This step may take up to 30 minutes.">
<div>
<Badge variant="default" className="gap-1.5">
<Spinner className="size-3" />
Deploying
</Badge>
</div>
</ToolTipLabel>
);
} else if (status === "paymentFailed") {
badge = (
<Tooltip label="There was an error charging your payment method. Please contact [email protected].">
<Badge
borderRadius="full"
size="label.sm"
variant="subtle"
px={3}
py={1.5}
colorScheme="red"
>
Payment Failed
</Badge>
</Tooltip>
<ToolTipLabel
label={
"There was an error charging your payment method. Please contact [email protected]."
}
>
<div>
<Badge variant="destructive" className="gap-1.5">
<CircleAlertIcon className="size-3" />
Payment Failed
</Badge>
</div>
</ToolTipLabel>
);
}
return (
<Stack py={2}>
<div className="py-3">
{badge ? (
<HStack spacing={4}>
<Text fontWeight="600" size="body.lg">
{name}
</Text>
<div className="flex items-center gap-4 py-2">
<p className="font-semibold text-lg"> {name} </p>
{badge}
</HStack>
</div>
) : (
<>
<Button
onClick={() => {
const instance = instances.find((i) => i.id === id);
if (instance) {
connectToInstance(instance);
}
}}
variant="link"
colorScheme="blue"
w="fit-content"
rightIcon={<FiArrowRight />}
justifyContent="flex-start"
<div className="flex flex-col gap-0.5">
<Link
href={`/dashboard/engine/${id}`}
className="text-foreground flex text-lg font-semibold items-center before:absolute before:inset-0 before:bg-transparent"
>
{name}
</Button>
<Text size="body.sm" color="gray.700">
{url}
</Text>
</>
</Link>
<p className="text-muted-foreground text-sm">{url}</p>
</div>
)}
</Stack>
</div>
);
},
}),
@@ -251,7 +155,7 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
label: "open-modal",
});
setInstanceToUpdate(instance);
editDisclosure.onOpen();
setIsEditModalOpen(true);
},
},
{
@@ -264,24 +168,28 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
label: "open-modal",
});
setInstanceToUpdate(instance);
removeDisclosure.onOpen();
setIsRemoveModalOpen(true);
},
isDestructive: true,
},
]}
bodyRowClassName="hover:bg-secondary relative"
/>
{editDisclosure.isOpen && instanceToUpdate && (
{instanceToUpdate && (
<EditModal
instance={instanceToUpdate}
disclosure={editDisclosure}
open={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
refetch={refetch}
/>
)}
{removeDisclosure.isOpen && instanceToUpdate && (
{instanceToUpdate && (
<RemoveModal
instance={instanceToUpdate}
disclosure={removeDisclosure}
onOpenChange={setIsRemoveModalOpen}
open={isRemoveModalOpen}
refetch={refetch}
/>
)}
@@ -289,18 +197,14 @@ export const EngineInstancesTable: React.FC<EngineInstancesTableProps> = ({
);
};
const EditModal = ({
instance,
disclosure,
refetch,
}: {
disclosure: UseDisclosureReturn;
const EditModal = (props: {
open: boolean;
onOpenChange: (open: boolean) => void;
instance: EngineInstance;
refetch: () => void;
}) => {
const toast = useToast();
const { mutate: editInstance } = useEngineEditInstance();
const { onClose } = disclosure;
const editInstance = useEngineEditInstance();
const { onOpenChange, instance, open, refetch } = props;
const form = useForm<EditEngineInstanceInput>({
defaultValues: {
@@ -308,242 +212,298 @@ const EditModal = ({
name: instance.name,
url: instance.url,
},
reValidateMode: "onChange",
});
return (
<Modal isOpen onClose={onClose} isCentered size="lg">
<ModalOverlay />
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
<DialogHeader className="mb-3">
<DialogTitle className="text-2xl font-semibold tracking-tight">
Edit Engine Instance
</DialogTitle>
</DialogHeader>
<ModalContent>
<form
onSubmit={form.handleSubmit((data) =>
editInstance(data, {
editInstance.mutate(data, {
onSuccess: () => {
toast({
status: "success",
description: "Successfully updated this Engine.",
});
toast.success("Engine updated successfully");
refetch();
onClose();
onOpenChange(false);
},
onError: () => {
toast({
status: "error",
description: "Error updating this Engine.",
});
toast.error("Failed to update Engine");
},
}),
)}
>
<ModalHeader>Edit Engine Instance</ModalHeader>
<div className="flex flex-col gap-6">
<FormControl isRequired>
<FormLabel>Name</FormLabel>
<Input
type="text"
placeholder="Enter a descriptive label"
autoFocus
{...form.register("name", {
required: true,
})}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>URL</FormLabel>
<Input
type="url"
placeholder="Enter your Engine URL"
{...form.register("url", {
required: true,
})}
/>
</FormControl>
</div>
<ModalBody>
<Stack spacing={4}>
<FormControl>
<FormLabel>Name</FormLabel>
<Input
type="text"
placeholder="Enter a descriptive label"
autoFocus
{...form.register("name")}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>URL</FormLabel>
<Input
type="url"
placeholder="Enter your Engine URL"
{...form.register("url")}
/>
</FormControl>
</Stack>
</ModalBody>
<ModalFooter as={Flex} gap={3}>
<Button onClick={onClose} variant="ghost">
<DialogFooter className="mt-10 gap-2">
<Button onClick={() => onOpenChange(false)} variant="outline">
Cancel
</Button>
<Button type="submit" colorScheme="blue">
<Button
type="submit"
variant="primary"
className="gap-2"
disabled={!form.formState.isDirty}
>
{editInstance.isLoading ? (
<Spinner className="size-4" />
) : (
<SendIcon className="size-4" />
)}
Update
</Button>
</ModalFooter>
</DialogFooter>
</form>
</ModalContent>
</Modal>
</DialogContent>
</Dialog>
);
};
const RemoveModal = ({
instance,
disclosure,
refetch,
}: {
const RemoveModal = (props: {
instance: EngineInstance;
disclosure: UseDisclosureReturn;
open: boolean;
onOpenChange: (open: boolean) => void;
refetch: () => void;
}) => {
const toast = useToast();
const { mutate: removeFromDashboard } = useEngineRemoveFromDashboard();
const { mutate: removeCloudHosted } = useEngineRemoveCloudHosted();
const { onClose } = disclosure;
const { instance, open, onOpenChange, refetch } = props;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
{instance.status === "paymentFailed" ||
(instance.status === "active" && !instance.cloudDeployedAt) ? (
<RemoveFromDashboardModalContent
refetch={refetch}
instance={instance}
close={() => onOpenChange(false)}
/>
) : (
<CancelSubscriptionModalContent
refetch={refetch}
instance={instance}
close={() => onOpenChange(false)}
/>
)}
</DialogContent>
</Dialog>
);
};
function RemoveFromDashboardModalContent(props: {
refetch: () => void;
instance: EngineInstance;
close: () => void;
}) {
const { refetch, instance, close } = props;
const removeFromDashboard = useEngineRemoveFromDashboard();
return (
<>
<DialogHeader>
<DialogTitle className="text-2xl font-semibold tracking-tight mb-3">
Remove Engine Instance
</DialogTitle>
<DialogDescription className="text-muted-foreground">
<span className="block mb-2">
Are you sure you want to remove{" "}
<em className="not-italic font-semibold">"{instance.name}"</em> from
your dashboard?
</span>
<span className="block">
This action does not modify your Engine infrastructure. You can
re-add it at any time.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-8 gap-1">
<Button onClick={close} variant="outline">
Close
</Button>
<Button
onClick={() => {
removeFromDashboard.mutate(instance.id, {
onSuccess: () => {
toast.success("Removed an Engine instance from your dashboard");
refetch();
close();
},
onError: () => {
toast.error(
"Error removing an Engine instance from your dashboard",
);
},
});
}}
variant="destructive"
className="gap-2"
>
{removeFromDashboard.isLoading ? (
<Spinner className="size-4" />
) : (
<Trash2Icon className="size-4" />
)}
Remove
</Button>
</DialogFooter>
</>
);
}
function CancelSubscriptionModalContent(props: {
refetch: () => void;
instance: EngineInstance;
close: () => void;
}) {
const { refetch, instance, close } = props;
const removeCloudHosted = useEngineRemoveCloudHosted();
const form = useForm<RemoveCloudHostedInput>({
defaultValues: {
instanceId: instance.id,
},
reValidateMode: "onChange",
});
return (
<Modal isOpen onClose={onClose} isCentered size="lg">
<ModalOverlay />
<ModalContent>
{instance.status === "paymentFailed" ||
(instance.status === "active" && !instance.cloudDeployedAt) ? (
<>
<ModalHeader>Remove Engine Instance</ModalHeader>
<div>
<DialogHeader>
<DialogTitle className="text-2xl font-semibold tracking-tight mb-1">
Cancel Engine Subscription
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Complete this form to request to cancel your Engine subscription. This
may take up to 2 business days.
</DialogDescription>
</DialogHeader>
<ModalBody as={Flex} flexDir="column" gap={2}>
<Text>
Are you sure you want to remove{" "}
<strong>{instance?.name}</strong> from your dashboard?
</Text>
<Text>
This action does not modify your Engine infrastructure. You can
re-add it at any time.
</Text>
</ModalBody>
<div className="h-3" />
<ModalFooter as={Flex} gap={3}>
<Button onClick={onClose} variant="ghost">
Close
</Button>
<Button
onClick={() => {
removeFromDashboard(instance.id, {
onSuccess: () => {
toast({
status: "success",
description:
"Removed an Engine instance from your dashboard.",
});
refetch();
onClose();
},
onError: () => {
toast({
status: "error",
description:
"Error removing an Engine instance from your dashboard.",
});
},
});
}}
colorScheme="red"
>
Remove
</Button>
</ModalFooter>
</>
) : (
<form
onSubmit={form.handleSubmit((data) =>
removeCloudHosted(data, {
onSuccess: () => {
toast({
status: "success",
description:
"Submitted a request to cancel your Engine subscription. This may take up to 2 business days.",
});
refetch();
onClose();
<Alert variant="destructive">
<TriangleAlertIcon className="size-5 !text-destructive-text" />
<AlertTitle>This action is irreversible!</AlertTitle>
<AlertDescription>
You will no longer be able to access this Engine's local backend
wallets. <strong>Any remaining mainnet funds will be lost.</strong>
</AlertDescription>
</Alert>
<div className="h-5" />
<form
onSubmit={form.handleSubmit((data) =>
removeCloudHosted.mutate(data, {
onSuccess: () => {
toast.success(
"Submitted a request to cancel your Engine subscription. This may take up to 2 business days.",
{
dismissible: true,
duration: 10000,
},
onError: () => {
toast({
status: "error",
description:
"Error requesting to cancel your Engine subscription.",
});
},
}),
)}
>
<ModalHeader>Cancel Engine Subscription</ModalHeader>
);
<ModalBody as={Stack} gap={4}>
<Text>
Complete this form to request to cancel your Engine
subscription. This may take up to 2 business days.
</Text>
{/* Form */}
<FormControl>
<FormLabel>
Please share any feedback to help us improve
</FormLabel>
<RadioGroup>
<Stack>
<Radio
value="USING_SELF_HOSTED"
{...form.register("reason", { required: true })}
>
<Text>Migrating to self-hosted</Text>
</Radio>
<Radio
value="TOO_EXPENSIVE"
{...form.register("reason", { required: true })}
>
<Text>Too expensive</Text>
</Radio>
<Radio
value="MISSING_FEATURES"
{...form.register("reason", { required: true })}
>
<Text>Missing features</Text>
</Radio>
<Radio
value="OTHER"
{...form.register("reason", { required: true })}
>
<Text>Other</Text>
</Radio>
</Stack>
</RadioGroup>
<Textarea
placeholder="Provide additional feedback"
mt={2}
{...form.register("feedback")}
/>
</FormControl>
<Alert status="warning" variant="left-accent">
<Flex direction="column" gap={2}>
<Heading as={AlertTitle} size="label.md">
This action is irreversible!
</Heading>
<Text as={AlertDescription} size="body.md">
You will no longer be able to access this Engine&apos;s
local backend wallets.{" "}
<strong>Any remaining mainnet funds will be lost.</strong>
</Text>
</Flex>
</Alert>
</ModalBody>
<ModalFooter as={Flex} gap={3}>
<Button onClick={onClose} variant="ghost">
Close
</Button>
<Button
type="submit"
colorScheme="primary"
isDisabled={!form.formState.isValid}
>
Request to cancel
</Button>
</ModalFooter>
</form>
refetch();
close();
},
onError: () => {
toast.error(
"Error requesting to cancel your Engine subscription",
);
},
}),
)}
</ModalContent>
</Modal>
>
{/* Form */}
<FormControl isRequired>
<FormLabel className="!text-base">
Please share any feedback to help us improve
</FormLabel>
<RadioGroup>
<Stack>
<Radio
value="USING_SELF_HOSTED"
{...form.register("reason", { required: true })}
>
<span className="text-sm"> Migrating to self-hosted </span>
</Radio>
<Radio
value="TOO_EXPENSIVE"
{...form.register("reason", { required: true })}
>
<span className="text-sm"> Too expensive </span>
</Radio>
<Radio
value="MISSING_FEATURES"
{...form.register("reason", { required: true })}
>
<span className="text-sm"> Missing features </span>
</Radio>
<Radio
value="OTHER"
{...form.register("reason", { required: true })}
>
<span className="text-sm"> Other </span>
</Radio>
</Stack>
</RadioGroup>
</FormControl>
<div className="h-2" />
<Textarea
className="mt-3"
placeholder="Provide additional feedback"
{...form.register("feedback")}
/>
<div className="h-8" />
<DialogFooter className="gap-2">
<Button onClick={close} variant="outline">
Close
</Button>
<Button
type="submit"
variant="destructive"
disabled={!form.formState.isValid}
className="gap-2"
>
{removeCloudHosted.isLoading ? (
<Spinner className="size-4" />
) : (
<SendIcon className="size-4" />
)}
Request to cancel
</Button>
</DialogFooter>
</form>
</div>
);
};
}
@@ -1,164 +1,188 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import {
type EngineInstance,
useEngineInstances,
} from "@3rdweb-sdk/react/hooks/useEngine";
import {
Center,
Divider,
Flex,
Icon,
ListItem,
Stack,
UnorderedList,
} from "@chakra-ui/react";
import { ChakraNextImage } from "components/Image";
import { useEngineInstances } from "@3rdweb-sdk/react/hooks/useEngine";
import { useTrack } from "hooks/analytics/useTrack";
import type { Dispatch, SetStateAction } from "react";
import { VscBook } from "react-icons/vsc";
import invariant from "tiny-invariant";
import { Button, Card, Heading, Link, Text } from "tw-components";
import { CreateEngineInstanceButton } from "./create-engine-instance";
import {
CloudDownloadIcon,
ExternalLinkIcon,
PlusIcon,
RocketIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { UnorderedList } from "../../@/components/ui/List/List";
import { Button } from "../../@/components/ui/button";
import { EngineInstancesTable } from "./engine-instances-table";
import { ImportEngineInstanceButton } from "./import-engine-instance";
interface EngineInstancesListProps {
setConnectedInstance: Dispatch<SetStateAction<EngineInstance | undefined>>;
}
export const EngineInstancesList = ({
setConnectedInstance,
}: EngineInstancesListProps) => {
invariant(
process.env.NEXT_PUBLIC_DEMO_ENGINE_URL,
"missing NEXT_PUBLIC_DEMO_ENGINE_URL",
);
const NEXT_PUBLIC_DEMO_ENGINE_URL = process.env.NEXT_PUBLIC_DEMO_ENGINE_URL;
const { data } = useAccount();
export const EngineInstancesList = () => {
const instancesQuery = useEngineInstances();
const instances = instancesQuery.data ?? [];
const trackEvent = useTrack();
if (instancesQuery.isLoading) {
return (
<div className="grid place-items-center">
<div className="flex justify-center items-center h-[300px]">
<Spinner className="size-14" />
</div>
);
}
return (
<Stack spacing={8}>
{instances.length === 0 ? (
<Center>
<Stack spacing={8}>
<ChakraNextImage
alt="Engine hero image"
src={require("../../../public/assets/engine/empty-state-header.png")}
maxW={650}
/>
<Heading size="title.lg" as="h1">
Your scalable web3 backend server
</Heading>
<UnorderedList color="gray.600">
<ListItem>
Read, write, and deploy contracts at production scale
</ListItem>
<ListItem>
Reliably parallelize and retry transactions with gas &amp; nonce
management
</ListItem>
<ListItem>Securely manage backend wallets</ListItem>
<ListItem>
Built-in support for account abstraction, relayers, and more
</ListItem>
</UnorderedList>
if (instances.length === 0) {
return (
<div className="max-w-[600px] mx-auto">
<Image
alt="Engine hero image"
src={require("../../../public/assets/engine/empty-state-header.png")}
/>
<Flex gap={4}>
<CreateEngineInstanceButton
ctaText="Get Started"
refetch={instancesQuery.refetch}
/>
<Button
onClick={() => {
if (!data) return;
<div className="h-6" />
trackEvent({
category: "engine",
action: "try-demo",
label: "clicked-try-demo",
});
<h1 className="text-2xl md:text-3xl font-bold tracking-tighter">
Your scalable web3 backend server
</h1>
// Need to hardcode the demo engine instance details
// but using the signed-in user account id
setConnectedInstance({
id: "sandbox",
url: NEXT_PUBLIC_DEMO_ENGINE_URL,
name: "Demo Engine",
status: "active",
lastAccessedAt: new Date().toISOString(),
accountId: data.id,
});
}}
variant="outline"
px={6}
>
🚀 Try Demo
</Button>
</Flex>
<Flex direction="column" gap={2}>
<Divider my="2" />
<Text>Already have an Engine Instance?</Text>
<ImportEngineInstanceButton refetch={instancesQuery.refetch} />
</Flex>
</Stack>
</Center>
) : (
<>
<div className="flex flex-col md:flex-row gap-4 justify-between md:items-center">
<h1 className="text-4xl font-bold">Engine</h1>
<div className="flex flex-row gap-2">
<ImportEngineInstanceButton refetch={instancesQuery.refetch} />
<CreateEngineInstanceButton
ctaText="Create Engine Instance"
refetch={instancesQuery.refetch}
/>
</div>
</div>
<div className="h-3" />
<Stack spacing={4}>
<EngineInstancesTable
instances={instancesQuery.data ?? ([] as EngineInstance[])}
isFetched={instancesQuery.isFetched}
isLoading={instancesQuery.isLoading}
refetch={instancesQuery.refetch}
setConnectedInstance={setConnectedInstance}
/>
</Stack>
</>
)}
<UnorderedList>
<li>Read, write, and deploy contracts at production scale</li>
<li>
Reliably parallelize and retry transactions with gas & nonce
management
</li>
<li>Securely manage backend wallets</li>
<li>Built-in support for account abstraction, relayers, and more</li>
</UnorderedList>
<Divider />
<div className="h-6" />
<Card p={8}>
<Stack>
<Flex gap={2} align="center">
<Icon as={VscBook} />
<Heading size="title.xs">Learn more about Engine</Heading>
</Flex>
<Text>Dive into features and integration guides.</Text>
<Link
href="https://portal.thirdweb.com/engine"
isExternal
color="blue.500"
fontSize="small"
<div className="flex gap-3">
<CreateEngineLink label="Get Started" />
<Button
asChild
onClick={() => {
trackEvent({
category: "engine",
action: "try-demo",
label: "clicked-try-demo",
});
}}
variant="outline"
>
View Docs &rarr;
</Link>
</Stack>
</Card>
</Stack>
<Link href={"/dashboard/engine/sandbox"} className="gap-2">
Try Demo
<RocketIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
</div>
<div className="h-10" />
<div className="flex items-center gap-4 justify-between rounded-lg border border-border p-6 bg-secondary">
<p className="text-lg font-semibold tracking-tight">
Already have an Engine Instance?
</p>
<ImportEngineLink label="Import" />
</div>
<div className="h-20" />
<LearnMoreCard />
</div>
);
}
return (
<div>
<div className="flex flex-col md:flex-row gap-4 justify-between md:items-center">
<h1 className="text-4xl font-bold">Engine</h1>
<div className="flex flex-row gap-2">
<ImportEngineLink label="Import" />
<CreateEngineLink label="Create Engine Instance" />
</div>
</div>
<div className="h-6" />
<EngineInstancesTable
instances={instances}
isFetched={instancesQuery.isFetched}
isLoading={instancesQuery.isLoading}
refetch={instancesQuery.refetch}
/>
<div className="h-10" />
<LearnMoreCard />
</div>
);
};
function LearnMoreCard() {
return (
<div className="p-4 border border-border rounded-lg relative hover:bg-secondary">
<h3 className="font-semibold tracking-tight mb-1">
<Link
href="https://portal.thirdweb.com/engine"
target="_blank"
className="before:content before:absolute before:inset-0"
>
Learn more about Engine
</Link>
</h3>
<p className="text-muted-foreground text-sm">
Dive into features and integration guides.
</p>
<ExternalLinkIcon className="size-4 text-muted-foreground absolute right-4 top-4" />
</div>
);
}
function CreateEngineLink(props: {
label: string;
}) {
const trackEvent = useTrack();
return (
<Button
asChild
variant="primary"
onClick={() => {
trackEvent({
category: "engine",
action: "click",
label: "add-engine-instance",
});
}}
>
<Link href={"/dashboard/engine/create"} className="gap-2">
<PlusIcon className="size-4" />
{props.label}
</Link>
</Button>
);
}
function ImportEngineLink(props: {
label: string;
}) {
const trackEvent = useTrack();
return (
<Button
asChild
variant="outline"
onClick={() => {
trackEvent({
category: "engine",
action: "import",
});
}}
>
<Link href={"/dashboard/engine/import"} className="gap-2">
<CloudDownloadIcon className="size-4" />
{props.label}
</Link>
</Button>
);
}
@@ -1,164 +0,0 @@
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
import { Flex, Stack } from "@chakra-ui/react";
import { SidebarNav } from "core-ui/sidebar/nav";
import type { Route } from "core-ui/sidebar/types";
import { useTrack } from "hooks/analytics/useTrack";
import {
type Dispatch,
type SetStateAction,
useCallback,
useMemo,
useState,
} from "react";
import { FiArrowLeft } from "react-icons/fi";
import { Button, Heading, Text } from "tw-components";
import { EngineVersionBadge } from "./badges/version";
import { EngineConfiguration } from "./configuration/engine-configuration";
import { EngineWebhooks } from "./configuration/engine-webhooks";
import { EngineContractSubscriptions } from "./contract-subscription/engine-contract-subscription";
import { EngineExplorer } from "./explorer/engine-explorer";
import { EngineOverview } from "./overview/engine-overview";
import { EngineAccessTokens } from "./permissions/engine-access-tokens";
import { EngineAdmins } from "./permissions/engine-admins";
import { EngineRelayer } from "./relayer/engine-relayer";
import { EngineSystemMetrics } from "./system-metrics";
interface EngineNavigationProps {
instance: EngineInstance;
setConnectedInstance: Dispatch<SetStateAction<EngineInstance | undefined>>;
}
export const EngineNavigation: React.FC<EngineNavigationProps> = ({
instance,
setConnectedInstance,
}) => {
const trackEvent = useTrack();
const onClickBack = () => {
setConnectedInstance(undefined);
};
const [activePage, setActivePage] = useState<string>("overview");
const handleClick = useCallback(
(id: string) => {
trackEvent({
category: "engine",
action: "navigate-tab",
label: id,
url: instance.url,
});
setActivePage(id);
},
[instance.url, trackEvent],
);
const links = useMemo(
() =>
[
{
path: "/dashboard/engine/overview",
title: "Overview",
name: "overview",
onClick: () => handleClick("overview"),
},
{
path: "/dashboard/engine/explorer",
title: "Explorer",
name: "explorer",
onClick: () => handleClick("explorer"),
},
{
path: "/dashboard/engine/relayers",
title: "Relayers",
name: "relayers",
onClick: () => handleClick("relayers"),
},
{
path: "/dashboard/engine/contract-subscriptions",
title: "Contract Subscriptions",
name: "contract-subscriptions",
onClick: () => handleClick("contract-subscriptions"),
},
{
path: "/dashboard/engine/admins",
title: "Admins",
name: "admins",
onClick: () => handleClick("admins"),
},
{
path: "/dashboard/engine/access-tokens",
title: "Access Tokens",
name: "access-tokens",
onClick: () => handleClick("access-tokens"),
},
{
path: "/dashboard/engine/webhooks",
title: "Webhooks",
name: "webhooks",
onClick: () => handleClick("webhooks"),
},
{
path: "/dashboard/engine/configuration",
title: "Configuration",
name: "configuration",
onClick: () => handleClick("configuration"),
},
{
path: "/dashboard/engine/metrics",
title: "Metrics",
name: "metrics",
onClick: () => handleClick("metrics"),
},
] satisfies Route[],
[handleClick],
);
const activeComponent = useMemo(() => {
const tabs = {
overview: <EngineOverview instanceUrl={instance.url} />,
explorer: <EngineExplorer instanceUrl={instance.url} />,
relayers: <EngineRelayer instanceUrl={instance.url} />,
"contract-subscriptions": (
<EngineContractSubscriptions instanceUrl={instance.url} />
),
admins: <EngineAdmins instanceUrl={instance.url} />,
"access-tokens": <EngineAccessTokens instanceUrl={instance.url} />,
webhooks: <EngineWebhooks instanceUrl={instance.url} />,
configuration: <EngineConfiguration instance={instance} />,
metrics: <EngineSystemMetrics instance={instance} />,
};
return tabs[activePage as keyof typeof tabs];
}, [instance, activePage]);
return (
<>
<SidebarNav links={links} activePage={activePage} title="Engine" />
<Stack spacing={4}>
<Button
onClick={onClickBack}
variant="link"
leftIcon={<FiArrowLeft />}
w="fit-content"
>
Back
</Button>
<Stack>
<Heading size="title.lg" as="h1" isTruncated>
{instance.name}
</Heading>
<Flex gap={3} alignItems="center">
{!instance.name.startsWith("https://") && (
<Text color="gray.600">{instance.url}</Text>
)}
<EngineVersionBadge instance={instance} />
</Flex>
</Stack>
{activeComponent}
</Stack>
</>
);
};
@@ -1,186 +0,0 @@
import {
Alert,
AlertDescription,
AlertIcon,
Flex,
FormControl,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Stack,
type UseDisclosureReturn,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { THIRDWEB_API_HOST } from "constants/urls";
import { useTrack } from "hooks/analytics/useTrack";
import { useSingleQueryParam } from "hooks/useQueryParam";
import { useForm } from "react-hook-form";
import { Button, FormLabel, Text, TrackedLink } from "tw-components";
interface ImportEngineInstanceButtonProps {
refetch: () => void;
}
export const ImportEngineInstanceButton = ({
refetch,
}: ImportEngineInstanceButtonProps) => {
const hasImportUrl = !!useSingleQueryParam("importUrl");
const disclosure = useDisclosure({
defaultIsOpen: !!hasImportUrl,
});
const trackEvent = useTrack();
return (
<>
<Button
onClick={() => {
trackEvent({
category: "engine",
action: "import",
label: "open-modal",
});
disclosure.onOpen();
}}
variant="outline"
px={6}
width="fit-content"
>
Import
</Button>
{disclosure.isOpen && (
<ModalImportEngine
disclosure={disclosure}
onSuccess={() => refetch()}
/>
)}
</>
);
};
interface ImportEngineInput {
name: string;
url: string;
}
const ModalImportEngine = ({
disclosure,
onSuccess,
}: {
disclosure: UseDisclosureReturn;
onSuccess: () => void;
}) => {
const toast = useToast();
const defaultUrl = useSingleQueryParam("importUrl");
const form = useForm<ImportEngineInput>({
defaultValues: {
name: "My Engine Instance",
url: defaultUrl ? decodeURIComponent(defaultUrl) : undefined,
},
});
const onSubmit = async (data: ImportEngineInput) => {
try {
// Instance URLs should end with a /.
const url = data.url.endsWith("/") ? data.url : `${data.url}/`;
const res = await fetch(`${THIRDWEB_API_HOST}/v1/engine`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: data.name,
url,
}),
});
if (!res.ok) {
throw new Error(`Unexpected status ${res.status}`);
}
disclosure.onClose();
onSuccess();
} catch (e) {
toast({
status: "error",
description:
"Error importing Engine. Please check if the details are correct.",
});
}
};
return (
<Modal
isOpen={disclosure.isOpen}
onClose={disclosure.onClose}
isCentered
size="lg"
>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<form onSubmit={form.handleSubmit(onSubmit)}>
<ModalHeader>Import Engine Instance</ModalHeader>
<ModalBody>
<Stack spacing={4}>
<Text>
Import an Engine instance hosted on your infrastructure.
<br />
<TrackedLink
href="https://portal.thirdweb.com/infrastructure/engine/get-started"
isExternal
category="engine"
label="clicked-self-host-instructions"
color="blue.500"
>
Get help setting up Engine for free.
</TrackedLink>
</Text>
<FormControl>
<FormLabel>Name</FormLabel>
<Input
type="text"
placeholder="Enter a descriptive label"
autoFocus
{...form.register("name")}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>URL</FormLabel>
<Input
type="url"
placeholder="Enter your Engine URL"
{...form.register("url")}
/>
</FormControl>
<Alert status="warning" borderRadius="m">
<AlertIcon />
<AlertDescription>
Do not import a URL you do not recognize.
</AlertDescription>
</Alert>
</Stack>
</ModalBody>
<ModalFooter as={Flex} gap={3}>
<Button onClick={disclosure.onClose} variant="ghost">
Cancel
</Button>
<Button type="submit" colorScheme="primary">
Import
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
);
};
@@ -18,6 +18,7 @@ import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
import { useAllChainsData } from "hooks/chains/allChains";
import { useState } from "react";
import { FiArrowLeft, FiArrowRight, FiInfo } from "react-icons/fi";
import { toTokens } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import {
Badge,
@@ -311,6 +312,8 @@ const TransactionDetailsDrawer = ({
const chain: ChainMetadata | undefined =
chainIdToChainRecord[Number.parseInt(transaction.chainId)];
const decimals = chain.nativeCurrency.decimals || 18;
const symbol = chain.nativeCurrency.symbol || "ETH";
const explorer = chain?.explorers?.[0];
const status = statusDetails[transaction.status as EngineStatus];
@@ -320,12 +323,15 @@ const TransactionDetailsDrawer = ({
: transaction.functionName ?? null;
let txFeeDisplay = "N/A";
if (transaction.gasLimit && transaction.gasPrice) {
const txFee =
(Number.parseFloat(transaction.gasLimit) *
Number.parseFloat(transaction.gasPrice)) /
10 ** (chain?.nativeCurrency.decimals || 18);
txFeeDisplay = `${txFee} ${chain?.nativeCurrency.symbol || "ETH"}`;
if (transaction.gasLimit) {
const gasPrice =
transaction.effectiveGasPrice ||
transaction.gasPrice ||
transaction.maxFeePerGas;
if (gasPrice) {
const txFeeWei = BigInt(transaction.gasLimit) * BigInt(gasPrice);
txFeeDisplay = `${toTokens(txFeeWei, decimals)} ${symbol}`;
}
}
return (
@@ -397,7 +403,9 @@ const TransactionDetailsDrawer = ({
)}
<FormControl>
<FormLabel>From Address</FormLabel>
<FormLabel>
{transaction.accountAddress ? "Signer Address" : "From Address"}
</FormLabel>
<LinkButton
variant="ghost"
isExternal
@@ -412,8 +420,31 @@ const TransactionDetailsDrawer = ({
</LinkButton>
</FormControl>
{transaction.accountAddress && (
<FormControl>
<FormLabel>Account Address</FormLabel>
<LinkButton
variant="ghost"
isExternal
size="xs"
href={
explorer
? `${explorer.url}/address/${transaction.accountAddress}`
: "#"
}
>
<Text fontFamily="mono">{transaction.accountAddress}</Text>
</LinkButton>
</FormControl>
)}
<FormControl>
<FormLabel>To Address</FormLabel>
{/* The "to" address is usually a contract except for native token transfers. */}
<FormLabel>
{functionCalled === "transfer"
? "Recipient Address"
: "Contract Address"}
</FormLabel>
<LinkButton
variant="ghost"
isExternal
@@ -456,6 +487,24 @@ const TransactionDetailsDrawer = ({
{/* On-chain details */}
<FormControl>
<Flex>
<FormLabel>Value</FormLabel>
<Tooltip
label={`The amount of ${symbol} sent to the "To" .`}
shouldWrapChildren
>
<FiInfo />
</Tooltip>
</Flex>
<Text>
{transaction.value
? toTokens(BigInt(transaction.value), decimals)
: 0}{" "}
{symbol}
</Text>
</FormControl>
{transaction.transactionHash && (
<>
<FormControl>
@@ -528,6 +577,25 @@ const TransactionDetailsDrawer = ({
<Text>{Number(transaction.gasPrice).toLocaleString()}</Text>
</FormControl>
)}
{transaction.blockNumber && (
<FormControl>
<FormLabel>Block</FormLabel>
<LinkButton
variant="ghost"
isExternal
size="xs"
href={
explorer
? `${explorer.url}/block/${transaction.blockNumber}`
: "#"
}
maxW="100%"
>
<Text>{transaction.blockNumber}</Text>
</LinkButton>
</FormControl>
)}
</Stack>
</Collapse>
@@ -36,7 +36,7 @@ export const EngineSystemMetrics: React.FC<EngineStatusProps> = ({
<Text>
Upgrade to a{" "}
<TrackedLink
href="/dashboard/engine?requestCloudHosted"
href="/dashboard/engine/create"
isExternal
color="blue.500"
category="engine"
@@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { useLoggedInUser } from "../../@3rdweb-sdk/react/hooks/useLoggedInUser";
// If it fails to fetch, the server is unreachable.
// If it returns a 401, the user is not a valid admin.
export function useHasEnginePermission(props: {
instanceUrl: string;
}) {
const instanceUrl = props.instanceUrl;
const token = useLoggedInUser().user?.jwt ?? null;
return useQuery({
queryKey: ["auth/permissions/get-all", instanceUrl, token],
queryFn: async () => {
const res = await fetch(`${instanceUrl}auth/permissions/get-all`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (res.ok) {
return {
hasPermission: true,
};
}
if (res.status === 401) {
return {
hasPermission: false,
reason: "Unauthorized",
};
}
throw new Error("Unexpected status code");
},
retry: false,
});
}
@@ -103,7 +103,7 @@ export const CustomChainRenderer = ({
{!disableChainConfig && storedChain && (
<Button
variant="ghost"
className="ml-auto p-2 leading-4 opacity-0 group-hover:opacity-100 hover:bg-transparent transition-opacity"
className="ml-auto p-2 leading-4 md:opacity-0 group-hover:opacity-100 hover:bg-transparent transition-opacity"
aria-label="Configure Network"
onClick={() => {
setEditChain(storedChain);
@@ -1,13 +1,17 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import {
ButtonGroup,
Center,
Divider,
Flex,
Icon,
IconButton,
Menu,
MenuButton,
MenuList,
Spinner,
Table,
Tbody,
@@ -29,7 +33,7 @@ import { type SetStateAction, useMemo, useState } from "react";
import { FaEllipsisVertical } from "react-icons/fa6";
import { FiArrowRight } from "react-icons/fi";
import type { IconType } from "react-icons/lib";
import { Button, MenuItem, TableContainer, Text } from "tw-components";
import { TableContainer, Text } from "tw-components";
type CtaMenuItem<TRowData> = {
icon?: IconType;
@@ -54,6 +58,7 @@ type TWTableProps<TRowData> = {
showLess?: boolean;
};
title: string;
bodyRowClassName?: string;
};
export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
@@ -148,13 +153,13 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
</Th>
))}
{(tableProps.onRowClick || tableProps.onMenuClick) && (
<Th border="none" />
<Th border="none" className="w-0" />
)}
</Tr>
))}
</Thead>
<Tbody>
<Tbody className="!bg-background">
{table.getRowModel().rows.map((row) => {
return (
<Tr
@@ -176,6 +181,7 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
},
}
: {})}
className={tableProps.bodyRowClassName}
>
{row.getVisibleCells().map((cell) => {
return (
@@ -207,30 +213,37 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
borderBottomWidth="inherit"
borderBottomColor="accent.100"
>
<Menu>
<MenuButton
as={IconButton}
variant="outline"
icon={<Icon as={FaEllipsisVertical} boxSize={4} />}
aria-label="Actions"
/>
<MenuList>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
aria-label="Actions"
className="relative z-10 p-2.5 !h-auto"
>
<FaEllipsisVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{tableProps.onMenuClick.map(
({ icon, text, onClick, isDestructive }) => {
return (
<MenuItem
<DropdownMenuItem
key={text}
onClick={() => onClick(row.original)}
icon={icon && <Icon as={icon} boxSize={4} />}
color={isDestructive ? "red.500" : undefined}
className={cn(
"gap-3 px-3 py-3 min-w-[170px] cursor-pointer",
isDestructive &&
"!text-destructive-text hover:!bg-destructive",
)}
>
{icon && <Icon as={icon} boxSize={4} />}
{text}
</MenuItem>
</DropdownMenuItem>
);
},
)}
</MenuList>
</Menu>
</DropdownMenuContent>
</DropdownMenu>
</Td>
) : null}
</Tr>
+6
View File
@@ -0,0 +1,6 @@
/**
* publicly available wallet for local nodes
* extracted from SDK v4, used to request localhost testnet funds
*/
export const LOCAL_NODE_PKEY =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
+16
View File
@@ -49,3 +49,19 @@ const AddressSchema = z.custom<string>(
export const AddressOrEnsSchema = z.union([AddressSchema, EnsSchema], {
invalid_type_error: "Provided value was not a valid address or ENS name",
});
const FileSchema = z.instanceof(File) as z.ZodType<InstanceType<typeof File>>;
const FileOrStringSchema = z.union([FileSchema, z.string()]);
export const CommonContractSchema = z
.object({
name: z.string(),
description: z.string().optional(),
image: FileOrStringSchema.optional(),
external_link: z.string().optional(),
app_uri: z.string().optional(),
social_urls: z.record(z.string()).optional(),
defaultAdmin: AddressOrEnsSchema.optional(),
})
.catchall(z.unknown());
@@ -4,6 +4,7 @@ import { useEns } from "components/contract-components/hooks";
import { ContractOverviewPage } from "contract-ui/tabs/overview/page";
import type { EnhancedRoute } from "contract-ui/types/types";
import dynamic from "next/dynamic";
import { useAnalyticsSupportedForChain } from "../../data/analytics/hooks";
const LazyContractExplorerPage = dynamic(() =>
import("../tabs/explorer/page").then(
@@ -100,6 +101,10 @@ export function useContractRouteConfig(
const contractQuery = useContract(ensQuery.data?.address);
const contractTypeQuery = contractType.useQuery(contractAddress);
const analyticsSupported = useAnalyticsSupportedForChain(
contractQuery.contract?.chainId,
);
const claimconditionExtensionDetection = extensionDetectedState({
contractQuery,
feature: [
@@ -158,6 +163,11 @@ export function useContractRouteConfig(
component: LazyContractAnalyticsPage,
isDefault: true,
isBeta: true,
isEnabled: analyticsSupported.isLoading
? "loading"
: analyticsSupported.data
? "enabled"
: "disabled",
},
{
title: "NFTs",
@@ -9,23 +9,15 @@ import {
Flex,
SimpleGrid,
Skeleton,
Stack,
Stat,
StatLabel,
StatNumber,
} from "@chakra-ui/react";
import type { UseQueryResult } from "@tanstack/react-query";
import type {
AreaChartProps,
GenericDataType,
} from "components/analytics/area-chart";
import { AutoBarChart } from "components/analytics/auto-bar-chart";
import { BarChart } from "components/analytics/bar-chart";
import { ChartContainer } from "components/analytics/chart-container";
import {
type AnalyticsQueryParams,
type TotalQueryResult,
useAnalyticsSupportedChains,
useAnalyticsSupportedForChain,
useEventsAnalytics,
useFunctionsAnalytics,
useLogsAnalytics,
@@ -36,7 +28,8 @@ import {
useUniqueWalletsAnalytics,
} from "data/analytics/hooks";
import { Suspense, useMemo, useState } from "react";
import { Card, Heading, Text } from "tw-components";
import { Card, Heading } from "tw-components";
import { ThirdwebBarChart } from "../../../@/components/blocks/charts/bar-chart";
import { useIsomorphicLayoutEffect } from "../../../@/lib/useIsomorphicLayoutEffect";
interface ContractAnalyticsPageProps {
@@ -61,11 +54,32 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
const evmContractInfo = useEVMContractInfo();
if (!contractAddress) {
const analyticsSupported = useAnalyticsSupportedForChain(
evmContractInfo?.chain?.chainId,
);
if (
!contractAddress ||
!evmContractInfo?.chain?.chainId ||
analyticsSupported.isLoading
) {
// TODO build a skeleton for this
return <div>Loading...</div>;
}
if (!analyticsSupported.data) {
return (
<Alert status="warning" borderRadius="md" mb={4}>
<AlertIcon />
<AlertTitle>Analytics is not supported for this chain.</AlertTitle>
<AlertDescription>
Analytics support is rolling out to additional chains. Please check
back later.
</AlertDescription>
</Alert>
);
}
return (
<Flex direction="column" gap={6}>
{contractAddress && evmContractInfo?.chain && (
@@ -111,124 +125,40 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
</Flex>
</Flex>
<SimpleGrid columns={{ base: 1, md: 1 }} gap={4}>
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
<Stack spacing={0}>
<Heading as="h3" size="subtitle.sm">
Unique Wallets
</Heading>
<Text>
The number of unique wallet addresses that have sent a
transaction to this contract.
</Text>
</Stack>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
startDate={startDate}
endDate={endDate}
index={"time"}
categories={[{ id: "wallets", label: "Unique Wallets" }]}
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
useAnalytics={useUniqueWalletsAnalytics}
/>
</ChartContainer>
</Flex>
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
<Stack spacing={0}>
<Heading as="h3" size="subtitle.sm">
Total Transactions
</Heading>
<Text>
The number of transactions that have been sent to this
contract.
</Text>
</Stack>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
startDate={startDate}
endDate={endDate}
index={"time"}
categories={[{ id: "count", label: "Transactions" }]}
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
useAnalytics={useTransactionAnalytics}
/>
</ChartContainer>
</Flex>
<UniqueWalletsChart
chainId={evmContractInfo.chain.chainId}
contractAddress={contractAddress}
startDate={startDate}
endDate={endDate}
/>
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
<Stack spacing={0}>
<Heading as="h3" size="subtitle.sm">
Total Events
</Heading>
<Text>
The number of on-chain events that have been emitted from this
contract.
</Text>
</Stack>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
startDate={startDate}
endDate={endDate}
index={"time"}
categories={[{ id: "count", label: "Events" }]}
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
useAnalytics={useLogsAnalytics}
/>
</ChartContainer>
</Flex>
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
<Stack spacing={0}>
<Heading as="h3" size="subtitle.sm">
Function Breakdown
</Heading>
<Text>
The breakdown of calls to each write function from
transactions.
</Text>
</Stack>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
startDate={startDate}
endDate={endDate}
index={"time"}
categories={"auto"}
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
useAnalytics={useFunctionsAnalytics}
/>
</ChartContainer>
</Flex>
<Flex flexDir="column" gap={4} as={Card} bg="backgroundHighlight">
<Stack spacing={0}>
<Heading as="h3" size="subtitle.sm">
Event Breakdown
</Heading>
<Text>The breakdown of events emitted by this contract.</Text>
</Stack>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
startDate={startDate}
endDate={endDate}
index={"time"}
categories={"auto"}
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
useAnalytics={useEventsAnalytics}
/>
</ChartContainer>
</Flex>
<TotalTransactionsChart
chainId={evmContractInfo.chain.chainId}
contractAddress={contractAddress}
startDate={startDate}
endDate={endDate}
/>
<TotalEventsChart
chainId={evmContractInfo.chain.chainId}
contractAddress={contractAddress}
startDate={startDate}
endDate={endDate}
/>
<FunctionBreakdownChart
chainId={evmContractInfo.chain.chainId}
contractAddress={contractAddress}
startDate={startDate}
endDate={endDate}
/>
<EventBreakdownChart
chainId={evmContractInfo.chain.chainId}
contractAddress={contractAddress}
startDate={startDate}
endDate={endDate}
/>
</SimpleGrid>
</>
)}
@@ -236,87 +166,127 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
);
};
interface AnalyticsChartProps<
TAnalytics extends GenericDataType = GenericDataType,
> {
chainId: number;
type ChartProps = {
contractAddress: string;
chainId: number;
startDate: Date;
endDate: Date;
index: string;
categories: AreaChartProps<TAnalytics, "time">["categories"] | "auto";
useAnalytics: (params: AnalyticsQueryParams) => UseQueryResult<TAnalytics[]>;
showXAxis?: boolean;
showYAxis?: boolean;
}
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
chainId,
contractAddress,
startDate,
endDate,
index,
categories,
useAnalytics,
showXAxis,
showYAxis,
}) => {
const supportedChainsQuery = useAnalyticsSupportedChains();
const analyticsQuery = useAnalytics({
contractAddress,
chainId,
startDate,
endDate,
});
const data = useMemo(() => {
if (!analyticsQuery.data) {
return [];
}
return analyticsQuery.data;
}, [analyticsQuery.data]);
if (data.length <= 1) {
const supportedChains = supportedChainsQuery.data ?? [];
return (
<Alert status="info" borderRadius="md" mb={4}>
<AlertIcon />
{supportedChains.includes(chainId) ? (
<AlertDescription>No recent activity.</AlertDescription>
) : (
<AlertDescription>
Analytics for this chain not currently supported.
</AlertDescription>
)}
</Alert>
);
}
if (categories === "auto") {
return (
<AutoBarChart
data={data}
index={{ id: index }}
showXAxis
showYAxis
stacked
/>
);
}
};
function UniqueWalletsChart(props: ChartProps) {
const { data } = useUniqueWalletsAnalytics(props);
return (
<BarChart
data={data}
index={{ id: index }}
categories={categories}
showXAxis={showXAxis !== undefined ? showXAxis : true}
showYAxis={showYAxis !== undefined ? showYAxis : true}
<ThirdwebBarChart
title="Unique Wallets"
description="The number of unique wallet addresses that have sent a transaction to this contract."
data={data || []}
config={{
wallets: {
label: "Unique Wallets",
color: "hsl(var(--chart-1))",
},
}}
chartClassName="aspect[2] lg:aspect-[4.5]"
/>
);
};
}
function TotalTransactionsChart(props: ChartProps) {
const { data } = useTransactionAnalytics({
chainId: props.chainId,
contractAddress: props.contractAddress,
endDate: props.endDate,
startDate: props.startDate,
});
return (
<ThirdwebBarChart
title="Total Transactions"
description="The number of transactions that have been sent to this contract."
data={data || []}
config={{
count: {
label: "Transactions",
color: "hsl(var(--chart-1))",
},
}}
chartClassName="aspect[2] lg:aspect-[4.5]"
/>
);
}
function TotalEventsChart(props: ChartProps) {
const { data } = useLogsAnalytics(props);
return (
<ThirdwebBarChart
title="Total Events"
description="The number of on-chain events that have been emitted from this contract."
data={data || []}
config={{
count: {
label: "Events",
color: "hsl(var(--chart-1))",
},
}}
chartClassName="aspect[2] lg:aspect-[4.5]"
/>
);
}
function FunctionBreakdownChart(props: ChartProps) {
const { data } = useFunctionsAnalytics(props);
return (
<ThirdwebBarChart
title="Function Breakdown"
description="The breakdown of calls to each write function from transactions."
data={data || []}
config={Object.keys(data?.[0] || {}).reduce(
(acc, key) => {
if (key === "time") {
return acc;
}
acc[key] = {
label: key,
color: `hsl(var(--chart-${(Object.keys(acc).length % 15) + 1}))`,
};
return acc;
},
{} as Record<string, { label: string; color: string }>,
)}
chartClassName="aspect[2] lg:aspect-[4.5]"
showLegend
/>
);
}
function EventBreakdownChart(props: ChartProps) {
const { data } = useEventsAnalytics(props);
return (
<ThirdwebBarChart
title="Event Breakdown"
description="The breakdown of events emitted by this contract."
data={data || []}
config={Object.keys(data?.[0] || {}).reduce(
(acc, key) => {
if (key === "time") {
return acc;
}
acc[key] = {
label: key,
color: `hsl(var(--chart-${(Object.keys(acc).length % 15) + 1}))`,
};
return acc;
},
{} as Record<string, { label: string; color: string }>,
)}
chartClassName="aspect[2] lg:aspect-[4.5]"
showLegend
/>
);
}
interface AnalyticsStatProps {
label: string;
@@ -615,7 +615,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
// TODO: Automatically start editing the new phase after adding it
}}
>
<Flex>
<div className="flex items-center gap-1">
{ClaimConditionTypeData[type].name}
<TooltipBox
content={
@@ -627,7 +627,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
</Text>
}
/>
</Flex>
</div>
</MenuItem>
);
})}
@@ -1,3 +1,4 @@
import { ToolTipLabel } from "@/components/ui/tooltip";
import { thirdwebClient } from "@/constants/client";
import { AdminOnly } from "@3rdweb-sdk/react/components/roles/admin-only";
import { Box } from "@chakra-ui/react";
@@ -6,12 +7,11 @@ import {
useResetClaimConditions,
} from "@thirdweb-dev/react";
import { TransactionButton } from "components/buttons/TransactionButton";
import { TooltipBox } from "components/configure-networks/Form/TooltipBox";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { useV5DashboardChain } from "lib/v5-adapter";
import { CircleHelpIcon } from "lucide-react";
import { getContract } from "thirdweb";
import { Text } from "tw-components";
interface ResetClaimEligibilityProps {
isErc20: boolean;
@@ -88,18 +88,19 @@ export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
size="sm"
>
Reset Eligibility{" "}
<TooltipBox
iconColor="secondary.500"
content={
<Text>
This contract&apos;s claim eligibility stores who has already
<ToolTipLabel
label={
<>
This {`contract's`} claim eligibility stores who has already
claimed {isErc20 ? "tokens" : "NFTs"} from this contract and
carries across claim phases. Resetting claim eligibility will
reset this state permanently, and wallets that have already
claimed to their limit will be able to claim again.
</Text>
</>
}
/>
>
<CircleHelpIcon className="size-4 text-muted-foreground ml-2" />
</ToolTipLabel>
</TransactionButton>
</AdminOnly>
);
@@ -11,13 +11,13 @@ import {
useClipboard,
} from "@chakra-ui/react";
import { IoMdCheckmark } from "@react-icons/all-files/io/IoMdCheckmark";
import type { DropContract } from "@thirdweb-dev/react";
import { useTrack } from "hooks/analytics/useTrack";
import { useSupportedChainsRecord } from "hooks/chains/configureChains";
import { useTxNotifications } from "hooks/useTxNotifications";
import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { FiCopy } from "react-icons/fi";
import type { ThirdwebContract } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import {
Button,
@@ -31,7 +31,7 @@ import {
import type { StoredChain } from "../../../../contexts/configured-chains";
interface EmbedSetupProps {
contract: DropContract;
contract: ThirdwebContract;
ercOrMarketplace: string;
}
@@ -81,7 +81,7 @@ const isValidUrl = (url: string | undefined) => {
};
const buildIframeSrc = (
contract?: DropContract,
contract: ThirdwebContract,
ercOrMarketplace?: string,
options?: IframeSrcOptions,
): string => {
@@ -114,7 +114,7 @@ const buildIframeSrc = (
`https://embed.ipfscdn.io/ipfs/${contractEmbedHash}/${contractPath}`,
);
url.searchParams.append("contract", contract.getAddress());
url.searchParams.append("contract", contract.address);
url.searchParams.append("chain", chain);
url.searchParams.append("clientId", clientId);
@@ -565,7 +565,7 @@ export const EmbedSetup: React.FC<EmbedSetupProps> = ({
category: "embed",
action: "click",
label: "copy-code",
address: contract?.getAddress(),
address: contract.address,
chainId,
});
}}
@@ -1,6 +1,9 @@
import { Flex } from "@chakra-ui/react";
import { getErcs, useContract, useContractType } from "@thirdweb-dev/react";
import { detectFeatures } from "components/contract-components/utils";
import { thirdwebClient } from "lib/thirdweb-client";
import { useV5DashboardChain } from "lib/v5-adapter";
import { getContract } from "thirdweb";
import { EmbedSetup } from "./components/embed-setup";
interface ContractEmbedPageProps {
@@ -33,18 +36,27 @@ export const ContractEmbedPage: React.FC<ContractEmbedPageProps> = ({
? "erc721"
: null;
const chain = useV5DashboardChain(contractQuery.contract?.chainId);
if (contractQuery.isLoading) {
// TODO build a skeleton for this
return <div>Loading...</div>;
}
if (!contractQuery.contract || !chain) {
return null;
}
const contract = getContract({
address: contractQuery.contract.getAddress(),
chain,
client: thirdwebClient,
});
return (
<Flex direction="column" gap={6}>
{contractQuery?.contract && ercOrMarketplace && (
<EmbedSetup
contract={contractQuery.contract}
ercOrMarketplace={ercOrMarketplace}
/>
<EmbedSetup contract={contract} ercOrMarketplace={ercOrMarketplace} />
)}
</Flex>
);
@@ -1,25 +1,21 @@
import { Flex, GridItem, Icon, SimpleGrid, Tooltip } from "@chakra-ui/react";
import { ChartContainer } from "components/analytics/chart-container";
import { AnalyticsChart } from "contract-ui/tabs/analytics/page";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { Button } from "@/components/ui/button";
import { useTabHref } from "contract-ui/utils";
import {
useAnalyticsSupportedForChain,
useLogsAnalytics,
useTransactionAnalytics,
useUniqueWalletsAnalytics,
} from "data/analytics/hooks";
import { useState } from "react";
import { FiInfo } from "react-icons/fi";
import {
Card,
Heading,
TrackedLink,
type TrackedLinkProps,
} from "tw-components";
import { useTrack } from "hooks/analytics/useTrack";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
interface AnalyticsOverviewProps {
chainId: number;
contractAddress: string;
trackingCategory: TrackedLinkProps["category"];
trackingCategory: string;
}
export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
@@ -27,10 +23,13 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
contractAddress,
trackingCategory,
}) => {
const analyticsSupported = useAnalyticsSupportedForChain(chainId);
const trackEvent = useTrack();
const [startDate] = useState(
(() => {
const date = new Date();
date.setDate(date.getDate() - 11);
date.setDate(date.getDate() - 14);
return date;
})(),
);
@@ -38,152 +37,94 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
const analyticsHref = useTabHref("analytics");
return (
<Flex direction="column" gap={{ base: 3, md: 6 }}>
<Flex align="center" justify="space-between" w="full">
<Heading size="title.sm">Analytics</Heading>
<TrackedLink
category={trackingCategory}
label="view_all_nfts"
color="blue.400"
_light={{
color: "blue.600",
}}
gap={4}
href={analyticsHref}
>
View all -&gt;
</TrackedLink>
</Flex>
if (!analyticsSupported.data) {
return null;
}
<SimpleGrid gap={{ base: 3, md: 6 }} columns={{ base: 1, md: 3 }}>
<GridItem>
<Flex
flexDir="column"
gap={4}
as={Card}
bg="backgroundHighlight"
h="full"
>
<Flex align="center" justify="space-between">
<Heading size="label.md">Unique Wallets</Heading>
<Tooltip
p={0}
label={
<Flex p={2} fontSize="small" color="white">
The number of unique wallet addresses that have sent a
transaction to this contract.
</Flex>
}
bgColor="backgroundCardHighlight"
borderRadius="lg"
placement="right"
shouldWrapChildren
>
<Icon as={FiInfo} boxSize={4} color="gray.700" />
</Tooltip>
</Flex>
<ChartContainer className="w-full" ratio={1.7}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={chainId}
startDate={startDate}
endDate={endDate}
index={"time"}
categories={[{ id: "wallets", label: "Unique Wallets" }]}
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
useAnalytics={useUniqueWalletsAnalytics}
showYAxis={false}
/>
</ChartContainer>
</Flex>
</GridItem>
<GridItem>
<Flex
flexDir="column"
gap={4}
as={Card}
bg="backgroundHighlight"
h="full"
>
<Flex align="center" justify="space-between">
<Heading size="label.md">Total Transactions</Heading>
<Tooltip
p={0}
label={
<Flex p={2} fontSize="small" color="white">
The number of transactions that have been sent to this
contract.
</Flex>
}
bgColor="backgroundCardHighlight"
borderRadius="lg"
placement="right"
shouldWrapChildren
>
<Icon as={FiInfo} boxSize={4} color="gray.700" />
</Tooltip>
</Flex>
<ChartContainer className="w-full" ratio={1.7}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={chainId}
startDate={startDate}
endDate={endDate}
index={"time"}
categories={[{ id: "count", label: "Transactions" }]}
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
useAnalytics={useTransactionAnalytics}
showYAxis={false}
/>
</ChartContainer>
</Flex>
</GridItem>
<GridItem>
<Flex
flexDir="column"
gap={4}
as={Card}
bg="backgroundHighlight"
h="full"
>
<Flex align="center" justify="space-between">
<Heading size="label.md">Total Events</Heading>
<Tooltip
p={0}
label={
<Flex p={2} fontSize="small" color="white">
The number of on-chain events that have been emitted from
this contract.
</Flex>
}
bgColor="backgroundCardHighlight"
borderRadius="lg"
placement="right"
shouldWrapChildren
>
<Icon as={FiInfo} boxSize={4} color="gray.700" />
</Tooltip>
</Flex>
<ChartContainer className="w-full" ratio={1.7}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={chainId}
startDate={startDate}
endDate={endDate}
index={"time"}
categories={[{ id: "count", label: "Events" }]}
// FIXME
// eslint-disable-next-line react-compiler/react-compiler
useAnalytics={useLogsAnalytics}
showYAxis={false}
/>
</ChartContainer>
</Flex>
</GridItem>
</SimpleGrid>
</Flex>
return (
<div className="relative">
<Button
asChild
className="absolute top-4 right-6 flex flex-row gap-1 items-center"
size="sm"
variant="outline"
onClick={() => {
trackEvent({
category: trackingCategory,
action: "click",
label: "view_all_analytics",
});
}}
>
<Link href={analyticsHref}>
<span>View All</span>
<ArrowRightIcon className="size-4" />
</Link>
</Button>
<OverviewAnalytics
chainId={chainId}
contractAddress={contractAddress}
endDate={endDate}
startDate={startDate}
/>
</div>
);
};
type ChartProps = {
contractAddress: string;
chainId: number;
startDate: Date;
endDate: Date;
};
function OverviewAnalytics(props: ChartProps) {
const wallets = useUniqueWalletsAnalytics(props);
const transactions = useTransactionAnalytics(props);
const events = useLogsAnalytics(props);
const mergedData = useMemo(() => {
const time = (wallets.data || transactions.data || events.data || []).map(
(wallet) => wallet.time,
);
return time.map((time) => {
const wallet = wallets.data?.find((wallet) => wallet.time === time);
const transaction = transactions.data?.find(
(transaction) => transaction.time === time,
);
const event = events.data?.find((event) => event.time === time);
return {
time,
wallets: wallet?.wallets || 0,
transactions: transaction?.count || 0,
events: event?.count || 0,
};
});
}, [wallets.data, transactions.data, events.data]);
return (
<ThirdwebAreaChart
title="Analytics"
config={{
wallets: {
label: "Unique Wallets",
color: "hsl(var(--chart-1))",
},
transactions: {
label: "Total Transactions",
color: "hsl(var(--chart-2))",
},
events: {
label: "Total Events",
color: "hsl(var(--chart-3))",
},
}}
data={mergedData}
showLegend
chartClassName="aspect[2] lg:aspect-[4.5]"
/>
);
}
@@ -9,26 +9,23 @@ import {
Textarea,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useContractMetadata } from "@thirdweb-dev/react";
import {
CommonContractSchema,
type ValidContractInstance,
} from "@thirdweb-dev/sdk/evm";
import type { ExtensionDetectedState } from "components/buttons/ExtensionDetectButton";
import { TransactionButton } from "components/buttons/TransactionButton";
import { FileInput } from "components/shared/FileInput";
import { CommonContractSchema } from "constants/schemas";
import { useTrack } from "hooks/analytics/useTrack";
import { useInvalidatev4Contract } from "hooks/invalidate-v4-contract";
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
import { useTxNotifications } from "hooks/useTxNotifications";
import { thirdwebClient } from "lib/thirdweb-client";
import { useV5DashboardChain } from "lib/v5-adapter";
import { useMemo } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { FiPlus, FiTrash } from "react-icons/fi";
import { getContract } from "thirdweb";
import { setContractMetadata } from "thirdweb/extensions/common";
import { useSendAndConfirmTransaction } from "thirdweb/react";
import type { ThirdwebContract } from "thirdweb";
import {
getContractMetadata,
setContractMetadata,
} from "thirdweb/extensions/common";
import { useReadContract, useSendAndConfirmTransaction } from "thirdweb/react";
import {
Button,
Card,
@@ -63,27 +60,41 @@ function extractDomain(url: string) {
}
}
export const SettingsMetadata = <
TContract extends ValidContractInstance | undefined,
>({
const SocialUrlSchema = z.record(z.string(), z.string());
export const SettingsMetadata = ({
contract,
detectedState,
}: {
contract: TContract;
contract: ThirdwebContract;
detectedState: ExtensionDetectedState;
}) => {
const trackEvent = useTrack();
const metadata = useContractMetadata(contract);
const metadata = useReadContract(getContractMetadata, { contract });
const sendTransaction = useSendAndConfirmTransaction();
const invalidateContract = useInvalidatev4Contract();
const transformedQueryData = useMemo(() => {
let socialUrls: z.infer<typeof SocialUrlSchema> = {
twitter: "",
discord: "",
};
if (metadata.data?.social_urls) {
try {
const parsed = SocialUrlSchema.parse(metadata.data.social_urls);
socialUrls = parsed;
} catch (err) {
console.error(err);
}
}
return {
...metadata.data,
name: metadata.data?.name || "",
dashboard_social_urls: Object.entries(
metadata.data?.social_urls || { twitter: "", discord: "" },
).map(([key, value]) => ({ key, value })),
image: metadata.data?.image || "",
dashboard_social_urls: Object.entries(socialUrls).map(([key, value]) => ({
key,
value,
})),
};
}, [metadata.data]);
@@ -111,20 +122,10 @@ export const SettingsMetadata = <
name: "dashboard_social_urls",
});
const chain = useV5DashboardChain(contract?.chainId);
const contractV5 =
contract && chain
? getContract({
address: contract.getAddress(),
chain: chain,
client: thirdwebClient,
})
: null;
const { onSuccess, onError } = useTxNotifications(
"Successfully updated metadata",
"Error updating metadata",
contractV5,
contract,
);
return (
@@ -153,9 +154,6 @@ export const SettingsMetadata = <
},
{},
);
if (!contract || !contractV5) {
return;
}
trackEvent({
category: "settings",
@@ -164,7 +162,7 @@ export const SettingsMetadata = <
});
const tx = setContractMetadata({
contract: contractV5,
contract,
...data,
social_urls: socialUrlsObj,
});
@@ -189,8 +187,8 @@ export const SettingsMetadata = <
},
onSettled: () => {
return invalidateContract({
contractAddress: contract.getAddress(),
chainId: contract.chainId,
contractAddress: contract.address,
chainId: contract.chain.id,
});
},
});
@@ -323,24 +321,22 @@ export const SettingsMetadata = <
</Flex>
</Flex>
{contractV5 && (
<AdminOnly contract={contractV5}>
<TransactionButton
colorScheme="primary"
transactionCount={1}
isDisabled={metadata.isLoading || !formState.isDirty}
type="submit"
isLoading={sendTransaction.isPending}
loadingText="Saving..."
size="md"
borderRadius="xl"
borderTopLeftRadius="0"
borderTopRightRadius="0"
>
Update Metadata
</TransactionButton>
</AdminOnly>
)}
<AdminOnly contract={contract}>
<TransactionButton
colorScheme="primary"
transactionCount={1}
isDisabled={metadata.isLoading || !formState.isDirty}
type="submit"
isLoading={sendTransaction.isPending}
loadingText="Saving..."
size="md"
borderRadius="xl"
borderTopLeftRadius="0"
borderTopRightRadius="0"
>
Update Metadata
</TransactionButton>
</AdminOnly>
</Flex>
</Card>
);
@@ -55,12 +55,14 @@ export const ContractSettingsPage: React.FC<ContractSettingsPageProps> = ({
<Flex direction="column" gap={4}>
<Flex gap={8} w="100%">
<SimpleGrid columns={1} w="100%" gap={8}>
<GridItem order={detectedMetadata === "enabled" ? 0 : 100}>
<SettingsMetadata
contract={contractQuery.contract}
detectedState={detectedMetadata}
/>
</GridItem>
{contract && (
<GridItem order={detectedMetadata === "enabled" ? 0 : 100}>
<SettingsMetadata
contract={contract}
detectedState={detectedMetadata}
/>
</GridItem>
)}
{contract && (
<GridItem order={detectedPrimarySale === "enabled" ? 2 : 101}>
<SettingsPrimarySale
@@ -18,11 +18,11 @@ import { useContract } from "@thirdweb-dev/react";
import type { Abi } from "@thirdweb-dev/sdk";
import { SourcesPanel } from "components/contract-components/shared/sources-panel";
import { useContractSources } from "contract-ui/hooks/useContractSources";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { FiCheckCircle, FiXCircle } from "react-icons/fi";
import { toast } from "sonner";
import { Badge, Button, Card, Heading } from "tw-components";
import { useDashboardRouter } from "../../../@/lib/DashboardRouter";
interface ContractSourcesPageProps {
contractAddress?: string;
@@ -257,7 +257,7 @@ function RefreshContractMetadataButton(props: {
chainId: number;
contractAddress: string;
}) {
const router = useRouter();
const router = useDashboardRouter();
const queryClient = useQueryClient();
const contractCacheMutation = useMutation({
mutationFn: async () => {
@@ -65,6 +65,7 @@ export const ContractSidebar: React.FC<ContractSidebarProps> = ({
href: `/${r.path.replace("overview", "")}`,
isBeta: r.isBeta,
isDeprecated: r.isDeprecated,
extensionDetectedState: r.isEnabled,
onClick: () => {
openState.onClose();
},
@@ -131,25 +132,25 @@ const NavLinkSection: React.FC<NavLinkSectionProps> = ({
</Text>
</Flex>
{filteredLinks.map((link) => (
<Flex gap={2} key={link.href}>
<DetailNavLink key={link.href} {...link}>
<DetailNavLink key={link.href} {...link}>
<Flex gap={2} as="span">
{link.title}
</DetailNavLink>
{link.isBeta && (
<Box>
<Badge colorScheme="green" variant="subtle">
Beta
</Badge>
</Box>
)}
{link.isDeprecated && (
<Box>
<Badge colorScheme="orange" variant="subtle">
Deprecated
</Badge>
</Box>
)}
</Flex>
{link.isBeta && (
<Box>
<Badge colorScheme="green" variant="subtle">
Beta
</Badge>
</Box>
)}
{link.isDeprecated && (
<Box>
<Badge colorScheme="orange" variant="subtle">
Deprecated
</Badge>
</Box>
)}
</Flex>
</DetailNavLink>
))}
</Flex>
);
@@ -1 +0,0 @@
export const THIRDWEB_ANALYTICS_API_HOSTNAME = "https://chainsaw.thirdweb.com";
+19 -13
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { THIRDWEB_ANALYTICS_API_HOSTNAME } from "./constants";
export type AnalyticsQueryParams = {
chainId: number;
@@ -17,7 +16,7 @@ async function makeQuery(
.filter(([, value]) => !!value)
.map(([key, value]) => `${key}=${value}`)
.join("&")}`;
return fetch(`${THIRDWEB_ANALYTICS_API_HOSTNAME}${path}${queryString}`, {
return fetch(`/api/server-proxy/chainsaw/${path}${queryString}`, {
method: "GET",
});
}
@@ -374,17 +373,24 @@ export function useTotalWalletsAnalytics(params: AnalyticsQueryParams) {
});
}
export function useAnalyticsSupportedChains() {
return useQuery(
["analytics-supported-chains"] as const,
async (): Promise<number[]> => {
const res = await makeQuery("/api/v1/supported-chains", {});
if (!res.ok) {
throw new Error(`Unexpected status ${res.status}`);
}
export function useAnalyticsSupportedForChain(chainId: number | undefined) {
return useQuery({
queryKey: ["analytics-supported-chains", chainId] as const,
queryFn: async (): Promise<boolean> => {
try {
const res = await makeQuery(`/service/chains/${chainId}`, {});
if (!res.ok) {
// assume not supported if we get a non-200 response
return false;
}
const { results } = await res.json();
return results;
const { data } = await res.json();
return data;
} catch (e) {
console.error("Error checking if analytics is supported for chain", e);
return false;
}
},
);
enabled: !!chainId,
});
}
+1
View File
@@ -144,6 +144,7 @@ export enum PageId {
// thirdweb.com/dashboard/engine
EngineManage = "engine-manage",
EngineCreate = "engine-create",
// ---------------------------------------------------------------------------
// settings pages
@@ -3,14 +3,7 @@ import {
useEVMContractInfo,
useSetEVMContractInfo,
} from "@3rdweb-sdk/react";
import {
Alert,
AlertIcon,
Box,
Container,
Flex,
Spinner,
} from "@chakra-ui/react";
import { Box, Container, Flex } from "@chakra-ui/react";
import {
type DehydratedState,
QueryClient,
@@ -20,7 +13,6 @@ import { AppLayout } from "components/app-layouts/app";
import { ConfigureNetworks } from "components/configure-networks/ConfigureNetworks";
import { ensQuery } from "components/contract-components/hooks";
import { ContractMetadata } from "components/custom-contract/contract-header/contract-metadata";
import { HomepageSection } from "components/product-pages/homepage/HomepageSection";
import { THIRDWEB_DOMAIN } from "constants/urls";
import { SupportedChainsReadyContext } from "contexts/configured-chains";
import { PrimaryDashboardButton } from "contract-ui/components/primary-dashboard-button";
@@ -47,6 +39,8 @@ import { isERC1155 } from "thirdweb/extensions/erc1155";
import { fetchChain } from "utils/fetchChain";
import type { ThirdwebNextPage } from "utils/types";
import { shortenIfAddress } from "utils/usedapp-external";
import { Spinner } from "../../@/components/ui/Spinner/Spinner";
import { Alert } from "../../@/components/ui/alert";
import { ClientOnly } from "../../components/ClientOnly/ClientOnly";
import { DeprecatedAlert } from "../../components/shared/DeprecatedAlert";
import { mapV4ChainToV5Chain } from "../../contexts/map-chains";
@@ -187,35 +181,25 @@ const ContractPage: ThirdwebNextPage = () => {
if (!contractInfo) {
return (
<Flex h="100%" justifyContent="center" alignItems="center">
<Spinner size="xl" />
</Flex>
<div className="h-full flex items-center justify-center">
<Spinner className="size-10" />
</div>
);
}
if (chainNotFound) {
return (
<HomepageSection maxW="container.md" mx="auto">
<Box mb={8} mt={8}>
<Alert borderRadius="md" background="backgroundHighlight">
<AlertIcon />
You tried to connecting to {isSlugNumber ? "Chain" : "Network"} ID{" "}
{`"`}
{chainSlug}
{`"`} but it is not configured yet. Please configure it and try
again.
</Alert>
</Box>
<div className="mx-auto max-w-[500px]">
<Alert variant="warning">
You tried to connecting to {isSlugNumber ? "Chain" : "Network"} ID{" "}
{`"`}
{chainSlug}
{`"`} but it is not configured yet. Please configure it and try again
</Alert>
<Box
border="1px solid"
borderRadius="md"
borderColor="backgroundHighlight"
overflow="hidden"
_light={{
background: "white",
}}
>
<div className="h-10" />
<div className="border border-border rounded-lg">
<ConfigureNetworks
prefillSlug={isSlugNumber ? undefined : chainSlug}
prefillChainId={isSlugNumber ? chainSlug : undefined}
@@ -228,16 +212,16 @@ const ContractPage: ThirdwebNextPage = () => {
}
}}
/>
</Box>
</HomepageSection>
</div>
</div>
);
}
if (!isSupportedChainsReady) {
return (
<Flex h="100%" justifyContent="center" alignItems="center">
<Spinner size="xl" />
</Flex>
<div className="h-full flex items-center justify-center">
<Spinner className="size-10" />
</div>
);
}
@@ -369,9 +353,9 @@ ContractPage.getLayout = (page, props: EVMContractProps) => {
function PageSkeleton() {
return (
<Flex h="100%" justifyContent="center" alignItems="center">
<Spinner size="xl" />
</Flex>
<div className="h-full flex items-center justify-center">
<Spinner className="size-10" />
</div>
);
}
+3
View File
@@ -23,6 +23,7 @@ import { generateBreakpointTypographyCssVars } from "tw-components/utils/typogra
import type { ThirdwebNextPage } from "utils/types";
import chakraTheme from "../theme";
import "@/styles/globals.css";
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
const inter = interConstructor({
subsets: ["latin"],
@@ -269,6 +270,8 @@ const ConsoleApp = memo(function ConsoleApp({
transitionTimingFunction="ease"
/>
<DashboardRouterTopProgressBar />
<TailwindTheme>
<ChakraProvider theme={chakraThemeWithFonts}>
<AnnouncementBanner />
@@ -0,0 +1,39 @@
import type { NextRequest } from "next/server";
export const config = {
runtime: "edge",
};
const handler = async (req: NextRequest) => {
if (!process.env.CHAINSAW_API_KEY) {
return new Response("CHAINSAW_API_KEY not set", { status: 401 });
}
// get the full path from the request including query params
let pathname = req.nextUrl.pathname;
// remove the /api/server-proxy prefix
pathname = pathname.replace(/^\/api\/server-proxy\/chainsaw/, "");
const searchParams = req.nextUrl.searchParams;
searchParams.delete("paths");
// create a new URL object for the chainsaw service
// TODO: this should really point at dev vs prod based on the environment - but for now prod only will have to suffice
const CHAINSAW_URL = new URL("https://chainsaw.thirdweb.com");
CHAINSAW_URL.pathname = pathname;
searchParams.forEach((value, key) => {
CHAINSAW_URL.searchParams.append(key, value);
});
return fetch(CHAINSAW_URL, {
method: req.method,
headers: {
"content-type": "application/json",
// pass the shared secret
"x-service-api-key": process.env.CHAINSAW_API_KEY,
},
body: req.body,
});
};
export default handler;
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineAccessTokens } from "components/engine/permissions/engine-access-tokens";
export default createEnginePage("access-tokens", ({ instance }) => (
<EngineAccessTokens instanceUrl={instance.url} />
));
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineAdmins } from "components/engine/permissions/engine-admins";
export default createEnginePage("admins", ({ instance }) => (
<EngineAdmins instanceUrl={instance.url} />
));
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineConfiguration } from "components/engine/configuration/engine-configuration";
export default createEnginePage("configuration", ({ instance }) => (
<EngineConfiguration instance={instance} />
));
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineContractSubscriptions } from "components/engine/contract-subscription/engine-contract-subscription";
export default createEnginePage("contract-subscriptions", ({ instance }) => (
<EngineContractSubscriptions instanceUrl={instance.url} />
));
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineExplorer } from "components/engine/explorer/engine-explorer";
export default createEnginePage("explorer", ({ instance }) => (
<EngineExplorer instanceUrl={instance.url} />
));
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineOverview } from "components/engine/overview/engine-overview";
export default createEnginePage("overview", ({ instance }) => (
<EngineOverview instanceUrl={instance.url} />
));
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineSystemMetrics } from "components/engine/system-metrics";
export default createEnginePage("metrics", ({ instance }) => (
<EngineSystemMetrics instance={instance} />
));
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineRelayer } from "components/engine/relayer/engine-relayer";
export default createEnginePage("relayers", ({ instance }) => (
<EngineRelayer instanceUrl={instance.url} />
));
@@ -0,0 +1,6 @@
import { createEnginePage } from "components/engine/EnginePage";
import { EngineWebhooks } from "components/engine/configuration/engine-webhooks";
export default createEnginePage("webhooks", ({ instance }) => (
<EngineWebhooks instanceUrl={instance.url} />
));
@@ -0,0 +1,219 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { RocketIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "../../../@/components/ui/button";
import { useDashboardRouter } from "../../../@/lib/DashboardRouter";
import { AppLayout } from "../../../components/app-layouts/app";
import { EngineSidebar } from "../../../components/engine/EngineSidebar";
import {
EngineTierCard,
MONTHLY_PRICE_USD,
} from "../../../components/engine/tier-card";
import { LazyOnboardingBilling } from "../../../components/onboarding/LazyOnboardingBilling";
import { OnboardingModal } from "../../../components/onboarding/Modal";
import { THIRDWEB_API_HOST } from "../../../constants/urls";
import { useTrack } from "../../../hooks/analytics/useTrack";
import { PageId } from "../../../page-id";
import type { ThirdwebNextPage } from "../../../utils/types";
const Page: ThirdwebNextPage = () => {
const trackEvent = useTrack();
const router = useDashboardRouter();
const [selectedTier, setSelectedTier] = useState<"STARTER" | "PREMIUM">();
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] =
useState(false);
const accountQuery = useAccount();
const [isBillingModalOpen, setIsBillingModalOpen] = useState(false);
async function createEngineInstance(tier: "STARTER" | "PREMIUM") {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier: tier,
});
try {
toast.info("Starting Engine deployment");
const res = await fetch(
`${THIRDWEB_API_HOST}/v1/engine/add-cloud-hosted`,
{
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify({
tier: tier,
}),
},
);
if (!res.ok) {
if (res.status === 409) {
toast.warning(
"There is a pending Engine deployment. Please contact [email protected] if this takes longer than 2 hours.",
);
return;
}
const json = await res.json();
const error =
json.error?.message ??
"Unexpected error. Please try again or visit https://thirdweb.com/support.";
throw error;
}
toast.success(
"Thank you! Your Engine deployment will begin shortly. You can view the progress in Overview page.",
);
} catch (e) {
toast.error(`${e}`);
}
}
return (
<div>
{selectedTier && (
<ConfirmEngineTierDialog
tier={selectedTier}
onOpenChange={setIsConfirmationDialogOpen}
open={isConfirmationDialogOpen}
onConfirm={async () => {
setIsConfirmationDialogOpen(false);
// If Payment is already setup, deploy the Engine
if (accountQuery.data?.status === AccountStatus.ValidPayment) {
await createEngineInstance(selectedTier);
} else {
trackEvent({
category: "engine",
action: "click",
label: "clicked-add-payment-method",
});
setIsBillingModalOpen(true);
}
}}
/>
)}
<OnboardingModal
isOpen={isBillingModalOpen}
onClose={() => setIsBillingModalOpen(false)}
>
<LazyOnboardingBilling
onSave={async () => {
if (!selectedTier) {
return;
}
await createEngineInstance(selectedTier);
setIsBillingModalOpen(false);
}}
onCancel={() => setIsBillingModalOpen(false)}
/>
</OnboardingModal>
<h1 className="text-2xl font-semibold tracking-tight mb-2">
Choose an Engine deployment
</h1>
<p className="text-muted-foreground mb-7">
Host Engine on thirdweb with no setup or maintenance required
</p>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<EngineTierCard
tier="STARTER"
onClick={() => {
setSelectedTier("STARTER");
setIsConfirmationDialogOpen(true);
}}
/>
<EngineTierCard
tier="PREMIUM"
isPrimaryCta
onClick={() => {
setSelectedTier("PREMIUM");
setIsConfirmationDialogOpen(true);
}}
/>
<EngineTierCard
tier="ENTERPRISE"
previousTier="Premium Engine"
onClick={() => {
trackEvent({
category: "engine",
action: "click",
label: "clicked-cloud-hosted",
tier: "ENTERPRISE",
});
router.push("/contact-us");
}}
/>
</div>
</div>
);
};
// Confirmation Dialog for confirming the selected Engine Tier
const ConfirmEngineTierDialog = (props: {
tier: "STARTER" | "PREMIUM";
onConfirm: () => void;
onOpenChange: (v: boolean) => void;
open: boolean;
}) => {
const { tier, onConfirm, open, onOpenChange } = props;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[400px]">
<DialogHeader>
<DialogTitle className="leading-snug pr-4">
Are you sure you want to deploy a{" "}
{tier === "STARTER" ? "Standard" : "Premium"} Engine?
</DialogTitle>
<DialogDescription>
You will be charged ${MONTHLY_PRICE_USD[tier]} per month for the
subscription
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-5">
<Button onClick={() => onOpenChange(false)} variant="outline">
Cancel
</Button>
<Button onClick={onConfirm} variant="primary" className="gap-2">
<RocketIcon className="size-3" />
Deploy now
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
Page.pageId = PageId.EngineCreate;
export default Page;
Page.getLayout = (page, props) => (
<AppLayout {...props} hasSidebar={true}>
<EngineSidebar />
{page}
</AppLayout>
);
@@ -0,0 +1,148 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { FormControl } from "@chakra-ui/react";
import {
CircleAlertIcon,
CloudDownloadIcon,
ExternalLinkIcon,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useDashboardRouter } from "../../../@/lib/DashboardRouter";
import { AppLayout } from "../../../components/app-layouts/app";
import { EngineSidebar } from "../../../components/engine/EngineSidebar";
import { THIRDWEB_API_HOST } from "../../../constants/urls";
import { useSingleQueryParam } from "../../../hooks/useQueryParam";
import { PageId } from "../../../page-id";
import { FormLabel } from "../../../tw-components";
import type { ThirdwebNextPage } from "../../../utils/types";
type ImportEngineInput = {
name: string;
url: string;
};
const Page: ThirdwebNextPage = () => {
const defaultUrl = useSingleQueryParam("importUrl");
const router = useDashboardRouter();
const form = useForm<ImportEngineInput>({
defaultValues: {
name: "",
url: defaultUrl ? decodeURIComponent(defaultUrl) : undefined,
},
});
const onSubmit = async (data: ImportEngineInput) => {
try {
// Instance URLs should end with a /.
const url = data.url.endsWith("/") ? data.url : `${data.url}/`;
const res = await fetch(`${THIRDWEB_API_HOST}/v1/engine`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: data.name,
url,
}),
});
if (!res.ok) {
throw new Error(`Unexpected status ${res.status}`);
}
toast.success("Engine imported successfully");
router.push("/dashboard/engine");
} catch (e) {
toast.error(
"Error importing Engine. Please check if the details are correct.",
);
}
};
return (
<div className="max-w-[550px] mx-auto">
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
Import Engine Instance
</h1>
<div className="h-3" />
<p className="text-muted-foreground">
Import an Engine instance hosted on your infrastructure.
</p>
<div className="h-3" />
<TrackedLinkTW
className="p-3 rounded-lg border border-border bg-secondary flex items-center gap-2 justify-between hover:bg-accent text-sm"
href="https://portal.thirdweb.com/infrastructure/engine/get-started"
target="_blank"
category="engine"
label="clicked-self-host-instructions"
>
Get help setting up Engine for free
<ExternalLinkIcon className="size-4" />
</TrackedLinkTW>
<div className="h-10" />
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
<FormControl>
<FormLabel>Name</FormLabel>
<Input
type="text"
placeholder="Enter a descriptive label"
autoFocus
{...form.register("name", {
required: "Name is required",
})}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>URL</FormLabel>
<Input
type="url"
placeholder="Enter your Engine URL"
{...form.register("url", {
required: "URL is required",
})}
/>
<div className="flex items-center gap-2 mt-2">
<CircleAlertIcon className="size-3 text-warning-foreground !static" />
<p className="text-sm text-muted-foreground">
Do not import a URL you do not recognize{" "}
</p>
</div>
</FormControl>
</div>
<div className="h-10" />
<Button
type="submit"
variant="primary"
className="w-full text-base gap-2"
>
<CloudDownloadIcon className="size-4" />
Import
</Button>
</form>
</div>
);
};
Page.pageId = PageId.EngineCreate;
Page.getLayout = (page, props) => (
<AppLayout {...props} hasSidebar={true}>
<EngineSidebar />
{page}
</AppLayout>
);
export default Page;
@@ -1,38 +1,16 @@
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
import { AppLayout } from "components/app-layouts/app";
import { EngineInstancesList } from "components/engine/engine-list";
import { EngineNavigation } from "components/engine/engine-navigation";
import { SidebarNav } from "core-ui/sidebar/nav";
import { PageId } from "page-id";
import { useState } from "react";
import type { ThirdwebNextPage } from "utils/types";
import { EngineSidebar } from "../../../components/engine/EngineSidebar";
const EngineManage: ThirdwebNextPage = () => {
const [connectedInstance, setConnectedInstance] = useState<
EngineInstance | undefined
>();
if (connectedInstance) {
return (
// contains its own sidebarnav component
<EngineNavigation
instance={connectedInstance}
setConnectedInstance={setConnectedInstance}
/>
);
}
return (
<>
{/* just placeholder, so it doesn't look empty */}
<SidebarNav title="Engine" />
<EngineInstancesList setConnectedInstance={setConnectedInstance} />
</>
);
return <EngineInstancesList />;
};
EngineManage.getLayout = (page, props) => (
<AppLayout {...props} hasSidebar={true}>
<EngineSidebar />
{page}
</AppLayout>
);
Submodule apps/hardhat-boilerplate deleted from 638fd58ec8
+1 -2
View File
@@ -47,10 +47,9 @@ export default function RootLayout({
sansFont.variable,
monoFont.variable,
)}
suppressHydrationWarning
>
<Providers>
<div className="relative flex min-h-screen flex-col">
<div className="relative flex flex-col min-h-screen">
<Header />
{children}
<div
+2 -2
View File
@@ -26,7 +26,7 @@ Engine enables your app to:
- Deploy and interact with smart accounts.
- Subscribe to contract events and transactions.
🚀 [Get Engine hosted by thirdweb](https://thirdweb.com/dashboard/engine?requestCloudHosted) or [self-host for free](/engine/self-host).
🚀 [Get Engine hosted by thirdweb](https://thirdweb.com/dashboard/engine/create) or [self-host for free](/engine/self-host).
<DocImage
src={OverviewImage}
@@ -78,7 +78,7 @@ Engine Premium provides added scaling and resilience. It include server autoscal
## Pricing
Cloud-hosting Engine with thirdweb starts at $99 per month. Learn more about [Engine pricing](https://thirdweb.com/dashboard/engine?requestCloudHosted).
Cloud-hosting Engine with thirdweb starts at $99 per month. Learn more about [Engine pricing](https://thirdweb.com/dashboard/engine/create).
Self-hosting Engine is always free.
@@ -10,7 +10,7 @@ dashboard](https://thirdweb.com/dashboard/engine).
variant="info"
>
[Get Engine hosted by thirdweb.](https://thirdweb.com/dashboard/engine?requestCloudHosted)
[Get Engine hosted by thirdweb.](https://thirdweb.com/dashboard/engine/create)
</Callout>
+12
View File
@@ -0,0 +1,12 @@
# Root domain for the app
NEXT_PUBLIC_ROOT_DOMAIN=ecosystem.localhost:3000
# Thirdweb client ID
NEXT_PUBLIC_THIRDWEB_CLIENT_ID=
# Thirdweb API key
THIRDWEB_SECRET_KEY=
# SimpleHash API key
SIMPLEHASH_API_KEY=
# Whether or not to include testnets in the UI
NEXT_PUBLIC_INCLUDE_TESTNETS=1
# Admin private key to generate JWT with
THIRDWEB_ADMIN_PRIVATE_KEY=
+2 -1
View File
@@ -26,7 +26,8 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env*
.env.local
.env
# vercel
.vercel
+1 -4
View File
@@ -1,7 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.1/schema.json",
"extends": ["../../biome.json"],
"files": {
"ignore": []
}
"extends": ["../../biome.json"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "gray",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+15 -7
View File
@@ -35,15 +35,23 @@ const securityHeaders = [
},
];
export async function headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
}
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
webpack: (config) => {
config.externals.push("pino-pretty", "lokijs", "encoding");
return config;
},
images: {
dangerouslyAllowSVG: true,
},
};
+20 -1
View File
@@ -10,10 +10,29 @@
"fix": "biome check ./src --fix"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@noble/curves": "1.4.0",
"@radix-ui/react-dialog": "1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.48.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"lucide-react": "^0.399.0",
"next": "14.2.5",
"next-themes": "^0.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"thirdweb": "workspace:*"
"react-hook-form": "7.52.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"thirdweb": "workspace:*",
"tiny-invariant": "^1.3.3",
"zod": "3.23.8"
},
"devDependencies": {
"@types/node": "20.14.9",
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1,35 @@
import ConnectButton from "@/components/ConnectButton";
import { authedOnly } from "@/lib/auth";
import { getEcosystemInfo } from "@/lib/ecosystems";
export default async function Layout({
children,
params,
}: {
children: React.ReactNode;
params: { ecosystem: string };
}) {
await authedOnly();
const ecosystem = await getEcosystemInfo(params.ecosystem);
return (
<div className="flex flex-col items-stretch w-full">
<header className="hidden sm:block w-full py-4 border-b border-accent bg-card">
<div className="container mx-auto flex justify-between">
<div className="flex items-center gap-2">
<img
className="w-8 h-8"
src={ecosystem.imageUrl}
alt={ecosystem.name}
width={100}
height={100}
/>
<h1 className="text-xl font-semibold">{ecosystem.name}</h1>
</div>
<ConnectButton ecosystem={`ecosystem.${params.ecosystem}`} />
</div>
</header>
<main className="flex w-full h-full">{children}</main>
</div>
);
}
@@ -0,0 +1,73 @@
import { ChainCombobox } from "@/components/ChainCombobox";
import { getCurrentUser } from "@/lib/auth";
import { getChains } from "@/lib/chains";
import { client } from "@/lib/client";
import { getEcosystemInfo } from "@/lib/ecosystems";
import { SIMPLEHASH_NFT_SUPPORTED_CHAIN_IDS } from "@/util/simplehash";
import type { Metadata, ResolvingMetadata } from "next";
import { redirect } from "next/navigation";
import { resolveName } from "thirdweb/extensions/ens";
import { shortenAddress } from "thirdweb/utils";
export async function generateMetadata(
{ params }: { params: { ecosystem: string; address: string } },
parent: ResolvingMetadata,
): Promise<Metadata> {
const [ecosystem, parentMetadata] = await Promise.all([
getEcosystemInfo(params.ecosystem),
parent,
]);
const previousImages = parentMetadata.openGraph?.images || [];
return {
title: `${ecosystem.name} | ${shortenAddress(params.address)}`,
openGraph: {
images: [ecosystem.imageUrl, ...previousImages],
},
};
}
export default async function Layout({
children,
params,
}: {
children: React.ReactNode;
params: { ecosystem: string; address: string };
}) {
const userAddressPromise = getCurrentUser();
const ensPromise = resolveName({
client,
address: params.address,
});
const thirdwebChainsPromise = getChains();
const [userAddress, ens, thirdwebChains] = await Promise.all([
userAddressPromise,
ensPromise,
thirdwebChainsPromise,
]);
if (userAddress !== params.address) {
redirect(`/wallet/${userAddress}`);
}
const simpleHashChains = thirdwebChains.filter((chain) =>
SIMPLEHASH_NFT_SUPPORTED_CHAIN_IDS.includes(chain.chainId),
);
return (
<div className="flex flex-col w-full">
<div className="mb-4 border-b pt-36 bg-card">
<div className="container flex flex-col items-end justify-between w-full gap-4 py-4 md:flex-row bg-card">
<h2 className="text-4xl font-bold">
{ens || shortenAddress(params.address)}
</h2>
<ChainCombobox chains={simpleHashChains} />
</div>
</div>
<div className="container flex-1 py-4">
<div className="h-full">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import { NftGalleryLoading } from "@/components/NftGallery";
export default function Loading() {
return <NftGalleryLoading />;
}

Some files were not shown because too many files have changed in this diff Show More