Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2c7d8411b9 Version Packages (#7863)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-18 12:25:40 -07:00
gregandGitHub 16ab7da64f [Dashboard] Fix: Do NOT use next's image component (#7869) 2025-08-18 12:18:03 -07:00
MananTank 467ba50907 [BLD-125] SDK: Fix missing props for details modal on useWalletDetailsModal hook (#7865)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `useWalletDetailsModal` hook by adding missing properties for better configuration of the details modal, along with adjustments to the UI components for displaying wallet details.

### Detailed summary
- Added missing props for the details modal in the `useWalletDetailsModal` hook.
- Updated `DetailsModal` to include `manageWallet`, `showBalanceInFiat`, and `hiddenWallets` props.
- Modified the `TokenTab` and `NftTab` components to use inline styles instead of utility classes.
- Enhanced storybook stories for `useWalletDetailsModal` with various configurations and examples.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- New Features
  - Wallet Details modal: show balances in fiat, configure manage-wallet options, hide specific wallets, and pass connect options from the switcher.
  - Assets view: default active tab now adapts dynamically based on available tabs.
- Documentation
  - Added comprehensive Storybook examples for the wallet details modal covering configuration, theming, locales, and callbacks.
  - Clarified notes to keep related modal options in sync.
- Style
  - Updated tab label styling to inline CSS for consistent layout.
- Chores
  - Added a patch-level changeset.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-19 00:14:53 +05:30
gregandGitHub 2f627e1483 [Dashboard] Feature: New payments page ui (#7848) 2025-08-18 11:41:49 -07:00
Jonas DanielsandGitHub 6b80f75e3a Make RPC optional in chain infrastructure checkout (#7867) 2025-08-18 11:36:54 -07:00
MananTank c43c686eac SDK, Dashboard: Fix error when airdropping decimal token amount in new token creation flow (#7866)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on changing the data type of the `amount` property in the `DistributeContent` type and related usages from `bigint` to `string` to accommodate string representations of numbers.

### Detailed summary
- Changed `amount` type in `DistributeContent` from `bigint` to `string`.
- Updated `amount` assignment in `create-token-page-impl.tsx` to use `recipient.quantity` directly as a `string`.
- Modified the total amount calculation to sum `recipient.quantity` as `Number` instead of `BigInt`.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed inconsistencies in token airdrop amount handling, ensuring accurate totals and smoother approvals.
  * Resolved submission issues caused by mismatched numeric formats during distribution.

* **Refactor**
  * Standardized how token distribution amounts are represented to improve compatibility with forms and API requests.
  * Simplified airdrop calculations for better reliability and user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-18 17:42:55 +00:00
MananTank 92896ddf3f Create shared UI library - @workspace/ui (#7855)
created a @workspace/ui package - shared by dashboard and playground. for now it only has button component for testing

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refactoring the `postcss` and `tailwind` configurations across multiple applications to centralize styles in the `@workspace/ui` package, along with various updates to components and layouts.

### Detailed summary
- Deleted `postcss.config.js` files and replaced them with `postcss.config.mjs` referencing `@workspace/ui`.
- Updated CSS imports to use `@workspace/ui/global.css`.
- Added `tailwindcss` configuration in `@workspace/ui` and updated references.
- Introduced new utility functions in `packages/ui/src/lib/utils.ts`.
- Modified component imports to utilize centralized components from `@workspace/ui`.
- Updated `package.json` files to include `@workspace/ui` as a dependency.
- Refactored button components to use a centralized button definition in `@workspace/ui`.
- Adjusted TypeScript configurations to align with the new structure.
- Enhanced global styles and configurations across multiple applications to ensure consistency.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- New Features
  - Added a shared UI package and unified Button component used across apps.

- Style
  - Apps now load shared global styles and animations from the UI package; global CSS and theme tokens consolidated.

- Bug Fixes
  - AI chat header uses a safe fallback when the client ID is missing.

- Refactor
  - Replaced local UI implementations with shared re-exports and composed Tailwind configs.

- Chores
  - Added UI package dependencies, PostCSS delegation, and enabled Next.js transpilation for the UI package; lint/scan ignore lists updated.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-16 12:58:24 +00:00
gregandGitHub a08b62be9a Feature: swapping guide (#7864) 2025-08-15 19:49:31 -06:00
MananTank 7f116577ab SDK: Fix PayEmbed not rendering anything when mode prop is not set (#7858)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `PayEmbed` component to default to a mode of "fund_wallet" with an amount of "0.01" and chain set to `ethereum` when no mode is specified. It also includes new story examples for the `PayEmbed` component.

### Detailed summary
- Deleted `PayEmbed.stories.ts` and test files.
- Updated `PayEmbed` component to default `mode` to "fund_wallet" and `amount` to "0.01".
- Added handling for `chain` defaulting to `ethereum`.
- Created multiple story functions for various use cases of `PayEmbed`.
- Included stories for different payment modes and configurations.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * PayEmbed now defaults to a wallet-funding flow when mode is missing (amount 0.01; Ethereum chain) and tolerates partial prefill data.

* **Enhancements**
  * Buy flow renders more flexibly when mode is omitted or set to fund_wallet.

* **Documentation**
  * Storybook updated/reorganized with comprehensive PayEmbed examples: fund wallet, direct payments, transactions, ERC20, metadata, and theme variants.

* **Chores**
  * Added a changeset for a patch release.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-15 23:28:17 +00:00
kumaryash90 d227bc389b Dashboard, SDK: ERC20 assets (#7321)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `thirdweb` package by introducing new functionalities and updates for managing ERC20 assets, including the implementation of various events, functions, and utility methods for asset deployment and interaction.

### Detailed summary
- Added `initialize` functions to various asset-related contracts.
- Introduced event handling for `AssetDistributed`, `RouterUpdated`, and `RewardLockerUpdated`.
- Implemented methods for checking router and reward locker support.
- Enhanced the `distributeToken` function to handle multiple recipients.
- Updated ABI files for new function signatures and events.
- Improved utility functions for encoding parameters and transaction preparation.

> The following files were skipped due to too many changes: `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/initialize.ts`, `packages/thirdweb/src/extensions/assets/__generated__/FeeManager/write/initialize.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/listAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/distributeAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/createAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/sellAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/buyAsset.ts`, `packages/thirdweb/src/extensions/assets/__generated__/ERC20Asset/write/initialize.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/addImplementation.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/createAssetById.ts`, `packages/thirdweb/src/extensions/assets/__generated__/AssetInfraDeployer/write/deployInfraProxyDeterministic.ts`, `packages/thirdweb/package.json`, `packages/thirdweb/src/extensions/assets/__generated__/AssetEntrypointERC20/write/createAssetByImplementationConfig.ts`, `packages/thirdweb/src/assets/bootstrap.ts`

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added on-page Rewards workflow (Claim Rewards page, UI, and sidebar link) and expanded token creation flows for ERC20 assets with dedicated sale/airdrop options.

* **UI / UX**
  * Improved token sale & distribution UIs, grouped recent transfers, loading placeholders, styling tweaks, chain prioritization in network selectors, customizable wallet fallback icon, and chart legend/value display.

* **API / Props**
  * Several component props and callbacks updated to include chain/contract info and router availability.

* **Chores**
  * Release metadata added for an ERC20 assets patch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-15 22:33:20 +00:00
Jonas DanielsandGitHub 6c711e8783 Rename /nebula to /ai and add redirect (#7862) 2025-08-15 15:11:56 -07:00
kumaryash90 6f581c80b3 Update default config (#7859)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on the removal of the `THIRDWEB_PUBLISHER_ADDRESS` constant from the `addresses.ts` file and its associated usage in the `custom-contract.tsx` file, simplifying the logic related to default fee configuration.

### Detailed summary
- Removed the `THIRDWEB_PUBLISHER_ADDRESS` constant from `addresses.ts`.
- Updated `custom-contract.tsx` to eliminate the use of `THIRDWEB_PUBLISHER_ADDRESS`.
- Simplified the `hasInbuiltDefaultFeeConfig` logic by only checking `defaultFeeRecipientFunction`.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * Corrected default platform-fee selection during contract deployment: when a default fee recipient is configured, the updated default fee rate is applied regardless of publisher identity, ensuring consistent platform fees on non–zero-fee chains.
* **Chores**
  * Removed an unused publisher address constant from configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-15 15:06:06 +00:00
MananTank b1ac15c2ac Dashboard: use BuyWidget instead of CheckoutWidget in TransactionButton (#7857)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `MismatchButton` component by replacing the `CheckoutWidget` with the `BuyWidget`, streamlining the payment interface.

### Detailed summary
- Replaced `CheckoutWidget` with `BuyWidget` in the render method of the `MismatchButton` component.
- Removed the `name` and `seller` props from the `BuyWidget`.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* New Features
  * Introduced a new Buy experience in the pay dialog for adding funds.

* Improvements
  * Streamlined purchase flow with fewer required inputs and a cleaner interface.
  * Pay dialog can be opened and initiated without first connecting a wallet/account.
  * Consistent styling and theming applied across the new purchase widget.

* Chores
  * Updated dependencies to use the latest purchase widget component.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-14 20:05:26 +00:00
MananTank 98eefdf90b Dashboard: Fix No Funds PayEmbed in TransactionButton (#7856)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `MismatchButton` component by replacing the `PayEmbed` with `CheckoutWidget`, adjusting the transition duration in `DynamicHeight`, and streamlining the dialog content structure.

### Detailed summary
- Changed transition duration in `DynamicHeight` from `250ms` to `220ms`.
- Replaced `PayEmbed` with `CheckoutWidget` in the `MismatchButton` component.
- Simplified the dialog content structure by removing the `DynamicHeight` wrapper.
- Adjusted class names for consistency and clarity.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- New Features
  - “Not enough funds” flow now uses a new checkout widget and only appears when your account is connected.

- Style
  - Payment dialog is wider and uses full available width for improved readability.
  - Spacing and layout refined for a cleaner experience.
  - Slightly faster height transition animations for snappier UI feedback.

- Refactor
  - Simplified dialog content by removing an extra height wrapper.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-14 18:58:15 +00:00
MananTank 857d4dfc16 Playground: Fix theme toggle UI when sidebar is collapsed (#7853)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on refining the sidebar component in the application by adjusting styles, modifying the theme toggle functionality, and enhancing the UI elements for better user experience.

### Detailed summary
- Updated `SIDEBAR_WIDTH` and `SIDEBAR_WIDTH_MOBILE` from `"19rem"` to `"18rem"`.
- Removed `ChevronDownIcon` and replaced it with `ChevronRightIcon` in `RenderSidebarSubmenu`.
- Added `<SidebarSeparator />` in `FullWidthSidebarLayoutInner`.
- Replaced `Button` with `SidebarMenu`, `SidebarMenuButton`, and `SidebarMenuItem` in `ThemeToggle`.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- New Features
  - Theme toggle now appears as a sidebar menu item with improved loading placeholders for icon and label.
- UI/UX
  - Sidebar width reduced from 19rem to 18rem for a more compact layout.
  - Chevron indicators updated: right-chevron rotates 90° when open for clearer state.
  - Added a visual separator in the sidebar footer for better grouping.
- Bug Fixes
  - Prevented rendering of empty sidebar menus/submenus.
- Refactor
  - Streamlined imports and removed unused variants; maintained existing toggle behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-13 23:35:36 +00:00
MananTank 22aad59e7d Dashboard: Fix Active Users calculation in team and project analytics charts (#7854)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on modifying the `aggregateFn` function in two files related to analytics highlights cards, ensuring consistent functionality for aggregating data.

### Detailed summary
- In both `highlights-card.tsx` files, the `aggregateFn` function is updated to include logic for handling the `activeUsers` key.
- The function calculates the maximum value for `activeUsers` and sums values for other keys.

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

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * Standardized analytics aggregation across Highlights cards. All metrics, including Active Users, now show the total over the selected period (previously Active Users showed the peak value). Expect updated figures in both Team Analytics Highlights and Project Overview Highlights, ensuring consistency between summary cards and detailed views.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-13 18:47:23 +00:00
Joaquim VergesandGitHub a3b2f8f4b0 [Playground] integration guide link in chat ai footer (#7852) 2025-08-12 19:10:00 -07:00
330 changed files with 25267 additions and 1638 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ module.exports = [
"/storage",
// -- end scale category
// -- nebula
"/nebula",
// -- ai
"/ai",
// -- contracts
"/contracts",
"/contracts/modular-contracts",
+4 -1
View File
@@ -10,8 +10,11 @@
"ignoreDependencies": [
"@thirdweb-dev/service-utils",
"@thirdweb-dev/vault-sdk",
"thirdweb",
"@types/color",
"fast-xml-parser"
"fast-xml-parser",
"@workspace/ui",
"tailwindcss-animate"
],
"next": true,
"project": ["src/**"]
+1 -1
View File
@@ -115,6 +115,7 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
const FRAMER_ADDITIONAL_LANGUAGES = ["es"];
const baseNextConfig: NextConfig = {
transpilePackages: ["@workspace/ui"],
eslint: {
ignoreDuringBuilds: true,
},
@@ -196,7 +197,6 @@ const baseNextConfig: NextConfig = {
]),
];
},
serverExternalPackages: ["pino-pretty"],
};
function getConfig(): NextConfig {
+1
View File
@@ -28,6 +28,7 @@
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.2.2",
"@vercel/og": "^0.6.8",
"@workspace/ui": "workspace:*",
"abitype": "1.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {},
tailwindcss: {},
},
};
+1
View File
@@ -0,0 +1 @@
export { default } from "@workspace/ui/postcss.config";
+6
View File
@@ -460,6 +460,12 @@ async function redirects() {
permanent: false,
source: "/universal-bridge",
},
// redirect /nebula to /ai
{
destination: "/ai",
permanent: false,
source: "/nebula",
},
...legacyDashboardToTeamRedirects,
...projectPageRedirects,
...teamPageRedirects,
+36 -6
View File
@@ -224,7 +224,11 @@ export function reportChainConfigurationAdded(properties: {
// ASSETS
// ----------------------------
type AssetContractType = "DropERC20" | "DropERC1155" | "DropERC721";
type AssetContractType =
| "DropERC20"
| "DropERC1155"
| "DropERC721"
| "ERC20Asset";
/**
* ### Why do we need to report this event?
@@ -334,6 +338,15 @@ export function reportAssetCreationSuccessful(properties: {
});
}
type CoinCreationStep =
| "erc20-asset:deploy-contract"
| "erc20-asset:airdrop-tokens"
| "erc20-asset:approve-airdrop-tokens"
| "drop-erc20:deploy-contract"
| "drop-erc20:set-claim-conditions"
| "drop-erc20:mint-tokens"
| "drop-erc20:airdrop-tokens";
/**
* ### Why do we need to report this event?
* - To track number of failed asset creations
@@ -355,11 +368,7 @@ export function reportAssetCreationFailed(
}
| {
assetType: "coin";
step:
| "deploy-contract"
| "set-claim-conditions"
| "mint-tokens"
| "airdrop-tokens";
step: CoinCreationStep;
}
),
) {
@@ -517,3 +526,24 @@ export function reportTokenUpsellClicked(params: {
}) {
posthog.capture("token upsell clicked", params);
}
// ----------------------------
// CHAIN INFRASTRUCTURE CHECKOUT
// ----------------------------
/**
* ### Why do we need to report this event?
* - To record explicit user acknowledgement when proceeding to checkout without RPC
* - To measure how often customers choose to omit RPC while purchasing Insight and/or Account Abstraction
* - To correlate potential support issues arising from missing RPC
*
* ### Who is responsible for this event?
* @jnsdls
*/
export function reportChainInfraRpcOmissionAgreed(properties: {
chainId: number;
frequency: "monthly" | "annual";
includeInsight: boolean;
includeAccountAbstraction: boolean;
}) {
posthog.capture("chain infra checkout rpc omission agreed", properties);
}
@@ -29,16 +29,18 @@ export function MultiNetworkSelector(props: {
client: ThirdwebClient;
chainIds?: number[];
}) {
let { allChains, idToChain } = useAllChainsData();
if (props.chainIds && props.chainIds.length > 0) {
allChains = allChains.filter((chain) =>
props.chainIds?.includes(chain.chainId),
);
}
const { allChains, idToChain } = useAllChainsData();
const options = useMemo(() => {
let sortedChains = allChains;
let chains = allChains.filter((chain) => chain.status !== "deprecated");
if (props.chainIds && props.chainIds.length > 0) {
chains = allChains.filter((chain) =>
props.chainIds?.includes(chain.chainId),
);
}
let sortedChains = chains;
if (props.priorityChains) {
const priorityChainsSet = new Set();
@@ -69,7 +71,13 @@ export function MultiNetworkSelector(props: {
value: String(chain.chainId),
};
});
}, [allChains, props.priorityChains, idToChain, props.hideTestnets]);
}, [
allChains,
props.priorityChains,
idToChain,
props.hideTestnets,
props.chainIds,
]);
const searchFn = useCallback(
(option: Option, searchValue: string) => {
@@ -155,16 +163,38 @@ export function SingleNetworkSelector(props: {
disableDeprecated?: boolean;
placeholder?: string;
client: ThirdwebClient;
priorityChains?: number[];
}) {
const { allChains, idToChain } = useAllChainsData();
const chainsToShow = useMemo(() => {
let chains = allChains;
chains = chains.filter((chain) => chain.status !== "deprecated");
if (props.disableTestnets) {
chains = chains.filter((chain) => !chain.testnet);
}
if (props.priorityChains) {
const priorityChainsSet = new Set();
for (const chainId of props.priorityChains || []) {
priorityChainsSet.add(chainId);
}
const priorityChains = (props.priorityChains || [])
.map((chainId) => {
return idToChain.get(chainId);
})
.filter((v) => !!v);
const otherChains = allChains.filter(
(chain) => !priorityChainsSet.has(chain.chainId),
);
chains = [...priorityChains, ...otherChains];
}
if (props.chainIds) {
const chainIdSet = new Set(props.chainIds);
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
@@ -180,6 +210,8 @@ export function SingleNetworkSelector(props: {
props.chainIds,
props.disableTestnets,
props.disableDeprecated,
props.priorityChains,
idToChain,
]);
const options = useMemo(() => {
@@ -204,7 +204,7 @@ export function TokenSelector(props: {
searchPlaceholder="Search by name or symbol"
showCheck={props.showCheck}
side={props.side}
value={selectedValue}
value={tokensQuery.isPending ? undefined : selectedValue}
/>
);
}
@@ -3,12 +3,15 @@ import { cn } from "@/lib/utils";
export type Segment = {
label: string;
percent: number;
value: string;
color: string;
};
type DistributionBarChartProps = {
segments: Segment[];
title: string;
title?: string;
titleClassName?: string;
barClassName?: string;
};
export function DistributionBarChart(props: DistributionBarChartProps) {
@@ -21,24 +24,36 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
return (
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-sm">{props.title}</h3>
<div
className={cn(
"font-medium text-muted-foreground text-sm",
invalidTotalPercentage && "text-red-500",
)}
>
Total: {totalPercentage}%
{props.title && (
<div className="mb-2 flex items-center justify-between">
<h3 className={cn("font-medium text-sm", props.titleClassName)}>
{props.title}
</h3>
<div
className={cn(
"font-medium text-muted-foreground text-sm",
invalidTotalPercentage && "text-red-500",
)}
>
Total: {totalPercentage}%
</div>
</div>
</div>
)}
{/* Bar */}
<div className="flex h-3 overflow-hidden rounded-lg">
<div
className={cn(
"flex h-3 overflow-hidden rounded-lg",
props.barClassName,
)}
>
{props.segments.map((segment) => {
return (
<div
className="flex h-full items-center justify-center transition-all duration-200"
className={cn(
"flex h-full items-center justify-center transition-all duration-200",
segment.percent > 0 && "border-r-2 border-background",
)}
key={segment.label}
style={{
backgroundColor: segment.color,
@@ -67,7 +82,7 @@ export function DistributionBarChart(props: DistributionBarChartProps) {
"text-destructive-text",
)}
>
{segment.label}: {segment.percent}%
{segment.label}: {segment.value}
</p>
</div>
);
@@ -34,7 +34,7 @@ export function MultiStepStatus<T extends string>(props: {
}) {
return (
<DynamicHeight>
<div className="space-y-4">
<div className="space-y-4 overflow-hidden">
{props.steps.map((step) => (
<div className="flex items-start space-x-3 " key={step.label}>
{step.status.type === "completed" ? (
@@ -46,7 +46,7 @@ export function MultiStepStatus<T extends string>(props: {
) : (
<CircleIcon className="mt-0.5 size-5 flex-shrink-0 text-muted-foreground/70" />
)}
<div className="flex-1">
<div className="grow">
<p
className={`font-medium ${
step.status.type === "pending"
@@ -73,7 +73,7 @@ export function MultiStepStatus<T extends string>(props: {
{step.status.type === "error"
? props.renderError?.(step, step.status.message) || (
<div className="mt-1 space-y-2">
<p className="mb-1 text-red-500 text-sm">
<p className="mb-1 text-red-500 text-sm whitespace-pre-wrap break-all">
{step.status.message}
</p>
<Button
@@ -1,5 +1,5 @@
"use client";
import { CheckIcon, CopyIcon, XIcon } from "lucide-react";
import { CheckIcon, CircleSlashIcon, CopyIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { isAddress, type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
@@ -23,6 +23,7 @@ export function WalletAddress(props: {
className?: string;
iconClassName?: string;
client: ThirdwebClient;
fallbackIcon?: React.ReactNode;
}) {
// default back to zero address if no address provided
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
@@ -59,9 +60,16 @@ export function WalletAddress(props: {
// special case for zero address
if (address === ZERO_ADDRESS) {
return (
<span className={cn("cursor-pointer font-mono", props.className)}>
{shortenedAddress}
</span>
<div className="flex items-center gap-2 py-2">
<CircleSlashIcon
className={cn("size-6 text-muted-foreground/70", props.iconClassName)}
/>
<span
className={cn("cursor-pointer font-mono text-sm", props.className)}
>
{shortenedAddress}
</span>
</div>
);
}
@@ -82,6 +90,7 @@ export function WalletAddress(props: {
iconClassName={props.iconClassName}
profiles={profiles.data || []}
thirdwebClient={props.client}
fallbackIcon={props.fallbackIcon}
/>
)}
<span className="cursor-pointer font-mono">
@@ -173,6 +182,7 @@ function WalletAvatar(props: {
profiles: SocialProfile[];
thirdwebClient: ThirdwebClient;
iconClassName?: string;
fallbackIcon?: React.ReactNode;
}) {
const avatar = useMemo(() => {
return props.profiles.find(
@@ -199,6 +209,8 @@ function WalletAvatar(props: {
className={cn("size-5 object-cover", props.iconClassName)}
src={resolvedAvatarSrc}
/>
) : props.fallbackIcon ? (
props.fallbackIcon
) : (
<Blobbie
address={props.address}
@@ -300,6 +300,7 @@ const contractTypeToAssetTypeRecord: Record<string, string | undefined> = {
DropERC20: "Coin",
DropERC721: "NFT Collection",
DropERC1155: "NFT Collection",
ERC20Asset: "Coin",
};
const NetworkFilterCell = React.memo(function NetworkFilterCell({
@@ -1,5 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { DialogDescription } from "@radix-ui/react-dialog";
import { useMutation } from "@tanstack/react-query";
import type { ProjectService } from "@thirdweb-dev/service-utils";
import { SERVICES } from "@thirdweb-dev/service-utils";
@@ -18,6 +17,7 @@ import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -1,7 +1,7 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
import type { ThirdwebClient } from "thirdweb";
import { analyticsServerProxy } from "@/actions/proxies";
import {
type SponsoredTransaction,
@@ -18,7 +18,7 @@ import {
} from "thirdweb";
import { type Chain, type ChainMetadata, localhost } from "thirdweb/chains";
import {
PayEmbed,
BuyWidget,
useActiveAccount,
useActiveWallet,
useActiveWalletChain,
@@ -29,7 +29,6 @@ import {
import { privateKeyToAccount, type Wallet } from "thirdweb/wallets";
import { apiServerProxy } from "@/actions/proxies";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Dialog,
DialogContent,
@@ -251,39 +250,32 @@ export const MismatchButton = forwardRef<
>
<DialogContent
className={cn(
"gap-0 p-0",
dialog === "no-funds" && "md:!max-w-[480px]",
dialog === "pay" && "md:!max-w-[360px] border-none bg-transparent",
"gap-0 p-0 md:!max-w-[480px]",
dialog === "pay" && "border-none bg-transparent",
)}
dialogCloseClassName="focus:ring-0"
>
<DynamicHeight>
{dialog === "no-funds" && (
<NoFundsDialogContent
chain={txChain}
client={props.client}
isLoggedIn={props.isLoggedIn}
onCloseModal={() => setDialog(undefined)}
openPayModal={() => {
setDialog("pay");
}}
/>
)}
{dialog === "no-funds" && (
<NoFundsDialogContent
chain={txChain}
client={props.client}
isLoggedIn={props.isLoggedIn}
onCloseModal={() => setDialog(undefined)}
openPayModal={() => {
setDialog("pay");
}}
/>
)}
{dialog === "pay" && (
<PayEmbed
className="!w-auto"
client={props.client}
payOptions={{
prefillBuy: {
amount: "0.01",
chain: txChain,
},
}}
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
/>
)}
</DynamicHeight>
{dialog === "pay" && (
<BuyWidget
className="!w-full"
client={props.client}
chain={txChain}
amount="0.01"
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
/>
)}
</DialogContent>
</Dialog>
</>
@@ -362,11 +354,7 @@ function NoFundsDialogContent(props: {
(x) => x.enabled && x.service === "pay",
) ? (
// pay case
<Button
className="w-full"
onClick={props.openPayModal}
variant="primary"
>
<Button className="w-full" onClick={props.openPayModal}>
Buy Funds
</Button>
) : // no funds options available
@@ -25,6 +25,7 @@ export function CopyAddressButton(props: {
copyIconPosition={props.copyIconPosition}
textToCopy={props.address}
textToShow={shortenedAddress}
iconClassName={props.iconClassName}
tooltip={props.tooltip || "Copy Address"}
variant={props.variant}
/>
@@ -17,7 +17,7 @@ export function DynamicHeight(props: {
boxSizing: "border-box",
height: height ? `${height}px` : "auto",
overflow: "hidden",
transition: props.transition || "height 250ms ease",
transition: props.transition || "height 220ms ease",
}}
>
<div
+3 -83
View File
@@ -1,83 +1,3 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-10 px-4 py-2",
icon: "h-10 w-10",
lg: "h-11 rounded-md px-8",
sm: "h-9 rounded-md px-3",
},
variant: {
default: "bg-foreground text-background hover:bg-foreground/90",
destructive:
"bg-destructive hover:bg-destructive/90 text-destructive-foreground ",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
secondary:
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
upsell:
"bg-green-600 text-white hover:bg-green-700 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
},
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
// "button" elements automatically handle the `disabled` attribute.
// For non-button elements rendered via `asChild` (e.g. <a>), we still want
// to visually convey the disabled state and prevent user interaction.
// We do that by conditionally adding the same utility classes that the
// `disabled:` pseudo-variant would normally apply and by setting
// `aria-disabled` for accessibility.
const disabledClass = disabled ? "pointer-events-none opacity-50" : "";
const btnOnlyProps =
Comp === "button"
? {
type:
(props as React.ButtonHTMLAttributes<HTMLButtonElement>).type ||
("button" as const),
}
: undefined;
return (
<Comp
aria-disabled={disabled ? true : undefined}
className={cn(
buttonVariants({ className, size, variant }),
disabledClass,
)}
disabled={disabled}
ref={ref}
{...props}
{...btnOnlyProps}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
"use client";
export type { ButtonProps } from "@workspace/ui/components/button";
export { Button, buttonVariants } from "@workspace/ui/components/button";
+7 -1
View File
@@ -98,6 +98,7 @@ export function TabButtons(props: {
shadowColor?: string;
tabIconClassName?: string;
hideBottomLine?: boolean;
bottomLineClassName?: string;
}) {
const { containerRef, lineRef, activeTabRef } =
useUnderline<HTMLButtonElement>();
@@ -106,7 +107,12 @@ export function TabButtons(props: {
<div className={cn("relative", props.containerClassName)}>
{/* Bottom line */}
{!props.hideBottomLine && (
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
<div
className={cn(
"absolute right-0 bottom-0 left-0 h-[1px] bg-border",
props.bottomLineClassName,
)}
/>
)}
<ScrollShadow
@@ -1,9 +1,6 @@
export const THIRDWEB_DEPLOYER_ADDRESS =
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024";
export const THIRDWEB_PUBLISHER_ADDRESS =
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024";
export const DEFAULT_FEE_RECIPIENT =
"0x1af20c6b23373350ad464700b5965ce4b0d2ad94";
@@ -1,5 +1,4 @@
"use client";
import { CircleAlertIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { createContext, useCallback, useContext, useState } from "react";
@@ -11,7 +11,12 @@ export function useAddContractToProject() {
contractAddress: string;
chainId: string;
deploymentType: "asset" | undefined;
contractType: "DropERC20" | "DropERC721" | "DropERC1155" | undefined;
contractType:
| "ERC20Asset"
| "DropERC721"
| "DropERC1155"
| "DropERC20"
| undefined;
}) => {
const res = await apiServerProxy({
body: JSON.stringify({
@@ -1,5 +1,4 @@
// FIXME: export listing status type
import type { ListingStatus } from "thirdweb/dist/types/extensions/marketplace/types";
import type { ListingStatus } from "thirdweb/extensions/marketplace";
export const LISTING_STATUS: Record<ListingStatus, string> = {
ACTIVE: "Active",
@@ -5,13 +5,12 @@ import {
toUnits,
} from "thirdweb";
import { getContract } from "thirdweb/contract";
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
import { getContractMetadata } from "thirdweb/extensions/common";
import * as ERC20Ext from "thirdweb/extensions/erc20";
import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { download } from "thirdweb/storage";
import type { OverrideEntry } from "thirdweb/utils";
import { maxUint256 } from "thirdweb/utils";
import type { z } from "zod";
import type {
@@ -26,20 +25,15 @@ type SnapshotEntry = {
currencyAddress?: string | undefined;
};
type CombinedClaimCondition = Prettify<
Omit<
LegacyClaimCondition,
"price" | "waitInSeconds" | "availableSupply" | "currencyMetadata"
> & {
price: bigint;
currencyMetadata: Omit<
LegacyClaimCondition["currencyMetadata"],
"value"
> & {
value: bigint;
};
}
>;
type CombinedClaimCondition = Omit<
LegacyClaimCondition,
"price" | "waitInSeconds" | "availableSupply" | "currencyMetadata"
> & {
price: bigint;
currencyMetadata: Omit<LegacyClaimCondition["currencyMetadata"], "value"> & {
value: bigint;
};
};
type Options =
| {
@@ -25,6 +25,7 @@ export type ContractPageMetadata = {
isAccount: boolean;
isAccountPermissionsSupported: boolean;
functionSelectors: string[];
showClaimRewards: boolean;
};
export async function getContractPageMetadata(contract: ThirdwebContract) {
@@ -1,6 +1,8 @@
import type { ThirdwebContract } from "thirdweb";
import { contractType as getContractType } from "thirdweb/extensions/thirdweb";
import { getDeployedEntrypointERC20 } from "thirdweb/tokens";
import { resolveFunctionSelectors } from "@/lib/selectors";
import { getValidReward } from "../../../../../team/[team_slug]/[project_slug]/contract/[chainIdOrSlug]/[contractAddress]/rewards/utils/rewards";
import {
isERC20ClaimConditionsSupported,
isERC721ClaimConditionsSupported,
@@ -43,6 +45,7 @@ type ContractPageMetadata = {
isAccount: boolean;
isAccountPermissionsSupported: boolean;
functionSelectors: string[];
showClaimRewards: boolean;
};
export async function getContractPageMetadataSetup(
@@ -53,10 +56,14 @@ export async function getContractPageMetadataSetup(
functionSelectorsResult,
isInsightSupportedResult,
contractTypeResult,
claimRewardResult,
] = await Promise.allSettled([
resolveFunctionSelectors(contract),
isAnalyticsSupportedFn(contract.chain.id),
getContractType({ contract }),
isClaimRewardsSupported({
assetContract: contract,
}),
]);
const functionSelectors =
@@ -72,6 +79,11 @@ export async function getContractPageMetadataSetup(
const contractType =
contractTypeResult.status === "fulfilled" ? contractTypeResult.value : null;
const showClaimRewards =
claimRewardResult.status === "fulfilled"
? !!claimRewardResult.value
: false;
return {
embedType: getEmbedTypeToShow(functionSelectors),
functionSelectors,
@@ -93,5 +105,30 @@ export async function getContractPageMetadataSetup(
isSplitSupported: contractType === "Split",
isVoteContract: contractType === "VoteERC20",
supportedERCs: supportedERCs(functionSelectors),
showClaimRewards,
};
}
async function isClaimRewardsSupported(params: {
assetContract: ThirdwebContract;
}): Promise<boolean> {
try {
const entrypointContract = await getDeployedEntrypointERC20({
chain: params.assetContract.chain,
client: params.assetContract.client,
});
if (!entrypointContract) {
return false;
}
const reward = await getValidReward({
assetContract: params.assetContract,
entrypointContract,
});
return !!reward;
} catch {
return false;
}
}
@@ -140,6 +140,12 @@ export function getContractPageSidebarLinks(data: {
href: `${layoutPrefix}/permissions`,
label: "Permissions",
},
{
exactMatch: true,
hide: !data.metadata.showClaimRewards,
href: `${layoutPrefix}/rewards`,
label: "Rewards",
},
];
const extensionsToShow = extensionsLinks.filter((l) => !l.hide);
@@ -1,11 +1,12 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
ArrowRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
ExternalLinkIcon,
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { type ThirdwebClient, type ThirdwebContract, toTokens } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { WalletAddress } from "@/components/blocks/wallet-address";
@@ -20,7 +21,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
type TokenTransfersData,
useTokenTransfers,
@@ -45,6 +45,32 @@ function RecentTransfersUI(props: {
explorerUrl: string;
client: ThirdwebClient;
}) {
const groupedData = useMemo(() => {
const data: Array<{
group: TokenTransfersData[];
transactionHash: string;
blockTimestamp: string;
}> = [];
for (const transfer of props.data) {
const existingGroup = data.find(
(group) => group.transactionHash === transfer.transaction_hash,
);
if (existingGroup) {
existingGroup.group.push(transfer);
} else {
data.push({
group: [transfer],
transactionHash: transfer.transaction_hash,
blockTimestamp: transfer.block_timestamp,
});
}
}
return data;
}, [props.data]);
return (
<div>
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
@@ -74,76 +100,89 @@ function RecentTransfersUI(props: {
// biome-ignore lint/suspicious/noArrayIndexKey: EXPECTED
<SkeletonRow key={index} />
))
: props.data.map((transfer) => (
: groupedData.map((group) => (
<TableRow
className="fade-in-0 animate-in duration-300"
key={
transfer.transaction_hash +
transfer.amount +
transfer.block_number +
transfer.from_address
}
key={group.transactionHash}
>
<TableCell className="text-sm">
<WalletAddress
address={transfer.from_address}
client={props.client}
/>
{/* From */}
<TableCell className="relative space-y-1">
{group.group.map((transfer) => (
<div
className="h-10 flex items-center gap-6 w-[150px]"
key={transfer.log_index}
>
<WalletAddress
address={transfer.from_address}
client={props.client}
iconClassName="size-4.5"
/>
<ArrowRightIcon className="size-4 text-muted-foreground/50 absolute -right-1 lg:right-3" />
</div>
))}
</TableCell>
<TableCell className="text-sm">
<WalletAddress
address={transfer.to_address}
client={props.client}
/>
{/* To */}
<TableCell className="relative space-y-1">
{group.group.map((transfer) => (
<div
className="h-10 flex items-center gap-6 w-[150px]"
key={transfer.log_index}
>
<WalletAddress
address={transfer.to_address}
client={props.client}
key={transfer.log_index}
iconClassName="size-4.5"
/>
</div>
))}
</TableCell>
<TableCell className="text-sm ">
<div className="flex items-center gap-1.5">
<span>
{tokenAmountFormatter.format(
Number(
toTokens(
BigInt(transfer.amount),
props.tokenMetadata.decimals,
),
),
)}
</span>
<span className="text-muted-foreground text-xs">
{props.tokenMetadata.symbol}
</span>
{/* Amount */}
<TableCell className="space-y-1">
{group.group.map((transfer) => (
<div
className="h-10 flex items-center"
key={transfer.log_index}
>
<TokenAmount
amount={transfer.amount}
decimals={props.tokenMetadata.decimals}
symbol={props.tokenMetadata.symbol}
/>
</div>
))}
</TableCell>
{/* timestamp */}
<TableCell>
<div
key={group.blockTimestamp}
className="capitalize text-muted-foreground text-sm"
>
{timestamp(group.blockTimestamp)}
</div>
</TableCell>
<TableCell className="text-sm">
{formatDistanceToNow(
new Date(
transfer.block_timestamp.endsWith("Z")
? transfer.block_timestamp
: `${transfer.block_timestamp}Z`,
),
{
addSuffix: true,
},
)}
</TableCell>
{/* transaction */}
<TableCell>
<Button
asChild
className="h-8 w-8 p-0"
size="sm"
variant="ghost"
>
<a
className={cn(
"flex items-center justify-center",
"hover:bg-accent hover:text-accent-foreground",
)}
href={`${props.explorerUrl}/tx/${transfer.transaction_hash}`}
rel="noopener noreferrer"
target="_blank"
<div className="flex items-center justify-center">
<Button
asChild
size="sm"
variant="outline"
className="text-muted-foreground hover:text-foreground rounded-full size-9 p-0 flex items-center justify-center"
>
<ExternalLinkIcon className="h-4 w-4" />
</a>
</Button>
<a
href={`${props.explorerUrl}/tx/${group.transactionHash}`}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLinkIcon className="size-3.5" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -183,6 +222,34 @@ function RecentTransfersUI(props: {
);
}
function timestamp(block_timestamp: string) {
return formatDistanceToNow(
new Date(
block_timestamp.endsWith("Z") ? block_timestamp : `${block_timestamp}Z`,
),
{
addSuffix: true,
},
);
}
function TokenAmount(props: {
amount: string;
decimals: number;
symbol: string;
}) {
return (
<div className="flex items-center gap-1.5">
<span>
{tokenAmountFormatter.format(
Number(toTokens(BigInt(props.amount), props.decimals)),
)}
</span>
<span className="text-muted-foreground text-xs">{props.symbol}</span>
</div>
);
}
function SkeletonRow() {
return (
<TableRow className="fade-in-0 h-[73px] animate-in duration-300">
@@ -199,7 +266,7 @@ function SkeletonRow() {
<Skeleton className="h-6 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-6" />
<Skeleton className="size-9 rounded-full" />
</TableCell>
</TableRow>
);
@@ -47,7 +47,6 @@ import {
DEFAULT_FEE_BPS,
DEFAULT_FEE_BPS_NEW,
DEFAULT_FEE_RECIPIENT,
THIRDWEB_PUBLISHER_ADDRESS,
} from "@/constants/addresses";
import { LAST_USED_PROJECT_ID, LAST_USED_TEAM_ID } from "@/constants/cookie";
import { ZERO_FEE_CHAINS } from "@/constants/fee-config";
@@ -218,9 +217,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
const defaultFeeRecipientFunction = metadata.abi.find(
(a) => a.type === "function" && a.name === "DEFAULT_FEE_RECIPIENT",
);
const hasInbuiltDefaultFeeConfig =
defaultFeeRecipientFunction &&
metadata.publisher === THIRDWEB_PUBLISHER_ADDRESS;
const hasInbuiltDefaultFeeConfig = defaultFeeRecipientFunction;
const isFeeExempt =
walletChain?.id && ZERO_FEE_CHAINS.includes(walletChain.id);
+1 -1
View File
@@ -1,4 +1,4 @@
import "../../global.css";
import "@workspace/ui/global.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import NextTopLoader from "nextjs-toploader";
@@ -76,9 +76,6 @@ export function TeamHighlightsCard({
: "activeUsers"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
@@ -1,11 +1,20 @@
"use client";
import { useQueryState } from "nuqs";
import { useMemo, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { getChainInfraCheckoutURL } from "@/actions/billing";
import { reportChainInfraRpcOmissionAgreed } from "@/analytics/report";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { InsightIcon } from "@/icons/InsightIcon";
import { RPCIcon } from "@/icons/RPCIcon";
@@ -44,7 +53,7 @@ const SERVICE_CONFIG = {
icon: "RPCIcon",
label: "RPC",
monthlyPrice: 1500,
required: true,
required: false,
sku: "chain:infra:rpc" as const,
},
} satisfies Record<
@@ -80,19 +89,26 @@ export function DeployInfrastructureForm(props: {
searchParams.addons.withOptions({ history: "replace", startTransition }),
);
const [rpcParam, setRpcParam] = useQueryState(
"rpc",
searchParams.rpc.withOptions({ history: "replace", startTransition }),
);
const addons = useMemo(() => {
return addonsStr ? addonsStr.split(",").filter(Boolean) : [];
}, [addonsStr]);
const includeInsight = addons.includes("insight");
const includeAA = addons.includes("aa");
const includeRPC = rpcParam === "on";
const selectedOrder = useMemo(() => {
const arr: (keyof typeof SERVICE_CONFIG)[] = ["rpc"];
const arr: (keyof typeof SERVICE_CONFIG)[] = [];
if (includeRPC) arr.push("rpc");
if (includeInsight) arr.push("insight");
if (includeAA) arr.push("accountAbstraction");
return arr;
}, [includeInsight, includeAA]);
}, [includeInsight, includeAA, includeRPC]);
// NEW: count selected services and prepare bundle discount hint
const selectedCount = selectedOrder.length;
@@ -112,9 +128,9 @@ export function DeployInfrastructureForm(props: {
return {
accountAbstraction: includeAA,
insight: includeInsight,
rpc: true,
rpc: includeRPC,
} as const;
}, [includeInsight, includeAA]);
}, [includeInsight, includeAA, includeRPC]);
const pricePerService = useMemo(() => {
const isAnnual = frequency === "annual";
@@ -172,10 +188,13 @@ export function DeployInfrastructureForm(props: {
const chainId = props.chain.chainId;
const checkout = () => {
const [showRpcWarning, setShowRpcWarning] = useState(false);
const proceedToCheckout = () => {
startTransition(async () => {
try {
const skus: ChainInfraSKU[] = [SERVICE_CONFIG.rpc.sku];
const skus: ChainInfraSKU[] = [];
if (includeRPC) skus.push(SERVICE_CONFIG.rpc.sku);
if (includeInsight) skus.push(SERVICE_CONFIG.insight.sku);
if (includeAA) skus.push(SERVICE_CONFIG.accountAbstraction.sku);
@@ -186,11 +205,9 @@ export function DeployInfrastructureForm(props: {
teamSlug: props.teamSlug,
});
// If the action returns, it means redirect did not happen and we have an error
if (res.status === "error") {
toast.error(res.error);
} else if (res.status === "success") {
// replace the current page with the checkout page (which will then redirect back to us)
window.location.href = res.data;
}
} catch (err) {
@@ -202,6 +219,15 @@ export function DeployInfrastructureForm(props: {
});
};
const checkout = () => {
const hasAddons = includeInsight || includeAA;
if (!includeRPC && hasAddons) {
setShowRpcWarning(true);
return;
}
proceedToCheckout();
};
const periodLabel = frequency === "annual" ? "/yr" : "/mo";
const isAnnual = frequency === "annual";
@@ -211,21 +237,22 @@ export function DeployInfrastructureForm(props: {
<div className="flex flex-col gap-4">
<h3 className="text-lg font-semibold">Select Services</h3>
{/* Required service */}
{/* RPC (now optional) */}
<div className="flex flex-col gap-2 mb-6">
<ServiceCard
description={SERVICE_CONFIG.rpc.description}
disabled
icon={SERVICE_CONFIG.rpc.icon}
label={SERVICE_CONFIG.rpc.label}
onToggle={() => {}}
onToggle={() => {
const newVal = !includeRPC;
setRpcParam(newVal ? "on" : "off");
}}
originalPrice={
isAnnual ? SERVICE_CONFIG.rpc.monthlyPrice * 12 : undefined
}
periodLabel={periodLabel}
price={pricePerService.rpc}
required
selected
selected={includeRPC}
/>
</div>
@@ -350,7 +377,11 @@ export function DeployInfrastructureForm(props: {
<Button
className="w-full"
disabled={isTransitionPending || !props.isOwner}
disabled={
isTransitionPending ||
!props.isOwner ||
selectedOrder.length === 0
}
onClick={checkout}
>
Proceed to Checkout
@@ -362,6 +393,54 @@ export function DeployInfrastructureForm(props: {
)}
</div>
</div>
{/* RPC Omission Warning Modal */}
<Dialog open={showRpcWarning} onOpenChange={setShowRpcWarning}>
<DialogContent>
<DialogHeader>
<DialogTitle>Proceed without RPC (not recommended)</DialogTitle>
<DialogDescription className="space-y-3">
<p>
RPC powers core functionality used by <strong>Insight</strong>{" "}
and <strong>Account Abstraction</strong>.
</p>
<div className="space-y-1">
<p>Without RPC, you may experience:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Delayed or missing data in Insight</li>
<li>
Transaction failures or degraded reliability for Account
Abstraction
</li>
<li>Limited or unsupported features across both services</li>
</ul>
</div>
<p>
thirdweb <strong>cannot guarantee</strong> that Insight or
Account Abstraction will work as expected without RPC. To ensure
reliability, keep RPC enabled.
</p>
<p>If you still want to continue, confirm below.</p>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
onClick={() => {
reportChainInfraRpcOmissionAgreed({
chainId,
frequency: frequency === "annual" ? "annual" : "monthly",
includeInsight,
includeAccountAbstraction: includeAA,
});
setShowRpcWarning(false);
proceedToCheckout();
}}
variant="destructive"
>
I understand — proceed without RPC
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -3,4 +3,5 @@ import { parseAsString, parseAsStringEnum } from "nuqs/server";
export const searchParams = {
addons: parseAsString.withDefault(""),
freq: parseAsStringEnum(["monthly", "annual"]).withDefault("monthly"),
rpc: parseAsStringEnum(["on", "off"]).withDefault("on"),
};
@@ -1,8 +1,8 @@
import { DialogDescription } from "@radix-ui/react-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -70,9 +70,6 @@ export function ProjectHighlightsCard(props: {
: "activeUsers"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
@@ -1,6 +1,6 @@
"use client";
import { CodeIcon, WebhookIcon } from "lucide-react";
import { ArrowRightLeftIcon, CodeIcon, WebhookIcon } from "lucide-react";
import { FeatureCard } from "./FeatureCard.client";
export function AdvancedSection({
@@ -45,6 +45,20 @@ export function AdvancedSection({
label: "Setup Webhooks",
}}
/>
<FeatureCard
title="Swap Tokens"
icon={ArrowRightLeftIcon}
setupTime={5}
color="green"
id="swap_tokens"
features={["Swap any token", "Cross-chain swaps"]}
description="Swap tokens cross-chain with dedicated swapping endpoints."
link={{
href: `https://portal.thirdweb.com/payments/swap`,
label: "Setup Swaps",
target: "_blank",
}}
/>
</div>
</section>
);
@@ -0,0 +1,23 @@
import type { ThirdwebClient } from "thirdweb";
import { isProd } from "@/constants/env-utils";
export async function createTokenOnUniversalBridge(params: {
chainId: number;
tokenAddress: string;
client: ThirdwebClient;
}) {
const domain = isProd ? "thirdweb.com" : "thirdweb-dev.com";
const res = await fetch(`https://bridge.${domain}/v1/tokens`, {
body: JSON.stringify({
chainId: params.chainId.toString(),
tokenAddress: params.tokenAddress,
}),
headers: {
"Content-Type": "application/json",
"x-client-id": params.client.clientId,
},
method: "POST",
});
return res;
}
@@ -34,7 +34,7 @@ export function SocialUrlsFieldset<T extends WithSocialUrls>(props: {
<h2 className="mb-2 font-medium text-sm">Social URLs</h2>
{fields.length > 0 && (
<div className="mb-4 space-y-3">
<div className="mb-4 space-y-4">
{fields.map((field, index) => (
<div
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
@@ -1,4 +1,4 @@
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
import type { ThirdwebClient } from "thirdweb";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { ChainIconClient } from "@/icons/ChainIcon";
@@ -35,7 +35,7 @@ export function StepCard(props: {
{props.children}
{(props.prevButton || props.nextButton) && (
<div className="flex justify-end gap-3 border-t p-6">
<div className="flex justify-end gap-3 border-t p-4 md:p-6">
{props.prevButton && (
<Button
className="gap-2"
@@ -9,6 +9,7 @@ import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { AdminAddressesFieldset } from "../../_common/admin-addresses-fieldset";
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
@@ -95,7 +96,7 @@ export function NFTCollectionInfoFieldset(props: {
isRequired
label="Chain"
>
<ClientOnly ssr={null}>
<ClientOnly ssr={<Skeleton className="h-10" />}>
<SingleNetworkSelector
chainId={Number(form.watch("chain"))}
className="bg-background"
@@ -1,6 +1,7 @@
import type { UseFormReturn } from "react-hook-form";
import * as z from "zod";
import { addressSchema, socialUrlsSchema } from "../../_common/schema";
import { getInitialTickValue, isValidTickValue } from "../utils/calculate-tick";
export const tokenInfoFormSchema = z.object({
chain: z.string().min(1, "Chain is required"),
@@ -15,38 +16,69 @@ export const tokenInfoFormSchema = z.object({
.max(10, "Symbol must be 10 characters or less"),
});
const priceAmountSchema = z.string().refine(
(value) => {
const number = Number(value);
return !Number.isNaN(number) && number >= 0;
},
{
message: "Amount must be number larger than or equal to 0",
},
);
export const tokenDistributionFormSchema = z.object({
// airdrop
airdropAddresses: z.array(
z.object({
address: addressSchema,
quantity: z.string(),
}),
),
// UI states
airdropEnabled: z.boolean(),
saleAllocationPercentage: z.string().refine(
(value) => {
const number = Number(value);
if (Number.isNaN(number)) {
// sales ---
erc20Asset_poolMode: z.object({
startingPricePerToken: priceAmountSchema.refine((value) => {
const numValue = Number(value);
if (numValue === 0) {
return false;
}
return number >= 0 && number <= 100;
},
{
message: "Must be a number between 0 and 100",
},
),
const tick = getInitialTickValue({
startingPricePerToken: Number(value),
});
return isValidTickValue(tick);
}, "Invalid price"),
saleAllocationPercentage: z.string().refine(
(value) => {
const number = Number(value);
if (Number.isNaN(number)) {
return false;
}
return number >= 0 && number <= 100;
},
{
message: "Must be a number between 0 and 100",
},
),
}),
dropERC20Mode: z.object({
pricePerToken: priceAmountSchema,
saleTokenAddress: addressSchema,
saleAllocationPercentage: z.string().refine(
(value) => {
const number = Number(value);
if (Number.isNaN(number)) {
return false;
}
return number >= 0 && number <= 100;
},
{
message: "Must be a number between 0 and 100",
},
),
}),
saleEnabled: z.boolean(),
salePrice: z.string().refine(
(value) => {
const number = Number(value);
return !Number.isNaN(number) && number >= 0;
},
{
message: "Must be number larger than or equal to 0",
},
),
saleTokenAddress: z.string(),
saleMode: z.enum(["erc20-asset:pool", "drop-erc20:token-drop"]),
supply: z.string().min(1, "Supply is required"),
});
@@ -8,34 +8,37 @@ import {
sendAndConfirmTransaction,
type ThirdwebClient,
toUnits,
toWei,
} from "thirdweb";
import { deployERC20Contract } from "thirdweb/deploys";
import type { ClaimConditionsInput } from "thirdweb/dist/types/utils/extensions/drops/types";
import {
approve,
claimTo,
getActiveClaimCondition,
setClaimConditions as setClaimConditionsExtension,
transferBatch,
} from "thirdweb/extensions/erc20";
import { useActiveAccount } from "thirdweb/react";
import {
createToken,
distributeToken,
getDeployedEntrypointERC20,
} from "thirdweb/tokens";
import type { ClaimConditionsInput } from "thirdweb/utils";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
reportContractDeployed,
} from "@/analytics/report";
import { reportContractDeployed } from "@/analytics/report";
import type { Team } from "@/api/team/get-team";
import {
DEFAULT_FEE_BPS_NEW,
DEFAULT_FEE_RECIPIENT,
} from "@/constants/addresses";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { useAddContractToProject } from "@/hooks/project-contracts";
import { defineDashboardChain } from "@/lib/defineDashboardChain";
import { parseError } from "@/utils/errorParser";
import { pollWithTimeout } from "@/utils/pollWithTimeout";
import { createTokenOnUniversalBridge } from "../_apis/create-token-on-bridge";
import type { CreateAssetFormValues } from "./_common/form";
import { CreateTokenAssetPageUI } from "./create-token-page.client";
import { getInitialTickValue } from "./utils/calculate-tick";
export function CreateTokenAssetPage(props: {
accountAddress: string;
@@ -47,7 +50,6 @@ export function CreateTokenAssetPage(props: {
teamPlan: Team["billingPlan"];
}) {
const activeAccount = useActiveAccount();
const { idToChain } = useAllChainsData();
const addContractToProject = useAddContractToProject();
const contractAddressRef = useRef<string | undefined>(undefined);
@@ -83,7 +85,149 @@ export function CreateTokenAssetPage(props: {
});
}
async function deployContract(params: {
// ERC20Asset ---
async function Erc20Asset_deployContract(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const account = getAccount(params.gasless);
const socialUrls = params.values.socialUrls.reduce(
(acc, url) => {
if (url.url && url.platform) {
acc[url.platform] = url.url;
}
return acc;
},
{} as Record<string, string>,
);
const salePercent = params.values.saleEnabled
? Number(params.values.erc20Asset_poolMode.saleAllocationPercentage)
: 0;
const saleAmount = Math.floor(
Number(params.values.supply) * (salePercent / 100),
);
const contractAddress = await createToken({
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(params.values.chain)),
client: props.client,
launchConfig:
params.values.saleEnabled && saleAmount !== 0
? {
kind: "pool",
config: {
amount: BigInt(saleAmount),
initialTick: getInitialTickValue({
startingPricePerToken: Number(
params.values.erc20Asset_poolMode.startingPricePerToken,
),
}),
developerRewardBps: 1250, // 12.5%
},
}
: undefined,
params: {
description: params.values.description,
image: params.values.image,
maxSupply: BigInt(params.values.supply),
name: params.values.name,
social_urls: socialUrls,
symbol: params.values.symbol,
},
developerAddress: "0x1Af20C6B23373350aD464700B5965CE4B0D2aD94",
});
// add contract to project in background
addContractToProject.mutateAsync({
chainId: params.values.chain,
contractAddress: contractAddress,
contractType: "ERC20Asset",
deploymentType: "asset",
projectId: props.projectId,
teamId: props.teamId,
});
reportContractDeployed({
address: contractAddress,
chainId: Number(params.values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: account.address,
});
contractAddressRef.current = contractAddress;
return {
contractAddress: contractAddress,
};
}
async function ERC20Asset_airdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const account = getAccount(gasless);
const contract = getDeployedContract({ chain: values.chain });
const airdropTx = await distributeToken({
chain: contract.chain,
client: props.client,
contents: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
recipient: recipient.address,
})),
tokenAddress: contract.address,
});
await sendAndConfirmTransaction({
account,
transaction: airdropTx,
});
}
async function ERC20Asset_approveAirdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const account = getAccount(gasless);
const contract = getDeployedContract({ chain: values.chain });
const totalAmountToAirdrop = values.airdropAddresses.reduce(
(acc, recipient) => acc + Number(recipient.quantity),
0,
);
const entrypoint = await getDeployedEntrypointERC20({
chain: contract.chain,
client: props.client,
});
if (!entrypoint) {
throw new Error("Entrypoint not found");
}
const approvalTx = approve({
amountWei: toWei(totalAmountToAirdrop.toString()),
contract: contract,
spender: entrypoint.address,
});
await sendAndConfirmTransaction({
account,
transaction: approvalTx,
});
}
// DropERC20 ----
async function DropERC20_deployContract(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
@@ -101,73 +245,53 @@ export function CreateTokenAssetPage(props: {
{} as Record<string, string>,
);
try {
const contractAddress = await deployERC20Contract({
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineDashboardChain(
Number(values.chain),
idToChain.get(Number(values.chain)),
),
client: props.client,
params: {
description: values.description,
image: values.image,
// metadata
name: values.name,
// platform fees
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
// primary sale
saleRecipient: account.address,
social_urls: socialUrls,
symbol: values.symbol,
},
type: "DropERC20",
});
const contractAddress = await deployERC20Contract({
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(values.chain)),
client: props.client,
params: {
description: values.description,
image: values.image,
// metadata
name: values.name,
// platform fees
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
// primary sale
saleRecipient: account.address,
social_urls: socialUrls,
symbol: values.symbol,
},
type: "DropERC20",
});
contractAddressRef.current = contractAddress;
contractAddressRef.current = contractAddress;
// add contract to project in background
addContractToProject.mutateAsync({
chainId: values.chain,
contractAddress: contractAddress,
contractType: "DropERC20",
deploymentType: "asset",
projectId: props.projectId,
teamId: props.teamId,
});
// add contract to project in background
addContractToProject.mutateAsync({
chainId: values.chain,
contractAddress: contractAddress,
contractType: "DropERC20",
deploymentType: "asset",
projectId: props.projectId,
teamId: props.teamId,
});
reportContractDeployed({
address: contractAddress,
chainId: Number(values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
});
reportContractDeployed({
address: contractAddress,
chainId: Number(values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
});
return {
contractAddress: contractAddress,
};
} catch (e) {
console.error(e);
const parsedError = parseError(e);
const errorMessage =
typeof parsedError === "string" ? parsedError : "Unknown error";
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
error: errorMessage,
step: "deploy-contract",
});
console.error(errorMessage);
throw e;
}
return {
contractAddress: contractAddress,
};
}
async function airdropTokens(params: {
async function DropERC20_airdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
@@ -183,34 +307,21 @@ export function CreateTokenAssetPage(props: {
throw new Error("No connected account");
}
try {
const airdropTx = transferBatch({
batch: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
to: recipient.address,
})),
contract,
});
const airdropTx = transferBatch({
batch: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
to: recipient.address,
})),
contract,
});
await sendAndConfirmTransaction({
account,
transaction: airdropTx,
});
} catch (e) {
console.error(e);
const errorMessage = parseError(e);
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
error: errorMessage,
step: "airdrop-tokens",
});
throw e;
}
await sendAndConfirmTransaction({
account,
transaction: airdropTx,
});
}
async function mintTokens(params: {
async function DropERC20_mintTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
@@ -235,39 +346,25 @@ export function CreateTokenAssetPage(props: {
const totalSupply = Number(values.supply);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
? Number(values.dropERC20Mode.saleAllocationPercentage)
: 0;
const ownerAndAirdropPercent = 100 - salePercent;
const ownerSupplyTokens = (totalSupply * ownerAndAirdropPercent) / 100;
try {
const claimTx = claimTo({
contract,
quantity: ownerSupplyTokens.toString(),
to: account.address,
});
const claimTx = claimTo({
contract,
quantity: ownerSupplyTokens.toString(),
to: account.address,
});
await sendAndConfirmTransaction({
account,
transaction: claimTx,
});
} catch (e) {
const errorMessage = parseError(e);
console.error(e);
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
error: errorMessage,
step: "mint-tokens",
});
throw e;
}
await sendAndConfirmTransaction({
account,
transaction: claimTx,
});
}
async function setClaimConditions(params: {
async function DropERC20_setClaimConditions(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
@@ -279,7 +376,7 @@ export function CreateTokenAssetPage(props: {
const account = getAccount(gasless);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
? Number(values.dropERC20Mode.saleAllocationPercentage)
: 0;
const totalSupply = Number(values.supply);
@@ -288,10 +385,10 @@ export function CreateTokenAssetPage(props: {
const phases: ClaimConditionsInput[] = [
{
currencyAddress:
getAddress(values.saleTokenAddress) ===
getAddress(values.dropERC20Mode.saleTokenAddress) ===
getAddress(NATIVE_TOKEN_ADDRESS)
? undefined
: values.saleTokenAddress,
: values.dropERC20Mode.saleTokenAddress,
maxClaimablePerWallet: values.saleEnabled ? undefined : 0n,
maxClaimableSupply: totalSupplyWei,
metadata: {
@@ -307,7 +404,10 @@ export function CreateTokenAssetPage(props: {
price: "0",
},
],
price: values.saleEnabled && salePercent > 0 ? values.salePrice : "0",
price:
values.saleEnabled && salePercent > 0
? values.dropERC20Mode.pricePerToken
: "0",
startTime: new Date(),
},
];
@@ -317,23 +417,10 @@ export function CreateTokenAssetPage(props: {
phases,
});
try {
await sendAndConfirmTransaction({
account,
transaction: preparedTx,
});
} catch (e) {
const errorMessage = parseError(e);
console.error(e);
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
error: errorMessage,
step: "set-claim-conditions",
});
throw e;
}
await sendAndConfirmTransaction({
account,
transaction: preparedTx,
});
}
return (
@@ -341,12 +428,24 @@ export function CreateTokenAssetPage(props: {
accountAddress={props.accountAddress}
client={props.client}
createTokenFunctions={{
airdropTokens,
deployContract,
mintTokens,
setClaimConditions,
ERC20Asset: {
airdropTokens: ERC20Asset_airdropTokens,
deployContract: Erc20Asset_deployContract,
approveAirdropTokens: ERC20Asset_approveAirdropTokens,
},
DropERC20: {
deployContract: DropERC20_deployContract,
airdropTokens: DropERC20_airdropTokens,
mintTokens: DropERC20_mintTokens,
setClaimConditions: DropERC20_setClaimConditions,
},
}}
onLaunchSuccess={() => {
onLaunchSuccess={(params) => {
createTokenOnUniversalBridge({
chainId: params.chainId,
client: props.client,
tokenAddress: params.contractAddress,
});
revalidatePathAction(
`/team/${props.teamSlug}/project/${props.projectId}/tokens`,
"page",
@@ -1,16 +1,24 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
defineChain,
getAddress,
NATIVE_TOKEN_ADDRESS,
type ThirdwebClient,
} from "thirdweb";
import { useActiveWalletChain } from "thirdweb/react";
import {
getDeployedContractFactory,
isPoolRouterEnabled,
} from "thirdweb/tokens";
import { reportAssetCreationStepConfigured } from "@/analytics/report";
import type { Team } from "@/api/team/get-team";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { StepCard } from "../_common/step-card";
import {
type CreateAssetFormValues,
type TokenDistributionFormValues,
@@ -28,21 +36,33 @@ type CreateTokenFunctionsParams = {
};
export type CreateTokenFunctions = {
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
ERC20Asset: {
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
airdropTokens: (values: CreateTokenFunctionsParams) => Promise<void>;
approveAirdropTokens: (values: CreateTokenFunctionsParams) => Promise<void>;
};
DropERC20: {
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
};
};
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
export function CreateTokenAssetPageUI(props: {
accountAddress: string;
client: ThirdwebClient;
createTokenFunctions: CreateTokenFunctions;
onLaunchSuccess: () => void;
onLaunchSuccess: (params: {
chainId: number;
contractAddress: string;
}) => void;
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
@@ -50,11 +70,11 @@ export function CreateTokenAssetPageUI(props: {
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
"token-info",
);
const activeWalletChain = useActiveWalletChain();
const activeChain = useActiveWalletChain();
const tokenInfoForm = useForm<TokenInfoFormValues>({
defaultValues: {
chain: activeWalletChain?.id.toString() || "1",
chain: activeChain?.id.toString() || "8453",
description: "",
image: undefined,
name: "",
@@ -74,22 +94,70 @@ export function CreateTokenAssetPageUI(props: {
reValidateMode: "onChange",
});
const isERC20AssetSupportedQuery = useQuery({
queryKey: ["is-erc20-asset-supported", tokenInfoForm.watch("chain")],
queryFn: async () => {
try {
const res = await getDeployedContractFactory({
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(tokenInfoForm.watch("chain"))),
client: props.client,
});
return !!res;
} catch {
return false;
}
},
});
const isAssetRouterEnabledQuery = useQuery({
queryFn: async () => {
try {
return await isPoolRouterEnabled({
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(tokenInfoForm.watch("chain"))),
client: props.client,
});
} catch {
return false;
}
},
queryKey: ["is-asset-router-enabled", tokenInfoForm.watch("chain")],
});
const defaultSaleMode = isERC20AssetSupportedQuery.data
? "erc20-asset:pool"
: "drop-erc20:token-drop";
const tokenDistributionForm = useForm<TokenDistributionFormValues>({
resolver: zodResolver(tokenDistributionFormSchema),
reValidateMode: "onChange",
values: {
airdropAddresses: [],
// airdrop
airdropEnabled: false,
// sale fieldset
saleAllocationPercentage: "0",
saleEnabled: false,
salePrice: "0.1",
saleTokenAddress: checksummedNativeTokenAddress,
supply: "1000000",
erc20Asset_poolMode: {
startingPricePerToken: "0.000000001", // 1gwei per token
saleAllocationPercentage: "100",
},
dropERC20Mode: {
pricePerToken: "0.1",
saleAllocationPercentage: "100",
saleTokenAddress: nativeTokenAddress,
},
supply: "1000000000", // 1 billion
saleEnabled: !(
defaultSaleMode === "erc20-asset:pool" &&
!isAssetRouterEnabledQuery.data
),
saleMode: defaultSaleMode,
},
mode: "onChange",
resolver: zodResolver(tokenDistributionFormSchema),
reValidateMode: "onChange",
});
const distributionFieldIsPending =
isERC20AssetSupportedQuery.isPending || isAssetRouterEnabledQuery.isPending;
return (
<div>
{step === "token-info" && (
@@ -97,10 +165,10 @@ export function CreateTokenAssetPageUI(props: {
client={props.client}
form={tokenInfoForm}
onChainUpdated={() => {
// if the chain is updated, set the sale token address to the native token address
// reset the token address to the native token address on chain change
tokenDistributionForm.setValue(
"saleTokenAddress",
checksummedNativeTokenAddress,
"dropERC20Mode.saleTokenAddress",
nativeTokenAddress,
);
}}
onNext={() => {
@@ -113,25 +181,44 @@ export function CreateTokenAssetPageUI(props: {
/>
)}
{step === "distribution" && (
<TokenDistributionFieldset
accountAddress={props.accountAddress}
chainId={tokenInfoForm.watch("chain")}
client={props.client}
form={tokenDistributionForm}
onNext={() => {
reportAssetCreationStepConfigured({
assetType: "coin",
step: "token-distribution",
});
setStep("launch");
}}
onPrevious={() => {
setStep("token-info");
}}
tokenSymbol={tokenInfoForm.watch("symbol")}
/>
)}
{step === "distribution" &&
(distributionFieldIsPending ? (
<StepCard
nextButton={{
disabled: true,
type: "submit",
}}
prevButton={{
onClick: () => {
setStep("token-info");
},
}}
title="Coin Distribution"
>
<div className="h-[400px] flex items-center justify-center">
<Spinner className="size-10" />
</div>
</StepCard>
) : (
<TokenDistributionFieldset
form={tokenDistributionForm}
accountAddress={props.accountAddress}
chainId={tokenInfoForm.watch("chain")}
isRouterEnabled={isAssetRouterEnabledQuery.data === true}
client={props.client}
onNext={() => {
reportAssetCreationStepConfigured({
assetType: "coin",
step: "token-distribution",
});
setStep("launch");
}}
onPrevious={() => {
setStep("token-info");
}}
tokenSymbol={tokenInfoForm.watch("symbol")}
/>
))}
{step === "launch" && (
<LaunchTokenStatus
@@ -1,7 +1,10 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
import { storybookThirdwebClient } from "@/storybook/utils";
import { CreateTokenAssetPageUI } from "./create-token-page.client";
import {
CreateTokenAssetPageUI,
type CreateTokenFunctions,
} from "./create-token-page.client";
const meta = {
component: CreateTokenAssetPageUI,
@@ -9,9 +12,9 @@ const meta = {
(Story) => (
<ThirdwebProvider>
<div className="container max-w-5xl py-10">
<ConnectButton client={storybookThirdwebClient} />
<div className="h-10" />
<Story />
<div className="h-10" />
<ConnectButton client={storybookThirdwebClient} />
</div>
</ThirdwebProvider>
),
@@ -22,19 +25,33 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
const mockCreateTokenFunctions = {
airdropTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
const mockCreateTokenFunctions: CreateTokenFunctions = {
ERC20Asset: {
airdropTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
approveAirdropTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { contractAddress: "0x123" };
},
},
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { contractAddress: "0x123" };
},
mintTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
setClaimConditions: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
DropERC20: {
airdropTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { contractAddress: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" };
},
setClaimConditions: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
mintTokens: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
},
},
};
@@ -56,9 +73,12 @@ export const ErrorOnDeploy: Story = {
client: storybookThirdwebClient,
createTokenFunctions: {
...mockCreateTokenFunctions,
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
throw new Error("Failed to deploy contract");
ERC20Asset: {
...mockCreateTokenFunctions.ERC20Asset,
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
throw new Error("Failed to deploy contract");
},
},
},
onLaunchSuccess: () => {},
@@ -74,11 +94,14 @@ export const StorageErrorOnDeploy: Story = {
client: storybookThirdwebClient,
createTokenFunctions: {
...mockCreateTokenFunctions,
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
throw new Error(
"You have reached your storage limit. Please add a valid payment method to continue using the service.",
);
ERC20Asset: {
...mockCreateTokenFunctions.ERC20Asset,
deployContract: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
throw new Error(
"You have reached your storage limit. Please add a valid payment method to continue using the service.",
);
},
},
},
onLaunchSuccess: () => {},
@@ -0,0 +1,177 @@
"use client";
import type { ThirdwebClient } from "thirdweb";
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { TokenSelector } from "@/components/blocks/TokenSelector";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { DecimalInput } from "@/components/ui/decimal-input";
import { Switch } from "@/components/ui/switch";
import type { TokenDistributionForm } from "../_common/form";
export function DropERC20_TokenSaleSection(props: {
form: TokenDistributionForm;
chainId: string;
client: ThirdwebClient;
}) {
const totalSupply = Number(props.form.watch("supply"));
const sellSupply = Math.floor(
(totalSupply *
Number(props.form.watch("dropERC20Mode.saleAllocationPercentage"))) /
100,
);
const isEnabled = props.form.watch("saleEnabled");
const protocolFee = 2;
const convenienceFee = 0.5;
const recipientFee = 100 - protocolFee - convenienceFee;
return (
<DynamicHeight>
<div className="relative border-t border-dashed p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-xl tracking-tight">Sale</h2>
<p className="text-muted-foreground text-sm">
Make your coin available for purchase by setting a price
</p>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => {
props.form.setValue("saleEnabled", checked);
if (!checked) {
props.form.setValue(
"dropERC20Mode.saleAllocationPercentage",
"0",
);
props.form.setValue("dropERC20Mode.pricePerToken", "0");
} else if (!props.form.getValues("airdropEnabled")) {
props.form.setValue(
"dropERC20Mode.saleAllocationPercentage",
"100",
{
shouldValidate: true,
},
);
}
}}
/>
</div>
{isEnabled && (
<div>
<div className="mt-4 flex flex-col lg:flex-row gap-5 lg:max-w-2xl fade-in-0 duration-300 animate-in">
<FormFieldSetup
errorMessage={
props.form.formState.errors.dropERC20Mode
?.saleAllocationPercentage?.message
}
helperText={`${sellSupply} tokens`}
isRequired
label="Sell % of Total Supply"
>
<div className="relative lg:w-64">
<DecimalInput
maxValue={100}
onChange={(value) => {
props.form.setValue(
"dropERC20Mode.saleAllocationPercentage",
value,
);
}}
value={props.form.watch(
"dropERC20Mode.saleAllocationPercentage",
)}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
%
</span>
</div>
</FormFieldSetup>
<FormFieldSetup
className="grow"
errorMessage={
props.form.formState.errors.dropERC20Mode?.pricePerToken
?.message ||
props.form.formState.errors.dropERC20Mode?.saleTokenAddress
?.message
}
isRequired
label="Price per Token"
>
<div className="relative flex items-center">
<DecimalInput
onChange={(value) => {
props.form.setValue("dropERC20Mode.pricePerToken", value);
}}
value={props.form.watch("dropERC20Mode.pricePerToken")}
className="rounded-r-none w-42"
/>
<TokenSelector
addNativeTokenIfMissing={true}
chainId={Number(props.chainId)}
className="bg-background border-l-0 rounded-l-none"
client={props.client}
disableAddress={true}
popoverContentClassName="!w-[320px]"
onChange={(value) => {
props.form.setValue(
"dropERC20Mode.saleTokenAddress",
value.address,
);
}}
selectedToken={{
address: props.form.watch(
"dropERC20Mode.saleTokenAddress",
),
chainId: Number(props.chainId),
}}
showCheck={true}
/>
</div>
</FormFieldSetup>
</div>
<div className="mt-5 relative border-t border-dashed pt-5">
<p className="text-muted-foreground text-sm mb-3">
All primary sales are subjected to{" "}
<em className="text-foreground font-medium not-italic">2.5%</em>{" "}
fee
</p>
<div className="relative">
<DistributionBarChart
segments={[
{
label: "Your Wallet",
percent: recipientFee,
color: "hsl(var(--chart-1))",
value: `${recipientFee}%`,
},
{
label: "Protocol",
percent: protocolFee,
color: "hsl(var(--chart-2))",
value: `${protocolFee}%`,
},
{
label: "Convenience Fee",
percent: convenienceFee,
color: "hsl(var(--chart-3))",
value: `${convenienceFee}%`,
},
]}
/>
</div>
</div>
</div>
)}
</div>
</DynamicHeight>
);
}
@@ -66,10 +66,10 @@ export function TokenAirdropSection(props: {
return (
<DynamicHeight>
<div className="relative border-t border-dashed">
<div className="flex items-center justify-between gap-3 px-6 py-5">
<div className="relative border-t border-dashed px-4 py-8 md:px-6">
<div className="flex items-center justify-between gap-3 ">
<div>
<h2 className="font-semibold text-lg">Airdrop</h2>
<h2 className="font-semibold text-xl tracking-tight">Airdrop</h2>
<p className="text-muted-foreground text-sm">
Airdrop tokens to a list of addresses with each address receiving
a specific quantity
@@ -88,7 +88,7 @@ export function TokenAirdropSection(props: {
</div>
{isEnabled && (
<div className="flex justify-start px-6 pb-6">
<div className="flex justify-start mt-4 fade-in-0 duration-300 animate-in">
{airdropAddresses.length > 0 ? (
<div className="flex w-full flex-col gap-4 rounded-lg border bg-background p-4 md:flex-row lg:items-center lg:justify-between">
{/* left */}
@@ -14,21 +14,28 @@ import type {
TokenDistributionForm,
TokenDistributionFormValues,
} from "../_common/form";
import { DropERC20_TokenSaleSection } from "./drop-erc20-token-sale";
import { TokenAirdropSection } from "./token-airdrop";
import { TokenSaleSection } from "./token-sale";
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 10,
notation: "compact",
});
export function TokenDistributionFieldset(props: {
accountAddress: string;
onNext: () => void;
onPrevious: () => void;
form: TokenDistributionForm;
chainId: string;
form: TokenDistributionForm;
client: ThirdwebClient;
tokenSymbol: string | undefined;
isRouterEnabled: boolean;
}) {
const { form } = props;
const distributionError = getDistributionError(form);
const distributionError = getDistributionError(form);
const supplyId = useId();
return (
@@ -45,40 +52,51 @@ export function TokenDistributionFieldset(props: {
title="Coin Distribution"
>
<div>
<div className="space-y-6 p-4 md:px-6 md:py-6">
<div className="p-4 md:px-6 md:py-6">
<FormFieldSetup
errorMessage={form.formState.errors.supply?.message}
htmlFor={supplyId}
helperText={`${compactNumberFormatter.format(Number(form.watch("supply")))} tokens`}
isRequired
label="Total Supply"
>
<div className="relative">
<div className="relative max-w-96">
<Input id={supplyId} {...form.register("supply")} />
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
{props.tokenSymbol || "Tokens"}
</span>
</div>
</FormFieldSetup>
<div className="flex flex-col gap-3">
<TokenDistributionBarChart
distributionFormValues={form.watch()}
/>
{distributionError && (
<div className="text-destructive-text text-sm">
{distributionError}
</div>
)}
</div>
</div>
{form.watch("saleMode") === "drop-erc20:token-drop" ? (
<DropERC20_TokenSaleSection
chainId={props.chainId}
client={props.client}
form={form}
/>
) : (
<TokenSaleSection
chainId={props.chainId}
isRouterEnabled={props.isRouterEnabled}
client={props.client}
form={form}
/>
)}
<TokenAirdropSection client={props.client} form={form} />
<TokenSaleSection
chainId={props.chainId}
client={props.client}
form={form}
/>
<div className="flex flex-col gap-3 p-4 py-8 md:px-6 border-t border-dashed">
<TokenDistributionBarChart
distributionFormValues={form.watch()}
/>
{distributionError && (
<div className="text-destructive-text text-sm">
{distributionError}
</div>
)}
</div>
</div>
</StepCard>
</form>
@@ -97,10 +115,12 @@ function getDistributionError(form: TokenDistributionForm) {
}
const saleSupplyPercentage = SafeNumber(
form.watch("saleAllocationPercentage"),
form.watch("saleMode") === "erc20-asset:pool"
? form.watch("erc20Asset_poolMode.saleAllocationPercentage")
: form.watch("dropERC20Mode.saleAllocationPercentage"),
);
const saleSupply = (saleSupplyPercentage / 100) * supply;
const saleSupply = Math.round((saleSupplyPercentage / 100) * supply);
const ownerSupply = Math.max(supply - totalAirdrop - saleSupply, 0);
const totalSumOfSupply = totalAirdrop + saleSupply + ownerSupply;
@@ -127,7 +147,10 @@ export function TokenDistributionBarChart(props: {
);
const airdropPercentage = (totalAirdropSupply / totalSupply) * 100;
const salePercentage = Number(
props.distributionFormValues.saleAllocationPercentage,
props.distributionFormValues.saleMode === "erc20-asset:pool"
? props.distributionFormValues.erc20Asset_poolMode
.saleAllocationPercentage
: props.distributionFormValues.dropERC20Mode.saleAllocationPercentage,
);
const ownerPercentage = Math.max(100 - airdropPercentage - salePercentage, 0);
@@ -137,16 +160,19 @@ export function TokenDistributionBarChart(props: {
color: "hsl(var(--chart-1))",
label: "Owner",
percent: ownerPercentage,
value: `${ownerPercentage}%`,
},
{
color: "hsl(var(--chart-3))",
label: "Airdrop",
percent: airdropPercentage,
value: `${airdropPercentage}%`,
},
{
color: "hsl(var(--chart-4))",
label: "Sale",
percent: salePercentage,
value: `${salePercentage}%`,
},
];
@@ -1,110 +1,139 @@
"use client";
import { DollarSignIcon, XIcon } from "lucide-react";
import type { ThirdwebClient } from "thirdweb";
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { TokenSelector } from "@/components/blocks/TokenSelector";
import { Badge } from "@/components/ui/badge";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { DecimalInput } from "@/components/ui/decimal-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useAllChainsData } from "@/hooks/chains/allChains";
import type { TokenDistributionForm } from "../_common/form";
export function TokenSaleSection(props: {
form: TokenDistributionForm;
chainId: string;
client: ThirdwebClient;
isRouterEnabled: boolean;
}) {
const totalSupply = Number(props.form.watch("supply"));
const sellSupply = Math.floor(
(totalSupply * Number(props.form.watch("saleAllocationPercentage"))) / 100,
);
const { idToChain } = useAllChainsData();
const chainMeta = idToChain.get(Number(props.chainId));
const isSaleEnabled = props.form.watch("saleEnabled");
const protocolFee = 20;
const leftOverFee = 100 - protocolFee;
const convenienceFee = (12.5 * leftOverFee) / 100;
const deployerFee = leftOverFee - convenienceFee;
const isEnabled = props.form.watch("saleEnabled");
return (
<DynamicHeight>
<div className="relative border-t border-dashed p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg">Sale</h2>
<p className="text-muted-foreground text-sm">
Make your coin available for purchase by setting a price
</p>
<div className="relative border-t border-dashed px-4 md:px-6 py-8">
<div>
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-xl tracking-tight">Sale</h2>
<p className="text-muted-foreground text-sm">
List your coin on a decentralized exchange and earn rewards on
every trade
</p>
</div>
<Switch
checked={isSaleEnabled}
disabled={!props.isRouterEnabled}
onCheckedChange={(checked) => {
if (!props.isRouterEnabled) {
return;
}
props.form.setValue("saleEnabled", checked);
if (checked && !props.form.getValues("airdropEnabled")) {
props.form.setValue(
"erc20Asset_poolMode.saleAllocationPercentage",
"100",
{
shouldValidate: true,
},
);
} else {
props.form.setValue(
"erc20Asset_poolMode.saleAllocationPercentage",
"0",
{
shouldValidate: true,
},
);
}
}}
/>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => {
props.form.setValue("saleEnabled", checked);
if (!checked) {
props.form.setValue("saleAllocationPercentage", "0");
props.form.setValue("salePrice", "0");
}
}}
/>
{props.isRouterEnabled === false && (
<div className="mt-4 flex items-center gap-1.5 fade-in-0 duration-300 animate-in">
<div className="rounded-full border bg-background p-1">
<XIcon className="size-3 text-muted-foreground" />
</div>
<h2 className="text-muted-foreground text-sm">
Not Available on {chainMeta?.name || props.chainId}
</h2>
</div>
)}
</div>
{isEnabled && (
<div className="mt-4 flex flex-col gap-6">
<FormFieldSetup
errorMessage={
props.form.formState.errors.saleAllocationPercentage?.message
}
helperText={`${sellSupply} tokens`}
isRequired
label="Sell % of Total Supply"
>
<div className="relative">
<DecimalInput
maxValue={100}
onChange={(value) => {
props.form.setValue("saleAllocationPercentage", value);
}}
value={props.form.watch("saleAllocationPercentage")}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
%
</span>
</div>
</FormFieldSetup>
{isSaleEnabled && props.isRouterEnabled === true && (
<div className="pt-4 fade-in-0 duration-300 animate-in">
<PoolConfig
chainId={props.chainId}
client={props.client}
form={props.form}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<FormFieldSetup
errorMessage={props.form.formState.errors.salePrice?.message}
isRequired
label="Price per Token"
>
<div className="relative">
<DecimalInput
onChange={(value) => {
props.form.setValue("salePrice", value);
}}
value={props.form.watch("salePrice")}
/>
<div className="mt-5 border p-4 bg-background rounded-lg relative">
<div className="flex mb-4">
<div className="p-2 rounded-full border bg-card">
<DollarSignIcon className="size-5 text-muted-foreground" />
</div>
</FormFieldSetup>
</div>
<h3 className="font-medium text-base mt-2"> Sale Rewards </h3>
<p className="text-muted-foreground text-sm mb-4">
All trades on the market are subjected to{" "}
<em className="text-foreground font-medium not-italic"> 1% </em>{" "}
fee distributed as:
</p>
<FormFieldSetup
errorMessage={
props.form.formState.errors.saleTokenAddress?.message
}
isRequired
label="Currency"
>
<TokenSelector
addNativeTokenIfMissing={true}
chainId={Number(props.chainId)}
className="bg-background"
client={props.client}
disableAddress={true}
onChange={(value) => {
props.form.setValue("saleTokenAddress", value.address);
}}
selectedToken={{
address: props.form.watch("saleTokenAddress"),
chainId: Number(props.chainId),
}}
showCheck={true}
<div className="relative">
<div className="absolute -top-6 right-0 text-sm">Total: 1%</div>
<DistributionBarChart
segments={[
{
label: "Your Wallet",
percent: deployerFee,
color: "hsl(var(--chart-1))",
value: `${deployerFee / 100}%`,
},
{
label: "Protocol",
percent: protocolFee,
color: "hsl(var(--chart-2))",
value: `${protocolFee / 100}%`,
},
{
label: "Convenience fee",
percent: convenienceFee,
color: "hsl(var(--chart-3))",
value: `${convenienceFee / 100}%`,
},
]}
/>
</FormFieldSetup>
</div>
</div>
</div>
)}
@@ -112,3 +141,123 @@ export function TokenSaleSection(props: {
</DynamicHeight>
);
}
function PoolConfig(props: {
form: TokenDistributionForm;
chainId: string;
client: ThirdwebClient;
}) {
const { idToChain } = useAllChainsData();
const chainMeta = idToChain.get(Number(props.chainId));
const totalSupply = Number(props.form.watch("supply"));
const sellSupply = Math.floor(
(totalSupply *
Number(
props.form.watch("erc20Asset_poolMode.saleAllocationPercentage"),
)) /
100,
);
return (
<div className="space-y-6">
{/* Pricing Strategy */}
<FormFieldSetup
isRequired
label="Pricing Strategy"
errorMessage={undefined}
helperText="Bonding Curve pricing is a static formula for pricing tokens based on supply and demand"
>
<Select value="bonding-curve">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bonding-curve">Bonding Curve</SelectItem>
<SelectItem value="fixed-price" disabled>
<span className="flex items-center gap-2">
Dynamic Bonding Curve{" "}
<Badge variant="secondary">Coming Soon</Badge>
</span>
</SelectItem>
<SelectItem value="dutch-auction" disabled>
<span className="flex items-center gap-2">
Fixed Price <Badge variant="secondary">Coming Soon</Badge>
</span>
</SelectItem>
</SelectContent>
</Select>
</FormFieldSetup>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:max-w-[536px]">
{/* supply % */}
<FormFieldSetup
errorMessage={
props.form.formState.errors.erc20Asset_poolMode
?.saleAllocationPercentage?.message
}
helperText={`${compactNumberFormatter.format(sellSupply)} tokens`}
isRequired
label="Sell % of Total Supply"
>
<div className="relative">
<DecimalInput
maxValue={100}
onChange={(value) => {
props.form.setValue(
"erc20Asset_poolMode.saleAllocationPercentage",
value,
{
shouldValidate: true,
},
);
}}
value={props.form.watch(
"erc20Asset_poolMode.saleAllocationPercentage",
)}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-sm text-muted-foreground">
%
</span>
</div>
</FormFieldSetup>
{/* starting price */}
<FormFieldSetup
errorMessage={
props.form.formState.errors.erc20Asset_poolMode
?.startingPricePerToken?.message
}
isRequired
label="Starting price per token"
>
<div className="relative">
<DecimalInput
className="pr-10"
onChange={(value) => {
props.form.setValue(
"erc20Asset_poolMode.startingPricePerToken",
value,
{
shouldValidate: true,
},
);
}}
value={props.form.watch(
"erc20Asset_poolMode.startingPricePerToken",
)}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-sm text-muted-foreground">
{chainMeta?.nativeCurrency.symbol || "ETH"}
</span>
</div>
</FormFieldSetup>
</div>
</div>
);
}
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 10,
notation: "compact",
});
@@ -38,10 +38,15 @@ import type { CreateTokenFunctions } from "../create-token-page.client";
import { TokenDistributionBarChart } from "../distribution/token-distribution";
const stepIds = {
"airdrop-tokens": "airdrop-tokens",
"deploy-contract": "deploy-contract",
"mint-tokens": "mint-tokens",
"set-claim-conditions": "set-claim-conditions",
// asset ---
"erc20-asset:airdrop-tokens": "erc20-asset:airdrop-tokens",
"erc20-asset:approve-airdrop-tokens": "erc20-asset:approve-airdrop-tokens",
"erc20-asset:deploy-contract": "erc20-asset:deploy-contract",
// fallback ---
"drop-erc20:deploy-contract": "drop-erc20:deploy-contract",
"drop-erc20:set-claim-conditions": "drop-erc20:set-claim-conditions",
"drop-erc20:mint-tokens": "drop-erc20:mint-tokens",
"drop-erc20:airdrop-tokens": "drop-erc20:airdrop-tokens",
} as const;
type StepId = keyof typeof stepIds;
@@ -51,7 +56,10 @@ export function LaunchTokenStatus(props: {
values: CreateAssetFormValues;
onPrevious: () => void;
client: ThirdwebClient;
onLaunchSuccess: () => void;
onLaunchSuccess: (params: {
chainId: number;
contractAddress: string;
}) => void;
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
@@ -60,7 +68,7 @@ export function LaunchTokenStatus(props: {
const { createTokenFunctions } = props;
const [steps, setSteps] = useState<MultiStepState<StepId>[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [contractLink, setContractLink] = useState<string | null>(null);
const [contractAddress, setContractAddress] = useState<string | null>(null);
const activeWallet = useActiveWallet();
const walletRequiresApproval = activeWallet?.id !== "inApp";
@@ -85,35 +93,67 @@ export function LaunchTokenStatus(props: {
}
async function handleSubmitClick() {
const initialSteps: MultiStepState<StepId>[] = [
{
id: stepIds["deploy-contract"],
label: "Deploy contract",
status: { type: "idle" },
},
{
id: stepIds["set-claim-conditions"],
label: "Set claim conditions",
status: { type: "idle" },
},
{
id: stepIds["mint-tokens"],
label: "Mint tokens",
status: { type: "idle" },
},
];
if (formValues.saleMode === "erc20-asset:pool") {
const initialSteps: MultiStepState<StepId>[] = [
{
id: stepIds["erc20-asset:deploy-contract"],
label: "Deploy contract",
status: { type: "idle" },
},
];
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
initialSteps.push({
id: stepIds["airdrop-tokens"],
label: "Airdrop tokens",
status: { type: "idle" },
});
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
initialSteps.push({
id: stepIds["erc20-asset:approve-airdrop-tokens"],
label: "Approve spending tokens for airdrop",
status: { type: "idle" },
});
initialSteps.push({
id: stepIds["erc20-asset:airdrop-tokens"],
label: "Airdrop tokens",
status: { type: "idle" },
});
}
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0, isGasless);
} else {
const initialSteps: MultiStepState<StepId>[] = [
{
id: stepIds["drop-erc20:deploy-contract"],
label: "Deploy contract",
status: { type: "idle" },
},
{
id: stepIds["drop-erc20:set-claim-conditions"],
label: "Set claim conditions",
status: { type: "idle" },
},
];
// if user is selling 100% of the tokens, owner share is 0% - so skip minting
if (Number(formValues.dropERC20Mode.saleAllocationPercentage) !== 100) {
initialSteps.push({
id: stepIds["drop-erc20:mint-tokens"],
label: "Mint tokens",
status: { type: "idle" },
});
}
if (formValues.airdropEnabled && formValues.airdropAddresses.length > 0) {
initialSteps.push({
id: stepIds["drop-erc20:airdrop-tokens"],
label: "Airdrop tokens",
status: { type: "idle" },
});
}
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0, isGasless);
}
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0, isGasless);
}
const isComplete = steps.every((step) => step.status.type === "completed");
@@ -125,17 +165,28 @@ export function LaunchTokenStatus(props: {
values: formValues,
};
if (stepId === "deploy-contract") {
const result = await createTokenFunctions.deployContract(params);
setContractLink(
`/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${result.contractAddress}`,
);
} else if (stepId === "set-claim-conditions") {
await createTokenFunctions.setClaimConditions(params);
} else if (stepId === "mint-tokens") {
await createTokenFunctions.mintTokens(params);
} else if (stepId === "airdrop-tokens") {
await createTokenFunctions.airdropTokens(params);
// erc20-asset
if (stepId === "erc20-asset:deploy-contract") {
const result =
await createTokenFunctions.ERC20Asset.deployContract(params);
setContractAddress(result.contractAddress);
} else if (stepId === "erc20-asset:airdrop-tokens") {
await createTokenFunctions.ERC20Asset.airdropTokens(params);
} else if (stepId === "erc20-asset:approve-airdrop-tokens") {
await createTokenFunctions.ERC20Asset.approveAirdropTokens(params);
}
// drop-erc20
else if (stepId === "drop-erc20:deploy-contract") {
const result =
await createTokenFunctions.DropERC20.deployContract(params);
setContractAddress(result.contractAddress);
} else if (stepId === "drop-erc20:set-claim-conditions") {
await createTokenFunctions.DropERC20.setClaimConditions(params);
} else if (stepId === "drop-erc20:mint-tokens") {
await createTokenFunctions.DropERC20.mintTokens(params);
} else if (stepId === "drop-erc20:airdrop-tokens") {
await createTokenFunctions.DropERC20.airdropTokens(params);
}
}
@@ -165,7 +216,10 @@ export function LaunchTokenStatus(props: {
reportAssetCreationFailed({
assetType: "coin",
contractType: "DropERC20",
contractType:
formValues.saleMode === "drop-erc20:token-drop"
? "DropERC20"
: "ERC20Asset",
error: errorMessage,
step: currentStep.id,
});
@@ -181,10 +235,18 @@ export function LaunchTokenStatus(props: {
reportAssetCreationSuccessful({
assetType: "coin",
contractType: "DropERC20",
contractType:
formValues.saleMode === "drop-erc20:token-drop"
? "DropERC20"
: "ERC20Asset",
});
props.onLaunchSuccess();
if (contractAddress) {
props.onLaunchSuccess({
chainId: Number(formValues.chain),
contractAddress,
});
}
}
async function handleRetry(step: MultiStepState<StepId>, gasless: boolean) {
@@ -196,6 +258,10 @@ export function LaunchTokenStatus(props: {
await executeSteps(steps, startIndex, gasless);
}
const contractLink = contractAddress
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${contractAddress}`
: null;
return (
<StepCard
nextButton={{
@@ -302,7 +368,7 @@ export function LaunchTokenStatus(props: {
className="gap-0 overflow-hidden p-0 md:max-w-[480px]"
dialogCloseClassName="hidden"
>
<div className="flex flex-col gap-6 p-6">
<div className="flex flex-col gap-6 p-6 overflow-hidden">
<DialogHeader className="space-y-0.5">
<DialogTitle className="font-semibold text-xl tracking-tight">
Status
@@ -8,6 +8,7 @@ import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
import { StepCard } from "../../_common/step-card";
@@ -47,7 +48,7 @@ export function TokenInfoFieldset(props: {
client={props.client}
setValue={(file) =>
form.setValue("image", file, {
shouldTouch: true,
shouldValidate: true,
})
}
value={form.watch("image")}
@@ -59,7 +60,7 @@ export function TokenInfoFieldset(props: {
{/* name + symbol */}
<div className="flex flex-col gap-6 lg:flex-row lg:gap-4">
<FormFieldSetup
className="grow"
className="lg:max-w-[300px] grow"
errorMessage={form.formState.errors.name?.message}
htmlFor={nameId}
isRequired
@@ -73,7 +74,7 @@ export function TokenInfoFieldset(props: {
</FormFieldSetup>
<FormFieldSetup
className="lg:max-w-[200px]"
className="lg:max-w-[150px]"
errorMessage={form.formState.errors.symbol?.message}
htmlFor={symbolId}
isRequired
@@ -94,14 +95,28 @@ export function TokenInfoFieldset(props: {
isRequired
label="Chain"
>
<ClientOnly ssr={null}>
<ClientOnly ssr={<Skeleton className="h-10" />}>
<SingleNetworkSelector
priorityChains={[
8453, // base
84532, // base sepolia
1, // ethereum
56, // bnb smart chain mainnet (bsc)
42161, // arbitrum one mainnet
43114, // avalanche mainnet
146, // sonic
137, // polygon
80094, // berachain mainnet
10, // optimism
]}
chainId={Number(form.watch("chain"))}
className="bg-background"
client={props.client}
disableChainId
onChange={(chain) => {
form.setValue("chain", chain.toString());
form.setValue("chain", chain.toString(), {
shouldValidate: true,
});
props.onChainUpdated();
}}
/>
@@ -0,0 +1,17 @@
const MIN_TICK = -887200;
const MAX_TICK = 887200;
const TICK_SPACING = 200;
export function getInitialTickValue(params: { startingPricePerToken: number }) {
const calculatedTick =
Math.log(params.startingPricePerToken) / Math.log(1.0001);
// Round to nearest tick spacing
const tick = Math.round(calculatedTick / TICK_SPACING) * TICK_SPACING;
return tick;
}
export function isValidTickValue(tick: number) {
return tick >= MIN_TICK && tick <= MAX_TICK;
}
@@ -1,4 +1,3 @@
import { DialogDescription } from "@radix-ui/react-dialog";
import { AlertTriangleIcon } from "lucide-react";
import type { WebhookConfig } from "@/api/project/webhook-configs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -6,6 +5,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -0,0 +1,96 @@
import type { Meta } from "@storybook/nextjs";
import { toUnits } from "thirdweb";
import { base } from "thirdweb/chains";
import { ThirdwebProvider } from "thirdweb/react";
import { storybookThirdwebClient } from "@/storybook/utils";
import { ClaimRewardsPageUI } from "./claim-rewards-page";
const meta = {
component: ClaimRewardsPageUI,
title: "contracts/extensions/claim-rewards",
decorators: [
(Story) => (
<ThirdwebProvider>
<div className="container max-w-[1154px] py-10">
<Story />
</div>
</ThirdwebProvider>
),
],
} satisfies Meta<typeof ClaimRewardsPageUI>;
export default meta;
const recipient = "0x8C00f3F231c88CcAc2382AaC6e09A78D4F42129d";
const developer = "0x1Af20C6B23373350aD464700B5965CE4B0D2aD94";
function unclaimedFeesStub(token0Amount: bigint, token1Amount: bigint) {
return {
token0: {
address: "0x1234567890123456789012345678901234567890",
amount: token0Amount,
symbol: "FOO",
},
token1: {
address: "0x0987654321098765432109876543210987654321",
amount: token1Amount,
symbol: "BAR",
},
};
}
export function LargeAmounts() {
return (
<Variant
token0Amount={toUnits("100000", 18)}
token1Amount={toUnits("500000", 18)}
includeChainExplorer
/>
);
}
export function SmallAmounts() {
return (
<Variant
token0Amount={toUnits("0.001", 18)}
token1Amount={toUnits("0.0005", 18)}
includeChainExplorer
/>
);
}
export function ZeroAmount() {
return (
<Variant
token0Amount={toUnits("0", 18)}
token1Amount={toUnits("0", 18)}
includeChainExplorer
/>
);
}
export function ZeroAmountNoChainExplorer() {
return (
<Variant token0Amount={toUnits("0", 18)} token1Amount={toUnits("0", 18)} />
);
}
function Variant(props: {
token0Amount: bigint;
token1Amount: bigint;
includeChainExplorer?: boolean;
}) {
return (
<ClaimRewardsPageUI
developerBps={5000}
chainSlug="base"
recipient={recipient}
developer={developer}
client={storybookThirdwebClient}
handleClaim={() => {}}
isClaimPending={false}
chain={base}
unclaimedFees={unclaimedFeesStub(props.token0Amount, props.token1Amount)}
/>
);
}
@@ -0,0 +1,322 @@
"use client";
import { DollarSignIcon, ExternalLinkIcon, SplitIcon } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import {
type Chain,
type ThirdwebClient,
type ThirdwebContract,
toTokens,
} from "thirdweb";
import { TokenIcon, TokenProvider, useSendTransaction } from "thirdweb/react";
import { claimRewards } from "thirdweb/tokens";
import { DistributionBarChart } from "@/components/blocks/distribution-chart";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { TransactionButton } from "@/components/tx-button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { parseError } from "@/utils/errorParser";
import { tryCatch } from "@/utils/try-catch";
import type { getValidReward } from "../utils/rewards";
export function ClaimRewardsPage(props: {
assetContractClient: ThirdwebContract;
entrypointContractClient: ThirdwebContract;
reward: NonNullable<Awaited<ReturnType<typeof getValidReward>>>;
unclaimedFees: {
token0: {
address: string;
amount: bigint;
symbol: string;
};
token1: {
address: string;
amount: bigint;
symbol: string;
};
};
chainSlug: string;
}) {
const sendTx = useSendTransaction();
const router = useDashboardRouter();
async function handleClaim() {
const claimRewardsTx = claimRewards({
asset: props.assetContractClient.address,
contract: props.entrypointContractClient,
});
const claimRewardsResult = await tryCatch(
sendTx.mutateAsync(claimRewardsTx),
);
if (claimRewardsResult.error) {
toast.error("Failed to distribute rewards", {
description: parseError(claimRewardsResult.error),
});
} else {
toast.success("Rewards distributed successfully");
router.refresh();
}
}
return (
<ClaimRewardsPageUI
unclaimedFees={props.unclaimedFees}
recipient={props.reward.recipient}
developer={props.reward.developer}
handleClaim={handleClaim}
isClaimPending={sendTx.isPending}
client={props.assetContractClient.client}
chain={props.assetContractClient.chain}
chainSlug={props.chainSlug}
developerBps={props.reward.developerBps}
/>
);
}
function calculateFees(developerBps: number) {
// 20% of is protocol fees
// remaining is split between developer and recipient
const protocolFees = 20;
const remaining = 100 - protocolFees;
const developerPercentageFinal = (remaining * developerBps) / 10000;
return {
protocolFees,
developerPercentage: developerPercentageFinal,
recipientPercentage: 100 - protocolFees - developerPercentageFinal,
};
}
export function ClaimRewardsPageUI(props: {
unclaimedFees: {
token0: {
address: string;
amount: bigint;
symbol: string;
};
token1: {
address: string;
amount: bigint;
symbol: string;
};
};
recipient: string;
developer: string;
developerBps: number;
handleClaim: () => void;
isClaimPending: boolean;
client: ThirdwebClient;
chain: Chain;
chainSlug: string;
}) {
const fees = calculateFees(props.developerBps);
const recipientColor = `hsl(var(--chart-1))`;
const developerColor = `hsl(var(--chart-2))`;
const protocolFeesColor = `hsl(var(--chart-3))`;
const hasUnclaimedRewards =
props.unclaimedFees.token0.amount > 0 ||
props.unclaimedFees.token1.amount > 0;
return (
<div>
<div className="bg-card rounded-lg border">
<div className="p-4 lg:p-6 border-b border-dashed">
<div className="flex mb-3">
<div className="rounded-full border bg-background p-2">
<DollarSignIcon className="size-5 text-muted-foreground" />
</div>
</div>
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
Rewards
</h2>
<p className="text-muted-foreground text-sm">
Earnings received by Liquidity Providers (LPs) in exchange for
depositing tokens into {props.unclaimedFees.token0.symbol} /{" "}
{props.unclaimedFees.token1.symbol} Uniswap liquidity pool
</p>
</div>
<div className="p-4 lg:px-6 py-8 border-b border-dashed">
<div className="mb-4">
<h3 className="font-medium text-base mb-1">Unclaimed Rewards</h3>
<p className="text-muted-foreground text-sm">
The rewards that are earned but haven't been distributed yet
</p>
</div>
<div className="flex flex-col gap-4 lg:flex-row">
<TokenReward
token={props.unclaimedFees.token0}
client={props.client}
chain={props.chain}
chainSlug={props.chainSlug}
/>
<TokenReward
token={props.unclaimedFees.token1}
client={props.client}
chain={props.chain}
chainSlug={props.chainSlug}
/>
</div>
</div>
<div className="p-4 lg:px-6 py-8">
<h3 className="font-medium text-base mb-1">Reward Distribution</h3>
<p className="text-muted-foreground text-sm mb-4">
The unclaimed rewards will be distributed as:
</p>
<DistributionBarChart
segments={[
{
label: "Recipient",
color: recipientColor,
percent: fees.recipientPercentage,
value: `${fees.recipientPercentage}%`,
},
{
label: "Protocol",
color: protocolFeesColor,
percent: fees.protocolFees,
value: `${fees.protocolFees}%`,
},
{
label: "Developer",
color: developerColor,
percent: fees.developerPercentage,
value: `${fees.developerPercentage}%`,
},
]}
/>
<div className="h-6" />
<div className="flex gap-5">
<div>
<p className="font-medium text-sm">Recipient</p>
<WalletAddress
address={props.recipient}
client={props.client}
iconClassName="size-3"
className="h-auto py-1 text-xs"
fallbackIcon={
<div
className="size-3 rounded-full"
style={{
backgroundColor: recipientColor,
}}
/>
}
/>
</div>
<div className="border-l pl-5">
<p className="font-medium text-sm">Developer</p>
<WalletAddress
address={props.developer}
client={props.client}
iconClassName="size-3"
className="h-auto py-1 text-xs"
fallbackIcon={
<div
className="size-3 rounded-full"
style={{
backgroundColor: developerColor,
}}
/>
}
/>
</div>
</div>
</div>
<div className="p-4 lg:p-6 border-t border-dashed flex-col lg:flex-row flex lg:justify-between lg:items-center gap-4">
{hasUnclaimedRewards && (
<p className="text-muted-foreground text-sm">
Click on "Distribute Rewards" to distribute unclaimed rewards
</p>
)}
{!hasUnclaimedRewards && (
<p className="text-muted-foreground text-sm">
There are no unclaimed rewards available for distribution
</p>
)}
<TransactionButton
onClick={props.handleClaim}
className="bg-background rounded-full"
transactionCount={undefined}
isPending={props.isClaimPending}
txChainID={props.chain.id}
isLoggedIn={true}
client={props.client}
disabled={!hasUnclaimedRewards}
size="sm"
variant="outline"
>
<SplitIcon className="size-3.5 text-muted-foreground" />
Distribute Rewards
</TransactionButton>
</div>
</div>
</div>
);
}
function TokenReward(props: {
token: {
address: string;
amount: bigint;
symbol: string;
};
client: ThirdwebClient;
chain: Chain;
chainSlug: string;
}) {
const fallbackIcon = (
<div className="size-9 rounded-full from-muted to-card bg-gradient-to-br flex items-center justify-center text-muted-foreground font-bold uppercase">
{props.token.symbol[0]}
</div>
);
return (
<div className="border p-3 rounded-xl flex items-center gap-3 min-w-[300px] bg-background relative hover:border-active-border">
<div className="rounded-full border shrink-0">
<TokenProvider
address={props.token.address}
client={props.client}
chain={props.chain}
>
<TokenIcon
className="size-9 rounded-full"
loadingComponent={fallbackIcon}
fallbackComponent={fallbackIcon}
/>
</TokenProvider>
</div>
<div className="space-y-0.5">
<p className="font-bold text-sm">
{toTokens(props.token.amount, 18)} {props.token.symbol}
</p>
<Link
target="_blank"
rel="noopener noreferrer"
href={`/${props.chainSlug}/${props.token.address}`}
className="flex items-center gap-1.5 text-muted-foreground before:absolute before:inset-0"
>
<span className="text-xs font-mono">
{props.token.address.slice(0, 6)}...
{props.token.address.slice(-4)}
</span>
<ExternalLinkIcon className="size-3" />
</Link>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import { notFound, redirect } from "next/navigation";
import { getContract } from "thirdweb";
import { getDeployedEntrypointERC20 } from "thirdweb/tokens";
import { getProject } from "@/api/project/projects";
import { getContractPageParamsInfo } from "../../../../../../../(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/getContractFromParams";
import type { ProjectContractPageParams } from "../types";
import { ClaimRewardsPage } from "./components/claim-rewards-page";
import { getValidReward } from "./utils/rewards";
import { getUnclaimedFees } from "./utils/unclaimed-fees";
export default async function Page(props: {
params: Promise<ProjectContractPageParams>;
}) {
const params = await props.params;
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
notFound();
}
const info = await getContractPageParamsInfo({
chainIdOrSlug: params.chainIdOrSlug,
contractAddress: params.contractAddress,
teamId: project.teamId,
});
if (!info) {
notFound();
}
const chain = info.clientContract.chain;
const assetContractServer = info.serverContract;
const serverClient = assetContractServer.client;
const { address: entrypointContractAddress } =
await getDeployedEntrypointERC20({
chain,
client: serverClient,
});
// Note: must use server contract/client here
const reward = await getValidReward({
assetContract: assetContractServer,
entrypointContract: getContract({
address: entrypointContractAddress,
chain,
client: serverClient,
}),
});
if (!reward) {
redirect(
`/team/${params.team_slug}/${params.project_slug}/contract/${params.chainIdOrSlug}/${params.contractAddress}`,
);
}
// Note: must use server contract/client here
const unclaimedFees = await getUnclaimedFees({
positionManager: getContract({
address: reward.positionManager,
chain,
client: serverClient,
}),
reward: {
tokenId: reward.positionId,
recipient: reward.recipient,
},
});
return (
<ClaimRewardsPage
assetContractClient={info.clientContract}
entrypointContractClient={getContract({
address: entrypointContractAddress,
chain: chain,
client: info.clientContract.client,
})}
reward={reward}
unclaimedFees={unclaimedFees}
chainSlug={info.chainMetadata.slug}
/>
);
}
@@ -0,0 +1,23 @@
import type { ThirdwebContract } from "thirdweb";
import { getRewards } from "thirdweb/tokens";
export async function getValidReward(params: {
assetContract: ThirdwebContract;
entrypointContract: ThirdwebContract;
}) {
try {
const rewards = await getRewards({
contract: params.entrypointContract,
asset: params.assetContract.address,
});
if (rewards.length === 0) {
return null;
}
// It's potentially possible to have multiple rewards locked up, but it's not the default use case.
return rewards[0];
} catch {
return null;
}
}
@@ -0,0 +1,88 @@
import type { ThirdwebContract } from "thirdweb";
import { getContract, readContract } from "thirdweb";
import { symbol } from "thirdweb/extensions/common";
const maxUint128 = 2n ** 128n - 1n;
export async function getUnclaimedFees(params: {
positionManager: ThirdwebContract;
reward: {
tokenId: bigint;
recipient: string;
};
}) {
const collectResultPromise = readContract({
contract: params.positionManager,
method:
"function collect((uint256 tokenId,address recipient,uint128 amount0Max,uint128 amount1Max)) returns (uint256,uint256)",
params: [
{
tokenId: params.reward.tokenId,
recipient: params.reward.recipient,
amount0Max: maxUint128,
amount1Max: maxUint128,
},
],
});
const positionsResultPromise = readContract({
contract: params.positionManager,
method:
"function positions(uint256 tokenId) view returns (uint96,address,address,address,uint24,int24,int24,uint128,uint256,uint256,uint128,uint128)",
params: [params.reward.tokenId],
});
const [collectResult, positionsResult] = await Promise.all([
collectResultPromise,
positionsResultPromise,
]);
// 0- nonce
// 1- owner
// 2- token0
// 3- token1
// 4 - fee
// 5 - tickLower
// 6 - tickUpper
// 7 - liquidity
// 8 - feeGrowthInside0LastX128
// 9 - feeGrowthInside1LastX128
// 10 - tokensOwed0
// 11 - tokensOwed1
const client = params.positionManager.client;
const chain = params.positionManager.chain;
const token0Address = positionsResult[2];
const token1Address = positionsResult[3];
const [token0Symbol, token1Symbol] = await Promise.all([
symbol({
contract: getContract({
address: token0Address,
chain,
client,
}),
}),
symbol({
contract: getContract({
address: token1Address,
chain,
client,
}),
}),
]);
return {
token0: {
address: token0Address,
amount: collectResult[0],
symbol: token0Symbol,
},
token1: {
address: token1Address,
amount: collectResult[1],
symbol: token1Symbol,
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
import "../../global.css";
import "@workspace/ui/global.css";
import { BridgeProviders } from "./components/client/Providers.client";
const fontSans = Inter({
+1 -1
View File
@@ -1,4 +1,4 @@
import "../../global.css";
import "@workspace/ui/global.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import NextTopLoader from "nextjs-toploader";
+192 -36
View File
@@ -1,10 +1,20 @@
import { ShieldCheckIcon } from "lucide-react";
import type { Metadata } from "next";
import { defineChain, getContract } from "thirdweb";
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
import { checksumAddress } from "thirdweb/utils";
import { Bridge, defineChain, toTokens } from "thirdweb";
import { getChainMetadata } from "thirdweb/chains";
import { shortenAddress } from "thirdweb/utils";
import { getPaymentLink } from "@/api/universal-bridge/links";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { Badge } from "@/components/ui/badge";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import {
API_SERVER_SECRET,
DASHBOARD_THIRDWEB_SECRET_KEY,
} from "@/constants/server-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
import { resolveEns } from "@/lib/ens";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { PayPageWidget } from "../components/client/PayPageWidget.client";
import { payAppThirdwebClient } from "../constants";
const title = "thirdweb Pay";
const description = "Fast, secure, and simple payments.";
@@ -32,40 +42,186 @@ export default async function PayPage({
paymentId: id,
});
const tokenContract = getContract({
address: paymentLink.destinationToken.address, // for this RPC call, use the dashboard client
// eslint-disable-next-line no-restricted-syntax
chain: defineChain(Number(paymentLink.destinationToken.chainId)),
client: getClientThirdwebClient(undefined),
});
const {
symbol,
decimals,
name: tokenName,
} = await getCurrencyMetadata({
contract: tokenContract,
});
const token = {
address: checksumAddress(paymentLink.destinationToken.address),
const projectMetadataPromise = getProjectMetadata(paymentLink.clientId);
const tokensPromise = Bridge.tokens({
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
chainId: Number(paymentLink.destinationToken.chainId),
decimals,
name: tokenName,
symbol,
};
tokenAddress: paymentLink.destinationToken.address,
});
const chainPromise = getChainMetadata(
// eslint-disable-next-line no-restricted-syntax
defineChain(Number(paymentLink.destinationToken.chainId)),
);
const recipientPromise = resolveEns(
paymentLink.receiver,
getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
);
const [tokens, projectMetadata, chain, recipientEnsOrAddress] =
await Promise.all([
tokensPromise,
projectMetadataPromise,
chainPromise,
recipientPromise,
]);
const token = tokens[0];
if (!token) {
throw new Error("Token not found");
}
return (
<PayPageWidget
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
chainId={Number(paymentLink.destinationToken.chainId)}
clientId={undefined} // Payment links don't need to use the same client ID to be executed
image={paymentLink.imageUrl}
name={paymentLink.title}
paymentLinkId={id}
purchaseData={paymentLink.purchaseData}
recipientAddress={paymentLink.receiver}
redirectUri={redirectUri}
theme={theme}
token={token}
/>
<div className="flex z-10 flex-col lg:flex-row h-full w-full">
<header className="min-w-full lg:min-w-[500px] border-b lg:border-r lg:h-full bg-card flex flex-col gap-4 items-start p-4 lg:p-8">
<div>
<div className="flex flex-row items-center justify-start gap-4">
{projectMetadata.image && (
<img
src={
resolveSchemeWithErrorHandler({
uri: projectMetadata.image,
client: payAppThirdwebClient,
}) || ""
}
alt={projectMetadata.name}
width={25}
height={25}
className="rounded-full overflow-hidden"
/>
)}
<h2 className="text-xl font-bold">{projectMetadata.name}</h2>
</div>
{projectMetadata.description && (
<p className="mt-2 text-sm text-muted-foreground">
{projectMetadata.description}
</p>
)}
</div>
<div className="hidden lg:block my-4 w-full">
{paymentLink.amount && (
<div className="flex flex-col gap-1 w-full my-4">
<span className="text-muted-foreground text-xs">Details</span>
<div className="font-medium flex-row flex justify-between items-center w-full">
<div className="flex flex-row items-center gap-2">
{token.iconUri && (
<img
src={resolveSchemeWithErrorHandler({
uri: token.iconUri,
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
})}
alt={token.name}
width={25}
height={25}
className="size-5 rounded-full overflow-hidden"
/>
)}
{toTokens(BigInt(paymentLink.amount), token.decimals)}{" "}
{token.symbol}
</div>
{token.prices.USD && (
<span>
$
{(
Number(token.prices.USD) *
Number(
toTokens(BigInt(paymentLink.amount), token.decimals),
)
).toFixed(2)}
</span>
)}
</div>
</div>
)}
{chain && (
<div className="flex flex-col gap-1 w-full my-4">
<span className="text-muted-foreground text-xs">Network</span>
<div className="font-medium flex-row flex justify-between items-center w-full">
<div className="flex flex-row items-center gap-2">
{chain.icon?.url && (
<img
src={resolveSchemeWithErrorHandler({
uri: chain.icon.url,
client: getConfiguredThirdwebClient({
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
teamId: undefined,
}),
})}
alt={chain.name}
width={chain.icon.width}
height={chain.icon.height}
className="size-5 rounded-full overflow-hidden"
/>
)}
{chain.name}
</div>
</div>
</div>
)}
{recipientEnsOrAddress.ensName ||
(recipientEnsOrAddress.address && (
<div className="flex flex-col gap-1 w-full my-4">
<span className="text-muted-foreground text-xs">Seller</span>
<div className="font-medium flex-row flex justify-between items-center w-full">
{recipientEnsOrAddress.ensName ??
shortenAddress(recipientEnsOrAddress.address)}
</div>
</div>
))}
</div>
<div className="mt-auto hidden lg:block">
<Badge className="flex items-center gap-1.5 bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-800">
<ShieldCheckIcon className="size-3" />
Secured by thirdweb
</Badge>
</div>
</header>
<main className="flex justify-center p-12 w-full items-center">
<PayPageWidget
amount={paymentLink.amount ? BigInt(paymentLink.amount) : undefined}
chainId={Number(paymentLink.destinationToken.chainId)}
clientId={undefined} // Payment links don't need to use the same client ID to be executed
image={paymentLink.imageUrl}
name={paymentLink.title}
paymentLinkId={id}
purchaseData={paymentLink.purchaseData}
recipientAddress={paymentLink.receiver}
redirectUri={redirectUri}
theme={theme}
token={token}
/>
</main>
</div>
);
}
async function getProjectMetadata(clientId: string) {
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v2/keys/lookup`);
url.searchParams.append("clientId", clientId);
const response = await fetch(url.toString(), {
headers: {
"x-service-api-key": API_SERVER_SECRET,
},
});
if (!response.ok) {
throw new Error("Failed to fetch project");
}
const { data } = (await response.json()) as {
data: { name: string; image: string | null; description: string | null };
};
return data;
}
+3 -12
View File
@@ -1,4 +1,4 @@
import "../../global.css";
import "@workspace/ui/global.css";
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { cn } from "@/lib/utils";
@@ -30,17 +30,8 @@ export default async function PayLayout({
disableTransitionOnChange
enableSystem={false}
>
<div className="relative mx-auto flex h-full w-full flex-col items-center justify-center overflow-x-hidden overflow-y-scroll py-10">
<main className="container z-10 flex justify-center">
{children}
</main>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0"
src="/assets/login/background.svg"
/>
<div className="relative mx-auto flex h-full w-full items-center justify-center overflow-x-hidden overflow-y-scroll">
{children}
</div>
</ThemeProvider>
</PayProviders>
+1 -1
View File
@@ -7,7 +7,7 @@ import { PayPageWidget } from "./components/client/PayPageWidget.client";
import type { PayParams } from "./components/types";
import { payAppThirdwebClient } from "./constants";
const title = "thirdweb Pay";
const title = "thirdweb Payments";
const description = "Fast, secure, and simple payments.";
export const metadata: Metadata = {
+13
View File
@@ -0,0 +1,13 @@
import tailwindConfig from "@workspace/ui/tailwind.config";
import type { Config } from "tailwindcss";
const config: Config = {
...tailwindConfig,
content: [
"./src/**/*.{ts,tsx}",
// add contents of ui package
"../../packages/ui/src/**/*.{ts,tsx}",
],
};
export default config;
+4 -4
View File
@@ -9,11 +9,11 @@
"isolatedModules": true,
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "node",
"module": "ESNext",
"moduleResolution": "Bundler",
"moduleDetection": "force",
"noEmit": true,
"noUncheckedIndexedAccess": true,
"plugins": [
{
"name": "next"
@@ -22,7 +22,7 @@
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2020"
"target": "ES2022"
},
"exclude": ["node_modules", ".next", "lib/__generated__"],
"include": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"ignore": ["src/components/ui/**", "src/app/insight/utils.ts"],
"ignoreDependencies": ["server-only"],
"ignoreDependencies": ["server-only", "@workspace/ui", "tailwindcss-animate"],
"next": true,
"project": ["src/**"]
}
+1 -4
View File
@@ -37,6 +37,7 @@ const securityHeaders = [
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@workspace/ui"],
eslint: {
ignoreDuringBuilds: true,
},
@@ -164,10 +165,6 @@ const nextConfig = {
},
];
},
webpack: (config) => {
config.externals.push("pino-pretty", "lokijs", "encoding");
return config;
},
};
export default nextConfig;
+1
View File
@@ -15,6 +15,7 @@
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "1.2.7",
"@tanstack/react-query": "5.81.5",
"@workspace/ui": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "4.1.0",
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {},
tailwindcss: {},
},
};
+1
View File
@@ -0,0 +1 @@
export { default } from "@workspace/ui/postcss.config";
+1 -1
View File
@@ -63,7 +63,7 @@ export async function promptNebula(params: {
const events = await stream(`${API_URL}/ai/chat`, {
body: JSON.stringify(body),
headers: {
"x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID!,
"x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "",
"Content-Type": "application/json",
},
method: "POST",
@@ -1,8 +1,3 @@
type SessionContextFilter = {
chain_ids: string[] | null;
wallet_address: string | null;
};
type NebulaUserMessageContentItem =
| {
type: "image";
@@ -26,17 +21,6 @@ export type NebulaUserMessage = {
content: NebulaUserMessageContent;
};
export type NebulaSessionHistoryMessage =
| {
role: "assistant" | "action" | "image";
content: string;
timestamp: number;
}
| {
role: "user";
content: NebulaUserMessageContent | string;
};
export type NebulaTxData = {
chainId: number;
data: `0x${string}`;
@@ -1,5 +1,9 @@
"use client";
import { ArrowRightIcon, MessageCircleIcon } from "lucide-react";
import {
ArrowRightIcon,
ExternalLinkIcon,
MessageCircleIcon,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { defineChain, prepareTransaction, type ThirdwebClient } from "thirdweb";
@@ -28,10 +32,7 @@ import { Img } from "../../../components/ui/Img";
import { Spinner } from "../../../components/ui/Spinner/Spinner";
import { THIRDWEB_CLIENT } from "../../../lib/client";
import { type NebulaContext, promptNebula } from "../api/chat";
import type {
NebulaSessionHistoryMessage,
NebulaUserMessage,
} from "../api/types";
import type { NebulaUserMessage } from "../api/types";
import { examplePrompts } from "../data/examplePrompts";
import { resolveSchemeWithErrorHandler } from "./resolveSchemeWithErrorHandler";
@@ -346,8 +347,14 @@ export function ChatPageContent(props: {
/>
{/* Footer disclaimer */}
<p className="mt-3 text-center text-muted-foreground text-xs opacity-75 lg:text-sm">
thirdweb AI can make mistakes. Please use with discretion
<p className="flex items-center justify-center gap-1 mt-3 text-center text-muted-foreground hover:text-foreground text-xs opacity-75 lg:text-sm">
<Link
href="https://portal.thirdweb.com/ai/chat"
target="_blank"
>
Learn how to integrate thirdweb AI into your apps
</Link>
<ExternalLinkIcon className="size-3" />
</p>
</div>
</div>
@@ -392,8 +399,14 @@ export function ChatPageContent(props: {
/>
{/* Footer disclaimer */}
<p className="mt-3 text-center text-muted-foreground text-xs opacity-75 lg:text-sm">
thirdweb AI can make mistakes. Please use with discretion
<p className="flex items-center justify-center gap-1 mt-3 text-center text-muted-foreground hover:text-foreground text-xs opacity-75 lg:text-sm">
<Link
href="https://portal.thirdweb.com/ai/chat"
target="_blank"
>
Learn how to integrate thirdweb AI into your apps
</Link>
<ExternalLinkIcon className="size-3" />
</p>
</div>
</div>
@@ -569,7 +582,7 @@ function SimpleChats(props: {
enableAutoScroll: boolean;
sendMessage: (message: NebulaUserMessage) => void;
}) {
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
const { messages, enableAutoScroll } = props;
const scrollAnchorRef = useRef<HTMLDivElement>(null);
// auto scroll to bottom when messages change
@@ -1022,37 +1035,3 @@ function handleNebulaPromptError(params: {
return newMessages;
});
}
function parseHistoryToMessages(history: NebulaSessionHistoryMessage[]) {
const messages: ChatMessage[] = [];
for (const message of history) {
switch (message.role) {
case "user": {
messages.push({
content:
typeof message.content === "string"
? [
{
text: message.content,
type: "text",
},
]
: message.content,
type: message.role,
});
break;
}
case "assistant": {
messages.push({
request_id: undefined,
text: message.content,
type: message.role,
});
}
}
}
return messages;
}
-156
View File
@@ -1,156 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* bg - neutral */
--background: 0 0% 98%;
--popover: 0 0% 100%;
--card: 0 0% 100%;
--secondary: 0 0% 90%;
--muted: 0 0% 93%;
--accent: 0 0% 93%;
--inverted: 0 0% 4%;
/* bg - colorful */
--primary: 221 83% 54%;
--destructive: 360 72% 51%;
/* Text */
--foreground: 0 0% 4%;
--card-foreground: 0 0% 4%;
--popover-foreground: 240 10% 4%;
--primary-foreground: 0 0% 100%;
--secondary-foreground: 0 0% 4%;
--muted-foreground: 0 0% 40%;
--accent-foreground: 0 0% 9%;
--destructive-foreground: 0 0% 100%;
--inverted-foreground: 0 0% 100%;
--link-foreground: 221.21deg 83.19% 53.33%;
--success-text: 142.09 70.56% 35.29%;
--warning-text: 38 92% 40%;
--destructive-text: 360 72% 60%;
/* Borders */
--border: 0 0% 85%;
--active-border: 0 0% 70%;
--input: 0 0% 85%;
--ring: 0 0% 80%;
/* Others */
--radius: 0.5rem;
/* Sidebar */
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 0 0% 4%;
--sidebar-primary: 221 83% 54%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 0 0% 93%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 85%;
--sidebar-ring: 0 0% 80%;
}
.dark {
/* bg - neutral */
--background: 0 0% 0%;
--card: 0 0% 3.92%;
--popover: 0 0% 0%;
--secondary: 0 0% 11%;
--muted: 0 0% 11%;
--accent: 0 0% 11%;
--inverted: 0 0% 100%;
/* bg - colorful */
--primary: 221 83% 54%;
--destructive: 360 72% 51%;
/* Text */
--foreground: 0 0% 98%;
--card-foreground: 0 0% 98%;
--popover-foreground: 0 0% 98%;
--primary-foreground: 0 0% 100%;
--secondary-foreground: 0 0% 98%;
--muted-foreground: 0 0% 63%;
--accent-foreground: 0 0% 98%;
--destructive-foreground: 0 0% 100%;
--link-foreground: 215.88 100% 65%;
--warning-text: 38 92% 50%;
--destructive-text: 360 72% 55%;
--success-text: 142 75% 50%;
--inverted-foreground: 0 0% 0%;
/* Borders */
--border: 0 0% 15%;
--active-border: 0 0% 22%;
--ring: 0 0% 30%;
--input: 0 0% 15%;
/* sidebar */
--sidebar-background: 0 0% 0%;
--sidebar-foreground: 0 0% 98%;
--sidebar-primary: 221 83% 54%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 0 0% 11%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 15%;
--sidebar-ring: 0 0% 30%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.shiki,
.shiki span {
background-color: transparent !important;
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark) !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
/* Fix colors on auto-filled inputs */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
/* Revert text color */
-webkit-text-fill-color: hsl(var(--foreground)) !important;
color: hsl(var(--foreground)) !important;
caret-color: hsl(var(--foreground)) !important;
/* Revert background color */
transition: background-color 5000s ease-in-out 0s;
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar,
.no-scrollbar *::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar,
.no-scrollbar * {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted)) transparent;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { metadataBase } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { AppSidebarLayout } from "./AppSidebar";
import { Providers } from "./providers";
import "./globals.css";
import "@workspace/ui/global.css";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import { Toaster } from "sonner";
@@ -3,35 +3,39 @@
import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { ClientOnly } from "@/components/ClientOnly";
import { Button } from "@/components/ui/button";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Skeleton } from "@/components/ui/skeleton";
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
return (
<div className="border-t pt-2">
<ClientOnly
ssr={<Skeleton className="size-[34px] rounded-full border bg-accent" />}
>
<Button
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
aria-label="Toggle theme"
className="w-full text-muted-foreground hover:text-foreground px-2 py-1.5 text-left justify-start gap-2 capitalize h-auto font-normal"
onClick={() => {
setTheme(theme === "dark" ? "light" : "dark");
}}
size="sm"
variant="ghost"
>
{theme === "light" ? (
<SunIcon className="size-4" />
) : (
<MoonIcon className="size-4" />
)}
<ClientOnly ssr={<Skeleton className="size-4" />}>
{theme === "light" ? (
<SunIcon className="size-4" />
) : (
<MoonIcon className="size-4" />
)}
</ClientOnly>
{theme}
</Button>
</ClientOnly>
</div>
<ClientOnly ssr={<Skeleton className="w-16 h-4" />}>
<span>{theme}</span>
</ClientOnly>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}
@@ -1,6 +1,6 @@
"use client";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { ChevronRightIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import {
@@ -126,6 +126,7 @@ function FullWidthSidebarLayoutInner(props: FullWidthSidebarLayoutProps) {
links={footerSidebarLinks}
fullPath={props.fullPath}
/>
<SidebarSeparator />
<ThemeToggle />
</SidebarFooter>
)}
@@ -289,7 +290,7 @@ function RenderSidebarSubmenu(props: {
<DynamicHeight transition="height 200ms ease">
<SidebarMenuItem>
<Collapsible
className="group/collapsible [&[data-state=open]>button>svg[data-chevron]]:rotate-180"
className="group/collapsible [&[data-state=open]>button>svg[data-chevron]]:rotate-90"
defaultOpen={open}
open={open}
onOpenChange={setOpen}
@@ -307,9 +308,9 @@ function RenderSidebarSubmenu(props: {
<span>{props.subMenu.label}</span>
{sidebar.open && (
<ChevronDownIcon
<ChevronRightIcon
data-chevron
className="transition-transform ml-auto"
className="transition-transform ml-auto text-foreground"
/>
)}
</SidebarMenuButton>
@@ -1,63 +1,3 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-10 px-4 py-2",
icon: "h-10 w-10",
lg: "h-11 rounded-md px-8",
sm: "h-9 rounded-md px-3",
},
variant: {
default: "bg-foreground text-background hover:bg-foreground/90",
destructive:
"bg-destructive hover:bg-destructive/90 text-semibold text-destructive-foreground ",
ghost: "hover:bg-accent text-semibold hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline text-semibold",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground text-semibold",
primary:
"bg-primary hover:bg-primary/90 text-semibold text-primary-foreground ",
secondary:
"bg-secondary hover:bg-secondary/80 text-semibold text-secondary-foreground ",
upsell:
"bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
},
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const btnOnlyProps =
Comp === "button" ? { type: "button" as const } : undefined;
return (
<Comp
className={cn(buttonVariants({ className, size, variant }))}
ref={ref}
{...btnOnlyProps}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
"use client";
export type { ButtonProps } from "@workspace/ui/components/button";
export { Button, buttonVariants } from "@workspace/ui/components/button";
@@ -27,8 +27,8 @@ import { cn } from "@/lib/utils";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "19rem";
const SIDEBAR_WIDTH_MOBILE = "19rem";
const SIDEBAR_WIDTH = "18rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
+11 -109
View File
@@ -1,111 +1,13 @@
import { fontFamily } from "tailwindcss/defaultTheme";
import tailwindConfig from "@workspace/ui/tailwind.config";
import type { Config } from "tailwindcss";
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{ts,tsx}"],
darkMode: ["class"],
plugins: [require("tailwindcss-animate")],
prefix: "",
theme: {
container: {
center: true,
padding: {
// 16px
DEFAULT: "1rem",
// 24px
lg: "1.5rem",
},
screens: {
"2xl": "1400px",
},
},
extend: {
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
"active-border": "hsl(var(--active-border))",
background: "hsl(var(--background))",
border: "hsl(var(--border))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
code: {
DEFAULT: "hsl(var(--code))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
text: "hsl(var(--destructive-text))",
},
foreground: "hsl(var(--foreground))",
input: "hsl(var(--input))",
inverted: {
DEFAULT: "hsl(var(--inverted))",
foreground: "hsl(var(--inverted-foreground))",
},
link: {
foreground: "hsl(var(--link-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
ring: "hsl(var(--ring))",
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
sidebar: {
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
ring: "hsl(var(--sidebar-ring))",
},
success: {
text: "hsl(var(--success-text))",
},
warning: {
text: "hsl(var(--warning-text))",
},
},
fontFamily: {
mono: ["var(--font-mono)", ...fontFamily.mono],
sans: ["var(--font-sans)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
},
},
const config: Config = {
...tailwindConfig,
content: [
"./src/**/*.{ts,tsx}",
// add contents of ui package
"../../packages/ui/src/**/*.{ts,tsx}",
],
};
export default config;
+9 -15
View File
@@ -2,31 +2,25 @@
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"next.config.{js,ts,cjs,mjs}",
"{instrumentation,middleware}.{js,ts}",
"app/global-error.{js,jsx,ts,tsx}",
"app/**/{error,layout,loading,not-found,page,template,default}.{js,jsx,ts,tsx,mdx}",
"app/**/route.{js,jsx,ts,tsx}",
"app/{manifest,sitemap,robots}.{js,ts}",
"app/**/{icon,apple-icon}.{js,jsx,ts,tsx}",
"app/**/{opengraph,twitter}-image.{js,jsx,ts,tsx}",
"pages/**/*.{js,jsx,ts,tsx}",
"src/{instrumentation,middleware}.{js,ts}",
"src/app/global-error.{js,jsx,ts,tsx}",
"src/app/**/{error,layout,loading,not-found,page,template,default}.{js,jsx,ts,tsx,mdx}",
"src/app/**/route.{js,jsx,ts,tsx}",
"src/app/{manifest,sitemap,robots}.{js,ts}",
"src/app/**/{icon,apple-icon}.{js,jsx,ts,tsx}",
"src/app/**/{opengraph,twitter}-image.{js,jsx,ts,tsx}",
"src/pages/**/*.{js,jsx,ts,tsx}",
"scripts/*.ts"
],
"ignore": ["src/components/ui/**", "src/icons/**", "src/components/others/Banner.tsx"],
"ignore": [
"src/components/ui/**",
"src/icons/**",
"src/components/others/Banner.tsx"
],
"ignoreBinaries": ["only-allow"],
"ignoreDependencies": [
"@thirdweb-dev/chains",
"@thirdweb-dev/wallets",
"thirdweb",
"@types/flexsearch"
"@workspace/ui",
"@types/flexsearch",
"@radix-ui/react-slot",
"tailwindcss-animate"
],
"next": true,
"project": ["src/**"]
+1 -4
View File
@@ -47,6 +47,7 @@ const withMDX = createMDX({
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@workspace/ui"],
eslint: {
ignoreDuringBuilds: true,
},
@@ -82,10 +83,6 @@ const nextConfig = {
},
];
},
webpack: (config) => {
config.externals.push("pino-pretty", "lokijs", "encoding");
return config;
},
};
export default withMDX(nextConfig);
+1
View File
@@ -55,6 +55,7 @@
"@types/tryghost__content-api": "^1.3.16",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@typescript-eslint/parser": "7.14.1",
"@workspace/ui": "workspace:*",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-config-biome": "1.9.4",
+1 -1
View File
@@ -341,7 +341,7 @@ export function Header() {
{/* Mobile menu */}
{showBurgerMenu && (
<div className="fixed inset-0 top-sticky-top-height z-[50] overflow-auto bg-card p-6 xl:hidden">
<div className="fixed inset-0 top-sticky-top-height z-50 overflow-auto bg-card p-6 xl:hidden">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<h3 className="font-semibold text-lg">Products</h3>
-157
View File
@@ -1,123 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* bg - neutral */
--background: 0 0% 98%;
--popover: 0 0% 100%;
--card: 0 0% 100%;
--secondary: 0 0% 90%;
--muted: 0 0% 97%;
--accent: 0 0% 93%;
--inverted: 0 0% 4%;
/* bg - colorful */
--primary: 221 83% 54%;
--destructive: 360 72% 51%;
/* Text */
--foreground: 0 0% 4%;
--card-foreground: 0 0% 4%;
--popover-foreground: 240 10% 4%;
--primary-foreground: 0 0% 100%;
--secondary-foreground: 0 0% 4%;
--muted-foreground: 0 0% 40%;
--accent-foreground: 0 0% 9%;
--destructive-foreground: 0 0% 100%;
--inverted-foreground: 0 0% 100%;
--link-foreground: 221.21deg 83.19% 53.33%;
--success-text: 142.09 70.56% 35.29%;
--warning-text: 38 92% 40%;
--destructive-text: 360 72% 60%;
/* Borders */
--border: 0 0% 85%;
--active-border: 0 0% 70%;
--input: 0 0% 85%;
--ring: 0 0% 80%;
/* Others */
--radius: 0.5rem;
/* Sidebar */
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 0 0% 4%;
--sidebar-primary: 221 83% 54%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 0 0% 93%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 85%;
--sidebar-ring: 0 0% 80%;
}
.dark {
/* bg - neutral */
--background: 0 0% 0%;
--card: 0 0% 3.92%;
--popover: 0 0% 0%;
--secondary: 0 0% 11%;
--muted: 0 0% 8%;
--accent: 0 0% 11%;
--inverted: 0 0% 100%;
/* bg - colorful */
--primary: 221 83% 54%;
--destructive: 360 72% 51%;
/* Text */
--foreground: 0 0% 98%;
--card-foreground: 0 0% 98%;
--popover-foreground: 0 0% 98%;
--primary-foreground: 0 0% 100%;
--secondary-foreground: 0 0% 98%;
--muted-foreground: 0 0% 63%;
--accent-foreground: 0 0% 98%;
--destructive-foreground: 0 0% 100%;
--link-foreground: 215.88 100% 65%;
--warning-text: 38 92% 50%;
--destructive-text: 360 72% 55%;
--success-text: 142 75% 50%;
--inverted-foreground: 0 0% 0%;
/* Borders */
--border: 0 0% 15%;
--active-border: 0 0% 22%;
--ring: 0 0% 30%;
--input: 0 0% 15%;
/* sidebar */
--sidebar-background: 0 0% 0%;
--sidebar-foreground: 0 0% 98%;
--sidebar-primary: 221 83% 54%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 0 0% 11%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 15%;
--sidebar-ring: 0 0% 30%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
code span {
color: var(--code-light-color);
}
@@ -131,43 +11,6 @@ code span {
background-color: transparent !important;
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark) !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
/* Fix colors on auto-filled inputs */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
/* Revert text color */
-webkit-text-fill-color: hsl(var(--foreground)) !important;
color: hsl(var(--foreground)) !important;
caret-color: hsl(var(--foreground)) !important;
/* Revert background color */
transition: background-color 5000s ease-in-out 0s;
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar,
.no-scrollbar *::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar,
.no-scrollbar * {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
.styled-scrollbar::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
+1
View File
@@ -1,3 +1,4 @@
import "@workspace/ui/global.css";
import "./globals.css";
import { Fira_Code, Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
+4
View File
@@ -41,6 +41,10 @@ export const sidebar: SideBar = {
href: `${paymentsSlug}/sell`,
name: "Sell Tokens",
},
{
href: `${paymentsSlug}/swap`,
name: "Swap Tokens",
},
{
href: `${paymentsSlug}/tokens`,
name: "Get Token Prices",
+645
View File
@@ -0,0 +1,645 @@
import {
Callout,
createMetadata,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
ArticleIconCard,
} from "@doc";
import {
ReactIcon,
TypeScriptIcon,
} from "@/icons";
export const metadata = createMetadata({
image: {
title: "Swap Tokens",
icon: "payments",
},
title: "Swap Tokens",
description: "Build a custom token swap interface using thirdweb Payments.",
});
# Swap Tokens
Build a custom token swap interface that allows users to exchange tokens across chains using thirdweb Payments.
<Tabs defaultValue="react">
<TabsList>
<TabsTrigger value="react" className="flex items-center gap-2 [&>p]:mb-0">
<ReactIcon className="w-4 h-4 mr-2" />
React
</TabsTrigger>
<TabsTrigger value="typescript" className="flex items-center gap-2 [&>p]:mb-0">
<TypeScriptIcon className="w-4 h-4 mr-2" />
TypeScript
</TabsTrigger>
</TabsList>
<TabsContent value="react">
### Simple Swap Component
Create a basic swap interface with token selection and amount input:
```tsx
import React, { useState, useEffect } from "react";
import { Bridge } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
import { createThirdwebClient } from "thirdweb";
import { ethereum, polygon } from "thirdweb/chains";
const client = createThirdwebClient({
clientId: "YOUR_CLIENT_ID",
});
function SwapInterface() {
const account = useActiveAccount();
const [fromToken, setFromToken] = useState("ETH");
const [toToken, setToToken] = useState("MATIC");
const [amount, setAmount] = useState("");
const [quote, setQuote] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const getQuote = async () => {
if (!amount || !account) return;
setIsLoading(true);
try {
const preparedQuote = await Bridge.Buy.prepare({
originChainId: ethereum.id,
originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", // ETH
destinationChainId: polygon.id,
destinationTokenAddress: "0x0000000000000000000000000000000000001010", // MATIC
amount: BigInt(amount * 1e18), // Convert to wei
sender: account.address,
receiver: account.address,
client,
});
setQuote(preparedQuote);
} catch (error) {
console.error("Failed to get quote:", error);
}
setIsLoading(false);
};
useEffect(() => {
if (amount && account) {
const timer = setTimeout(getQuote, 500);
return () => clearTimeout(timer);
}
}, [amount, account]);
const executeSwap = async () => {
if (!quote || !account) return;
try {
for (const step of quote.steps) {
for (const transaction of step.transactions) {
const result = await sendAndConfirmTransaction({
transaction,
account,
});
// Wait for cross-chain completion if needed
if (["buy", "sell", "transfer"].includes(transaction.action)) {
let swapStatus;
do {
swapStatus = await Bridge.status({
transactionHash: result.transactionHash,
chainId: transaction.chainId,
client,
});
if (swapStatus.status === "PENDING") {
await new Promise(resolve => setTimeout(resolve, 3000));
}
} while (swapStatus.status === "PENDING");
}
}
}
alert("Swap completed successfully!");
setQuote(null);
setAmount("");
} catch (error) {
console.error("Swap failed:", error);
alert("Swap failed. Please try again.");
}
};
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-lg">
<h2 className="text-2xl font-bold mb-6 text-center">Token Swap</h2>
{/* From Token */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">From</label>
<div className="flex gap-2">
<input
type="number"
placeholder="0.0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<select
value={fromToken}
onChange={(e) => setFromToken(e.target.value)}
className="p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="ETH">ETH</option>
<option value="USDC">USDC</option>
<option value="WETH">WETH</option>
</select>
</div>
<p className="text-sm text-gray-500 mt-1">Ethereum</p>
</div>
{/* Swap Arrow */}
<div className="flex justify-center mb-4">
<button className="p-2 rounded-full bg-gray-100 hover:bg-gray-200">
↓
</button>
</div>
{/* To Token */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">To</label>
<div className="flex gap-2">
<input
type="text"
placeholder="0.0"
value={quote ? (Number(quote.destinationAmount) / 1e18).toFixed(6) : ""}
readOnly
className="flex-1 p-3 border rounded-lg bg-gray-50"
/>
<select
value={toToken}
onChange={(e) => setToToken(e.target.value)}
className="p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="MATIC">MATIC</option>
<option value="USDC">USDC</option>
<option value="WETH">WETH</option>
</select>
</div>
<p className="text-sm text-gray-500 mt-1">Polygon</p>
</div>
{/* Quote Info */}
{quote && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm">
<span className="font-medium">Rate:</span> 1 {fromToken} = {(Number(quote.destinationAmount) / Number(quote.originAmount)).toFixed(6)} {toToken}
</p>
<p className="text-sm">
<span className="font-medium">Fee:</span> {((Number(quote.originAmount) - Number(amount) * 1e18) / 1e18).toFixed(6)} {fromToken}
</p>
<p className="text-sm">
<span className="font-medium">Estimated time:</span> {Math.round(quote.estimatedExecutionTimeMs / 1000)}s
</p>
</div>
)}
{/* Swap Button */}
<button
onClick={executeSwap}
disabled={!quote || isLoading || !account}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{!account ? "Connect Wallet" : isLoading ? "Getting Quote..." : "Swap"}
</button>
{!account && (
<p className="text-center text-sm text-gray-500 mt-3">
Connect your wallet to start swapping
</p>
)}
</div>
);
}
```
### Advanced Swap with Route Selection
Build a more sophisticated swap interface with multiple route options:
```tsx
import React, { useState, useEffect } from "react";
import { Bridge } from "thirdweb";
import { useActiveAccount } from "thirdweb/react";
function AdvancedSwapInterface() {
const account = useActiveAccount();
const [routes, setRoutes] = useState([]);
const [selectedRoute, setSelectedRoute] = useState(null);
const [quotes, setQuotes] = useState([]);
// Get available routes
const getRoutes = async () => {
try {
const availableRoutes = await Bridge.routes({
originChainId: 1, // Ethereum
originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
client,
});
setRoutes(availableRoutes.slice(0, 5)); // Show top 5 routes
} catch (error) {
console.error("Failed to get routes:", error);
}
};
// Get quotes for multiple routes
const getQuotes = async (amount) => {
if (!amount || !account) return;
const quotePromises = routes.map(async (route) => {
try {
const quote = await Bridge.Buy.prepare({
originChainId: route.originToken.chainId,
originTokenAddress: route.originToken.address,
destinationChainId: route.destinationToken.chainId,
destinationTokenAddress: route.destinationToken.address,
amount: BigInt(amount * 1e18),
sender: account.address,
receiver: account.address,
client,
});
return { route, quote };
} catch (error) {
return { route, error };
}
});
const results = await Promise.all(quotePromises);
setQuotes(results.filter(result => result.quote));
};
useEffect(() => {
getRoutes();
}, []);
return (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-6">Advanced Token Swap</h2>
{/* Amount Input */}
<div className="mb-6">
<input
type="number"
placeholder="Enter amount to swap"
onChange={(e) => getQuotes(e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Route Options */}
<div className="space-y-3">
<h3 className="text-lg font-medium">Available Routes</h3>
{quotes.map(({ route, quote }, index) => (
<div
key={index}
className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer"
onClick={() => setSelectedRoute({ route, quote })}
>
<div className="flex justify-between items-center">
<div>
<p className="font-medium">
{route.originToken.symbol} → {route.destinationToken.symbol}
</p>
<p className="text-sm text-gray-500">
{route.originToken.name} to {route.destinationToken.name}
</p>
</div>
<div className="text-right">
<p className="font-medium">
{(Number(quote.destinationAmount) / Math.pow(10, route.destinationToken.decimals)).toFixed(6)}
</p>
<p className="text-sm text-gray-500">
~{Math.round(quote.estimatedExecutionTimeMs / 1000)}s
</p>
</div>
</div>
</div>
))}
</div>
{selectedRoute && (
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium mb-2">Selected Route</h4>
<p>{selectedRoute.route.originToken.symbol} → {selectedRoute.route.destinationToken.symbol}</p>
<button
onClick={() => executeSwap(selectedRoute.quote)}
className="mt-3 w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Execute Swap
</button>
</div>
)}
</div>
);
}
```
</TabsContent>
<TabsContent value="typescript">
### Core Swap Logic
Create the core swap functionality that can be integrated into any UI:
```typescript
import { Bridge, sendAndConfirmTransaction, type Account } from "thirdweb";
interface SwapParams {
fromChainId: number;
fromTokenAddress: string;
toChainId: number;
toTokenAddress: string;
amount: bigint;
userAddress: string;
client: any;
}
interface SwapQuote {
originAmount: bigint;
destinationAmount: bigint;
estimatedExecutionTimeMs: number;
steps: any[];
}
export class SwapManager {
private client: any;
constructor(client: any) {
this.client = client;
}
async getQuote(params: SwapParams): Promise<SwapQuote> {
try {
const quote = await Bridge.Buy.prepare({
originChainId: params.fromChainId,
originTokenAddress: params.fromTokenAddress,
destinationChainId: params.toChainId,
destinationTokenAddress: params.toTokenAddress,
amount: params.amount,
sender: params.userAddress,
receiver: params.userAddress,
client: this.client,
});
return {
originAmount: quote.originAmount,
destinationAmount: quote.destinationAmount,
estimatedExecutionTimeMs: quote.estimatedExecutionTimeMs,
steps: quote.steps,
};
} catch (error) {
throw new Error(`Failed to get swap quote: ${error.message}`);
}
}
async executeSwap(quote: SwapQuote, account: Account): Promise<string[]> {
const transactionHashes: string[] = [];
try {
for (const step of quote.steps) {
for (const transaction of step.transactions) {
const result = await sendAndConfirmTransaction({
transaction,
account,
});
transactionHashes.push(result.transactionHash);
// Wait for cross-chain completion if needed
if (["buy", "sell", "transfer"].includes(transaction.action)) {
await this.waitForCompletion(result.transactionHash, transaction.chainId);
}
}
}
return transactionHashes;
} catch (error) {
throw new Error(`Swap execution failed: ${error.message}`);
}
}
private async waitForCompletion(transactionHash: string, chainId: number): Promise<void> {
let attempts = 0;
const maxAttempts = 200; // 10 minutes max wait
while (attempts < maxAttempts) {
try {
const status = await Bridge.status({
transactionHash,
chainId,
client: this.client,
});
if (status.status === "COMPLETED") {
return;
}
if (status.status === "FAILED") {
throw new Error("Cross-chain transaction failed");
}
// Wait 3 seconds before checking again
await new Promise(resolve => setTimeout(resolve, 3000));
attempts++;
} catch (error) {
attempts++;
if (attempts >= maxAttempts) {
throw error;
}
}
}
throw new Error("Transaction timeout - please check status manually");
}
async getAvailableRoutes(params: {
originChainId?: number;
originTokenAddress?: string;
destinationChainId?: number;
destinationTokenAddress?: string;
limit?: number;
}) {
try {
return await Bridge.routes({
...params,
client: this.client,
});
} catch (error) {
throw new Error(`Failed to get routes: ${error.message}`);
}
}
}
```
### Usage Example
```typescript
import { createThirdwebClient } from "thirdweb";
import { ethereum, polygon } from "thirdweb/chains";
const client = createThirdwebClient({
clientId: "YOUR_CLIENT_ID",
});
const swapManager = new SwapManager(client);
// Example: Swap ETH to MATIC
async function swapEthToMatic(amount: string, userAccount: Account) {
try {
// Get quote
const quote = await swapManager.getQuote({
fromChainId: ethereum.id,
fromTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", // ETH
toChainId: polygon.id,
toTokenAddress: "0x0000000000000000000000000000000000001010", // MATIC
amount: BigInt(parseFloat(amount) * 1e18),
userAddress: userAccount.address,
client,
});
console.log("Quote:", {
fromAmount: Number(quote.originAmount) / 1e18,
toAmount: Number(quote.destinationAmount) / 1e18,
estimatedTime: quote.estimatedExecutionTimeMs / 1000,
});
// Execute swap
const txHashes = await swapManager.executeSwap(quote, userAccount);
console.log("Swap completed! Transaction hashes:", txHashes);
return txHashes;
} catch (error) {
console.error("Swap failed:", error);
throw error;
}
}
```
### Advanced Price Comparison
Compare prices across multiple routes to find the best deal:
```typescript
interface RouteComparison {
route: any;
quote: SwapQuote;
priceImpact: number;
gasEstimate: bigint;
}
export class PriceComparator {
private swapManager: SwapManager;
constructor(swapManager: SwapManager) {
this.swapManager = swapManager;
}
async compareRoutes(
fromToken: { chainId: number; address: string },
amount: bigint,
userAddress: string
): Promise<RouteComparison[]> {
// Get available routes
const routes = await this.swapManager.getAvailableRoutes({
originChainId: fromToken.chainId,
originTokenAddress: fromToken.address,
limit: 10,
});
// Get quotes for each route
const comparisons: RouteComparison[] = [];
for (const route of routes) {
try {
const quote = await this.swapManager.getQuote({
fromChainId: route.originToken.chainId,
fromTokenAddress: route.originToken.address,
toChainId: route.destinationToken.chainId,
toTokenAddress: route.destinationToken.address,
amount,
userAddress,
client: this.swapManager.client,
});
const priceImpact = this.calculatePriceImpact(quote, amount);
const gasEstimate = this.estimateGasCosts(quote);
comparisons.push({
route,
quote,
priceImpact,
gasEstimate,
});
} catch (error) {
console.warn(`Failed to get quote for route ${route.originToken.symbol} -> ${route.destinationToken.symbol}:`, error);
}
}
// Sort by best output amount
return comparisons.sort((a, b) =>
Number(b.quote.destinationAmount) - Number(a.quote.destinationAmount)
);
}
private calculatePriceImpact(quote: SwapQuote, inputAmount: bigint): number {
// Simplified price impact calculation
const ratio = Number(quote.destinationAmount) / Number(quote.originAmount);
return Math.abs(1 - ratio) * 100; // Percentage
}
private estimateGasCosts(quote: SwapQuote): bigint {
// Sum up gas estimates from all transactions
return quote.steps.reduce((total, step) => {
return total + step.transactions.reduce((stepTotal: bigint, tx: any) => {
return stepTotal + (tx.gasLimit || 200000n); // Default estimate
}, 0n);
}, 0n);
}
}
```
</TabsContent>
</Tabs>
## View it in action:
<ArticleIconCard
title="Token Swap Playground"
description="Try building and testing swap interfaces with live data"
icon={ReactIcon}
href="https://playground.thirdweb.com/connect/pay"
/>
## Key Features
- **Cross-chain swaps** - Exchange tokens across 50+ supported chains
- **Real-time quotes** - Get up-to-date pricing and execution estimates
- **Route optimization** - Find the best path for any token pair
- **Price comparison** - Compare multiple routes to maximize output
- **Status tracking** - Monitor cross-chain transaction progress
<Callout variant="info">
**Gas Optimization Tip:** For frequently traded pairs, consider caching route data to reduce API calls and improve UI responsiveness.
</Callout>
## Going Further
- [Send a Payment](/payments/send) - Learn about peer-to-peer transfers
- [Get Routes](/payments/routes) - Explore route discovery APIs
- [Token Prices](/payments/tokens) - Access real-time token pricing
- [Webhooks](/payments/webhooks) - Get notified when swaps complete
## API Reference
- [Bridge.Buy.prepare](/references/typescript/v5/buy/prepare)
- [Bridge.routes](/references/typescript/v5/routes)
- [Bridge.status](/references/typescript/v5/status)
@@ -30,7 +30,7 @@ export function RenderCode(props: {
/>
</ScrollShadow>
<CopyButton
className="absolute top-4 right-4 z-[10] border bg-background p-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
className="absolute top-4 right-4 z-10 border bg-background p-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
iconClassName="size-3"
text={props.code}
/>
+3 -62
View File
@@ -1,62 +1,3 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-8 px-4 py-2 text-sm",
icon: "size-10",
lg: "h-11 rounded-md px-8",
sm: "h-7 rounded-md px-3 text-sm",
},
variant: {
default: "bg-foreground text-background hover:bg-foreground/90",
destructive:
"bg-destructive hover:bg-destructive/90 text-destructive-foreground ",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
secondary:
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
upsell:
"bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
},
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const btnOnlyProps =
Comp === "button" ? { type: "button" as const } : undefined;
return (
<Comp
className={cn(buttonVariants({ className, size, variant }))}
ref={ref}
{...btnOnlyProps}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
"use client";
export type { ButtonProps } from "@workspace/ui/components/button";
export { Button, buttonVariants } from "@workspace/ui/components/button";
+28 -114
View File
@@ -1,130 +1,44 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
import tailwindConfig from "@workspace/ui/tailwind.config";
import type { Config } from "tailwindcss";
const config: Config = {
...tailwindConfig,
content: [
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx,mdx}",
"./src/**/*.{ts,tsx,mdx}",
// add contents of ui package
"../../packages/ui/src/**/*.{ts,tsx}",
],
darkMode: ["class"],
plugins: [require("tailwindcss-animate")],
theme: {
container: {
center: true,
padding: "1rem",
screens: {
"2xl": "1400px",
},
},
...tailwindConfig.theme,
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"animate-in-slow": "animate-in 0.4s ease",
"text-shimmer": "text-shimmer 1.25s linear infinite",
},
colors: {
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
"active-border": "hsl(var(--active-border))",
background: "hsl(var(--background))",
border: "hsl(var(--border))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
current: "currentColor",
destructive: {
DEFAULT: "hsl(var(--destructive))",
// destructive-foreground should only be used on destructive bg
foreground: "hsl(var(--destructive-foreground))",
// destructive-text can be used on neutral bg - it's red-ish
text: "hsl(var(--destructive-text))",
},
foreground: "hsl(var(--foreground))",
input: "hsl(var(--input))",
inverted: {
DEFAULT: "hsl(var(--inverted))",
foreground: "hsl(var(--inverted-foreground))",
},
link: {
foreground: "hsl(var(--link-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
overlay: "hsl(var(--overlay))",
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
ring: "hsl(var(--ring))",
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
success: {
// success-text can be used on neutral bg, it's green-ish
text: "hsl(var(--success-text))",
},
transparent: "transparent",
warning: {
text: "hsl(var(--warning-text))",
},
},
keyframes: {
"accordion-down": {
from: { height: 0, opacity: 0 },
to: { height: "var(--radix-accordion-content-height)", opacity: 1 },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)", opacity: 1 },
to: { height: 0, opacity: 0 },
},
"text-shimmer": {
"0%": { backgroundPosition: "100% 50%" },
"100%": { backgroundPosition: "-100% 50%" },
},
},
...tailwindConfig.theme?.extend,
spacing: {
"offset-top": "calc(var(--sticky-top-height) + 18px)",
"offset-top-mobile": "calc(var(--sticky-top-height) + 100px)",
"sidebar-height": "calc(100vh - var(--sticky-top-height))",
"sticky-top-height": "var(--sticky-top-height)",
},
},
fontFamily: {
mono: ["var(--font-mono)", "monospace"],
sans: ["var(--font-sans)", "sans-serif"],
},
zIndex: {
// base
base: 0,
//
codeToken: 5,
codeTokenHighlight: 1,
//
copyCodeButton: 20,
dropdownMenu: 1200,
floatingButton: 1000,
menu: 1100,
modal: 1400,
modalOverlay: 1300,
stickyMobileSidebar: 500,
//
stickyTop: 1000,
zIndex: {
// base
base: "0",
//
codeToken: "5",
codeTokenHighlight: "1",
//
copyCodeButton: "20",
dropdownMenu: "1200",
floatingButton: "1000",
menu: "1100",
modal: "1400",
modalOverlay: "1300",
stickyMobileSidebar: "500",
//
stickyTop: "1000",
},
},
},
};
export default config;
-4
View File
@@ -74,10 +74,6 @@ const nextConfig = {
},
];
},
webpack: (config) => {
config.externals.push("pino-pretty", "lokijs", "encoding");
return config;
},
};
export default nextConfig;
+7
View File
@@ -1,5 +1,12 @@
# @thirdweb-dev/nebula
## 0.2.34
### Patch Changes
- Updated dependencies [[`7f11657`](https://github.com/thirdweb-dev/js/commit/7f116577aba529e21a48163b295b091f9d6008f7), [`467ba50`](https://github.com/thirdweb-dev/js/commit/467ba5090729dd951970b15ef320caf9d058cc0b), [`d227bc3`](https://github.com/thirdweb-dev/js/commit/d227bc389bef4b2f84544a73732a61782e3dca16)]:
- thirdweb@5.105.34
## 0.2.33
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.33"
"version": "0.2.34"
}
+10
View File
@@ -1,5 +1,15 @@
# thirdweb
## 5.105.34
### Patch Changes
- [#7858](https://github.com/thirdweb-dev/js/pull/7858) [`7f11657`](https://github.com/thirdweb-dev/js/commit/7f116577aba529e21a48163b295b091f9d6008f7) Thanks [@MananTank](https://github.com/MananTank)! - Fix PayEmbed UI when mode prop is not specified - Default to mode: "fund_wallet" with amount: "0.01" and chain: ethereum
- [#7865](https://github.com/thirdweb-dev/js/pull/7865) [`467ba50`](https://github.com/thirdweb-dev/js/commit/467ba5090729dd951970b15ef320caf9d058cc0b) Thanks [@MananTank](https://github.com/MananTank)! - Add missing props for details modal on `useWalletDetailsModal` hook
- [#7321](https://github.com/thirdweb-dev/js/pull/7321) [`d227bc3`](https://github.com/thirdweb-dev/js/commit/d227bc389bef4b2f84544a73732a61782e3dca16) Thanks [@kumaryash90](https://github.com/kumaryash90)! - ERC20 assets
## 5.105.33
### Patch Changes
+9 -1
View File
@@ -195,6 +195,11 @@
"import": "./dist/esm/exports/storage.js",
"default": "./dist/cjs/exports/storage.js"
},
"./tokens": {
"types": "./dist/types/exports/tokens.d.ts",
"import": "./dist/esm/exports/tokens.js",
"default": "./dist/cjs/exports/tokens.js"
},
"./transaction": {
"types": "./dist/types/exports/transaction.d.ts",
"import": "./dist/esm/exports/transaction.js",
@@ -391,6 +396,9 @@
"storage": [
"./dist/types/exports/storage.d.ts"
],
"tokens": [
"./dist/types/exports/tokens.d.ts"
],
"transaction": [
"./dist/types/exports/transaction.d.ts"
],
@@ -409,5 +417,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.105.33"
"version": "5.105.34"
}
@@ -0,0 +1,40 @@
[
"constructor(address owner, address manager)",
"function cancelOwnershipHandover() payable",
"function completeOwnershipHandover(address pendingOwner) payable",
"function computeDeployAddress(uint8 deployType, bytes bytecode, bytes constructorArgs, bytes32 salt) view returns (address)",
"function computeProxyAddress(uint8 deployType, address implementation, bytes data, bytes32 salt) view returns (address)",
"function deployContract(bytes32 id, uint8 deployType, bytes bytecode, bytes constructorArgs, bytes32 salt, bytes[] postDeployCalls) returns (address deployed)",
"function deployContractWithSignature((bytes32 id, uint8 deployType, bytes bytecode, bytes constructorArgs, bytes32 salt, bytes[] postDeployCalls, uint256 nonce, uint256 deadline) request, bytes signature) returns (address deployed)",
"function deployProxy(bytes32 id, uint8 deployType, address implementation, bytes data, bytes32 salt, bytes[] postDeployCalls) returns (address deployed)",
"function deployProxyWithSignature((bytes32 id, uint8 deployType, address implementation, bytes data, bytes32 salt, bytes[] postDeployCalls, uint256 nonce, uint256 deadline) request, bytes signature) returns (address deployed)",
"function getDeployment(bytes32 id, uint256 version) view returns (address)",
"function grantRoles(address user, uint256 roles) payable",
"function hasAllRoles(address user, uint256 roles) view returns (bool)",
"function hasAnyRole(address user, uint256 roles) view returns (bool)",
"function owner() view returns (address result)",
"function ownershipHandoverExpiresAt(address pendingOwner) view returns (uint256 result)",
"function renounceOwnership() payable",
"function renounceRoles(uint256 roles) payable",
"function requestOwnershipHandover() payable",
"function revokeRoles(address user, uint256 roles) payable",
"function rolesOf(address user) view returns (uint256 roles)",
"function transferOwnership(address newOwner) payable",
"event ContractDeployed(bytes32 id, uint256 version, address contractAddress, bytes32 salt)",
"event NonceUsed(address account, uint256 nonce)",
"event OwnershipHandoverCanceled(address indexed pendingOwner)",
"event OwnershipHandoverRequested(address indexed pendingOwner)",
"event OwnershipTransferred(address indexed oldOwner, address indexed newOwner)",
"event ProxyDeployed(bytes32 id, uint256 version, address proxyAddress, address implementation, bytes32 salt)",
"event RolesUpdated(address indexed user, uint256 indexed roles)",
"error AlreadyInitialized()",
"error DeploymentFailed()",
"error InvalidDeploymentType()",
"error InvalidImplementation()",
"error InvalidNonce()",
"error InvalidSignature()",
"error NewOwnerIsZeroAddress()",
"error NoHandoverRequest()",
"error SignatureExpired()",
"error Unauthorized()"
]
@@ -0,0 +1,49 @@
[
"constructor()",
"function DOMAIN_SEPARATOR() view returns (bytes32 result)",
"function allowance(address owner, address spender) view returns (uint256 result)",
"function approve(address spender, uint256 amount) returns (bool)",
"function balanceOf(address owner) view returns (uint256 result)",
"function burn(uint256 amount)",
"function burnFrom(address from, uint256 amount)",
"function cancelOwnershipHandover() payable",
"function completeOwnershipHandover(address pendingOwner) payable",
"function contractURI() view returns (string)",
"function decimals() view returns (uint8)",
"function initialize(string name, string symbol, string contractURI, uint256 maxSupply, address owner)",
"function name() view returns (string)",
"function nonces(address owner) view returns (uint256 result)",
"function owner() view returns (address result)",
"function ownershipHandoverExpiresAt(address pendingOwner) view returns (uint256 result)",
"function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)",
"function renounceOwnership() payable",
"function requestOwnershipHandover() payable",
"function setContractURI(string _contractURI)",
"function supportsInterface(bytes4 interfaceId) pure returns (bool)",
"function symbol() view returns (string)",
"function totalSupply() view returns (uint256 result)",
"function transfer(address to, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
"function transferOwnership(address newOwner) payable",
"event Approval(address indexed owner, address indexed spender, uint256 amount)",
"event ContractURIUpdated()",
"event Initialized(uint64 version)",
"event OwnershipHandoverCanceled(address indexed pendingOwner)",
"event OwnershipHandoverRequested(address indexed pendingOwner)",
"event OwnershipTransferred(address indexed oldOwner, address indexed newOwner)",
"event Transfer(address indexed from, address indexed to, uint256 amount)",
"error AllowanceOverflow()",
"error AllowanceUnderflow()",
"error AlreadyInitialized()",
"error InsufficientAllowance()",
"error InsufficientBalance()",
"error InvalidInitialization()",
"error InvalidPermit()",
"error NewOwnerIsZeroAddress()",
"error NoHandoverRequest()",
"error NotInitializing()",
"error Permit2AllowanceIsFixedAtInfinity()",
"error PermitExpired()",
"error TotalSupplyOverflow()",
"error Unauthorized()"
]

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