Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
a5bb36141d Version Packages (#7584)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 12:50:16 -07:00
Prithvish BaidyaandGitHub f3234c3c41 [thirdweb/server-wallet] fix address used for simulation for ERC4337 (#7581) 2025-07-10 11:55:04 -07:00
91e3781e83 [Dashboard] Improve user filtering functionality (#7572)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-10 11:53:41 -07:00
arcoraven 86e3aa8c04 feat: webhooks list in dashboard (#7505)
# [Dashboard] Feature: Add Webhook Management UI

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/38dbe90b-2bd9-47b6-b190-3c257e99d2fe.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/d0a40295-c1c7-45c5-9b23-224a2a631b83.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/42d5337d-fcfe-4ef7-a349-1f596013e003.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/b7b89fe6-a5f7-4961-b927-bfc754c6665f.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/88916c15-a501-4ceb-9267-a23290f683b9.png)

## Notes for the reviewer
This PR adds a new webhook management UI to the dashboard, allowing users to create, edit, delete, and monitor webhooks. The implementation includes:

- A new overview page for webhooks with a table to display all configured webhooks
- Create/edit webhook modals with form validation
- Topic selector for webhook event subscriptions
- Delete confirmation with activity warning
- Webhook metrics display showing request counts and error rates
- API integration for webhook configuration management

## How to test
Navigate to the webhooks section in a project to view the new UI. Create a new webhook, edit its configuration, and test the delete functionality. Verify that metrics are displayed correctly for existing webhooks.

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

* **New Features**
  * Introduced a comprehensive webhooks management interface, including creation, editing, deletion, and topic selection with optional JSON filters.
  * Added analytics dashboards for webhooks, featuring charts for request status codes and latency percentiles, with filtering by webhook and date range.
  * Implemented feature flag-based tab navigation for "Overview" and "Analytics" in the webhooks section.
  * Provided detailed webhook metrics, including recent activity and error rates.

* **Bug Fixes**
  * Improved error handling and user feedback for webhook configuration and analytics operations.

* **Chores**
  * Added PostHog server-side analytics integration.
  * Updated environment variable naming for analytics configuration.
  * Added new dependency for analytics support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces webhook analytics features and enhances the webhook management system, including new types for webhook statistics, UI components for creating and editing webhooks, and improvements in data fetching and handling.

### Detailed summary
- Added `posthog-node` dependency for server-side analytics.
- Updated environment variable from `NEXT_PUBLIC_POSTHOG_API_KEY` to `NEXT_PUBLIC_POSTHOG_KEY`.
- Introduced interfaces for `WebhookRequestStats`, `WebhookLatencyStats`, and `WebhookSummaryStats`.
- Created `CreateWebhookConfigModal` and `EditWebhookConfigModal` components for managing webhooks.
- Implemented `WebhooksAnalytics` and `WebhookAnalyticsServer` components for displaying analytics data.
- Enhanced `WebhookMetrics` to show metrics and error rates.
- Added `isFeatureFlagEnabled` function for feature flag checks.
- Implemented data fetching functions for webhook metrics in `analytics.ts`.
- Updated the webhook configuration management with new API endpoints and improved error handling.
- Created `TopicSelectorModal` for selecting topics with optional filters.
- Improved UI components for better user experience in managing webhooks.

> The following files were skipped due to too many changes: `pnpm-lock.yaml`

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

<!-- end pr-codex -->
2025-07-10 17:21:02 +00:00
MananTank aa1035d16c [TOOL-4988] Dashboard, SDK: Add gas sponsorship option for asset creation for in-app wallets (#7566)
<!--

## 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 handling of gasless transactions across various components in the `thirdweb` dashboard, specifically for NFT and token creation processes. It introduces new parameters and logic to support gas sponsorship features.

### Detailed summary
- Added `useAuthToken` option in `bundler.ts` fetch requests.
- Enhanced `fetch.ts` to include authentication headers for bundler requests.
- Updated `CreateTokenFunctions` and `CreateNFTCollectionFunctions` to accept `gasless` parameter.
- Modified multiple functions in the NFT and token creation pages to support gasless transactions.
- Implemented UI elements for enabling gas sponsorship in the `LaunchNFT` and `LaunchTokenStatus` components.
- Adjusted error handling for unsupported gas sponsorship on certain chains.

> ✨ 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

Here are the updated release notes based on the provided summary:

* **New Features**
  * Added support for gasless transactions when launching NFT collections and tokens, available for eligible team plans and in-app wallets.
  * Introduced a toggle in the launch UI to enable or disable gasless mode for NFT and token creation.
  * Updated UI labels and dialogs for launching NFT collections.

* **Refactor**
  * Standardized function signatures for NFT and token creation to accept a unified parameter object, including a new gasless flag.
  * Centralized account and contract retrieval logic for improved maintainability and consistency.
  * Improved error logging in the token creation workflow.

* **Bug Fixes**
  * Enhanced error handling during token launch to detect unsupported gas sponsorship and provide user-friendly retry options.

* **Chores**
  * Updated request headers logic to properly handle authorization tokens and additional headers for bundler URLs.
  * Enabled authentication token usage in bundler request configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 15:18:10 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
bd395a3cff Version Packages (#7579)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-10 00:51:14 -07:00
Joaquim VergesandGitHub 446a69b1d1 [SDK] update wallet definitions (#7580) 2025-07-09 21:51:41 -07:00
Manan TankandGitHub 826d9c80a8 Dashboard: Remove OP superchain banner, CTAs, credits (#7569) 2025-07-09 20:10:50 -07:00
Joaquim VergesandGitHub 772eb0cc53 [SDK] Handle different fiat currencies in payment widgets (#7578) 2025-07-09 19:59:35 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
5c6dd874e0 Version Packages (#7577)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-09 16:55:48 -07:00
a9e47e4759 Replace PayEmbed with BuyWidget code (#7574)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-09 15:47:29 -07:00
bfd728a9d1 Add branding hide prop to widgets (#7575)
Co-authored-by: Cursor Agent <[email protected]>
2025-07-09 15:46:29 -07:00
saminacodes 381a2823d7 [Docs] Remove Nebula from portal navigation (#7576)
```
<!--

## title your PR with this format: "[Portal] Feature: Remove Nebula from portal navigation"

## Notes for the reviewer

This PR removes all user-facing references to Nebula from the portal navigation and homepage, as requested.

-   **Key point:** All Nebula pages and underlying content remain untouched; only navigation links and homepage sections were removed.
-   Removed Nebula from the main header navigation and the APIs dropdown.
-   Removed Nebula sections from the homepage (API Reference and Learning Resources).
-   Updated the Vault section icon on the homepage from `NebulaIcon` (which was removed) to `ConnectIcon`.

## How to test

1.  Run the portal locally.
2.  Verify "Nebula" is no longer present in the main navigation bar or the "APIs" dropdown.
3.  On the homepage (`/`), confirm that the "Nebula" cards are removed from the "API Reference" and "Learning Resources" sections.
4.  Confirm that the "Vault" card in the "Learning Resources" section now uses the `ConnectIcon`.
5.  Run `npm run portal:build` to ensure the project builds successfully.

-->
```

---

[Slack Thread](https://thirdwebdev.slack.com/archives/C04DYC3G5NZ/p1752094214240499?thread_ts=1752094214.240499&cid=C04DYC3G5NZ)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on removing references to `Nebula` from the application, including links and UI components, while retaining and emphasizing `Insight` and `Vault`.

### Detailed summary
- Removed `Nebula` link from `connectLinks` in `Header.tsx`.
- Deleted `Nebula` API reference card from `ReferenceSection` in `page.tsx`.
- Eliminated `Nebula` card from `LearningResourcesSection` in `page.tsx`.

> ✨ 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**
  * None.

* **Bug Fixes**
  * None.

* **Refactor**
  * Removed all references to the "Nebula" feature from navigation menus and resource sections.
  * Updated the icon for the "Vault" article card.

* **Chores**
  * Cleaned up unused imports related to "Nebula".

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-09 21:53:40 +00:00
saminacodes 2111b8b12c Rename Universal Bridge to Payments (#7571)
```
<!--

## title your PR with this format: "[Dashboard] Feature: Rename Universal Bridge to Payments"

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

## Notes for the reviewer

This PR renames all instances of "Universal Bridge" to "Payments" across the projects dashboard. This is a branding update and does not change any underlying functionality or URLs.

Changes include:
- Sidebar navigation label
- Page titles and descriptions
- Callouts and sub-text (e.g., analytics, FTUX)
- Webhooks section (tab name and heading)

## How to test

1. Navigate to any project dashboard.
2. Verify the sidebar now shows "Payments" instead of "Universal Bridge".
3. Click on "Payments" in the sidebar and confirm the page title and description are updated.
4. Check the "Analytics" page to ensure "Universal Bridge" references are updated to "Payments".
5. Go to "Webhooks" and then the "Payments" tab to confirm the heading and tab name are correct.
6. Verify the FTUX section also reflects "Payments".

-->
```

---

[Slack Thread](https://thirdwebdev.slack.com/archives/C08GEMP858V/p1752037029611589?thread_ts=1752037029.611589&cid=C08GEMP858V)

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming references from "Universal Bridge" to "Payments" throughout the codebase, reflecting a change in terminology for a feature that facilitates payments.

### Detailed summary
- Updated `name` in `layout.tsx` from "Universal Bridge" to "Payments".
- Changed `label` in `ProjectSidebarLayout.tsx` from "Universal Bridge" to "Payments".
- Modified `title` in `ProjectFTUX.tsx` from "Universal Bridge" to "Payments".
- Changed heading in `page.tsx` from "Universal Bridge Webhooks" to "Payments Webhooks".
- Updated description in `analytics/page.tsx` from "Universal Bridge" to "Payments".
- Changed description in `sidebar/page.tsx` from "Universal Bridge" to "Payments".
- Updated heading in `universal-bridge/page.tsx` from "Get Started with Universal Bridge" to "Get Started with Payments".
- Changed heading in `universal-bridge/layout.tsx` from "Universal Bridge" to "Payments".
- Updated description in `universal-bridge/layout.tsx` to reflect "Payments" instead of "Universal Bridge".

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

<!-- end pr-codex -->
2025-07-09 05:13:22 +00:00
538 changed files with 11436 additions and 8412 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY=
# posthog API key
# - not required for prod/staging
NEXT_PUBLIC_POSTHOG_API_KEY="ignored"
NEXT_PUBLIC_POSTHOG_KEY=""
# Stripe Customer portal
NEXT_PUBLIC_STRIPE_KEY=
+1
View File
@@ -55,6 +55,7 @@
"papaparse": "^5.5.3",
"pluralize": "^8.0.0",
"posthog-js": "1.256.1",
"posthog-node": "^5.4.0",
"prettier": "3.6.2",
"qrcode": "^1.5.3",
"react": "19.1.0",
@@ -0,0 +1,45 @@
import "server-only";
import { PostHog } from "posthog-node";
let posthogServer: PostHog | null = null;
function getPostHogServer(): PostHog | null {
if (!posthogServer && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthogServer = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
host: "https://us.i.posthog.com",
});
}
return posthogServer;
}
/**
* Check if a feature flag is enabled for a specific user
* @param flagKey - The feature flag key
* @param userEmail - The user's email address for filtering
*/
export async function isFeatureFlagEnabled(
flagKey: string,
userEmail?: string,
): Promise<boolean> {
// For localdev environments where Posthog is not running, enable all feature flags.
if (!posthogServer) {
return true;
}
try {
const client = getPostHogServer();
if (client && userEmail) {
const isEnabled = await client.isFeatureEnabled(flagKey, userEmail, {
personProperties: {
email: userEmail,
},
});
if (isEnabled !== undefined) {
return isEnabled;
}
}
} catch (error) {
console.error(`Error checking feature flag ${flagKey}:`, error);
}
return false;
}
+57
View File
@@ -12,6 +12,9 @@ import type {
UserOpStats,
WalletStats,
WalletUserStats,
WebhookLatencyStats,
WebhookRequestStats,
WebhookSummaryStats,
} from "@/types/analytics";
import { getAuthToken } from "./auth-token";
import { getChains } from "./chain";
@@ -482,6 +485,60 @@ export async function getEngineCloudMethodUsage(
return json.data as EngineCloudStats[];
}
export async function getWebhookSummary(
params: AnalyticsQueryParams & { webhookId: string },
): Promise<{ data: WebhookSummaryStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
searchParams.append("webhookId", params.webhookId);
const res = await fetchAnalytics(
`v2/webhook/summary?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookSummaryStats[] };
}
export async function getWebhookRequests(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<{ data: WebhookRequestStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/requests?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookRequestStats[] };
}
export async function getWebhookLatency(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<{ data: WebhookLatencyStats[] } | { error: string }> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/latency?${searchParams.toString()}`,
);
if (!res.ok) {
const reason = await res.text();
return { error: reason };
}
return (await res.json()) as { data: WebhookLatencyStats[] };
}
export async function getInsightChainUsage(
params: AnalyticsQueryParams,
): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> {
-36
View File
@@ -1,44 +1,8 @@
import "server-only";
import type { ChainMetadata } from "thirdweb/chains";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
import type { ChainService } from "@/types/chain";
export async function getGasSponsoredChains() {
if (!API_SERVER_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains/gas-sponsored`,
{
headers: {
"Content-Type": "application/json",
"x-service-api-key": API_SERVER_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
},
);
if (!res.ok) {
console.error(
"Failed to fetch gas sponsored chains",
res.status,
res.statusText,
);
res.body?.cancel();
return [];
}
try {
return (await res.json()).data as number[];
} catch (e) {
console.error("Failed to parse gas sponsored chains", e);
return [];
}
}
export function getChains() {
return fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains`,
+312
View File
@@ -0,0 +1,312 @@
"use server";
import { getAuthToken } from "@/api/auth-token";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
export interface WebhookConfig {
id: string;
teamId: string;
projectId: string;
destinationUrl: string;
description: string;
pausedAt: string | null;
webhookSecret: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
topics: {
id: string;
serviceName: string;
description: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
filters: object | null;
}[];
}
type WebhookConfigsResponse =
| {
data: WebhookConfig[];
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
interface CreateWebhookConfigRequest {
topics: { id: string; filters: object | null }[];
destinationUrl: string;
description: string;
isPaused?: boolean;
}
type CreateWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
export interface Topic {
id: string;
serviceName: string;
description: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
type TopicsResponse =
| {
data: Topic[];
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
interface UpdateWebhookConfigRequest {
destinationUrl?: string;
topics?: { id: string; filters: object | null }[];
description?: string;
isPaused?: boolean;
}
type UpdateWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
type DeleteWebhookConfigResponse =
| {
data: WebhookConfig;
status: "success";
}
| {
body: string;
reason: string;
status: "error";
};
export async function getWebhookConfigs(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
}): Promise<WebhookConfigsResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "GET",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function createWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
config: CreateWebhookConfigRequest;
}): Promise<CreateWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
{
body: JSON.stringify(props.config),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function getAvailableTopics(): Promise<TopicsResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/webhook-topics`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "GET",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function updateWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
webhookConfigId: string;
config: UpdateWebhookConfigRequest;
}): Promise<UpdateWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
{
body: JSON.stringify(props.config),
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "PATCH",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
export async function deleteWebhookConfig(props: {
teamIdOrSlug: string;
projectIdOrSlug: string;
webhookConfigId: string;
}): Promise<DeleteWebhookConfigResponse> {
const authToken = await getAuthToken();
if (!authToken) {
return {
body: "Authentication required",
reason: "no_auth_token",
status: "error",
};
}
const response = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
{
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
method: "DELETE",
},
);
if (!response.ok) {
const body = await response.text();
return {
body,
reason: "unknown",
status: "error",
};
}
const result = await response.json();
return {
data: result.data,
status: "success",
};
}
@@ -1,45 +1,18 @@
import { formatDistance } from "date-fns";
import { CircleAlertIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import type { ThirdwebClient } from "thirdweb";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import type { Account, BillingCredit } from "@/hooks/useApi";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { ChainIconClient } from "@/icons/ChainIcon";
import type { BillingCredit } from "@/hooks/useApi";
import { formatToDollars } from "./formatToDollars";
interface CreditsItemProps {
credit?: BillingCredit;
isOpCreditDefault?: boolean;
onClickApply?: () => void;
twAccount: Account;
client: ThirdwebClient;
teamSlug: string;
}
export const CreditsItem: React.FC<CreditsItemProps> = ({
credit,
isOpCreditDefault,
onClickApply,
twAccount,
client,
teamSlug,
}) => {
const [hasAppliedForOpGrant] = useLocalStorage(
`appliedForOpGrant-${twAccount.id}`,
false,
);
const isOpCredit = credit?.name.startsWith("OP -") || isOpCreditDefault;
export const CreditsItem: React.FC<CreditsItemProps> = ({ credit }) => {
const isTwCredit = credit?.name.startsWith("TW -");
const isStartupCredit = credit?.name.startsWith("SU -");
let creditTitle = credit?.name ?? "thirdweb credits";
if (isOpCredit) {
creditTitle = "Optimism sponsorship credits";
} else if (isTwCredit) {
if (isTwCredit) {
creditTitle = "thirdweb credits";
} else if (isStartupCredit) {
creditTitle = "Startup grant credits";
@@ -50,13 +23,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
<div className="flex flex-col gap-3 p-4 lg:p-6">
<div className="relative">
<div className="absolute top-0 right-0">
{isOpCredit ? (
<ChainIconClient
className="size-6"
client={client}
src="ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
/>
) : isTwCredit ? (
{isTwCredit ? (
<Image
alt="tw-credit"
className="size-6"
@@ -112,35 +79,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
</div>
)}
</div>
{hasAppliedForOpGrant && !credit && isOpCredit && (
<Alert variant="info">
<CircleAlertIcon className="size-5" />
<AlertTitle>Grant application pending approval</AlertTitle>
<AlertDescription>
You will receive an email once your application&apos;s status
changes.
</AlertDescription>
</Alert>
)}
</div>
{!hasAppliedForOpGrant && isOpCredit && (
<div className="mt-2 flex justify-end border-t px-4 py-4 lg:px-6">
<Button asChild size="sm" variant="outline">
<Link
href={`/team/${teamSlug}/~/settings/credits`}
onClick={() => {
if (onClickApply) {
onClickApply();
}
}}
>
Apply Now
</Link>
</Button>
</div>
)}
</div>
);
};
@@ -2,41 +2,26 @@
// TODO - convert to RSC
import type { ThirdwebClient } from "thirdweb";
import { type Account, useAccountCredits } from "@/hooks/useApi";
import { useAccountCredits } from "@/hooks/useApi";
import { CreditsItem } from "./CreditsItem";
export const CreditsInfoCard = (props: {
twAccount: Account;
client: ThirdwebClient;
teamSlug: string;
}) => {
export const CreditsInfoCard = () => {
const { data: credits } = useAccountCredits();
if (!credits) {
return null;
}
const opCredit = credits.find((crd) => crd.name.startsWith("OP -"));
const restCredits = credits.filter((crd) => !crd.name.startsWith("OP -"));
if (restCredits.length === 0) {
return null;
}
return (
<section className="flex flex-col gap-4">
<CreditsItem
client={props.client}
credit={opCredit}
isOpCreditDefault={true}
teamSlug={props.teamSlug}
twAccount={props.twAccount}
/>
{restCredits?.map((credit) => (
<CreditsItem
client={props.client}
credit={credit}
key={credit.couponId}
teamSlug={props.teamSlug}
twAccount={props.twAccount}
/>
{restCredits.map((credit) => (
<CreditsItem credit={credit} key={credit.couponId} />
))}
</section>
);
@@ -7,22 +7,34 @@ import { embeddedWalletsKeys } from "../query-keys/cache-keys";
const fetchAccountList = ({
jwt,
clientId,
ecosystemSlug,
teamId,
pageNumber,
}: {
jwt: string;
clientId: string;
clientId?: string;
ecosystemSlug?: string;
teamId: string;
pageNumber: number;
}) => {
return async () => {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
url.searchParams.append("clientId", clientId);
// Add clientId or ecosystemSlug parameter
if (ecosystemSlug) {
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
} else if (clientId) {
url.searchParams.append("clientId", clientId);
}
url.searchParams.append("page", pageNumber.toString());
const res = await fetch(url.href, {
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
"x-client-id": clientId,
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
},
method: "GET",
});
@@ -38,23 +50,27 @@ const fetchAccountList = ({
};
export function useEmbeddedWallets(params: {
clientId: string;
clientId?: string;
ecosystemSlug?: string;
teamId: string;
page: number;
authToken: string;
}) {
const { clientId, page, authToken } = params;
const { clientId, ecosystemSlug, teamId, page, authToken } = params;
const address = useActiveAccount()?.address;
return useQuery({
enabled: !!address && !!clientId,
enabled: !!address && !!(clientId || ecosystemSlug),
queryFn: fetchAccountList({
clientId,
ecosystemSlug,
teamId,
jwt: authToken,
pageNumber: page,
}),
queryKey: embeddedWalletsKeys.embeddedWallets(
address || "",
clientId,
clientId || ecosystemSlug || "",
page,
),
});
@@ -67,7 +83,15 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
const address = useActiveAccount()?.address;
return useMutation({
mutationFn: async ({ clientId }: { clientId: string }) => {
mutationFn: async ({
clientId,
ecosystemSlug,
teamId,
}: {
clientId?: string;
ecosystemSlug?: string;
teamId: string;
}) => {
const responses: WalletUser[] = [];
let page = 1;
@@ -77,12 +101,14 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
}>({
queryFn: fetchAccountList({
clientId,
ecosystemSlug,
teamId,
jwt: authToken,
pageNumber: page,
}),
queryKey: embeddedWalletsKeys.embeddedWallets(
address || "",
clientId,
clientId || ecosystemSlug || "",
page,
),
});
+25
View File
@@ -66,6 +66,31 @@ export interface UniversalBridgeWalletStats {
developerFeeUsdCents: number;
}
export interface WebhookRequestStats {
date: string;
webhookId: string;
httpStatusCode: number;
totalRequests: number;
}
export interface WebhookLatencyStats {
date: string;
webhookId: string;
p50LatencyMs: number;
p90LatencyMs: number;
p99LatencyMs: number;
}
export interface WebhookSummaryStats {
webhookId: string;
totalRequests: number;
successRequests: number;
errorRequests: number;
successRate: number;
avgLatencyMs: number;
errorBreakdown: Record<string, number>;
}
export interface AnalyticsQueryParams {
teamId: string;
projectId?: string;
@@ -2,11 +2,7 @@ import "server-only";
import { notFound } from "next/navigation";
import type { ChainMetadata } from "thirdweb/chains";
import {
getChainServices,
getChains,
getGasSponsoredChains,
} from "@/api/chain";
import { getChainServices, getChains } from "@/api/chain";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import type { ChainMetadataWithServices, ChainServices } from "@/types/chain";
import type { ChainCTAProps } from "./[chain_id]/(chainPage)/components/server/cta-card";
@@ -25,7 +21,6 @@ import cotiBanner from "./temp-assets/COTI_Banner.jpg";
import cotiCTA from "./temp-assets/COTI_CTA.jpg";
import creatorBanner from "./temp-assets/creatorBanner.png";
import creatorCTA from "./temp-assets/creatorCTA.png";
import superchainCTABG from "./temp-assets/cta-bg-superchain.png";
import xaiCTABg from "./temp-assets/cta-bg-xai-connect.png";
import thirdwebCTA from "./temp-assets/cta-thirdweb.png";
import cyberChainBanner from "./temp-assets/cyberChainBanner.png";
@@ -135,17 +130,6 @@ type ExtraChainMetadata = Partial<{
cta: ChainCTAProps;
}>;
// TEMPORARY
const OP_CTA = {
backgroundImageUrl: superchainCTABG.src,
buttonLink: "/team/~/~/settings/credits",
buttonText: "Apply now",
description:
"Successful applicants receive gas grants for use across all supported Optimism Superchain networks. These grants can sponsor gas fees for any onchain activity using our Account Abstraction tools.",
title: "Optimism Superchain App Accelerator",
} satisfies ChainCTAProps;
const chainMetaRecord = {
// Flare
14: {
@@ -933,22 +917,12 @@ const chainMetaRecord = {
export async function getChainMetadata(
chainId: number,
): Promise<(ExtraChainMetadata & { gasSponsored?: true }) | null> {
const gasSponsoredChains = await getGasSponsoredChains();
const isGasSponsored = gasSponsoredChains.includes(chainId);
// TODO: fetch this from the API
if (chainId in chainMetaRecord) {
return {
...(isGasSponsored ? { cta: OP_CTA, gasSponsored: true } : {}),
// this will OVERRIDE the op CTA if there is a custom one configured
...chainMetaRecord[chainId as keyof typeof chainMetaRecord],
};
} else if (isGasSponsored) {
return {
cta: OP_CTA,
gasSponsored: true,
};
}
return null;
}
@@ -367,7 +367,7 @@ function AppHighlightsCard({
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/connect/pay/overview"
metric="Payments"
/>
@@ -1,12 +1,10 @@
import { redirect } from "next/navigation";
import { getStripeBalance } from "@/actions/stripe-actions";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug, type Team } from "@/api/team";
import { getMemberByAccountId } from "@/api/team-members";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { CreditsInfoCard } from "@/components/billing/PlanCard";
import { Coupons } from "@/components/billing/SubscriptionCoupons/Coupons";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { CreditBalanceSection } from "./components/credit-balance-section.client";
import { PlanInfoCardClient } from "./components/PlanInfoCard.client";
@@ -28,9 +26,8 @@ export default async function Page(props: {
const account = await getValidAccount(pagePath);
const [team, authToken, teamMember] = await Promise.all([
const [team, teamMember] = await Promise.all([
getTeamBySlug(params.team_slug),
getAuthToken(),
getMemberByAccountId(params.team_slug, account.id),
]);
@@ -50,11 +47,6 @@ export default async function Page(props: {
);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const highlightPlan =
typeof searchParams.highlight === "string"
? (searchParams.highlight as Team["billingPlan"])
@@ -84,11 +76,7 @@ export default async function Page(props: {
/>
)}
<CreditsInfoCard
client={client}
teamSlug={team.slug}
twAccount={account}
/>
<CreditsInfoCard />
<Coupons isPaymentSetup={validPayment} teamId={team.id} />
</div>
);
@@ -62,6 +62,10 @@ export async function EcosystemLayoutSlug({
name: "Analytics",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/analytics`,
},
{
name: "Users",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/users`,
},
{
name: "Design (coming soon)",
path: `${ecosystemLayoutPath}/${ecosystem.slug}/#`,
@@ -0,0 +1,40 @@
import { loginRedirect } from "@app/login/loginRedirect";
import { InAppWalletUsersPageContent } from "@app/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/ecosystems";
import { getTeamBySlug } from "@/api/team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
export default async function EcosystemUsersPage(props: {
params: Promise<{ team_slug: string; slug: string }>;
}) {
const params = await props.params;
const [authToken, ecosystem, team] = await Promise.all([
getAuthToken(),
fetchEcosystem(params.slug, params.team_slug),
getTeamBySlug(params.team_slug),
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/~/ecosystem/${params.slug}/users`);
}
if (!ecosystem || !team) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
return (
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
ecosystemSlug={ecosystem.slug}
teamId={team.id}
/>
);
}
@@ -1,10 +1,6 @@
"use client";
import { SmartWalletsBillingAlert } from "@app/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/Alerts";
import { ArrowRightIcon } from "lucide-react";
import Link from "next/link";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "@/components/ui/tabs";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { AAFooter } from "./AAFooterSection";
@@ -44,10 +40,6 @@ export function AccountAbstractionLayout(props: {
{props.hasSmartWalletsWithoutBilling && (
<SmartWalletsBillingAlert teamSlug={props.teamSlug} />
)}
<GasCreditAlert
projectId={props.projectId}
teamSlug={props.teamSlug}
/>
</div>
<div className="h-4" />
@@ -89,40 +81,3 @@ export function AccountAbstractionLayout(props: {
</div>
);
}
function GasCreditAlert(props: { teamSlug: string; projectId: string }) {
return (
<DismissibleAlert
description={
<>
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
<UnderlineLink
href="https://thirdweb.com/superchain"
rel="noopener noreferrer"
target="_blank"
>
Learn More
</UnderlineLink>
<div className="mt-4 flex items-center gap-4">
<Button
asChild
className="bg-background"
size="sm"
variant="outline"
>
<Link
className="gap-2"
href={`/team/${props.teamSlug}/~/settings/credits`}
target="_blank"
>
Claim your credits <ArrowRightIcon className="size-4" />
</Link>
</Button>
</div>
</>
}
localStorageId={`${props.projectId}-gas-credit-alert`}
title="OP Gas Credit Program"
/>
);
}
@@ -257,7 +257,7 @@ function ProductsSection(props: { teamSlug: string; projectSlug: string }) {
"Bridge, swap, and purchase cryptocurrencies with any fiat options or tokens via cross-chain routing",
href: `/team/${props.teamSlug}/${props.projectSlug}/universal-bridge`,
icon: PayIcon,
title: "Universal Bridge",
title: "Payments",
},
];
@@ -75,7 +75,7 @@ export function ProjectSidebarLayout(props: {
{
href: `${layoutPath}/universal-bridge`,
icon: PayIcon,
label: "Universal Bridge",
label: "Payments",
},
{
href: `${layoutPath}/tokens`,
@@ -435,7 +435,7 @@ function AppHighlightsCard({
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Universal Bridge."
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/connect/pay/overview"
metric="Payments"
/>
@@ -42,16 +42,29 @@ export type CreateNFTCollectionFunctions = {
address: string;
}[];
chain: string;
gasless: boolean;
}) => Promise<void>;
erc721: {
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
deployContract: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (values: CreateNFTCollectionAllValues) => Promise<void>;
lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise<void>;
setClaimConditions: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
};
erc1155: {
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
deployContract: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (params: {
@@ -60,8 +73,12 @@ export type CreateNFTCollectionFunctions = {
startIndex: number;
count: number;
};
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (params: {
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) => Promise<void>;
lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise<void>;
};
};
@@ -21,6 +21,7 @@ import {
import { grantRole } from "thirdweb/extensions/permissions";
import { useActiveAccount } from "thirdweb/react";
import { maxUint256 } from "thirdweb/utils";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
@@ -42,15 +43,26 @@ export function CreateNFTPage(props: {
teamPlan: Team["billingPlan"];
}) {
const activeAccount = useActiveAccount();
const addContractToProject = useAddContractToProject();
const contractAddressRef = useRef<string | undefined>(undefined);
function getContractAndAccount(params: { chain: string }) {
function getAccount(params: { gasless: boolean }) {
if (!activeAccount) {
throw new Error("Wallet is not connected");
}
if (params.gasless) {
return create7702MinimalAccount({
adminAccount: activeAccount,
client: props.client,
sponsorGas: true,
});
}
return activeAccount;
}
function getDeployedContract(params: { chain: string }) {
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(params.chain));
@@ -60,30 +72,26 @@ export function CreateNFTPage(props: {
throw new Error("Contract not found");
}
const contract = getContract({
return getContract({
address: contractAddress,
chain,
client: props.client,
});
return {
activeAccount,
contract,
};
}
async function handleContractDeploy(params: {
values: CreateNFTCollectionAllValues;
ercType: "erc721" | "erc1155";
gasless: boolean;
}) {
const { values: formValues, ercType } = params;
const { collectionInfo, sales } = formValues;
const contractType =
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
if (!activeAccount) {
throw new Error("Wallet is not connected");
}
const account = getAccount({
gasless: params.gasless,
});
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(collectionInfo.chain));
@@ -93,7 +101,7 @@ export function CreateNFTPage(props: {
if (ercType === "erc721") {
contractAddress = await deployERC721Contract({
account: activeAccount,
account,
chain: chain,
client: props.client,
params: {
@@ -110,7 +118,7 @@ export function CreateNFTPage(props: {
});
} else {
contractAddress = await deployERC1155Contract({
account: activeAccount,
account,
chain: chain,
client: props.client,
params: {
@@ -166,27 +174,32 @@ export function CreateNFTPage(props: {
}
async function handleLazyMintNFTs(params: {
formValues: CreateNFTCollectionAllValues;
values: CreateNFTCollectionAllValues;
ercType: "erc721" | "erc1155";
gasless: boolean;
}) {
const { formValues, ercType } = params;
const { values, ercType } = params;
const contractType =
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
const { contract, activeAccount } = getContractAndAccount({
chain: formValues.collectionInfo.chain,
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const account = getAccount({
gasless: params.gasless,
});
const lazyMintFn = ercType === "erc721" ? lazyMint721 : lazyMint1155;
const transaction = lazyMintFn({
contract,
nfts: formValues.nfts,
nfts: values.nfts,
});
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction,
});
} catch (error) {
@@ -205,14 +218,19 @@ export function CreateNFTPage(props: {
}
async function handleSetClaimConditionsERC721(params: {
formValues: CreateNFTCollectionAllValues;
values: CreateNFTCollectionAllValues;
gasless: boolean;
}) {
const { formValues } = params;
const { contract, activeAccount } = getContractAndAccount({
chain: formValues.collectionInfo.chain,
const { values } = params;
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const firstNFT = formValues.nfts[0];
const account = getAccount({
gasless: params.gasless,
});
const firstNFT = values.nfts[0];
if (!firstNFT) {
throw new Error("No NFTs found");
}
@@ -235,7 +253,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction,
});
} catch (error) {
@@ -259,12 +277,17 @@ export function CreateNFTPage(props: {
startIndex: number;
count: number;
};
gasless: boolean;
}) {
const { values, batch } = params;
const { contract, activeAccount } = getContractAndAccount({
const contract = getDeployedContract({
chain: values.collectionInfo.chain,
});
const account = getAccount({
gasless: params.gasless,
});
const endIndexExclusive = batch.startIndex + batch.count;
const nfts = values.nfts.slice(batch.startIndex, endIndexExclusive);
@@ -315,7 +338,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction: tx,
});
} catch (error) {
@@ -339,15 +362,20 @@ export function CreateNFTPage(props: {
admins: {
address: string;
}[];
gasless: boolean;
chain: string;
}) {
const { contract, activeAccount } = getContractAndAccount({
const contract = getDeployedContract({
chain: params.chain,
});
const account = getAccount({
gasless: params.gasless,
});
// remove the current account from the list - its already an admin, don't have to add it again
const adminsToAdd = params.admins.filter(
(admin) => admin.address !== activeAccount.address,
(admin) => admin.address !== account.address,
);
const encodedTxs = await Promise.all(
@@ -369,7 +397,7 @@ export function CreateNFTPage(props: {
try {
await sendAndConfirmTransaction({
account: activeAccount,
account,
transaction: tx,
});
} catch (e) {
@@ -392,35 +420,35 @@ export function CreateNFTPage(props: {
{...props}
createNFTFunctions={{
erc721: {
deployContract: (formValues) => {
deployContract: (params) => {
return handleContractDeploy({
ercType: "erc721",
values: formValues,
...params,
});
},
lazyMintNFTs: (formValues) => {
lazyMintNFTs: (params) => {
return handleLazyMintNFTs({
ercType: "erc721",
formValues,
...params,
});
},
setClaimConditions: async (formValues) => {
setClaimConditions: async (params) => {
return handleSetClaimConditionsERC721({
formValues,
...params,
});
},
},
erc1155: {
deployContract: (formValues) => {
deployContract: (params) => {
return handleContractDeploy({
ercType: "erc1155",
values: formValues,
...params,
});
},
lazyMintNFTs: (formValues) => {
lazyMintNFTs: (params) => {
return handleLazyMintNFTs({
ercType: "erc1155",
formValues,
...params,
});
},
setClaimConditions: async (params) => {
@@ -19,6 +19,7 @@ import {
reportAssetCreationSuccessful,
} from "@/analytics/report";
import type { Team } from "@/api/team";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status";
import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status";
import { WalletAddress } from "@/components/blocks/wallet-address";
@@ -154,18 +155,28 @@ export function LaunchNFT(props: {
return shouldDeployERC721 ? "erc721" : "erc1155";
}, [formValues.nfts]);
const canEnableGasless =
props.teamPlan !== "free" && activeWallet?.id === "inApp";
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = activeWallet?.id === "inApp";
const contractLink = contractAddressRef.current
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}`
: null;
async function executeStep(steps: MultiStepState<StepId>[], stepId: StepId) {
if (stepId === "deploy-contract") {
const result =
await props.createNFTFunctions[ercType].deployContract(formValues);
const result = await props.createNFTFunctions[ercType].deployContract({
gasless: isGasless,
values: formValues,
});
contractAddressRef.current = result.contractAddress;
} else if (stepId === "set-claim-conditions") {
if (ercType === "erc721") {
await props.createNFTFunctions.erc721.setClaimConditions(formValues);
await props.createNFTFunctions.erc721.setClaimConditions({
gasless: isGasless,
values: formValues,
});
} else {
if (batchCount > 1) {
const batchStartIndex = batchesProcessedRef.current;
@@ -190,6 +201,7 @@ export function LaunchNFT(props: {
count: batchSize,
startIndex: batchIndex * batchSize,
},
gasless: isGasless,
values: formValues,
});
@@ -201,12 +213,16 @@ export function LaunchNFT(props: {
count: formValues.nfts.length,
startIndex: 0,
},
gasless: isGasless,
values: formValues,
});
}
}
} else if (stepId === "mint-nfts") {
await props.createNFTFunctions[ercType].lazyMintNFTs(formValues);
await props.createNFTFunctions[ercType].lazyMintNFTs({
gasless: isGasless,
values: formValues,
});
} else if (stepId === "set-admins") {
// this is type guard, this can never happen
if (!contractAddressRef.current) {
@@ -218,6 +234,7 @@ export function LaunchNFT(props: {
chain: formValues.collectionInfo.chain,
contractAddress: contractAddressRef.current,
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
gasless: isGasless,
});
}
}
@@ -311,6 +328,7 @@ export function LaunchNFT(props: {
custom: (
<TransactionButton
client={props.client}
disableNoFundsPopup={isGasless}
isLoggedIn={true}
isPending={false}
onClick={handleSubmitClick}
@@ -319,7 +337,7 @@ export function LaunchNFT(props: {
variant="default"
>
<ArrowUpFromLineIcon className="size-4" />
Launch NFT
Launch NFT Collection
</TransactionButton>
),
type: "custom",
@@ -327,7 +345,7 @@ export function LaunchNFT(props: {
prevButton={{
onClick: props.onPrevious,
}}
title="Launch NFT"
title="Launch NFT Collection"
>
<Dialog
open={isModalOpen}
@@ -468,6 +486,7 @@ export function LaunchNFT(props: {
</div>
</div>
{/* NFTs */}
<div className="border-b border-dashed px-4 py-6 pb-6 md:px-6">
<h2 className="font-semibold text-base">NFTs</h2>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:gap-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
@@ -509,6 +528,7 @@ export function LaunchNFT(props: {
</div>
</div>
{/* sales and fees */}
<div className="px-4 py-6 pb-6 md:px-6">
<h2 className="font-semibold text-base">Sales and Fees</h2>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:gap-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
@@ -537,6 +557,31 @@ export function LaunchNFT(props: {
</OverviewField>
</div>
</div>
{/* gasless */}
{showGaslessSection && (
<div className="px-4 py-6 pb-6 md:px-6 border-t border-dashed">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Sponsor Gas</h2>
<p className="text-muted-foreground text-sm">
Sponsor gas fees for launching your NFT collection. <br /> This
allows you to launch the NFT collection without requiring any
balance in your wallet
</p>
</div>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
onCheckedChange: setIsGasless,
}}
teamSlug={props.teamSlug}
/>
</div>
</div>
)}
</StepCard>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useRef } from "react";
import { toast } from "sonner";
import {
defineChain,
getAddress,
getContract,
NATIVE_TOKEN_ADDRESS,
@@ -18,6 +18,7 @@ import {
transferBatch,
} from "thirdweb/extensions/erc20";
import { useActiveAccount } from "thirdweb/react";
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
import { revalidatePathAction } from "@/actions/revalidate";
import {
reportAssetCreationFailed,
@@ -45,18 +46,52 @@ export function CreateTokenAssetPage(props: {
projectSlug: string;
teamPlan: Team["billingPlan"];
}) {
const account = useActiveAccount();
const activeAccount = useActiveAccount();
const { idToChain } = useAllChainsData();
const addContractToProject = useAddContractToProject();
const contractAddressRef = useRef<string | undefined>(undefined);
async function deployContract(formValues: CreateAssetFormValues) {
if (!account) {
toast.error("No Connected Wallet");
function getAccount(gasless: boolean) {
if (!activeAccount) {
throw new Error("No Connected Wallet");
}
const socialUrls = formValues.socialUrls.reduce(
if (gasless) {
return create7702MinimalAccount({
adminAccount: activeAccount,
client: props.client,
sponsorGas: true,
});
}
return activeAccount;
}
function getDeployedContract(params: { chain: string }) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("Contract address not set");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineChain(Number(params.chain));
return getContract({
address: contractAddress,
chain,
client: props.client,
});
}
async function deployContract(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const account = getAccount(gasless);
const socialUrls = values.socialUrls.reduce(
(acc, url) => {
if (url.url && url.platform) {
acc[url.platform] = url.url;
@@ -71,29 +106,31 @@ export function CreateTokenAssetPage(props: {
account,
// eslint-disable-next-line no-restricted-syntax
chain: defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
Number(values.chain),
idToChain.get(Number(values.chain)),
),
client: props.client,
params: {
description: formValues.description,
image: formValues.image,
description: values.description,
image: values.image,
// metadata
name: formValues.name,
name: values.name,
// platform fees
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
// primary sale
saleRecipient: account.address,
social_urls: socialUrls,
symbol: formValues.symbol,
symbol: values.symbol,
},
type: "DropERC20",
});
contractAddressRef.current = contractAddress;
// add contract to project in background
addContractToProject.mutateAsync({
chainId: formValues.chain,
chainId: values.chain,
contractAddress: contractAddress,
contractType: "DropERC20",
deploymentType: "asset",
@@ -103,18 +140,17 @@ export function CreateTokenAssetPage(props: {
reportContractDeployed({
address: contractAddress,
chainId: Number(formValues.chain),
chainId: Number(values.chain),
contractName: "DropERC20",
deploymentType: "asset",
publisher: "deployer.thirdweb.eth",
});
contractAddressRef.current = contractAddress;
return {
contractAddress: contractAddress,
};
} catch (e) {
console.error(e);
const parsedError = parseError(e);
const errorMessage =
typeof parsedError === "string" ? parsedError : "Unknown error";
@@ -131,32 +167,25 @@ export function CreateTokenAssetPage(props: {
}
}
async function airdropTokens(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
async function airdropTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
if (!contractAddress) {
throw new Error("No contract address");
}
const contract = getDeployedContract({
chain: values.chain,
});
const account = getAccount(gasless);
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
});
try {
const airdropTx = transferBatch({
batch: formValues.airdropAddresses.map((recipient) => ({
batch: values.airdropAddresses.map((recipient) => ({
amount: recipient.quantity,
to: recipient.address,
})),
@@ -181,28 +210,18 @@ export function CreateTokenAssetPage(props: {
}
}
async function mintTokens(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("No contract address");
}
async function mintTokens(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
const contract = getDeployedContract({
chain: values.chain,
});
const account = getAccount(gasless);
// poll until claim conditions are set before moving on to minting
await pollWithTimeout({
shouldStop: async () => {
@@ -214,9 +233,9 @@ export function CreateTokenAssetPage(props: {
timeoutMs: 30000,
});
const totalSupply = Number(formValues.supply);
const salePercent = formValues.saleEnabled
? Number(formValues.saleAllocationPercentage)
const totalSupply = Number(values.supply);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
: 0;
const ownerAndAirdropPercent = 100 - salePercent;
@@ -248,48 +267,36 @@ export function CreateTokenAssetPage(props: {
}
}
async function setClaimConditions(formValues: CreateAssetFormValues) {
const contractAddress = contractAddressRef.current;
if (!contractAddress) {
throw new Error("No contract address");
}
if (!account) {
throw new Error("No connected account");
}
// eslint-disable-next-line no-restricted-syntax
const chain = defineDashboardChain(
Number(formValues.chain),
idToChain.get(Number(formValues.chain)),
);
const contract = getContract({
address: contractAddress,
chain,
client: props.client,
async function setClaimConditions(params: {
values: CreateAssetFormValues;
gasless: boolean;
}) {
const { values, gasless } = params;
const contract = getDeployedContract({
chain: values.chain,
});
const salePercent = formValues.saleEnabled
? Number(formValues.saleAllocationPercentage)
const account = getAccount(gasless);
const salePercent = values.saleEnabled
? Number(values.saleAllocationPercentage)
: 0;
const totalSupply = Number(formValues.supply);
const totalSupply = Number(values.supply);
const totalSupplyWei = toUnits(totalSupply.toString(), 18);
const phases: ClaimConditionsInput[] = [
{
currencyAddress:
getAddress(formValues.saleTokenAddress) ===
getAddress(values.saleTokenAddress) ===
getAddress(NATIVE_TOKEN_ADDRESS)
? undefined
: formValues.saleTokenAddress,
maxClaimablePerWallet: formValues.saleEnabled ? undefined : 0n,
: values.saleTokenAddress,
maxClaimablePerWallet: values.saleEnabled ? undefined : 0n,
maxClaimableSupply: totalSupplyWei,
metadata: {
name:
formValues.saleEnabled && salePercent > 0
values.saleEnabled && salePercent > 0
? "Coin Sale phase"
: "Only Owner phase",
},
@@ -300,10 +307,7 @@ export function CreateTokenAssetPage(props: {
price: "0",
},
],
price:
formValues.saleEnabled && salePercent > 0
? formValues.salePrice
: "0",
price: values.saleEnabled && salePercent > 0 ? values.salePrice : "0",
startTime: new Date(),
},
];
@@ -22,13 +22,18 @@ import { TokenDistributionFieldset } from "./distribution/token-distribution";
import { LaunchTokenStatus } from "./launch/launch-token";
import { TokenInfoFieldset } from "./token-info/token-info-fieldset";
type CreateTokenFunctionsParams = {
values: CreateAssetFormValues;
gasless: boolean;
};
export type CreateTokenFunctions = {
deployContract: (values: CreateAssetFormValues) => Promise<{
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
contractAddress: string;
}>;
setClaimConditions: (values: CreateAssetFormValues) => Promise<void>;
mintTokens: (values: CreateAssetFormValues) => Promise<void>;
airdropTokens: (values: CreateAssetFormValues) => Promise<void>;
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
};
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
@@ -13,6 +13,7 @@ import {
reportAssetCreationSuccessful,
} from "@/analytics/report";
import type { Team } from "@/api/team";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import {
type MultiStepState,
MultiStepStatus,
@@ -26,6 +27,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { parseError } from "@/utils/errorParser";
import { ChainOverview } from "../../_common/chain-overview";
import { FilePreview } from "../../_common/file-preview";
@@ -62,6 +64,13 @@ export function LaunchTokenStatus(props: {
const activeWallet = useActiveWallet();
const walletRequiresApproval = activeWallet?.id !== "inApp";
const canEnableGasless =
props.teamPlan !== "free" && activeWallet?.id === "inApp";
const [isGasless, setIsGasless] = useState(canEnableGasless);
const showGaslessSection = activeWallet?.id === "inApp";
const { idToChain } = useAllChainsData();
const chainMetadata = idToChain.get(Number(formValues.chain));
function updateStatus(
index: number,
newStatus: MultiStepState<StepId>["status"],
@@ -104,30 +113,36 @@ export function LaunchTokenStatus(props: {
setSteps(initialSteps);
setIsModalOpen(true);
executeSteps(initialSteps, 0);
executeSteps(initialSteps, 0, isGasless);
}
const isComplete = steps.every((step) => step.status.type === "completed");
const isPending = steps.some((step) => step.status.type === "pending");
async function executeStep(stepId: StepId) {
async function executeStep(stepId: StepId, gasless: boolean) {
const params = {
gasless,
values: formValues,
};
if (stepId === "deploy-contract") {
const result = await createTokenFunctions.deployContract(formValues);
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(formValues);
await createTokenFunctions.setClaimConditions(params);
} else if (stepId === "mint-tokens") {
await createTokenFunctions.mintTokens(formValues);
await createTokenFunctions.mintTokens(params);
} else if (stepId === "airdrop-tokens") {
await createTokenFunctions.airdropTokens(formValues);
await createTokenFunctions.airdropTokens(params);
}
}
async function executeSteps(
steps: MultiStepState<StepId>[],
startIndex: number,
gasless: boolean,
) {
for (let i = startIndex; i < steps.length; i++) {
const currentStep = steps[i];
@@ -140,7 +155,7 @@ export function LaunchTokenStatus(props: {
type: "pending",
});
await executeStep(currentStep.id);
await executeStep(currentStep.id, gasless);
updateStatus(i, {
type: "completed",
@@ -172,20 +187,22 @@ export function LaunchTokenStatus(props: {
props.onLaunchSuccess();
}
async function handleRetry(step: MultiStepState<StepId>) {
async function handleRetry(step: MultiStepState<StepId>, gasless: boolean) {
const startIndex = steps.findIndex((s) => s.id === step.id);
if (startIndex === -1) {
return;
}
await executeSteps(steps, startIndex);
await executeSteps(steps, startIndex, gasless);
}
return (
<StepCard
nextButton={{
custom: (
<TransactionButton
client={props.client}
disableNoFundsPopup={isGasless}
isLoggedIn={true}
isPending={false}
onClick={handleSubmitClick}
@@ -253,6 +270,30 @@ export function LaunchTokenStatus(props: {
<TokenDistributionBarChart distributionFormValues={formValues} />
</div>
{/* gasless */}
{showGaslessSection && (
<div className="px-4 py-6 pb-6 md:px-6 border-t border-dashed">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Sponsor Gas</h2>
<p className="text-muted-foreground text-sm">
Sponsor gas fees for launching your coin. <br /> This allows you
to launch the coin without requiring any balance in your wallet
</p>
</div>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan="starter"
switchProps={{
checked: isGasless,
onCheckedChange: setIsGasless,
}}
teamSlug={props.teamSlug}
/>
</div>
</div>
)}
<Dialog
open={isModalOpen}
// do not set onOpenChange
@@ -274,7 +315,7 @@ export function LaunchTokenStatus(props: {
</DialogHeader>
<MultiStepStatus
onRetry={handleRetry}
onRetry={(step) => handleRetry(step, isGasless)}
renderError={(step, errorMessage) => {
if (
props.teamPlan === "free" &&
@@ -282,13 +323,40 @@ export function LaunchTokenStatus(props: {
) {
return (
<StorageErrorPlanUpsell
onRetry={() => handleRetry(step)}
onRetry={() => handleRetry(step, isGasless)}
teamSlug={props.teamSlug}
trackingCampaign="create-coin"
/>
);
}
if (
errorMessage
.toLowerCase()
.includes("does not support eip-7702")
) {
return (
<div>
<p className="text-red-500 text-sm mb-2">
Gas Sponsorship is not supported on{" "}
{chainMetadata?.name || "selected chain"}
</p>
<Button
size="sm"
className="gap-2"
onClick={() => {
setIsGasless(false);
handleRetry(step, false);
}}
>
Continue without gas sponsorship
<ArrowRightIcon className="size-4" />
</Button>
</div>
);
}
return null;
}}
steps={steps}
@@ -1,13 +1,20 @@
export const embedCode = (clientId: string) => `\
import { createThirdwebClient } from "thirdweb";
import { PayEmbed } from "thirdweb/react";
import { BuyWidget } from "thirdweb/react";
import { ethereum } from "thirdweb/chains";
const client = createThirdwebClient({
clientId: "${clientId}",
});
export default function App() {
return <PayEmbed client={client} />;
return (
<BuyWidget
client={client}
chain={ethereum}
amount="0.1"
/>
);
}`;
export const sdkCode = (clientId: string) => `\
@@ -25,10 +25,10 @@ export default async function Layout(props: {
<div className="pt-4 lg:pt-6">
<div className="container max-w-7xl">
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
Universal Bridge
Payments
</h1>
<p className="max-w-3xl text-muted-foreground text-sm leading-relaxed">
Universal Bridge allows your users to bridge, swap, and purchase
Payments allows your users to bridge, swap, and purchase
cryptocurrencies and execute transactions with any fiat options or
tokens via cross-chain routing.{" "}
<UnderlineLink
@@ -63,9 +63,7 @@ export default async function Page(props: {
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h3 className="font-medium text-lg">
Get Started with Universal Bridge
</h3>
<h3 className="font-medium text-lg">Get Started with Payments</h3>
<p className="text-muted-foreground text-sm">
Simple, instant, and secure payments across any token and chain.
</p>
@@ -0,0 +1,95 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { SearchType } from "./types";
export function AdvancedSearchInput(props: {
onSearch: (searchType: SearchType, query: string) => void;
onClear: () => void;
isLoading: boolean;
hasResults: boolean;
}) {
const [searchType, setSearchType] = useState<SearchType>("email");
const [query, setQuery] = useState("");
const handleSearch = () => {
if (query.trim()) {
props.onSearch(searchType, query.trim());
}
};
const handleClear = () => {
setQuery("");
props.onClear();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Select
value={searchType}
onValueChange={(value) => setSearchType(value as SearchType)}
>
<SelectTrigger className="w-[140px] bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="id">ID</SelectItem>
<SelectItem value="address">Address</SelectItem>
</SelectContent>
</Select>
<div className="relative flex-1">
<Input
className="bg-card pl-9"
placeholder={`Search by ${searchType}...`}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
<Button
onClick={handleSearch}
disabled={!query.trim() || props.isLoading}
size="sm"
>
{props.isLoading ? "Searching..." : "Search"}
</Button>
</div>
{props.hasResults && (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={handleClear}
className="gap-2"
>
Clear Search & Return to List
</Button>
</div>
)}
</div>
);
}
@@ -1,22 +0,0 @@
"use client";
import { SearchIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
export function SearchInput(props: {
placeholder: string;
value: string;
onValueChange: (value: string) => void;
}) {
return (
<div className="relative">
<Input
className="bg-card pl-9"
onChange={(e) => props.onValueChange(e.target.value)}
placeholder={props.placeholder}
value={props.value}
/>
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
import { format } from "date-fns";
import type { ThirdwebClient } from "thirdweb";
import type { WalletUser } from "thirdweb/wallets";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const getUserIdentifier = (user: WalletUser) => {
const mainDetail = user.linkedAccounts[0]?.details;
return (
mainDetail?.email ??
mainDetail?.phone ??
mainDetail?.address ??
mainDetail?.id ??
user.id
);
};
export function SearchResults(props: {
results: WalletUser[];
client: ThirdwebClient;
}) {
if (props.results.length === 0) {
return (
<Card>
<CardContent className="py-8">
<div className="flex flex-col items-center gap-3">
<p className="text-sm">No users found</p>
<p className="text-muted-foreground text-sm">
Try searching with different criteria
</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{props.results.map((user) => {
const walletAddress = user.wallets?.[0]?.address;
const createdAt = user.wallets?.[0]?.createdAt;
const mainDetail = user.linkedAccounts?.[0]?.details;
const email = mainDetail?.email as string | undefined;
const phone = mainDetail?.phone as string | undefined;
return (
<Card key={user.id}>
<CardHeader>
<CardTitle className="text-lg">User Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">
User Identifier
</p>
<p className="text-sm">{getUserIdentifier(user)}</p>
</div>
{walletAddress && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Wallet Address
</p>
<WalletAddress
address={walletAddress}
client={props.client}
/>
</div>
)}
{email && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Email
</p>
<p className="text-sm">{email}</p>
</div>
)}
{phone && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Phone
</p>
<p className="text-sm">{phone}</p>
</div>
)}
{createdAt && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Created
</p>
<p className="text-sm">
{format(new Date(createdAt), "MMM dd, yyyy")}
</p>
</div>
)}
<div>
<p className="text-sm font-medium text-muted-foreground">
Login Methods
</p>
<div className="flex flex-wrap gap-1">
{user.linkedAccounts?.map((account, index) => (
<TooltipProvider
key={`${user.id}-${account.type}-${index}`}
>
<Tooltip>
<TooltipTrigger>
<Badge variant="secondary" className="text-xs">
{account.type}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm space-y-1">
{Object.entries(account.details).map(
([key, value]) => (
<div key={key}>
<span className="font-medium">{key}:</span>{" "}
{String(value)}
</div>
),
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
@@ -21,7 +21,10 @@ import {
useAllEmbeddedWallets,
useEmbeddedWallets,
} from "@/hooks/useEmbeddedWallets";
import { SearchInput } from "./SearchInput";
import { AdvancedSearchInput } from "./AdvancedSearchInput";
import { SearchResults } from "./SearchResults";
import { searchUsers } from "./searchUsers";
import type { SearchType } from "./types";
const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const mainDetail = accounts[0]?.details;
@@ -35,11 +38,16 @@ const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => {
const columnHelper = createColumnHelper<WalletUser>();
export function InAppWalletUsersPageContent(props: {
authToken: string;
projectClientId: string;
client: ThirdwebClient;
}) {
export function InAppWalletUsersPageContent(
props: {
authToken: string;
client: ThirdwebClient;
teamId: string;
} & (
| { projectClientId: string; ecosystemSlug?: never }
| { ecosystemSlug: string; projectClientId?: never }
),
) {
const columns = useMemo(() => {
return [
columnHelper.accessor("linkedAccounts", {
@@ -110,49 +118,57 @@ export function InAppWalletUsersPageContent(props: {
}, [props.client]);
const [activePage, setActivePage] = useState(1);
const [searchValue, setSearchValue] = useState("");
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearchResults, setHasSearchResults] = useState(false);
const walletsQuery = useEmbeddedWallets({
authToken: props.authToken,
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
page: activePage,
});
const wallets = walletsQuery?.data?.users || [];
const filteredWallets = searchValue
? wallets.filter((wallet) => {
const term = searchValue.toLowerCase();
if (wallet.id.toLowerCase().includes(term)) {
return true;
}
if (
wallet.wallets?.some((w) => w.address?.toLowerCase().includes(term))
) {
return true;
}
if (
wallet.linkedAccounts?.some((acc) => {
return Object.values(acc.details).some(
(detail) =>
typeof detail === "string" &&
detail.toLowerCase().includes(term),
);
})
) {
return true;
}
return false;
})
: wallets;
const { mutateAsync: getAllEmbeddedWallets, isPending } =
useAllEmbeddedWallets({
authToken: props.authToken,
});
const handleSearch = async (searchType: SearchType, query: string) => {
setIsSearching(true);
try {
const results = await searchUsers(
props.authToken,
props.projectClientId,
props.ecosystemSlug,
props.teamId,
searchType,
query,
);
setSearchResults(results);
setHasSearchResults(true);
} catch (error) {
console.error("Search failed:", error);
setSearchResults([]);
setHasSearchResults(true);
} finally {
setIsSearching(false);
}
};
const handleClearSearch = () => {
setSearchResults([]);
setHasSearchResults(false);
};
const downloadCSV = useCallback(async () => {
if (wallets.length === 0 || !getAllEmbeddedWallets) {
return;
}
const usersWallets = await getAllEmbeddedWallets({
clientId: props.projectClientId,
ecosystemSlug: props.ecosystemSlug,
teamId: props.teamId,
});
const csv = Papa.unparse(
usersWallets.map((row) => {
@@ -173,64 +189,79 @@ export function InAppWalletUsersPageContent(props: {
tempLink.href = csvUrl;
tempLink.setAttribute("download", "download.csv");
tempLink.click();
}, [wallets, props.projectClientId, getAllEmbeddedWallets]);
}, [
wallets,
props.projectClientId,
getAllEmbeddedWallets,
props.teamId,
props.ecosystemSlug,
]);
return (
<div>
<div className="flex flex-col gap-4">
{/* Top section */}
<div className="flex items-center justify-end gap-3">
<div className="w-full max-w-xs">
<SearchInput
onValueChange={setSearchValue}
placeholder="Search"
value={searchValue}
/>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end gap-3">
<div className="w-full max-w-lg">
<AdvancedSearchInput
onSearch={handleSearch}
onClear={handleClearSearch}
isLoading={isSearching}
hasResults={hasSearchResults}
/>
</div>
<Button
className="gap-2"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
size="sm"
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
<Button
className="gap-2"
disabled={wallets.length === 0 || isPending}
onClick={downloadCSV}
size="sm"
variant="outline"
>
{isPending && <Spinner className="size-4" />}
Download as .csv
</Button>
</div>
<div>
<TWTable
columns={columns}
data={filteredWallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-b-none"
title="in-app wallets"
/>
{hasSearchResults ? (
<SearchResults results={searchResults} client={props.client} />
) : (
<>
<TWTable
columns={columns}
data={wallets}
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-b-none"
title="in-app wallets"
/>
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
size="sm"
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
className="gap-2 bg-background"
disabled={wallets.length === 0 || walletsQuery.isPending}
onClick={() => setActivePage((p) => p + 1)}
size="sm"
variant="outline"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
<div className="flex justify-center gap-3 rounded-b-lg border border-t-0 bg-card p-6">
<Button
className="gap-2 bg-background"
disabled={activePage === 1 || walletsQuery.isPending}
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
size="sm"
variant="outline"
>
<ArrowLeftIcon className="size-4" />
Previous
</Button>
<Button
className="gap-2 bg-background"
disabled={wallets.length === 0 || walletsQuery.isPending}
onClick={() => setActivePage((p) => p + 1)}
size="sm"
variant="outline"
>
Next
<ArrowRightIcon className="size-4" />
</Button>
</div>
</>
)}
</div>
</div>
</div>
@@ -0,0 +1,56 @@
import type { WalletUser } from "thirdweb/wallets";
import { THIRDWEB_EWS_API_HOST } from "@/constants/urls";
import type { SearchType } from "./types";
export async function searchUsers(
authToken: string,
clientId: string | undefined,
ecosystemSlug: string | undefined,
teamId: string,
searchType: SearchType,
query: string,
): Promise<WalletUser[]> {
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
// Add clientId or ecosystemSlug parameter
if (ecosystemSlug) {
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
} else if (clientId) {
url.searchParams.append("clientId", clientId);
}
// Add search parameter based on search type
switch (searchType) {
case "email":
url.searchParams.append("email", query);
break;
case "phone":
url.searchParams.append("phone", query);
break;
case "id":
url.searchParams.append("id", query);
break;
case "address":
url.searchParams.append("address", query);
break;
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-thirdweb-team-id": teamId,
...(clientId && { "x-client-id": clientId }),
},
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to search users: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
return data.users as WalletUser[];
}
@@ -0,0 +1 @@
export type SearchType = "email" | "phone" | "id" | "address";
@@ -34,6 +34,7 @@ export default async function Page(props: {
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
);
}
@@ -0,0 +1,231 @@
"use client";
import { format } from "date-fns";
import { useMemo } from "react";
import { ResponsiveSuspense } from "responsive-rsc";
import type { WebhookConfig } from "@/api/webhook-configs";
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import type { ChartConfig } from "@/components/ui/chart";
import { Skeleton } from "@/components/ui/skeleton";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { DateRangeControls, WebhookPicker } from "./WebhookAnalyticsFilter";
interface WebhookAnalyticsChartsProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhookAnalyticsCharts({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhookAnalyticsChartsProps) {
// Data is already filtered server-side
const filteredRequestsData = requestsData;
const filteredLatencyData = latencyData;
// Process status code distribution data by individual status codes
const statusCodeData = useMemo(() => {
if (!filteredRequestsData.length) return [];
const groupedData = filteredRequestsData.reduce(
(acc, item) => {
const date = new Date(item.date).getTime();
if (!acc[date]) {
acc[date] = { time: date };
}
// Only include valid status codes (not 0) with actual request counts
if (item.httpStatusCode > 0 && item.totalRequests > 0) {
const statusKey = item.httpStatusCode.toString();
acc[date][statusKey] =
(acc[date][statusKey] || 0) + item.totalRequests;
}
return acc;
},
{} as Record<string, Record<string, number> & { time: number }>,
);
return Object.values(groupedData).sort(
(a, b) => (a.time || 0) - (b.time || 0),
);
}, [filteredRequestsData]);
// Process latency data for charts
const latencyChartData = useMemo(() => {
if (!filteredLatencyData.length) return [];
return filteredLatencyData
.map((item) => ({
p50: item.p50LatencyMs,
p90: item.p90LatencyMs,
p99: item.p99LatencyMs,
time: new Date(item.date).getTime(),
}))
.sort((a, b) => a.time - b.time);
}, [filteredLatencyData]);
// Chart configurations
const latencyChartConfig: ChartConfig = {
p50: {
color: "hsl(var(--chart-1))",
label: "P50 Latency",
},
p90: {
color: "hsl(var(--chart-2))",
label: "P90 Latency",
},
p99: {
color: "hsl(var(--chart-3))",
label: "P99 Latency",
},
};
// Generate status code chart config dynamically with class-based colors
const statusCodeConfig: ChartConfig = useMemo(() => {
const statusCodes = new Set<string>();
statusCodeData.forEach((item) => {
Object.keys(item).forEach((key) => {
if (key !== "time" && !Number.isNaN(Number.parseInt(key))) {
statusCodes.add(key);
}
});
});
const getColorForStatusCode = (statusCode: number): string => {
if (statusCode >= 200 && statusCode < 300) {
return "hsl(var(--chart-1))"; // Green for 2xx
} else if (statusCode >= 300 && statusCode < 400) {
return "hsl(var(--chart-2))"; // Yellow for 3xx
} else if (statusCode >= 400 && statusCode < 500) {
return "hsl(var(--chart-3))"; // Orange for 4xx
} else {
return "hsl(var(--chart-4))"; // Red for 5xx
}
};
const config: ChartConfig = {};
Array.from(statusCodes)
.sort((a, b) => {
const codeA = Number.parseInt(a);
const codeB = Number.parseInt(b);
return codeA - codeB;
})
.forEach((statusKey) => {
const statusCode = Number.parseInt(statusKey);
config[statusKey] = {
color: getColorForStatusCode(statusCode),
label: statusCode.toString(),
};
});
return config;
}, [statusCodeData]);
const hasData = statusCodeData.length > 0 || latencyChartData.length > 0;
const selectedWebhookConfig = webhookConfigs.find(
(w) => w.id === selectedWebhookId,
);
return (
<div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-6">
<WebhookPicker
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
<DateRangeControls />
</div>
{/* Selected webhook URL */}
{selectedWebhookConfig && selectedWebhookId !== "all" && (
<div className="mb-6">
<CopyTextButton
className="font-mono text-sm"
copyIconPosition="right"
textToCopy={selectedWebhookConfig.destinationUrl}
textToShow={selectedWebhookConfig.destinationUrl}
tooltip="Copy webhook URL"
variant="outline"
/>
</div>
)}
{!hasData ? (
<div className="flex items-center justify-center p-8 rounded-xl border border-border bg-card">
<div className="text-center">
<h3 className="text-lg font-semibold mb-2">
No webhook data available
</h3>
<p className="text-sm text-muted-foreground">
Webhook analytics will appear here once you start receiving
webhook events.
</p>
</div>
</div>
) : (
<ResponsiveSuspense
fallback={
<div className="flex flex-col gap-6">
<Skeleton className="h-[350px] rounded-xl" />
<Skeleton className="h-[350px] rounded-xl" />
</div>
}
searchParamsUsed={["from", "to", "interval", "webhook"]}
>
<div className="flex flex-col gap-6">
{/* Status Code Distribution Chart */}
<ThirdwebBarChart
chartClassName="aspect-[1.5] lg:aspect-[3]"
config={statusCodeConfig}
data={statusCodeData}
header={{
description: "Breakdown of responses by status code",
title: "Response Status Codes",
}}
isPending={false}
showLegend
toolTipLabelFormatter={(label) =>
format(
new Date(Number.parseInt(label as string)),
"MMM dd, yyyy HH:mm",
)
}
toolTipValueFormatter={(value) => `${value} requests`}
variant="stacked"
/>
{/* Latency Chart */}
<ThirdwebAreaChart
chartClassName="aspect-[1.5] lg:aspect-[3]"
className="w-full"
config={latencyChartConfig}
data={latencyChartData}
header={{
description: "Webhook response times in milliseconds",
title: "Response Latency",
}}
isPending={false}
showLegend
toolTipLabelFormatter={(label) =>
format(
new Date(Number.parseInt(label as string)),
"MMM dd, yyyy HH:mm",
)
}
toolTipValueFormatter={(value) => `${value}ms`}
/>
</div>
</ResponsiveSuspense>
)}
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import {
useResponsiveSearchParams,
useSetResponsiveSearchParams,
} from "responsive-rsc";
import { DateRangeSelector } from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
type SearchParams = {
from?: string;
to?: string;
webhook?: string;
interval?: "day" | "week";
};
interface WebhookAnalyticsFilterProps {
webhookConfigs: Array<{
id: string;
description: string | null;
}>;
selectedWebhookId: string;
}
export function WebhookPicker({
webhookConfigs,
selectedWebhookId,
}: WebhookAnalyticsFilterProps) {
const setResponsiveSearchParams = useSetResponsiveSearchParams();
return (
<Select
onValueChange={(value) => {
setResponsiveSearchParams((prev) => ({
...prev,
webhook: value,
}));
}}
value={selectedWebhookId}
>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Webhooks</SelectItem>
{webhookConfigs.map((config) => (
<SelectItem key={config.id} value={config.id}>
<span>{config.description || "Unnamed webhook"}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function DateRangeControls() {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-30",
from: responsiveSearchParams.from,
interval: responsiveSearchParams.interval,
to: responsiveSearchParams.to,
});
return (
<div className="flex items-center gap-3">
<DateRangeSelector
popoverAlign="end"
range={range}
setRange={(newRange) => {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
return newParams;
});
}}
/>
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v: SearchParams) => {
const newParams = {
...v,
interval: newInterval,
};
return newParams;
});
}}
/>
</div>
);
}
@@ -0,0 +1,29 @@
import type { WebhookConfig } from "@/api/webhook-configs";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { WebhookAnalyticsCharts } from "./WebhookAnalyticsCharts";
interface WebhookAnalyticsServerProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhookAnalyticsServer({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhookAnalyticsServerProps) {
return (
<WebhookAnalyticsCharts
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,29 @@
import type { WebhookConfig } from "@/api/webhook-configs";
import type {
WebhookLatencyStats,
WebhookRequestStats,
} from "@/types/analytics";
import { WebhookAnalyticsServer } from "./WebhookAnalyticsServer";
interface WebhooksAnalyticsProps {
webhookConfigs: WebhookConfig[];
requestsData: WebhookRequestStats[];
latencyData: WebhookLatencyStats[];
selectedWebhookId: string;
}
export function WebhooksAnalytics({
webhookConfigs,
requestsData,
latencyData,
selectedWebhookId,
}: WebhooksAnalyticsProps) {
return (
<WebhookAnalyticsServer
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,111 @@
import { notFound } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getWebhookLatency, getWebhookRequests } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { getWebhookConfigs } from "@/api/webhook-configs";
import { getFiltersFromSearchParams } from "@/lib/time";
import { WebhooksAnalytics } from "./components/WebhooksAnalytics";
export default async function WebhooksAnalyticsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
webhook?: string | undefined | string[];
}>;
}) {
const [authToken, params] = await Promise.all([getAuthToken(), props.params]);
const project = await getProject(params.team_slug, params.project_slug);
if (!project || !authToken) {
notFound();
}
const searchParams = await props.searchParams;
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-7",
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
// Get webhook configs
const webhookConfigsResponse = await getWebhookConfigs({
projectIdOrSlug: params.project_slug,
teamIdOrSlug: params.team_slug,
}).catch(() => ({
body: "",
data: [],
reason: "Failed to fetch webhook configs",
status: "error" as const,
}));
if (
webhookConfigsResponse.status === "error" ||
webhookConfigsResponse.data.length === 0
) {
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">
No webhook configurations found.
</p>
</div>
</ResponsiveSearchParamsProvider>
);
}
// Get selected webhook ID from search params
const selectedWebhookId = Array.isArray(searchParams.webhook)
? searchParams.webhook[0] || "all"
: searchParams.webhook || "all";
// Fetch webhook analytics data
const webhookId = selectedWebhookId === "all" ? undefined : selectedWebhookId;
const [requestsData, latencyData] = await Promise.all([
(async () => {
const res = await getWebhookRequests({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
});
if ("error" in res) {
console.error("Failed to fetch webhook requests:", res.error);
return [];
}
return res.data;
})(),
(async () => {
const res = await getWebhookLatency({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
});
if ("error" in res) {
console.error("Failed to fetch webhook latency:", res.error);
return [];
}
return res.data;
})(),
]);
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<WebhooksAnalytics
latencyData={latencyData}
requestsData={requestsData}
selectedWebhookId={selectedWebhookId}
webhookConfigs={webhookConfigsResponse.data}
/>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,23 @@
import type { Topic } from "@/api/webhook-configs";
import { WebhookConfigModal } from "./webhook-config-modal";
interface CreateWebhookConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
}
export function CreateWebhookConfigModal(props: CreateWebhookConfigModalProps) {
return (
<WebhookConfigModal
mode="create"
onOpenChange={props.onOpenChange}
open={props.open}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
/>
);
}
@@ -0,0 +1,101 @@
import { DialogDescription } from "@radix-ui/react-dialog";
import { AlertTriangleIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import type { WebhookSummaryStats } from "@/types/analytics";
import type { WebhookConfig } from "../../../../../../../../@/api/webhook-configs";
interface DeleteWebhookModalProps {
webhookConfig: WebhookConfig | null;
metrics: WebhookSummaryStats | null;
onConfirm: () => void;
isPending: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DeleteWebhookModal(props: DeleteWebhookModalProps) {
if (!props.webhookConfig) {
return null;
}
// Use real metrics data
const requests24h = props.metrics?.totalRequests ?? 0;
const hasRecentActivity = requests24h > 0;
return (
<Dialog onOpenChange={props.onOpenChange} open={props.open}>
<DialogContent className="p-0">
<DialogHeader className="mb-4 p-6">
<DialogTitle className="font-semibold text-2xl tracking-tight">
Delete Webhook Configuration
</DialogTitle>
<DialogDescription className="space-y-4">
<div>
Are you sure you want to delete this webhook configuration? This
action cannot be undone.
</div>
<div className="space-y-4 py-4">
<div>
<div className="font-medium text-sm">Description</div>
<div className="text-sm text-muted-foreground">
{props.webhookConfig.description || "No description"}
</div>
</div>
<div>
<div className="font-medium text-sm">URL</div>
<div className="text-sm text-muted-foreground break-all">
{props.webhookConfig.destinationUrl}
</div>
</div>
</div>
{hasRecentActivity && (
<Alert variant="warning">
<AlertTriangleIcon className="size-5" />
<AlertTitle className="text-sm">
Recent Activity Detected
</AlertTitle>
<AlertDescription className="mt-0.5 text-sm">
This webhook has received {requests24h} requests in the last
24 hours. Deleting it may impact your integrations.
</AlertDescription>
</Alert>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-4 border-border border-t bg-card p-6 lg:gap-2">
<Button
disabled={props.isPending}
onClick={() => {
props.onOpenChange(false);
}}
variant="outline"
>
Cancel
</Button>
<Button
className="min-w-28 gap-2"
disabled={props.isPending}
onClick={props.onConfirm}
variant="destructive"
>
{props.isPending && <Spinner className="size-4" />}
Delete Webhook
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,25 @@
import type { Topic, WebhookConfig } from "@/api/webhook-configs";
import { WebhookConfigModal } from "./webhook-config-modal";
interface EditWebhookConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
webhookConfig: WebhookConfig;
}
export function EditWebhookConfigModal(props: EditWebhookConfigModalProps) {
return (
<WebhookConfigModal
mode="edit"
onOpenChange={props.onOpenChange}
open={props.open}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
webhookConfig={props.webhookConfig}
/>
);
}
@@ -0,0 +1,50 @@
"use client";
import { redirect } from "next/navigation";
import type { WebhookSummaryStats } from "@/types/analytics";
import type {
Topic,
WebhookConfig,
} from "../../../../../../../../@/api/webhook-configs";
import { WebhookConfigsTable } from "./webhook-configs-table";
interface WebhooksOverviewProps {
teamId: string;
teamSlug: string;
projectId: string;
projectSlug: string;
webhookConfigs: WebhookConfig[];
topics: Topic[];
metricsMap: Map<string, WebhookSummaryStats | null>;
}
export function WebhooksOverview({
teamId,
teamSlug,
projectId,
projectSlug,
webhookConfigs,
topics,
metricsMap,
}: WebhooksOverviewProps) {
// Feature is enabled (matches server component behavior)
const isFeatureEnabled = true;
// Redirect to contracts tab if feature is disabled
if (!isFeatureEnabled) {
redirect(`/team/${teamSlug}/${projectSlug}/webhooks/contracts`);
}
// Show full webhook functionality
return (
<WebhookConfigsTable
metricsMap={metricsMap}
projectId={projectId}
projectSlug={projectSlug}
teamId={teamId}
teamSlug={teamSlug}
topics={topics}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -0,0 +1,195 @@
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { Topic } from "@/api/webhook-configs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
const TOPIC_IDS_THAT_SUPPORT_FILTERS = [
"insight.event.confirmed",
"insight.transaction.confirmed",
];
interface TopicSelectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
topics: Topic[];
selectedTopics: { id: string; filters: object | null }[];
onSelectionChange: (topics: { id: string; filters: object | null }[]) => void;
}
export function TopicSelectorModal(props: TopicSelectorModalProps) {
const [tempSelection, setTempSelection] = useState<
{ id: string; filters: object | null }[]
>(props.selectedTopics);
// Initialize topicFilters with existing filters
const [topicFilters, setTopicFilters] = useState<Record<string, string>>(
() => {
const initialFilters: Record<string, string> = {};
props.selectedTopics.forEach((topic) => {
if (topic.filters) {
initialFilters[topic.id] = JSON.stringify(topic.filters, null, 2);
}
});
return initialFilters;
},
);
const groupedTopics = useMemo(() => {
const groups: Record<string, Topic[]> = {};
props.topics.forEach((topic) => {
const service = topic.id.split(".")[0] || "other";
if (!groups[service]) {
groups[service] = [];
}
groups[service].push(topic);
});
// Sort groups by service name and topics within each group
const sortedGroups: Record<string, Topic[]> = {};
Object.keys(groups)
.sort()
.forEach((service) => {
sortedGroups[service] =
groups[service]?.sort((a, b) => a.id.localeCompare(b.id)) || [];
});
return sortedGroups;
}, [props.topics]);
function handleTopicToggle(topicId: string, checked: boolean) {
if (checked) {
// Preserve existing filters if re-selecting a topic
const existingTopic = props.selectedTopics.find((t) => t.id === topicId);
setTempSelection((prev) => [
...prev,
{ id: topicId, filters: existingTopic?.filters || null },
]);
} else {
setTempSelection((prev) => prev.filter((topic) => topic.id !== topicId));
}
}
function handleSave() {
const processedSelection = tempSelection.map((topic) => {
const filters = topicFilters[topic.id];
if (filters) {
try {
return { ...topic, filters: JSON.parse(filters) };
} catch (_error) {
toast.error(`Invalid JSON in filters for ${topic.id}`);
throw new Error(`Invalid JSON in filters for ${topic.id}`);
}
}
return topic;
});
props.onSelectionChange(processedSelection);
props.onOpenChange(false);
}
function handleCancel() {
setTempSelection(props.selectedTopics);
// Reset filter texts to original values
const originalFilters: Record<string, string> = {};
props.selectedTopics.forEach((topic) => {
if (topic.filters) {
originalFilters[topic.id] = JSON.stringify(topic.filters, null, 2);
}
});
setTopicFilters(originalFilters);
props.onOpenChange(false);
}
return (
<Dialog onOpenChange={handleCancel} open={props.open}>
<DialogContent className="max-h-[80vh] max-w-2xl p-0 overflow-hidden flex flex-col">
<DialogHeader className="p-6 pb-4 flex-shrink-0">
<DialogTitle className="font-semibold text-xl tracking-tight">
Select Topics
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 min-h-0">
<div className="space-y-6 pb-4">
{Object.entries(groupedTopics).map(([service, topics]) => (
<div key={service}>
<h3 className="font-medium text-sm mb-4 text-foreground capitalize">
{service}
</h3>
<div className="space-y-3 ml-4">
{topics.map((topic) => (
<div className="flex items-start space-x-3" key={topic.id}>
<Checkbox
checked={tempSelection.some((t) => t.id === topic.id)}
id={topic.id}
onCheckedChange={(checked) =>
handleTopicToggle(topic.id, !!checked)
}
/>
<div className="grid gap-1.5 leading-none flex-1">
<label
className="text-sm font-mono font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
htmlFor={topic.id}
>
{topic.id}
</label>
<p className="text-xs text-muted-foreground">
{topic.description}
</p>
{/* Show textarea when selecting a topic that supports filters */}
{TOPIC_IDS_THAT_SUPPORT_FILTERS.includes(topic.id) &&
tempSelection.some((t) => t.id === topic.id) && (
<div className="mt-2">
<Textarea
placeholder={`{\n "key": "value"\n}`}
rows={3}
value={topicFilters[topic.id] || ""}
onChange={(e) => {
setTopicFilters((prev) => ({
...prev,
[topic.id]: e.target.value,
}));
}}
className="text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
Enter JSON filters (optional). Leave empty to
receive all events.
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
<DialogFooter className="gap-4 border-border border-t bg-card p-6 lg:gap-2 flex-shrink-0">
<Button onClick={handleCancel} variant="outline">
Cancel
</Button>
<Button onClick={handleSave}>
Select {tempSelection.length} Topic
{tempSelection.length !== 1 ? "s" : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,346 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
createWebhookConfig,
type Topic,
updateWebhookConfig,
type WebhookConfig,
} from "@/api/webhook-configs";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Switch } from "@/components/ui/switch";
import { TopicSelectorModal } from "./topic-selector-modal";
const formSchema = z.object({
description: z.string().min(1, "Description is required"),
destinationUrl: z
.string()
.min(1, "Destination URL is required")
.url("Must be a valid URL")
.refine((url) => url.startsWith("https://"), {
message: "URL must start with https://",
}),
isPaused: z.boolean().default(false),
topics: z
.array(
z.object({ id: z.string(), filters: z.union([z.object({}), z.null()]) }),
)
.min(1, "At least one topic is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface WebhookConfigModalProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
teamSlug: string;
projectSlug: string;
topics: Topic[];
webhookConfig?: WebhookConfig; // Only required for edit mode
}
export function WebhookConfigModal(props: WebhookConfigModalProps) {
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false);
const queryClient = useQueryClient();
const isEditMode = props.mode === "edit";
const webhookConfig = props.webhookConfig;
const form = useForm<FormValues>({
defaultValues: {
description: isEditMode ? webhookConfig?.description || "" : "",
destinationUrl: isEditMode ? webhookConfig?.destinationUrl || "" : "",
isPaused: isEditMode ? !!webhookConfig?.pausedAt : false,
topics:
isEditMode && webhookConfig?.topics
? webhookConfig.topics.map((t) => ({
id: t.id,
filters: t.filters,
}))
: [],
},
resolver: zodResolver(formSchema),
});
const mutation = useMutation({
mutationFn: async (values: FormValues) => {
// Topics already have filters applied from the selector modal
const processedTopics = values.topics;
if (isEditMode && webhookConfig) {
const result = await updateWebhookConfig({
config: {
description: values.description,
destinationUrl: values.destinationUrl,
isPaused: values.isPaused,
topics: processedTopics,
},
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
webhookConfigId: webhookConfig.id,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
} else {
const result = await createWebhookConfig({
config: {
description: values.description,
destinationUrl: values.destinationUrl,
isPaused: values.isPaused,
topics: processedTopics,
},
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
}
},
onError: (error) => {
toast.error(`Failed to ${isEditMode ? "update" : "create"} webhook`, {
description: error.message,
});
},
onSuccess: () => {
toast.success(
`Webhook ${isEditMode ? "updated" : "created"} successfully`,
);
props.onOpenChange(false);
if (!isEditMode) {
form.reset();
}
queryClient.invalidateQueries({
queryKey: ["webhook-configs", props.teamSlug, props.projectSlug],
});
},
});
function onSubmit(values: FormValues) {
mutation.mutate(values);
}
function handleOpenChange(open: boolean) {
if (!open && !mutation.isPending) {
if (isEditMode && webhookConfig) {
// Reset form to original values when closing edit modal
form.reset({
description: webhookConfig.description || "",
destinationUrl: webhookConfig.destinationUrl || "",
isPaused: !!webhookConfig.pausedAt,
topics: webhookConfig.topics.map((t) => ({
id: t.id,
filters: t.filters,
})),
});
} else {
// Reset to empty values for create modal
form.reset();
}
}
props.onOpenChange(open);
}
return (
<Dialog onOpenChange={handleOpenChange} open={props.open}>
<DialogContent className="overflow-hidden p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6">
<DialogHeader className="mb-4">
<DialogTitle className="font-semibold text-2xl tracking-tight">
{isEditMode ? "Edit" : "Create"} Webhook Configuration
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-5">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder={
isEditMode
? "Enter webhook description..."
: "Enter a description"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destinationUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Destination URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/webhook"
type="url"
{...field}
/>
</FormControl>
<FormDescription>
{isEditMode
? "The URL where webhook events will be sent"
: "Enter your webhook URL. Only https:// is supported."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topics"
render={({ field }) => (
<FormItem>
<FormLabel>Topics</FormLabel>
<FormControl>
<Button
className="w-full justify-start text-left font-normal"
onClick={() => setIsTopicSelectorOpen(true)}
type="button"
variant="outline"
>
{field.value && field.value.length > 0
? `${field.value.length} topic${field.value.length !== 1 ? "s" : ""} selected`
: "Select topics to subscribe to"}
</Button>
</FormControl>
<FormDescription>
{isEditMode
? "Select the events you want to receive notifications for"
: "Select the events to trigger calls to your webhook."}
</FormDescription>
{field.value && field.value.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{field.value.map((topic) => {
return (
<div
className="flex items-center gap-1 rounded-full bg-accent px-2 py-1 text-xs"
key={topic.id}
>
{topic.id}
<button
className="ml-1 text-muted-foreground hover:text-foreground"
onClick={() => {
field.onChange(
field.value?.filter(
(t) => t.id !== topic.id,
),
);
}}
type="button"
>
×
</button>
</div>
);
})}
</div>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isPaused"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm font-medium">
{isEditMode ? "Paused" : "Start Paused"}
</FormLabel>
<FormDescription className="text-xs">
{isEditMode
? "Pause webhook notifications"
: "Do not send events yet. You can unpause at any time."}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<DialogFooter className="mt-4 gap-4 border-border border-t bg-card p-6 lg:gap-2">
<Button
disabled={mutation.isPending}
onClick={() => handleOpenChange(false)}
variant="outline"
>
Cancel
</Button>
<Button
className="min-w-28 gap-2"
disabled={mutation.isPending}
type="submit"
>
{mutation.isPending && <Spinner className="size-4" />}
{isEditMode ? "Update" : "Create"} Webhook
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<TopicSelectorModal
onOpenChange={setIsTopicSelectorOpen}
onSelectionChange={(topics) => {
form.setValue("topics", topics);
}}
open={isTopicSelectorOpen}
selectedTopics={form.watch("topics")}
topics={props.topics}
/>
</Dialog>
);
}
@@ -0,0 +1,428 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
ArrowUpDownIcon,
CalendarIcon,
CheckIcon,
EditIcon,
GlobeIcon,
LetterTextIcon,
MoreHorizontalIcon,
PauseIcon,
PlusIcon,
TrashIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { WebhookSummaryStats } from "@/types/analytics";
import type {
Topic,
WebhookConfig,
} from "../../../../../../../../@/api/webhook-configs";
import { deleteWebhookConfig } from "../../../../../../../../@/api/webhook-configs";
import { CreateWebhookConfigModal } from "./create-webhook-config-modal";
import { DeleteWebhookModal } from "./delete-webhook-modal";
import { EditWebhookConfigModal } from "./edit-webhook-config-modal";
import { WebhookMetrics } from "./webhook-metrics";
type SortById = "description" | "createdAt" | "destinationUrl" | "pausedAt";
export function WebhookConfigsTable(props: {
teamId: string;
teamSlug: string;
projectId: string;
projectSlug: string;
webhookConfigs: WebhookConfig[];
topics: Topic[];
metricsMap: Map<string, WebhookSummaryStats | null>;
}) {
const { webhookConfigs } = props;
const [sortBy, setSortBy] = useState<SortById>("createdAt");
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(
null,
);
const [deletingWebhook, setDeletingWebhook] = useState<WebhookConfig | null>(
null,
);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async (webhookId: string) => {
const result = await deleteWebhookConfig({
projectIdOrSlug: props.projectSlug,
teamIdOrSlug: props.teamSlug,
webhookConfigId: webhookId,
});
if (result.status === "error") {
throw new Error(result.body);
}
return result.data;
},
onError: (error) => {
toast.error("Failed to delete webhook", {
description: error.message,
});
},
onSuccess: () => {
toast.success("Webhook deleted successfully");
setDeletingWebhook(null);
queryClient.invalidateQueries({
queryKey: ["webhook-configs", props.teamSlug, props.projectSlug],
});
},
});
const sortedConfigs = useMemo(() => {
let _configsToShow = webhookConfigs;
if (sortBy === "description") {
_configsToShow = _configsToShow.sort((a, b) =>
(a.description || "").localeCompare(b.description || ""),
);
} else if (sortBy === "createdAt") {
_configsToShow = _configsToShow.sort(
(a, b) =>
new Date(b.createdAt || 0).getTime() -
new Date(a.createdAt || 0).getTime(),
);
} else if (sortBy === "destinationUrl") {
_configsToShow = _configsToShow.sort((a, b) =>
(a.destinationUrl || "").localeCompare(b.destinationUrl || ""),
);
} else if (sortBy === "pausedAt") {
_configsToShow = _configsToShow.sort((a, b) =>
a.pausedAt === b.pausedAt ? 0 : a.pausedAt ? 1 : -1,
);
}
return _configsToShow;
}, [sortBy, webhookConfigs]);
const pageSize = 10;
const [page, setPage] = useState(1);
const paginatedConfigs = sortedConfigs.slice(
(page - 1) * pageSize,
page * pageSize,
);
const showPagination = sortedConfigs.length > pageSize;
const totalPages = Math.ceil(sortedConfigs.length / pageSize);
const hasActiveFilters = sortBy !== "createdAt";
return (
<div>
<div className="relative flex flex-col gap-5 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="mb-1 font-semibold text-2xl tracking-tight">
Configuration
</h2>
<p className="text-muted-foreground text-sm">
Manage your webhook endpoints.
</p>
</div>
{/* Filters + Add New */}
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<SortDropdown
hasActiveFilters={hasActiveFilters}
onSortChange={(v) => {
setSortBy(v);
setPage(1);
}}
sortBy={sortBy}
/>
<Button
className="gap-1.5 rounded-full"
onClick={() => setIsCreateModalOpen(true)}
>
<PlusIcon className="size-4" />
Create Webhook
</Button>
</div>
</div>
{/* Webhook Configs Table */}
{paginatedConfigs.length === 0 ? (
<div className="flex min-h-[300px] grow items-center justify-center border rounded-lg border-dashed bg-card">
<div className="flex flex-col items-center">
<p className="text-center font-semibold text-lg">
No webhooks created yet.
</p>
</div>
</div>
) : (
<div className="flex grow flex-col">
<TableContainer className={cn(showPagination && "rounded-b-none")}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="max-w-[150px]">Description</TableHead>
<TableHead className="w-[150px]">Destination URL</TableHead>
<TableHead>Topics</TableHead>
<TableHead>Activity (24h)</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedConfigs.map((config) => (
<TableRow
className="hover:bg-accent/50"
key={config.id}
linkBox
>
<TableCell>
<span className="font-medium text-sm">
{config.description || "No description"}
</span>
</TableCell>
<TableCell className="w-[200px]">
{config.destinationUrl ? (
<CopyTextButton
className="max-w-[200px] text-sm text-muted-foreground font-mono justify-start p-0 h-auto"
textToCopy={config.destinationUrl}
textToShow={
config.destinationUrl.length > 30
? `${config.destinationUrl.substring(0, 30)}...`
: config.destinationUrl
}
variant="ghost"
copyIconPosition="right"
tooltip={config.destinationUrl}
/>
) : (
<span className="text-sm text-muted-foreground">
No URL
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(config.topics || []).slice(0, 3).map((topic) => {
// Check if this topic has filters (for webhook configs from new API)
const hasFilters =
topic.filters &&
Object.keys(topic.filters).length > 0;
return (
<span
className={cn(
"rounded-full px-2 py-1 text-xs",
hasFilters
? "bg-blue-100 text-blue-800"
: "bg-accent",
)}
key={topic.id}
>
{topic.id}
{hasFilters && " *"}
</span>
);
})}
{(config.topics || []).length > 3 && (
<span className="rounded-full bg-accent px-2 py-1 text-xs">
+{(config.topics || []).length - 3}
</span>
)}
</div>
</TableCell>
<TableCell>
<WebhookMetrics
isPaused={!!config.pausedAt}
metrics={props.metricsMap.get(config.id) || null}
/>
</TableCell>
<TableCell className="text-muted-foreground">
{config.createdAt
? format(new Date(config.createdAt), "MMM d, yyyy")
: "Unknown"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="h-8 w-8 p-0" variant="ghost">
<MoreHorizontalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
setEditingWebhook(config);
}}
>
<EditIcon className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onClick={() => {
setDeletingWebhook(config);
}}
>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{showPagination && (
<div className="rounded-b-lg border border-t-0 bg-card p-4">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
)}
<CreateWebhookConfigModal
onOpenChange={setIsCreateModalOpen}
open={isCreateModalOpen}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
/>
{editingWebhook && (
<EditWebhookConfigModal
onOpenChange={(open) => {
if (!open) setEditingWebhook(null);
}}
open={!!editingWebhook}
projectSlug={props.projectSlug}
teamSlug={props.teamSlug}
topics={props.topics}
webhookConfig={editingWebhook}
/>
)}
<DeleteWebhookModal
isPending={deleteMutation.isPending}
metrics={
deletingWebhook
? props.metricsMap.get(deletingWebhook.id) || null
: null
}
onConfirm={() => {
if (deletingWebhook) {
deleteMutation.mutate(deletingWebhook.id);
}
}}
onOpenChange={(open) => {
if (!open) setDeletingWebhook(null);
}}
open={!!deletingWebhook}
webhookConfig={deletingWebhook}
/>
</div>
);
}
const sortByIcon: Record<SortById, React.FC<{ className?: string }>> = {
createdAt: CalendarIcon,
description: LetterTextIcon,
destinationUrl: GlobeIcon,
pausedAt: PauseIcon,
};
function SortDropdown(props: {
sortBy: SortById;
onSortChange: (value: SortById) => void;
hasActiveFilters: boolean;
}) {
const values: SortById[] = [
"description",
"createdAt",
"destinationUrl",
"pausedAt",
];
const valueToLabel: Record<SortById, string> = {
createdAt: "Creation Date",
description: "Description",
destinationUrl: "Destination URL",
pausedAt: "Pause Status",
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="gap-1.5 rounded-full bg-card" variant="outline">
<ArrowUpDownIcon className="size-4 text-muted-foreground" />
<span>Sort by</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-60 rounded-xl p-1.5 shadow-lg"
sideOffset={10}
>
<DropdownMenuRadioGroup
className="flex flex-col gap-1"
onValueChange={(v) => props.onSortChange(v as SortById)}
value={props.sortBy}
>
{values.map((value) => {
const Icon = sortByIcon[value];
return (
<DropdownMenuItem
className={cn(
"flex cursor-pointer items-center justify-between gap-2 rounded-lg py-2",
props.sortBy === value && "bg-accent",
)}
key={value}
onClick={() => props.onSortChange(value)}
>
<div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" />
{valueToLabel[value]}
</div>
{props.sortBy === value ? (
<CheckIcon className="size-4 text-foreground" />
) : (
<div className="size-4" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,38 @@
"use client";
import type { WebhookSummaryStats } from "@/types/analytics";
interface WebhookMetricsProps {
metrics: WebhookSummaryStats | null;
isPaused: boolean;
}
export function WebhookMetrics({ metrics, isPaused }: WebhookMetricsProps) {
if (isPaused) {
return (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
Paused
</span>
);
}
if (!metrics) {
return (
<div className="text-sm text-muted-foreground">No metrics available</div>
);
}
const totalRequests = metrics.totalRequests ?? 0;
const errorRequests = metrics.errorRequests ?? 0;
const errorRate =
totalRequests > 0 ? (errorRequests / totalRequests) * 100 : 0;
return (
<div className="text-sm">
<div className="font-medium">{totalRequests} requests</div>
<div className="text-xs text-muted-foreground">
{errorRate.toFixed(1)}% errors
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { ContractsWebhooksPageContent } from "../contract-webhooks/contract-webhooks-page";
export default async function ContractsPage({
params,
}: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [authToken, resolvedParams] = await Promise.all([
getAuthToken(),
params,
]);
const project = await getProject(
resolvedParams.team_slug,
resolvedParams.project_slug,
);
if (!project || !authToken) {
notFound();
}
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Contract Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified about blockchain events, transactions and more.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/insight/webhooks"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</p>
<div className="h-4" />
<ContractsWebhooksPageContent authToken={authToken} project={project} />
</div>
);
}
@@ -1,3 +1,5 @@
import { getValidAccount } from "@app/account/settings/getAccount";
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
import { TabPathLinks } from "@/components/ui/tabs";
export default async function WebhooksLayout(props: {
@@ -7,6 +9,12 @@ export default async function WebhooksLayout(props: {
project_slug: string;
}>;
}) {
const account = await getValidAccount();
const isFeatureEnabled = await isFeatureFlagEnabled(
"webhook-analytics-tab",
account.email,
);
const params = await props.params;
return (
<div className="flex grow flex-col">
@@ -23,13 +31,26 @@ export default async function WebhooksLayout(props: {
<TabPathLinks
links={[
...(isFeatureEnabled
? [
{
exactMatch: true,
name: "Overview",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks`,
},
{
exactMatch: true,
name: "Analytics",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/analytics`,
},
]
: []),
{
exactMatch: true,
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks`,
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
{
name: "Universal Bridge",
name: "Payments",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
},
]}
@@ -1,8 +1,12 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/projects";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { ContractsWebhooksPageContent } from "./contract-webhooks/contract-webhooks-page";
import { getWebhookSummary } from "../../../../../../../@/api/analytics";
import {
getAvailableTopics,
getWebhookConfigs,
} from "../../../../../../../@/api/webhook-configs";
import { WebhooksOverview } from "./components/overview";
export default async function WebhooksPage({
params,
@@ -23,23 +27,59 @@ export default async function WebhooksPage({
notFound();
}
// Fetch webhook configs and topics in parallel
const [webhookConfigsResult, topicsResult] = await Promise.all([
getWebhookConfigs({
projectIdOrSlug: resolvedParams.project_slug,
teamIdOrSlug: resolvedParams.team_slug,
}),
getAvailableTopics(),
]);
if (
webhookConfigsResult.status === "error" ||
topicsResult.status === "error"
) {
notFound();
}
const webhookConfigs = webhookConfigsResult.data || [];
const topics = topicsResult.data || [];
// Fetch metrics for all webhooks in parallel
const webhookMetrics = await Promise.all(
webhookConfigs.map(async (config) => {
const metricsResult = await getWebhookSummary({
from: new Date(Date.now() - 24 * 60 * 60 * 1000),
period: "day",
projectId: project.id,
teamId: project.teamId, // 24 hours ago
to: new Date(),
webhookId: config.id,
});
return {
metrics:
"error" in metricsResult ? null : (metricsResult.data[0] ?? null),
webhookId: config.id,
};
}),
);
// Create a map for easy lookup
const metricsMap = new Map(
webhookMetrics.map((item) => [item.webhookId, item.metrics]),
);
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Contract Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified about blockchain events, transactions and more.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/insight/webhooks"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</p>
<div className="h-4" />
<ContractsWebhooksPageContent authToken={authToken} project={project} />
</div>
<WebhooksOverview
metricsMap={metricsMap}
projectId={project.id}
projectSlug={resolvedParams.project_slug}
teamId={project.teamId}
teamSlug={resolvedParams.team_slug}
topics={topics}
webhookConfigs={webhookConfigs}
/>
);
}
@@ -19,7 +19,7 @@ export default async function Page(props: {
return (
<div>
<h2 className="mb-0.5 font-semibold text-xl tracking-tight">
Universal Bridge Webhooks
Payments Webhooks
</h2>
<p className="text-muted-foreground text-sm">
Get notified for Bridge, Swap and Onramp events.{" "}
@@ -73,7 +73,7 @@ function Example() {
<${componentName}
client={client}
chain={defineChain(${options.payOptions.buyTokenChain.id})}
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t token="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t token="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
options.payOptions.widget === "transaction"
? `\n\t transaction={claimTo({
contract: nftContract,
@@ -2,6 +2,34 @@ import type { Chain } from "thirdweb/chains";
import type { ThemeOverrides } from "thirdweb/react";
import type { Address } from "thirdweb/utils";
const CURRENCIES = [
"USD",
"EUR",
"GBP",
"JPY",
"KRW",
"CNY",
"INR",
"NOK",
"SEK",
"CHF",
"AUD",
"CAD",
"NZD",
"MXN",
"BRL",
"CLP",
"CZK",
"DKK",
"HKD",
"HUF",
"IDR",
"ILS",
"ISK",
] as const;
type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
export type BridgeComponentsPlaygroundOptions = {
theme: {
type: "dark" | "light";
@@ -25,5 +53,9 @@ export type BridgeComponentsPlaygroundOptions = {
transactionData?: string; // Simplified for demo; could be more complex in real implementation
paymentMethods: ("crypto" | "card")[];
currency?: SupportedFiatCurrency;
showThirdwebBranding: boolean;
};
};
@@ -17,6 +17,13 @@ import { CustomRadioGroup } from "@/components/ui/CustomRadioGroup";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TokenSelector } from "@/components/ui/TokenSelector";
import { THIRDWEB_CLIENT } from "@/lib/client";
import type { TokenMetadata } from "@/lib/types";
@@ -134,6 +141,51 @@ export function LeftSection(props: {
/>
</section>
<section className="flex flex-col gap-3">
<Label htmlFor="currency">Display Currency</Label>
<Select
value={payOptions.currency || "USD"}
onValueChange={(value) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
currency: value,
},
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD - US Dollar</SelectItem>
<SelectItem value="EUR">EUR - Euro</SelectItem>
<SelectItem value="GBP">GBP - British Pound</SelectItem>
<SelectItem value="JPY">JPY - Japanese Yen</SelectItem>
<SelectItem value="KRW">KRW - Korean Won</SelectItem>
<SelectItem value="CNY">CNY - Chinese Yuan</SelectItem>
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
<SelectItem value="NOK">NOK - Norwegian Krone</SelectItem>
<SelectItem value="SEK">SEK - Swedish Krona</SelectItem>
<SelectItem value="CHF">CHF - Swiss Franc</SelectItem>
<SelectItem value="AUD">AUD - Australian Dollar</SelectItem>
<SelectItem value="CAD">CAD - Canadian Dollar</SelectItem>
<SelectItem value="NZD">NZD - New Zealand Dollar</SelectItem>
<SelectItem value="MXN">MXN - Mexican Peso</SelectItem>
<SelectItem value="BRL">BRL - Brazilian Real</SelectItem>
<SelectItem value="CLP">CLP - Chilean Peso</SelectItem>
<SelectItem value="CZK">CZK - Czech Koruna</SelectItem>
<SelectItem value="DKK">DKK - Danish Krone</SelectItem>
<SelectItem value="HKD">HKD - Hong Kong Dollar</SelectItem>
<SelectItem value="HUF">HUF - Hungarian Forint</SelectItem>
<SelectItem value="IDR">IDR - Indonesian Rupiah</SelectItem>
<SelectItem value="ILS">ILS - Israeli Shekel</SelectItem>
<SelectItem value="ISK">ISK - Icelandic Krona</SelectItem>
</SelectContent>
</Select>
</section>
{/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */}
{(!payOptions.widget ||
payOptions.widget === "buy" ||
@@ -464,6 +516,23 @@ export function LeftSection(props: {
}}
theme={options.theme}
/>
<div className="my-4 flex items-center gap-2">
<Checkbox
checked={payOptions.showThirdwebBranding}
id={"branding"}
onCheckedChange={(checked) => {
setOptions((v) => ({
...v,
payOptions: {
...v.payOptions,
showThirdwebBranding: checked === true,
},
}));
}}
/>
<Label htmlFor={"branding"}>Show Branding</Label>
</div>
</CollapsibleSection>
<CollapsibleSection icon={FuelIcon} title="Sponsor gas fees">
@@ -64,6 +64,8 @@ export function RightSection(props: {
theme={themeObj}
title={props.options.payOptions.title}
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
}
@@ -87,6 +89,8 @@ export function RightSection(props: {
seller={props.options.payOptions.sellerAddress}
theme={themeObj}
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
}
@@ -106,6 +110,8 @@ export function RightSection(props: {
to: account?.address || "",
tokenId: 2n,
})}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
/>
);
}
@@ -18,6 +18,8 @@ const defaultConnectOptions: BridgeComponentsPlaygroundOptions = {
title: "",
transactionData: "",
widget: "buy",
currency: "USD",
showThirdwebBranding: true,
},
theme: {
darkColorOverrides: {},
-8
View File
@@ -51,10 +51,6 @@ const links = [
href: "/insight",
name: "Insight",
},
{
href: "/nebula",
name: "Nebula",
},
{
href: "/vault",
name: "Vault",
@@ -127,10 +123,6 @@ export const connectLinks: Array<{
] as const;
const apisLinks = [
{
href: "/nebula/api-reference",
name: "Nebula",
},
{
href: "https://insight.thirdweb.com/reference",
name: "Insight",
+1 -14
View File
@@ -14,7 +14,6 @@ import { BridgeIcon } from "../icons/products/BridgeIcon";
import { ConnectIcon } from "../icons/products/ConnectIcon";
import { EngineIcon } from "../icons/products/EngineIcon";
import { InsightIcon } from "../icons/products/InsightIcon";
import { NebulaIcon } from "../icons/products/NebulaIcon";
import { PlaygroundIcon } from "../icons/products/PlaygroundIcon";
import { cn } from "../lib/utils";
import DocsHeroDark from "./_images/docs-hero-dark.png";
@@ -148,12 +147,6 @@ function ReferenceSection() {
iconClassName="text-muted-foreground"
title="Bundler"
/>
<SDKCard
href="/nebula/api-reference"
icon={NebulaIcon} // TODO: actual openAPI docs
iconClassName="text-muted-foreground"
title="Nebula"
/>
</Grid>
</section>
);
@@ -194,16 +187,10 @@ function LearningResourcesSection() {
icon={InsightIcon}
title="Insight"
/>
<ArticleCardIndex
description="API interface for LLMs"
href="/nebula"
icon={NebulaIcon}
title="Nebula"
/>
<ArticleCardIndex
description="Non-custodial key management service"
href="/vault"
icon={NebulaIcon}
icon={ConnectIcon}
title="Vault"
/>
</Grid>
+21
View File
@@ -1,5 +1,26 @@
# @thirdweb-dev/nebula
## 0.2.14
### Patch Changes
- Updated dependencies [[`f3234c3`](https://github.com/thirdweb-dev/js/commit/f3234c3c414f2fb4ee41c2394bf52a56fa081f73)]:
- thirdweb@5.105.14
## 0.2.13
### Patch Changes
- Updated dependencies [[`772eb0c`](https://github.com/thirdweb-dev/js/commit/772eb0cc53982951dc6b6baed1c4411bd3e39934), [`446a69b`](https://github.com/thirdweb-dev/js/commit/446a69b1d18b8711324b24377abecdbda10b8a1f)]:
- thirdweb@5.105.13
## 0.2.12
### Patch Changes
- Updated dependencies [[`bfd728a`](https://github.com/thirdweb-dev/js/commit/bfd728a9d1aab7d0044791b218abf9ac9dbc9bf6)]:
- thirdweb@5.105.12
## 0.2.11
### 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.11"
"version": "0.2.14"
}
+20
View File
@@ -1,5 +1,25 @@
# thirdweb
## 5.105.14
### Patch Changes
- [#7581](https://github.com/thirdweb-dev/js/pull/7581) [`f3234c3`](https://github.com/thirdweb-dev/js/commit/f3234c3c414f2fb4ee41c2394bf52a56fa081f73) Thanks [@d4mr](https://github.com/d4mr)! - fix engine server wallet usage with session keys
## 5.105.13
### Patch Changes
- [#7578](https://github.com/thirdweb-dev/js/pull/7578) [`772eb0c`](https://github.com/thirdweb-dev/js/commit/772eb0cc53982951dc6b6baed1c4411bd3e39934) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Handle different fiat currencies in payment widgets
- [#7580](https://github.com/thirdweb-dev/js/pull/7580) [`446a69b`](https://github.com/thirdweb-dev/js/commit/446a69b1d18b8711324b24377abecdbda10b8a1f) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Update wallet definitions
## 5.105.12
### Patch Changes
- [#7575](https://github.com/thirdweb-dev/js/pull/7575) [`bfd728a`](https://github.com/thirdweb-dev/js/commit/bfd728a9d1aab7d0044791b218abf9ac9dbc9bf6) Thanks [@gregfromstl](https://github.com/gregfromstl)! - Adds the ability to hide thirdweb branding in the payment widgets with showThirdwebBranding
## 5.105.11
### Patch Changes
+1 -1
View File
@@ -404,5 +404,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.105.11"
"version": "5.105.14"
}
+1 -1
View File
@@ -7,5 +7,5 @@ export type Token = {
symbol: string;
name: string;
iconUri?: string;
priceUsd: number;
prices: Record<string, number>;
};
@@ -1,4 +1,5 @@
import { verifyTypedData } from "src/auth/verify-typed-data.js";
import { toWei } from "src/utils/units.js";
import { beforeAll, describe, expect, it } from "vitest";
import { TEST_CLIENT } from "../../test/src/test-clients.js";
import { TEST_ACCOUNT_B } from "../../test/src/test-wallets.js";
@@ -8,6 +9,9 @@ import { baseSepolia } from "../chains/chain-definitions/base-sepolia.js";
import { sepolia } from "../chains/chain-definitions/sepolia.js";
import { getContract } from "../contract/contract.js";
import { setContractURI } from "../extensions/common/__generated__/IContractMetadata/write/setContractURI.js";
import { claimTo as claimToERC20 } from "../extensions/erc20/drops/write/claimTo.js";
import { getBalance } from "../extensions/erc20/read/getBalance.js";
import { transfer } from "../extensions/erc20/write/transfer.js";
import { setApprovalForAll } from "../extensions/erc1155/__generated__/IERC1155/write/setApprovalForAll.js";
import { claimTo } from "../extensions/erc1155/drops/write/claimTo.js";
import { getAllActiveSigners } from "../extensions/erc4337/__generated__/IAccountPermissions/read/getAllActiveSigners.js";
@@ -217,7 +221,7 @@ describe.runIf(
).rejects.toThrow();
});
it("should send a session key tx", async () => {
it("should send a basic session key tx", async () => {
const sessionKeyAccountAddress = process.env
.ENGINE_CLOUD_WALLET_ADDRESS_EOA as string;
const personalAccount = await generateAccount({
@@ -272,5 +276,117 @@ describe.runIf(
});
expect(tx).toBeDefined();
});
it("should send a session key tx with ERC20 claiming and transfer", async () => {
// The EOA is the session key signer, ie, it has session key permissions on the generated smart account
const sessionKeyAccountAddress = process.env
.ENGINE_CLOUD_WALLET_ADDRESS_EOA as string;
const personalAccount = await generateAccount({
client: TEST_CLIENT,
});
const smart = smartWallet({
chain: arbitrumSepolia,
sessionKey: {
address: sessionKeyAccountAddress,
permissions: {
approvedTargets: "*",
},
},
sponsorGas: true,
});
const smartAccount = await smart.connect({
client: TEST_CLIENT,
personalAccount,
});
expect(smartAccount.address).toBeDefined();
const signers = await getAllActiveSigners({
contract: getContract({
address: smartAccount.address,
chain: arbitrumSepolia,
client: TEST_CLIENT,
}),
});
expect(signers.map((s) => s.signer)).toContain(sessionKeyAccountAddress);
const serverWallet = Engine.serverWallet({
address: sessionKeyAccountAddress,
chain: arbitrumSepolia,
client: TEST_CLIENT,
executionOptions: {
entrypointVersion: "0.6",
signerAddress: sessionKeyAccountAddress,
smartAccountAddress: smartAccount.address,
type: "ERC4337",
},
vaultAccessToken: process.env.VAULT_TOKEN as string,
});
// Get the ERC20 contract
const erc20Contract = getContract({
// this ERC20 on arbitrumSepolia has infinite free public claim phase
address: "0xd4d3D9261e2da56c4cC618a06dD5BDcB1A7a21d7",
chain: arbitrumSepolia,
client: TEST_CLIENT,
});
// Check initial signer balance
const initialSignerBalance = await getBalance({
address: sessionKeyAccountAddress,
contract: erc20Contract,
});
// Claim 10 tokens to the smart account
const claimTx = claimToERC20({
contract: erc20Contract,
to: smartAccount.address,
quantity: "10",
});
const claimResult = await sendTransaction({
account: serverWallet,
transaction: claimTx,
});
expect(claimResult).toBeDefined();
// Check balance after claim
const balanceAfterClaim = await getBalance({
address: smartAccount.address,
contract: erc20Contract,
});
// Verify the smart account now has 10 tokens (since it started with 0)
expect(balanceAfterClaim.value).toBe(toWei("10"));
// Transfer tokens from smart account to signer
const transferTx = transfer({
contract: erc20Contract,
to: sessionKeyAccountAddress,
amount: "10",
});
const transferResult = await sendTransaction({
account: serverWallet,
transaction: transferTx,
});
expect(transferResult).toBeDefined();
// Check final balances
const finalSmartAccountBalance = await getBalance({
address: smartAccount.address,
contract: erc20Contract,
});
const finalSignerBalance = await getBalance({
address: sessionKeyAccountAddress,
contract: erc20Contract,
});
// Verify the transfer worked correctly
// Smart account should be back to 0 balance
expect(finalSmartAccountBalance.value).toBe(0n);
// Signer should have gained 10 tokens
expect(
BigInt(finalSignerBalance.value) - BigInt(initialSignerBalance.value),
).toBe(toWei("10"));
});
},
);
+11 -1
View File
@@ -266,8 +266,18 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet {
return data.transactions.map((t) => t.id);
};
const getAddress = () => {
if (
executionOptions?.type === "ERC4337" &&
executionOptions.smartAccountAddress
) {
return executionOptions.smartAccountAddress;
}
return address;
};
return {
address,
address: getAddress(),
enqueueBatchTransaction: async (args: {
transactions: PreparedTransaction[];
}) => {
@@ -300,14 +300,14 @@ export async function getBuyWithCryptoQuote(
Number(
Value.format(quote.originAmount, firstStep.originToken.decimals),
) *
firstStep.originToken.priceUsd *
(firstStep.originToken.prices["USD"] || 0) *
100,
amountWei: quote.originAmount.toString(),
token: {
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -323,7 +323,7 @@ export async function getBuyWithCryptoQuote(
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -337,7 +337,7 @@ export async function getBuyWithCryptoQuote(
Number(
Value.format(quote.originAmount, firstStep.originToken.decimals),
) *
firstStep.originToken.priceUsd *
(firstStep.originToken.prices["USD"] || 0) *
100,
gasCostUSDCents: 0,
slippageBPS: 0,
@@ -348,7 +348,7 @@ export async function getBuyWithCryptoQuote(
firstStep.destinationToken.decimals,
),
) *
firstStep.destinationToken.priceUsd *
(firstStep.destinationToken.prices["USD"] || 0) *
100,
toAmountUSDCents:
Number(
@@ -357,7 +357,7 @@ export async function getBuyWithCryptoQuote(
firstStep.destinationToken.decimals,
),
) *
firstStep.destinationToken.priceUsd *
(firstStep.destinationToken.prices["USD"] || 0) *
100,
},
fromAddress: quote.intent.sender,
@@ -372,7 +372,7 @@ export async function getBuyWithCryptoQuote(
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -395,7 +395,7 @@ export async function getBuyWithCryptoQuote(
chainId: firstStep.destinationToken.chainId,
decimals: firstStep.destinationToken.decimals,
name: firstStep.destinationToken.name,
priceUSDCents: firstStep.destinationToken.priceUsd * 100,
priceUSDCents: (firstStep.destinationToken.prices["USD"] || 0) * 100,
symbol: firstStep.destinationToken.symbol,
tokenAddress: firstStep.destinationToken.address,
},
@@ -198,14 +198,14 @@ export async function getBuyWithCryptoTransfer(
Number(
Value.format(quote.originAmount, firstStep.originToken.decimals),
) *
firstStep.originToken.priceUsd *
(firstStep.originToken.prices["USD"] || 0) *
100,
amountWei: quote.originAmount.toString(),
token: {
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -226,7 +226,7 @@ export async function getBuyWithCryptoTransfer(
firstStep.originToken.decimals,
),
) *
firstStep.originToken.priceUsd *
(firstStep.originToken.prices["USD"] || 0) *
100
: 0,
amountWei:
@@ -237,7 +237,7 @@ export async function getBuyWithCryptoTransfer(
chainId: firstStep.originToken.chainId,
decimals: firstStep.originToken.decimals,
name: firstStep.originToken.name,
priceUSDCents: firstStep.originToken.priceUsd * 100,
priceUSDCents: (firstStep.originToken.prices["USD"] || 0) * 100,
symbol: firstStep.originToken.symbol,
tokenAddress: firstStep.originToken.address,
},
@@ -367,12 +367,12 @@ export async function getBuyWithFiatQuote(
decimals: number;
symbol: string;
name: string;
priceUsd: number;
prices: Record<string, number>;
}): PayTokenInfo => ({
chainId: token.chainId,
decimals: token.decimals,
name: token.name,
priceUSDCents: Math.round(token.priceUsd * 100),
priceUSDCents: Math.round((token.prices["USD"] || 0) * 100),
symbol: token.symbol,
tokenAddress: token.address,
});
@@ -408,7 +408,7 @@ export async function getBuyWithFiatQuote(
const onRampTokenObject = {
amount: onRampTokenAmount,
amountUSDCents: Math.round(
Number(onRampTokenAmount) * onRampTokenRaw.priceUsd * 100,
Number(onRampTokenAmount) * (onRampTokenRaw.prices["USD"] || 0) * 100,
),
amountWei: onRampTokenAmountWei.toString(),
token: tokenToPayTokenInfo(onRampTokenRaw),
@@ -434,7 +434,7 @@ export async function getBuyWithFiatQuote(
routingTokenObject = {
amount: routingAmount,
amountUSDCents: Math.round(
Number(routingAmount) * routingTokenRaw.priceUsd * 100,
Number(routingAmount) * (routingTokenRaw.prices["USD"] || 0) * 100,
),
amountWei: routingAmountWei.toString(),
token: tokenToPayTokenInfo(routingTokenRaw),
@@ -26,7 +26,6 @@ export type ConvertCryptoToFiatParams = {
chain: Chain;
/**
* The fiat symbol. e.g "USD"
* Only USD is supported at the moment.
*/
to: SupportedFiatCurrency;
};
@@ -56,7 +55,7 @@ export type ConvertCryptoToFiatParams = {
export async function convertCryptoToFiat(
options: ConvertCryptoToFiatParams,
): Promise<{ result: number }> {
const { client, fromTokenAddress, chain, fromAmount } = options;
const { client, fromTokenAddress, chain, fromAmount, to } = options;
if (Number(fromAmount) === 0) {
return { result: 0 };
}
@@ -74,10 +73,11 @@ export async function convertCryptoToFiat(
);
}
const token = await getToken(client, fromTokenAddress, chain.id);
if (token.priceUsd === 0) {
const price = token?.prices[to] || 0;
if (!token || price === 0) {
throw new Error(
`Error: Failed to fetch price for token ${fromTokenAddress} on chainId: ${chain.id}`,
);
}
return { result: token.priceUsd * fromAmount };
return { result: price * fromAmount };
}
@@ -13,7 +13,6 @@ export type ConvertFiatToCryptoParams = {
client: ThirdwebClient;
/**
* The fiat symbol. e.g: "USD"
* Currently only USD is supported.
*/
from: SupportedFiatCurrency;
/**
@@ -57,7 +56,7 @@ export type ConvertFiatToCryptoParams = {
export async function convertFiatToCrypto(
options: ConvertFiatToCryptoParams,
): Promise<{ result: number }> {
const { client, to, chain, fromAmount } = options;
const { client, to, chain, fromAmount, from } = options;
if (Number(fromAmount) === 0) {
return { result: 0 };
}
@@ -73,10 +72,11 @@ export async function convertFiatToCrypto(
throw new Error("Invalid `to`. Expected a valid EVM contract address");
}
const token = await getToken(client, to, chain.id);
if (!token || token.priceUsd === 0) {
const price = token?.prices[from] || 0;
if (!token || price === 0) {
throw new Error(
`Error: Failed to fetch price for token ${to} on chainId: ${chain.id}`,
);
}
return { result: fromAmount / token.priceUsd };
return { result: fromAmount / price };
}
+21 -7
View File
@@ -1,16 +1,30 @@
const SUPPORTED_FIAT_CURRENCIES = [
const CURRENCIES = [
"USD",
"CAD",
"GBP",
"EUR",
"GBP",
"JPY",
"KRW",
"CNY",
"INR",
"NOK",
"SEK",
"CHF",
"AUD",
"CAD",
"NZD",
"MXN",
"BRL",
"CLP",
"CZK",
"DKK",
"HKD",
"HUF",
"IDR",
"ILS",
"ISK",
] as const;
/**
* @internal
*/
export type SupportedFiatCurrency = (typeof SUPPORTED_FIAT_CURRENCIES)[number];
export type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {});
export function getFiatSymbol(showBalanceInFiat: SupportedFiatCurrency) {
switch (showBalanceInFiat) {
@@ -106,7 +106,9 @@ export function usePaymentMethods(options: {
decimals: b.decimals,
iconUri: "",
name: b.name,
priceUsd: 0,
prices: {
USD: 0,
},
symbol: b.symbol,
} as Token,
}));
@@ -183,10 +185,10 @@ export function usePaymentMethods(options: {
validOwnedTokens.sort((a, b) => {
const aDollarBalance =
Number.parseFloat(toTokens(a.balance, a.originToken.decimals)) *
a.originToken.priceUsd;
(a.originToken.prices["USD"] || 0);
const bDollarBalance =
Number.parseFloat(toTokens(b.balance, b.originToken.decimals)) *
b.originToken.priceUsd;
(b.originToken.prices["USD"] || 0);
return bDollarBalance - aDollarBalance;
});
@@ -9,6 +9,7 @@ import { getCompilerMetadata } from "../../../contract/actions/get-compiler-meta
import { getContract } from "../../../contract/contract.js";
import { decimals } from "../../../extensions/erc20/read/decimals.js";
import { getToken } from "../../../pay/convert/get-token.js";
import type { SupportedFiatCurrency } from "../../../pay/convert/type.js";
import { encode } from "../../../transaction/actions/encode.js";
import type { PreparedTransaction } from "../../../transaction/prepare-transaction.js";
import { getTransactionGasCost } from "../../../transaction/utils.js";
@@ -43,6 +44,7 @@ interface UseTransactionDetailsOptions {
transaction: PreparedTransaction;
client: ThirdwebClient;
wallet: Wallet | undefined;
currency?: SupportedFiatCurrency;
}
/**
@@ -51,6 +53,7 @@ interface UseTransactionDetailsOptions {
*/
export function useTransactionDetails({
transaction,
currency,
client,
wallet,
}: UseTransactionDetailsOptions) {
@@ -153,9 +156,8 @@ export function useTransactionDetails({
: (value || 0n) + (gasCostWei || 0n);
const totalCost = toTokens(totalCostWei, decimal);
const usdValue = tokenInfo?.priceUsd
? Number(totalCost) * tokenInfo.priceUsd
: null;
const price = tokenInfo?.prices[currency || "USD"] || 0;
const usdValue = price ? Number(totalCost) * price : null;
return {
contractMetadata,
@@ -170,7 +172,7 @@ export function useTransactionDetails({
totalCostWei,
txCostDisplay: `${formatTokenAmount(costWei, decimal)} ${tokenSymbol}`,
usdValueDisplay: usdValue
? formatCurrencyAmount("USD", usdValue)
? formatCurrencyAmount(currency || "USD", usdValue)
: null,
};
},
@@ -2,6 +2,7 @@
import { useCallback, useMemo } from "react";
import type { Token } from "../../../../bridge/types/Token.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js";
import type { PurchaseData } from "../../../../pay/types.js";
import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js";
import type { Address } from "../../../../utils/address.js";
@@ -39,6 +40,7 @@ export type UIOptions = Prettify<
description?: string;
image?: string;
};
currency?: SupportedFiatCurrency;
} & (
| {
mode: "fund_wallet";
@@ -120,6 +122,12 @@ export interface BridgeOrchestratorProps {
* @default ["crypto", "card"]
*/
paymentMethods?: ("crypto" | "card")[];
/**
* Whether to show thirdweb branding in the widget.
* @default true
*/
showThirdwebBranding?: boolean;
}
export function BridgeOrchestrator({
@@ -135,6 +143,7 @@ export function BridgeOrchestrator({
paymentLinkId,
presetOptions,
paymentMethods = ["crypto", "card"],
showThirdwebBranding = true,
}: BridgeOrchestratorProps) {
// Initialize adapters
const adapters = useMemo(
@@ -145,6 +154,18 @@ export function BridgeOrchestrator({
[],
);
// Create modified connect options with branding setting
const modifiedConnectOptions = useMemo(() => {
if (!connectOptions) return undefined;
return {
...connectOptions,
connectModal: {
...connectOptions.connectModal,
showThirdwebBranding,
},
};
}, [connectOptions, showThirdwebBranding]);
// Use the payment machine hook
const [state, send] = usePaymentMachine(adapters, uiOptions.mode);
@@ -239,10 +260,11 @@ export function BridgeOrchestrator({
{state.value === "init" && uiOptions.mode === "fund_wallet" && (
<FundWallet
client={client}
connectOptions={connectOptions}
connectOptions={modifiedConnectOptions}
onContinue={handleRequirementsResolved}
presetOptions={presetOptions}
receiverAddress={receiverAddress}
showThirdwebBranding={showThirdwebBranding}
uiOptions={uiOptions}
/>
)}
@@ -250,8 +272,9 @@ export function BridgeOrchestrator({
{state.value === "init" && uiOptions.mode === "direct_payment" && (
<DirectPayment
client={client}
connectOptions={connectOptions}
connectOptions={modifiedConnectOptions}
onContinue={handleRequirementsResolved}
showThirdwebBranding={showThirdwebBranding}
uiOptions={uiOptions}
/>
)}
@@ -259,8 +282,9 @@ export function BridgeOrchestrator({
{state.value === "init" && uiOptions.mode === "transaction" && (
<TransactionPayment
client={client}
connectOptions={connectOptions}
connectOptions={modifiedConnectOptions}
onContinue={handleRequirementsResolved}
showThirdwebBranding={showThirdwebBranding}
uiOptions={uiOptions}
/>
)}
@@ -272,7 +296,7 @@ export function BridgeOrchestrator({
<PaymentSelection
client={client}
connectLocale={connectLocale || en}
connectOptions={connectOptions}
connectOptions={modifiedConnectOptions}
destinationAmount={state.context.destinationAmount}
destinationToken={state.context.destinationToken}
feePayer={
@@ -288,6 +312,7 @@ export function BridgeOrchestrator({
onPaymentMethodSelected={handlePaymentMethodSelected}
paymentMethods={paymentMethods}
receiverAddress={state.context.receiverAddress}
currency={uiOptions.currency}
/>
)}
@@ -7,6 +7,7 @@ import type { Chain } from "../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js";
import { getToken } from "../../../../pay/convert/get-token.js";
import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js";
import type { PurchaseData } from "../../../../pay/types.js";
import {
type Address,
@@ -102,6 +103,12 @@ export type BuyWidgetProps = {
className?: string;
/**
* Whether to show thirdweb branding in the widget.
* @default true
*/
showThirdwebBranding?: boolean;
/**
* The chain the accepted token is on.
*/
@@ -167,6 +174,12 @@ export type BuyWidgetProps = {
* @default ["crypto", "card"]
*/
paymentMethods?: ("crypto" | "card")[];
/**
* The currency to use for the payment.
* @default "USD"
*/
currency?: SupportedFiatCurrency;
};
// Enhanced UIOptions to handle unsupported token state
@@ -310,6 +323,7 @@ export function BuyWidget(props: BuyWidgetProps) {
title: props.title,
},
mode: "fund_wallet",
currency: props.currency || "USD",
},
type: "success",
};
@@ -391,6 +405,7 @@ export function BuyWidget(props: BuyWidgetProps) {
purchaseData={props.purchaseData}
receiverAddress={undefined}
uiOptions={bridgeDataQuery.data.data}
showThirdwebBranding={props.showThirdwebBranding}
/>
);
}
@@ -7,6 +7,7 @@ import type { Chain } from "../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js";
import { getToken } from "../../../../pay/convert/get-token.js";
import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js";
import type { PurchaseData } from "../../../../pay/types.js";
import { type Address, checksumAddress } from "../../../../utils/address.js";
import { stringify } from "../../../../utils/json.js";
@@ -98,6 +99,12 @@ export type CheckoutWidgetProps = {
className?: string;
/**
* Whether to show thirdweb branding in the widget.
* @default true
*/
showThirdwebBranding?: boolean;
/**
* The chain the accepted token is on.
*/
@@ -173,6 +180,12 @@ export type CheckoutWidgetProps = {
* @default ["crypto", "card"]
*/
paymentMethods?: ("crypto" | "card")[];
/**
* The currency to use for the payment.
* @default "USD"
*/
currency?: SupportedFiatCurrency;
};
// Enhanced UIOptions to handle unsupported token state
@@ -296,6 +309,7 @@ export function CheckoutWidget(props: CheckoutWidgetProps) {
title: props.name,
},
mode: "direct_payment",
currency: props.currency || "USD",
paymentInfo: {
amount: props.amount,
feePayer: props.feePayer === "seller" ? "receiver" : "sender",
@@ -353,6 +367,7 @@ export function CheckoutWidget(props: CheckoutWidgetProps) {
presetOptions={props.presetOptions}
purchaseData={props.purchaseData}
receiverAddress={props.seller}
showThirdwebBranding={props.showThirdwebBranding}
uiOptions={bridgeDataQuery.data.data}
/>
);
@@ -2,10 +2,9 @@
import type { Token } from "../../../../bridge/types/Token.js";
import { defineChain } from "../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import { type Address, shortenAddress } from "../../../../utils/address.js";
import type { Address } from "../../../../utils/address.js";
import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js";
import { useActiveAccount } from "../../../core/hooks/wallets/useActiveAccount.js";
import { useEnsName } from "../../../core/utils/wallet.js";
import { ConnectButton } from "../ConnectWallet/ConnectButton.js";
import { PoweredByThirdweb } from "../ConnectWallet/PoweredByTW.js";
import { FiatValue } from "../ConnectWallet/screens/Buy/swap/FiatValue.js";
@@ -39,6 +38,12 @@ export interface DirectPaymentProps {
* Connect options for wallet connection
*/
connectOptions?: PayEmbedConnectOptions;
/**
* Whether to show thirdweb branding in the widget.
* @default true
*/
showThirdwebBranding?: boolean;
}
export function DirectPayment({
@@ -46,6 +51,7 @@ export function DirectPayment({
client,
onContinue,
connectOptions,
showThirdwebBranding = true,
}: DirectPaymentProps) {
const activeAccount = useActiveAccount();
const chain = defineChain(uiOptions.paymentInfo.token.chainId);
@@ -57,12 +63,6 @@ export function DirectPayment({
uiOptions.paymentInfo.sellerAddress,
);
};
const ensName = useEnsName({
address: uiOptions.paymentInfo.sellerAddress,
client,
});
const sellerAddress =
ensName.data || shortenAddress(uiOptions.paymentInfo.sellerAddress);
const buyNow = (
<Container flex="row" gap="3xs">
@@ -70,6 +70,7 @@ export function DirectPayment({
Buy Now ·
</Text>
<FiatValue
currency={uiOptions.currency}
chain={chain}
client={client}
color="primaryButtonText"
@@ -96,6 +97,7 @@ export function DirectPayment({
}}
>
<FiatValue
currency={uiOptions.currency}
chain={chain}
client={client}
color="primaryText"
@@ -125,30 +127,6 @@ export function DirectPayment({
<Spacer y="md" />
{/* Seller section */}
<Container
flex="row"
style={{
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text color="secondaryText" size="sm">
Sold by
</Text>
<Text
color="primaryText"
size="sm"
style={{
fontFamily: "monospace",
}}
>
{sellerAddress}
</Text>
</Container>
<Spacer y="xs" />
<Container
flex="row"
style={{
@@ -224,9 +202,12 @@ export function DirectPayment({
/>
)}
<Spacer y="md" />
<PoweredByThirdweb />
{showThirdwebBranding ? (
<div>
<Spacer y="md" />
<PoweredByThirdweb />
</div>
) : null}
<Spacer y="lg" />
</Container>
</WithHeader>
@@ -45,6 +45,7 @@ export function ErrorBanner({
error: error.message,
event: "ub:ui:error",
});
return true;
},
queryKey: ["error_banner", userMessage],
});
@@ -17,6 +17,7 @@ import { ConnectButton } from "../ConnectWallet/ConnectButton.js";
import { OutlineWalletIcon } from "../ConnectWallet/icons/OutlineWalletIcon.js";
import { PoweredByThirdweb } from "../ConnectWallet/PoweredByTW.js";
import { WalletRow } from "../ConnectWallet/screens/Buy/swap/WalletRow.js";
import { formatCurrencyAmount } from "../ConnectWallet/screens/formatTokenBalance.js";
import { Container } from "../components/basic.js";
import { Button } from "../components/buttons.js";
import { Input } from "../components/formElements.js";
@@ -56,6 +57,12 @@ export interface FundWalletProps {
* Connect options for wallet connection
*/
connectOptions?: PayEmbedConnectOptions;
/**
* Whether to show thirdweb branding in the widget.
* @default true
*/
showThirdwebBranding?: boolean;
}
export function FundWallet({
@@ -65,6 +72,7 @@ export function FundWallet({
onContinue,
presetOptions = [5, 10, 20],
connectOptions,
showThirdwebBranding = true,
}: FundWalletProps) {
const [amount, setAmount] = useState(uiOptions.initialAmount ?? "");
const theme = useCustomTheme();
@@ -108,11 +116,13 @@ export function FundWallet({
};
const handleQuickAmount = (usdAmount: number) => {
if (uiOptions.destinationToken.priceUsd === 0) {
const price =
uiOptions.destinationToken.prices[uiOptions.currency || "USD"] || 0;
if (price === 0) {
return;
}
// Convert USD amount to token amount using token price
const tokenAmount = usdAmount / uiOptions.destinationToken.priceUsd;
const tokenAmount = usdAmount / price;
// Format to reasonable decimal places (up to 6 decimals, remove trailing zeros)
const formattedAmount = numberToPlainString(
Number.parseFloat(tokenAmount.toFixed(6)),
@@ -221,9 +231,13 @@ export function FundWallet({
size="md"
style={{ textWrap: "nowrap" }}
>
≈ $
{(Number(amount) * uiOptions.destinationToken.priceUsd).toFixed(
2,
≈{" "}
{formatCurrencyAmount(
uiOptions.currency || "USD",
Number(amount) *
(uiOptions.destinationToken.prices[
uiOptions.currency || "USD"
] || 0),
)}
</Text>
</Container>
@@ -335,9 +349,12 @@ export function FundWallet({
/>
)}
<Spacer y="md" />
<PoweredByThirdweb />
{showThirdwebBranding ? (
<div>
<Spacer y="md" />
<PoweredByThirdweb />
</div>
) : null}
<Spacer y="lg" />
</WithHeader>
);
@@ -48,6 +48,12 @@ export interface TransactionPaymentProps {
* Connect options for wallet connection
*/
connectOptions?: PayEmbedConnectOptions;
/**
* Whether to show thirdweb branding in the widget.
* @default true
*/
showThirdwebBranding?: boolean;
}
export function TransactionPayment({
@@ -55,6 +61,7 @@ export function TransactionPayment({
client,
onContinue,
connectOptions,
showThirdwebBranding = true,
}: TransactionPaymentProps) {
const theme = useCustomTheme();
const activeAccount = useActiveAccount();
@@ -121,10 +128,13 @@ export function TransactionPayment({
}}
/>
<Spacer y="md" />
<PoweredByThirdweb />
<Spacer y="md" />
{showThirdwebBranding ? (
<div>
<Spacer y="md" />
<PoweredByThirdweb />
<Spacer y="md" />
</div>
) : null}
</WithHeader>
);
}
@@ -342,9 +352,12 @@ export function TransactionPayment({
/>
)}
<Spacer y="md" />
<PoweredByThirdweb />
{showThirdwebBranding ? (
<div>
<Spacer y="md" />
<PoweredByThirdweb />
</div>
) : null}
<Spacer y="lg" />
</WithHeader>
);
@@ -7,6 +7,7 @@ import type { Chain } from "../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js";
import { getToken } from "../../../../pay/convert/get-token.js";
import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js";
import type { PurchaseData } from "../../../../pay/types.js";
import {
type PreparedTransaction,
@@ -106,6 +107,12 @@ export type TransactionWidgetProps = {
className?: string;
/**
* Whether to show thirdweb branding in the widget.
* @default true
*/
showThirdwebBranding?: boolean;
/**
* The token address needed to complete this transaction. Leave undefined if no token is required.
*/
@@ -176,6 +183,12 @@ export type TransactionWidgetProps = {
* @default ["crypto", "card"]
*/
paymentMethods?: ("crypto" | "card")[];
/**
* The currency to use for the payment.
* @default "USD"
*/
currency?: SupportedFiatCurrency;
};
// Enhanced UIOptions to handle unsupported token state
@@ -335,6 +348,7 @@ export function TransactionWidget(props: TransactionWidgetProps) {
return {
data: {
currency: props.currency || "USD",
metadata: {
description: props.description,
image: props.image,
@@ -413,6 +427,7 @@ export function TransactionWidget(props: TransactionWidgetProps) {
purchaseData={props.purchaseData}
receiverAddress={undefined}
uiOptions={bridgeDataQuery.data.data}
showThirdwebBranding={props.showThirdwebBranding}
/>
);
}
@@ -2,6 +2,7 @@ import styled from "@emotion/styled";
import type { Token } from "../../../../../bridge/index.js";
import { getCachedChain } from "../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
import { spacing } from "../../../../core/design-system/index.js";
import { FiatValue } from "../../ConnectWallet/screens/Buy/swap/FiatValue.js";
@@ -16,12 +17,14 @@ export function TokenBalanceRow({
amount,
onClick,
style,
currency,
}: {
client: ThirdwebClient;
token: Token;
amount: string;
onClick: (token: Token) => void;
style?: React.CSSProperties;
currency?: SupportedFiatCurrency;
}) {
const chain = getCachedChain(token.chainId);
return (
@@ -66,6 +69,7 @@ export function TokenBalanceRow({
}}
>
<FiatValue
currency={currency}
chain={chain}
client={client}
color="primaryText"
@@ -63,6 +63,7 @@ export function PaymentOverview(props: {
)}
{props.paymentMethod.type === "wallet" && (
<TokenBalanceRow
currency={props.uiOptions.currency}
amount={props.fromAmount}
client={props.client}
onClick={() => {}}
@@ -158,6 +159,7 @@ export function PaymentOverview(props: {
style={{ alignItems: "flex-end" }}
>
<FiatValue
currency={props.uiOptions.currency}
chain={defineChain(props.toToken.chainId)}
client={props.client}
color="primaryText"
@@ -174,6 +176,7 @@ export function PaymentOverview(props: {
)}
{props.uiOptions.mode === "fund_wallet" && (
<TokenBalanceRow
currency={props.uiOptions.currency}
amount={props.toAmount}
client={props.client}
onClick={() => {}}
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import type { ThirdwebClient } from "../../../../../client/client.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { checksumAddress } from "../../../../../utils/address.js";
import { toTokens } from "../../../../../utils/units.js";
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
@@ -24,6 +25,7 @@ interface FiatProviderSelectionProps {
toTokenAddress: string;
toAddress: string;
toAmount?: string;
currency?: SupportedFiatCurrency;
}
const PROVIDERS = [
@@ -54,6 +56,7 @@ export function FiatProviderSelection({
toTokenAddress,
toAddress,
toAmount,
currency,
}: FiatProviderSelectionProps) {
const theme = useCustomTheme();
@@ -62,7 +65,7 @@ export function FiatProviderSelection({
amount: toAmount || "0",
chainId: toChainId,
client,
currency: "USD",
currency: currency || "USD",
receiver: checksumAddress(toAddress),
tokenAddress: checksumAddress(toTokenAddress),
});
@@ -5,6 +5,7 @@ import { trackPayEvent } from "../../../../../analytics/track/pay.js";
import type { Token } from "../../../../../bridge/types/Token.js";
import { defineChain } from "../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import type { Address } from "../../../../../utils/address.js";
import { toUnits } from "../../../../../utils/units.js";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.js";
@@ -82,6 +83,12 @@ export interface PaymentSelectionProps {
* Fee payer
*/
feePayer?: "sender" | "receiver";
/**
* The currency to use for the payment.
* @default "USD"
*/
currency?: SupportedFiatCurrency;
}
type Step =
@@ -103,6 +110,7 @@ export function PaymentSelection({
includeDestinationToken,
paymentMethods = ["crypto", "card"],
feePayer,
currency,
}: PaymentSelectionProps) {
const connectedWallets = useConnectedWallets();
const activeWallet = useActiveWallet();
@@ -290,6 +298,7 @@ export function PaymentSelection({
toAmount={destinationAmount}
toChainId={destinationToken.chainId}
toTokenAddress={destinationToken.address}
currency={currency}
/>
)}
</Container>
@@ -2,12 +2,13 @@ import { useQuery } from "@tanstack/react-query";
import type { Chain } from "../../../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js";
import { convertCryptoToFiat } from "../../../../../../../pay/convert/cryptoToFiat.js";
import { formatNumber } from "../../../../../../../utils/formatNumber.js";
import type { SupportedFiatCurrency } from "../../../../../../../pay/convert/type.js";
import { fontSize } from "../../../../../../core/design-system/index.js";
import { Skeleton } from "../../../../components/Skeleton.js";
import type { TextProps } from "../../../../components/text.js";
import { Text } from "../../../../components/text.js";
import { useDebouncedValue } from "../../../../hooks/useDebouncedValue.js";
import { formatCurrencyAmount } from "../../formatTokenBalance.js";
import type { ERC20OrNativeToken } from "../../nativeToken.js";
import { getTokenAddress } from "../../nativeToken.js";
@@ -17,6 +18,7 @@ export function FiatValue(
token: ERC20OrNativeToken;
chain: Chain;
client: ThirdwebClient;
currency?: SupportedFiatCurrency;
} & TextProps,
) {
const deferredTokenAmount = useDebouncedValue(props.tokenAmount, 500);
@@ -27,7 +29,7 @@ export function FiatValue(
client: props.client,
fromAmount: Number(deferredTokenAmount),
fromTokenAddress: getTokenAddress(props.token),
to: "USD",
to: props.currency || "USD",
}),
queryKey: [
"cryptoToFiat",
@@ -43,13 +45,10 @@ export function FiatValue(
return cryptoToFiatQuery.data?.result ? (
<Text {...props}>
$
{Number(
formatNumber(cryptoToFiatQuery.data.result, 2).toFixed(2),
).toLocaleString(undefined, {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})}
{formatCurrencyAmount(
props.currency || "USD",
cryptoToFiatQuery.data.result,
)}
</Text>
) : null;
}
@@ -1,6 +1,5 @@
import { formatNumber } from "../../../../../utils/formatNumber.js";
import { toTokens } from "../../../../../utils/units.js";
import { getCurrencyMeta } from "./Buy/fiat/currencies.js";
/**
* @internal
@@ -34,11 +33,17 @@ export function formatTokenAmount(
).toString();
}
export function formatCurrencyAmount(
currency: string,
amount: number,
decimals = 2,
) {
const symbol = getCurrencyMeta(currency).symbol;
return `${symbol}${formatNumber(amount, decimals).toFixed(decimals)}`;
export function formatCurrencyAmount(currency: string, amount: number) {
return formatMoney(amount, "en-US", currency);
}
function formatMoney(
value: number,
locale: string,
currencyCode: string,
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currencyCode,
}).format(value);
}
@@ -26,7 +26,9 @@ export const ETH: Token = {
iconUri:
"https://coin-images.coingecko.com/coins/images/279/large/ethereum.png",
name: "Ethereum",
priceUsd: 1000,
prices: {
USD: 1000,
},
symbol: "ETH",
};
@@ -37,7 +39,9 @@ export const USDC: Token = {
iconUri:
"https://coin-images.coingecko.com/coins/images/6319/large/USD_Coin_icon.png",
name: "USD Coin",
priceUsd: 1,
prices: {
USD: 1,
},
symbol: "USDC",
};
@@ -48,7 +52,9 @@ export const UNI: Token = {
iconUri:
"https://coin-images.coingecko.com/coins/images/12504/large/uniswap-uni.png",
name: "Uniswap",
priceUsd: 1000,
prices: {
USD: 1000,
},
symbol: "UNI",
};
@@ -93,7 +99,9 @@ export const simpleOnrampQuote: BridgePrepareResult = JSON.parse(
iconUri:
"https://assets.coingecko.com/coins/images/6319/large/USD_Coin_icon.png",
name: "USD Coin (PoS)",
priceUsd: 1.0,
prices: {
USD: 1.0,
},
symbol: "USDC",
},
id: "onramp-simple-123",
@@ -122,7 +130,9 @@ export const onrampWithSwapsQuote: BridgePrepareResult = JSON.parse(
chainId: 1,
decimals: 18,
name: "Ethereum",
priceUsd: 2500.0,
prices: {
USD: 2500.0,
},
symbol: "ETH",
},
id: "onramp-swaps-456",
@@ -143,7 +153,9 @@ export const onrampWithSwapsQuote: BridgePrepareResult = JSON.parse(
chainId: 137,
decimals: 18,
name: "Wrapped Ether",
priceUsd: 2500.0,
prices: {
USD: 2500.0,
},
symbol: "WETH",
},
estimatedExecutionTimeMs: 30000, // 110 USDC
@@ -155,7 +167,9 @@ export const onrampWithSwapsQuote: BridgePrepareResult = JSON.parse(
iconUri:
"https://assets.coingecko.com/coins/images/6319/large/USD_Coin_icon.png",
name: "USD Coin (PoS)",
priceUsd: 1.0,
prices: {
USD: 1.0,
},
symbol: "USDC",
},
transactions: [
@@ -186,7 +200,9 @@ export const onrampWithSwapsQuote: BridgePrepareResult = JSON.parse(
chainId: 1,
decimals: 18,
name: "Ethereum",
priceUsd: 2500.0,
prices: {
USD: 2500.0,
},
symbol: "ETH",
},
estimatedExecutionTimeMs: 180000, // 0.044 WETH
@@ -196,7 +212,9 @@ export const onrampWithSwapsQuote: BridgePrepareResult = JSON.parse(
chainId: 137,
decimals: 18,
name: "Wrapped Ether",
priceUsd: 2500.0,
prices: {
USD: 2500.0,
},
symbol: "WETH",
},
transactions: [
@@ -251,7 +269,9 @@ export const simpleBuyQuote: BridgePrepareResult = JSON.parse(
iconUri:
"https://assets.coingecko.com/coins/images/6319/large/USD_Coin_icon.png",
name: "USD Coin",
priceUsd: 1.0,
prices: {
USD: 1.0,
},
symbol: "USDC",
},
estimatedExecutionTimeMs: 60000,
@@ -263,7 +283,9 @@ export const simpleBuyQuote: BridgePrepareResult = JSON.parse(
iconUri:
"https://assets.coingecko.com/coins/images/6319/large/USD_Coin_icon.png",
name: "Ethereum",
priceUsd: 2500.0,
prices: {
USD: 2500.0,
},
symbol: "ETH",
},
transactions: [
@@ -309,7 +331,9 @@ export const longTextBuyQuote: BridgePrepareResult = JSON.parse(
iconUri:
"https://assets.coingecko.com/coins/images/6319/large/USD_Coin_icon.png",
name: "USD Coin (USDC.e on Etherlink)",
priceUsd: 1.0,
prices: {
USD: 1.0,
},
symbol: "USDC.e",
},
estimatedExecutionTimeMs: 60000,
+19
View File
@@ -72,6 +72,25 @@ describe("getClientFetch", () => {
expect(headers.get("authorization")).toBe(null);
});
it("should send clientId, teamId and jwt for bundler requests", () => {
vi.spyOn(global, "fetch").mockResolvedValue(new Response());
const clientFetch = getClientFetch({
clientId: "test-client-id",
secretKey: "foo.bar.baz",
teamId: "test-team-id",
});
clientFetch("https://84532.bundler.thirdweb-dev.com/v2", {
useAuthToken: true, // bundler requests have useAuthToken set to true
});
// biome-ignore lint/suspicious/noExplicitAny: `any` type ok for tests
const headers = (global.fetch as any).mock.calls[0][1].headers;
expect(headers.get("x-client-id")).toBe("test-client-id");
expect(headers.get("x-team-id")).toBe("test-team-id");
expect(headers.get("authorization")).toBe("Bearer foo.bar.baz");
});
it("should send a bearer token if secret key is a JWT and useAuthToken is true", () => {
vi.spyOn(global, "fetch").mockResolvedValue(new Response());
const clientFetch = getClientFetch({
+12 -3
View File
@@ -57,13 +57,22 @@ export function getClientFetch(client: ThirdwebClient, ecosystem?: Ecosystem) {
: undefined;
const clientId = client.clientId;
if (authToken && isBundlerUrl(urlString)) {
headers.set("authorization", `Bearer ${authToken}`);
if (client.teamId) {
headers.set("x-team-id", client.teamId);
}
if (clientId) {
headers.set("x-client-id", clientId);
}
}
// if we have an auth token set, use that (thirdweb dashboard sets this for the user)
// pay urls should never send the auth token, because we always want the "developer" to be the one making the request, not the "end user"
if (
else if (
authToken &&
!isPayUrl(urlString) &&
!isInAppWalletUrl(urlString) &&
!isBundlerUrl(urlString)
!isInAppWalletUrl(urlString)
) {
headers.set("authorization", `Bearer ${authToken}`);
// if we have a specific teamId set, add it to the request headers
+2 -1
View File
@@ -434,12 +434,13 @@ export type WCSupportedWalletIds =
| "io.walletverse"
| "com.berasig";
// 70 wallets
// 71 wallets
export type InjectedSupportedWalletIds =
| "com.trustwallet.app"
| "io.metamask"
| "com.binance.wallet"
| "com.okex.wallet"
| "com.bitget.web3"
| "pro.tokenpocket"
| "com.bybit"
| "io.zerion.wallet"
File diff suppressed because it is too large Load Diff
@@ -2,31 +2,31 @@
// Do not modify this file manually.
export const wallet = {
id: "ag.jup",
name: "Jupiter",
homepage: "https://jup.ag/mobile",
image_id: "7601bec3-144f-4c9d-30a1-2d4a372ede00",
app: {
browser: null,
ios: "https://apps.apple.com/us/app/jupiter-solana-swap-wallet/id6484069059",
android:
"https://play.google.com/store/apps/details?id=ag.jup.jupiter.android",
browser: null,
chrome: null,
edge: null,
firefox: null,
ios: "https://apps.apple.com/us/app/jupiter-solana-swap-wallet/id6484069059",
linux: null,
mac: null,
opera: null,
safari: null,
windows: null,
linux: null,
chrome: null,
firefox: null,
safari: null,
edge: null,
opera: null,
},
rdns: null,
mobile: {
native: "jupiter://",
universal: null,
},
desktop: {
native: null,
universal: null,
},
homepage: "https://jup.ag/mobile",
id: "ag.jup",
image_id: "7601bec3-144f-4c9d-30a1-2d4a372ede00",
mobile: {
native: "jupiter://",
universal: null,
},
name: "Jupiter",
rdns: null,
} as const;
@@ -2,31 +2,31 @@
// Do not modify this file manually.
export const wallet = {
id: "ai.hacken",
name: "hAI by Hacken",
homepage: "https://hacken.ai",
image_id: "d2ff6baf-5051-4bf2-edec-32450b451000",
app: {
browser: "https://hacken.ai",
ios: "https://apps.apple.com/us/app/hai-by-hacken/id1501384789",
android:
"https://play.google.com/store/apps/details?id=com.hackenai.hackenaiapp&hl=en_US",
browser: "https://hacken.ai",
chrome: null,
edge: null,
firefox: null,
ios: "https://apps.apple.com/us/app/hai-by-hacken/id1501384789",
linux: null,
mac: null,
opera: null,
safari: null,
windows: null,
linux: null,
chrome: null,
firefox: null,
safari: null,
edge: null,
opera: null,
},
rdns: null,
mobile: {
native: "hai://",
universal: "https://hacken.ai",
},
desktop: {
native: null,
universal: null,
},
homepage: "https://hacken.ai",
id: "ai.hacken",
image_id: "d2ff6baf-5051-4bf2-edec-32450b451000",
mobile: {
native: "hai://",
universal: "https://hacken.ai",
},
name: "hAI by Hacken",
rdns: null,
} as const;
@@ -2,31 +2,31 @@
// Do not modify this file manually.
export const wallet = {
id: "ai.pundi",
name: "Pundi Wallet",
homepage: "https://pundi.ai/",
image_id: "f095abd7-575e-4851-cf9c-79c2c4e4cc00",
app: {
browser: null,
ios: "https://apps.apple.com/us/app/f-x-wallet-by-function-x-labs/id1504798360",
android:
"https://play.google.com/store/apps/details?id=com.pundix.functionx",
browser: null,
chrome: null,
edge: null,
firefox: null,
ios: "https://apps.apple.com/us/app/f-x-wallet-by-function-x-labs/id1504798360",
linux: null,
mac: null,
opera: null,
safari: null,
windows: null,
linux: null,
chrome: null,
firefox: null,
safari: null,
edge: null,
opera: null,
},
rdns: null,
mobile: {
native: "fxwallet://",
universal: "https://fx.wallet/wc",
},
desktop: {
native: null,
universal: null,
},
homepage: "https://pundi.ai/",
id: "ai.pundi",
image_id: "f095abd7-575e-4851-cf9c-79c2c4e4cc00",
mobile: {
native: "fxwallet://",
universal: "https://fx.wallet/wc",
},
name: "Pundi Wallet",
rdns: null,
} as const;
@@ -2,31 +2,31 @@
// Do not modify this file manually.
export const wallet = {
id: "ai.purewallet",
name: "PureWallet app",
homepage: "https://purewallet.ai",
image_id: "4e97b9d6-60eb-42cd-6256-5cd7205e3f00",
app: {
browser: null,
ios: "https://apps.apple.com/us/app/purewallet/id6738949168?platform=iphone",
android:
"https://play.google.com/store/apps/details?id=com.nslab.purewalletp&pli=1",
browser: null,
chrome: null,
edge: null,
firefox: null,
ios: "https://apps.apple.com/us/app/purewallet/id6738949168?platform=iphone",
linux: null,
mac: null,
opera: null,
safari: null,
windows: null,
linux: null,
chrome: null,
firefox: null,
safari: null,
edge: null,
opera: null,
},
rdns: null,
mobile: {
native: "purewalletp://",
universal: null,
},
desktop: {
native: null,
universal: null,
},
homepage: "https://purewallet.ai",
id: "ai.purewallet",
image_id: "4e97b9d6-60eb-42cd-6256-5cd7205e3f00",
mobile: {
native: "purewalletp://",
universal: null,
},
name: "PureWallet app",
rdns: null,
} as const;
@@ -2,31 +2,31 @@
// Do not modify this file manually.
export const wallet = {
id: "ai.spotonchain.platform",
name: "Spot On Chain App",
homepage: "https://platform.spotonchain.ai",
image_id: "1bcb5ed6-f6c9-4b0d-f891-b70c48b93d00",
app: {
browser: null,
ios: "https://apps.apple.com/us/app/spot-on-chain/id6449733489",
android:
"https://play.google.com/store/apps/details?id=network.spotonchain",
browser: null,
chrome: null,
edge: null,
firefox: null,
ios: "https://apps.apple.com/us/app/spot-on-chain/id6449733489",
linux: null,
mac: null,
opera: null,
safari: null,
windows: null,
linux: null,
chrome: null,
firefox: null,
safari: null,
edge: null,
opera: null,
},
rdns: null,
mobile: {
native: "spotonchain://",
universal: "https://spotonchain.ai/",
},
desktop: {
native: null,
universal: null,
},
homepage: "https://platform.spotonchain.ai",
id: "ai.spotonchain.platform",
image_id: "1bcb5ed6-f6c9-4b0d-f891-b70c48b93d00",
mobile: {
native: "spotonchain://",
universal: "https://spotonchain.ai/",
},
name: "Spot On Chain App",
rdns: null,
} as const;
@@ -2,30 +2,30 @@
// Do not modify this file manually.
export const wallet = {
id: "app.ammer",
name: "Ammer Wallet",
homepage: "https://ammer.app/",
image_id: "7d38dd8e-92ee-44bf-1ca4-818531de1900",
app: {
android: null,
browser: null,
chrome: null,
edge: null,
firefox: null,
ios: "https://apps.apple.com/us/app/ammer-wallet/id1599698329",
linux: null,
android: null,
mac: null,
opera: null,
safari: null,
windows: null,
linux: null,
chrome: null,
firefox: null,
safari: null,
edge: null,
opera: null,
},
rdns: null,
mobile: {
native: "ammerwallet://",
universal: null,
},
desktop: {
native: null,
universal: null,
},
homepage: "https://ammer.app/",
id: "app.ammer",
image_id: "7d38dd8e-92ee-44bf-1ca4-818531de1900",
mobile: {
native: "ammerwallet://",
universal: null,
},
name: "Ammer Wallet",
rdns: null,
} as const;
@@ -2,32 +2,32 @@
// Do not modify this file manually.
export const wallet = {
id: "app.backpack",
name: "Backpack",
homepage: "https://backpack.app",
image_id: "71ca9daf-a31e-4d2a-fd01-f5dc2dc66900",
app: {
browser: null,
ios: "https://apps.apple.com/us/app/backpack-wallet-exchange/id6445964121",
android:
"https://play.google.com/store/apps/details?id=app.backpack.mobile",
browser: null,
mac: null,
windows: null,
linux: null,
chrome:
"https://chrome.google.com/webstore/detail/backpack/aflkmfhebedbjioipglgcbcmnbpgliof",
edge: null,
firefox: null,
ios: "https://apps.apple.com/us/app/backpack-wallet-exchange/id6445964121",
linux: null,
mac: null,
opera: null,
safari: null,
windows: null,
edge: null,
opera: null,
},
rdns: "app.backpack",
mobile: {
native: "backpack://",
universal: "https://backpack.app/ul/v1",
},
desktop: {
native: null,
universal: null,
},
homepage: "https://backpack.app",
id: "app.backpack",
image_id: "71ca9daf-a31e-4d2a-fd01-f5dc2dc66900",
mobile: {
native: "backpack://",
universal: "https://backpack.app/ul/v1",
},
name: "Backpack",
rdns: "app.backpack",
} as const;

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