Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3c768a0ef9 Version Packages (#8503)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-05 23:07:48 -08:00
FirekeeperandGitHub a388439022 Add Solana facilitator guide to docs (#8511) 2025-12-06 05:53:27 +07:00
MananTank 7b873d38db [MNY-322] SDK: Update success message shown in SwapWidget component (#8509)
<!--

## 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 updates the success messages in the `SuccessScreen` component across various widgets in the `thirdweb` package, specifically for payment and swap transactions.

### Detailed summary
- Updated success message type in `SwapWidget` to `"swap-success"`.
- Updated success message type in `BuyWidget` and `CheckoutWidget` to `"payment-success"`.
- Added a new story `BasicSwapSuccess` in `SuccessScreen.stories.tsx`.
- Modified `SuccessScreen` to handle different message types.
- Updated displayed titles and descriptions based on the success type.
- Changed button text from "View Payment Receipt" to "View Transaction Receipt".

> ✨ 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**
  * Success screens now display dynamic, context-appropriate messaging tailored to transaction type, with distinct titles and descriptions for swap versus payment transactions.
  * Enhanced user experience with improved terminology—the option to "View Transaction Receipt" now displays consistently across all payment flows for better clarity and consistency.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-05 20:46:01 +00:00
Jonas Daniels d33a943176 Add AI privacy policy and terms to framer-rewrites.js 2025-12-05 12:38:02 -08:00
FirekeeperandGitHub d5e0b6eb73 Update API reference links in dashboard and portal (#8508) 2025-12-06 02:57:42 +07:00
Joaquim VergesandGitHub 75e9be0358 [SDK] Update token balances query to use client instead of clientId (#8507) 2025-12-04 18:32:44 -08:00
joaquim-verges a704c3f2b0 [SDK] Allow passing activeWallet to SwapWidget (#8504)
<!--

## 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 `SwapWidget` by allowing the passing of an `activeWallet` prop and improving the project wallet functionality in the dashboard. It also includes updates to the VSCode settings and introduces new components for swapping tokens.

### Detailed summary
- Added `activeWallet` prop to `SwapWidgetProps`.
- Modified `useActiveWalletInfo` to accept an `activeWalletOverride`.
- Introduced `SwapProjectWalletModalContent` for token swapping.
- Updated `ProjectWalletDetailsSection` to include swap functionality.
- Adjusted VSCode settings for formatting preferences.

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

<!-- end pr-codex -->

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

## Summary by CodeRabbit

* **New Features**
  * Added in-app token swap functionality for project wallets, allowing users to exchange tokens directly from the wallet interface with credentials management.
  * SwapWidget now supports wallet pre-selection, enabling faster and more streamlined token swap initiation.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-05 00:03:49 +00:00
MananTank b0eccf365a [MNY-318] SDK: Remove tabs from token selection ui in SwapWidget (#8501)
<!--

## 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 `SelectToken` component by simplifying token limit management, improving UI consistency, and refactoring token display logic. It also removes unnecessary tabs and optimizes the selection process for better user experience.

### Detailed summary
- Replaced constants `ALL_TOKENS_LIMIT` and `OWNED_TOKENS_LIMIT` with `INITIAL_LIMIT`.
- Unified token limit state management into a single `limit` state.
- Refactored token fetching and display logic to streamline data handling.
- Removed unnecessary tab UI for better clarity.
- Simplified the structure of token data handling in `SelectTokenUI`.
- Updated the rendering logic for owned and other tokens to improve performance and readability.

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

* **UI Improvements**
  * Redesigned token selection UI in SwapWidget: removed tab navigation and consolidated token lists into dedicated "Your Tokens" and "Other Tokens" sections for a clearer, more intuitive experience.

* **Bug Fixes**
  * Enhanced search functionality to properly reset token filtering, improving responsiveness.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-04 22:09:50 +00:00
MananTank f3525c7c01 [PRO-123] Dashboard: Move project webhooks (#8500)
<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refactoring and reorganizing the `webhooks` feature within the application, including changes to routing and component structure, as well as the introduction of a `bridge` section for webhooks.

### Detailed summary
- Deleted multiple files related to `webhooks` under the previous structure.
- Updated routing paths from `/webhooks/payments` to `/bridge/webhooks`.
- Refactored components to align with the new `bridge` structure.
- Adjusted imports to reflect new file locations.
- Removed unused `WebhookIcon` and streamlined sidebar links.
- Introduced new layout and page components under `tokens/(general)/webhooks`.

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

## Release Notes

* **Navigation & Organization**
  - Webhooks functionality relocated to the Bridge section for improved contextual organization
  - Updated navigation paths and sidebar structure accordingly

* **UI Improvements**
  - Redesigned empty state for webhooks with enhanced visual styling and icon display

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-04 21:35:46 +00:00
MananTank e516b4ddc1 [PRO-147] Dashboard: Update empty state for project analytics charts (#8499)
<!--

## 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 improving the handling of empty chart states across various components in the dashboard by standardizing the `EmptyChartState` usage and enhancing the user experience with informative content.

### Detailed summary
- Updated `EmptyChartState` components to use `content` prop instead of `type`.
- Enhanced `EmptyChartState` with more descriptive text and links.
- Introduced `EmptyChartStateGetStartedCTA` for better guidance in empty states.
- Adjusted multiple charts to incorporate the new empty state handling.
- Simplified chart rendering logic when no data is available.

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

* **UI/UX Improvements**
  * Unified and simplified empty-chart visuals (smaller message, cleaner layout, adjusted loading background) and consistent spacing across analytics cards.
  * Empty states now show contextual CTAs linking to relevant configuration/details pages.

* **New Features**
  * Reusable "Get Started" CTA for empty charts with title, description and action.

* **Refactor**
  * Charts can now accept and render provided empty-state content for a more consistent UX.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-04 18:38:25 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
dffa457a39 Version Packages (#8498)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-03 19:50:37 -08:00
Joaquim VergesandGitHub 859032dc17 [SDK] Skip swap review step when coming from swap widget (#8496) 2025-12-03 19:23:42 -08:00
74 changed files with 951 additions and 570 deletions
+5 -2
View File
@@ -1,4 +1,6 @@
{
"editor.insertSpaces": true,
"editor.tabSize": 2,
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
@@ -18,8 +20,9 @@
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.fixAll.biome": "explicit"
"quickfix.biome": "always",
"source.fixAll.biome": "always",
"source.organizeImports.biome": "always"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
+3
View File
@@ -68,4 +68,7 @@ module.exports = [
"/token",
"/vault",
"/monetize/bridge",
// ai
"/ai-privacy-policy",
"/ai-terms",
];
@@ -1,50 +1,23 @@
"use client";
import { XIcon } from "lucide-react";
import { useId, useMemo } from "react";
import { Area, AreaChart, Bar, BarChart } from "recharts";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import { ArrowUpRightIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { LoadingDots } from "@/components/ui/LoadingDots";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
type FakeCartData = {
value: number;
};
function generateRandomData(): FakeCartData[] {
return Array.from({ length: 30 }, () => ({
value: Math.floor(Math.random() * 100 + 30),
}));
}
const skeletonChartConfig = {
value: {
color: "hsl(var(--muted)/90%)",
label: "Value",
},
} satisfies ChartConfig;
export function EmptyChartState({
children,
type,
}: {
children?: React.ReactNode;
type: "bar" | "area" | "none";
}) {
const barChartData = useMemo(() => generateRandomData(), []);
export function EmptyChartState({ content }: { content?: React.ReactNode }) {
return (
<div className="relative z-0 h-full w-full">
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center text-base text-muted-foreground">
{children || (
{content || (
<div className="flex items-center gap-3 flex-col">
<div className="rounded-full border p-2 bg-background">
<XIcon className="size-4" />
</div>
<p className="text-base"> No data available </p>
<p className="text-sm"> No data available </p>
</div>
)}
</div>
{type !== "none" && <SkeletonBarChart data={barChartData} type={type} />}
</div>
);
}
@@ -53,7 +26,7 @@ export function LoadingChartState({ className }: { className?: string }) {
return (
<div
className={cn(
"pointer-events-none flex h-full w-full items-center justify-center rounded-lg bg-muted/30",
"pointer-events-none flex h-full w-full items-center justify-center bg-card rounded-lg",
className,
)}
>
@@ -62,51 +35,40 @@ export function LoadingChartState({ className }: { className?: string }) {
);
}
function SkeletonBarChart(props: {
data: FakeCartData[];
type: "bar" | "area";
export function EmptyChartStateGetStartedCTA(props: {
link: {
label: string;
href: string;
};
title: string;
description?: string;
}) {
const fillAreaSkeletonId = useId();
return (
<ChartContainer
className="pointer-events-none h-full w-full blur-[5px] aspect-auto"
config={skeletonChartConfig}
>
{props.type === "bar" ? (
<BarChart
data={props.data}
margin={{
top: 20,
}}
>
<Bar dataKey="value" fill="var(--color-value)" radius={8} />
</BarChart>
) : (
<AreaChart
data={props.data}
margin={{
top: 20,
}}
>
<defs>
<linearGradient id={fillAreaSkeletonId} x1="0" x2="0" y1="0" y2="1">
<stop
offset="5%"
stopColor={"hsl(var(--muted-foreground)/0.5)"}
stopOpacity={0.8}
/>
<stop offset="95%" stopColor="transparent" stopOpacity={0.1} />
</linearGradient>
</defs>
<Area
dataKey="value"
fill={`url(#${fillAreaSkeletonId})`}
radius={8}
stroke="hsl(var(--muted-foreground))"
type="monotone"
/>
</AreaChart>
)}
</ChartContainer>
<div className="flex items-center flex-col">
<div className="rounded-full border p-2 mb-4">
<XIcon className="size-4 text-foreground" />
</div>
<div className="mb-5 space-y-1">
<h3 className="text-base text-foreground text-center font-medium">
{props.title}
</h3>
{props.description && (
<p className="text-center text-sm text-muted-foreground">
{props.description}
</p>
)}
</div>
<Button
asChild
className="rounded-full gap-2"
variant="default"
size="sm"
>
<Link href={props.link.href}>
{props.link.label} <ArrowUpRightIcon className="size-4" />
</Link>
</Button>
</div>
);
}
@@ -90,9 +90,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
<EmptyChartState type="area">
{props.emptyChartState}
</EmptyChartState>
<EmptyChartState content={props.emptyChartState} />
) : (
<AreaChart
accessibilityLayer
@@ -78,9 +78,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
{props.isPending ? (
<LoadingChartState />
) : props.data.length === 0 ? (
<EmptyChartState type="bar">
{props.emptyChartState}
</EmptyChartState>
<EmptyChartState content={props.emptyChartState} />
) : (
<BarChart accessibilityLayer data={props.data}>
<CartesianGrid vertical={false} />
@@ -13,6 +13,7 @@ import {
import {
THIRDWEB_BRIDGE_URL,
THIRDWEB_BUNDLER_DOMAIN,
THIRDWEB_ENGINE_CLOUD_URL,
THIRDWEB_INAPP_WALLET_DOMAIN,
THIRDWEB_INSIGHT_API_DOMAIN,
THIRDWEB_PAY_DOMAIN,
@@ -38,6 +39,7 @@ export function getConfiguredThirdwebClient(options: {
rpc: THIRDWEB_RPC_DOMAIN,
social: THIRDWEB_SOCIAL_API_DOMAIN,
storage: THIRDWEB_STORAGE_DOMAIN,
engineCloud: new URL(THIRDWEB_ENGINE_CLOUD_URL).hostname,
});
}
+3
View File
@@ -25,3 +25,6 @@ export const THIRDWEB_INSIGHT_API_DOMAIN =
export const THIRDWEB_BRIDGE_URL =
process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb-dev.com";
export const THIRDWEB_ENGINE_CLOUD_URL =
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "https://engine.thirdweb-dev.com";
@@ -63,15 +63,17 @@ function MetricPlaceholders({
{metric.label}
</span>
<div className="h-32 w-full">
<EmptyChartState type="area">
{status === "active" ? (
<Badge>Coming Soon</Badge>
) : status === "pending" ? (
<p className="text-xs">Activation in progress.</p>
) : (
<p className="text-xs">Activate service to view metrics.</p>
)}
</EmptyChartState>
<EmptyChartState
content={
status === "active" ? (
<Badge>Coming Soon</Badge>
) : status === "pending" ? (
<p className="text-xs">Activation in progress.</p>
) : (
<p className="text-xs">Activate service to view metrics.</p>
)
}
/>
</div>
</Card>
))}
@@ -1,5 +1,6 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getAiUsage } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import { AiTokenUsageChartCardUI } from "../ai/analytics/chart/AiTokenUsageChartCard";
async function AsyncAiAnalytics(props: {
@@ -32,6 +33,16 @@ async function AsyncAiAnalytics(props: {
isPending={false}
aiUsageStats={stats}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`,
}}
title="No data available"
description="No AI usage found in selected time period"
/>
}
/>
);
}
@@ -1,5 +1,6 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getEOAAndInAppWalletConnections } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import {
AutoMergeBarChart,
type StatData,
@@ -75,7 +76,16 @@ function AllWalletConnectionsChartUI(props: {
isPending={props.isPending}
exportButton={undefined}
maxLabelsToShow={5}
emptyChartState={undefined}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets/configure`,
}}
title="No data available"
description="No wallet connections found in selected time period"
/>
}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets`}
/>
);
@@ -1,6 +1,10 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getUniversalBridgeUsage } from "@/api/analytics";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import {
EmptyChartState,
EmptyChartStateGetStartedCTA,
LoadingChartState,
} from "@/components/analytics/empty-chart-state";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import type { UniversalBridgeStats } from "@/types/analytics";
import { TotalVolumePieChart } from "../payments/components/TotalVolumePieChart";
@@ -76,7 +80,22 @@ function BridgeChartCardUI(props: {
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/bridge`}
/>
<div className="p-6 grow flex items-center justify-center">
<TotalVolumePieChart data={props.data} hideTotal={true} />
{total === 0 ? (
<EmptyChartState
content={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
}}
title="No data available"
description="No bridge volume found in selected time period"
/>
}
/>
) : (
<TotalVolumePieChart data={props.data} hideTotal={true} />
)}
</div>
</div>
);
@@ -1,7 +1,7 @@
"use client";
import { EmptyStateContent } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import type { InAppWalletStats, UniversalBridgeStats } from "@/types/analytics";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
@@ -39,10 +39,13 @@ export function ProjectHighlightsCard(props: {
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your app hasn't collected any fees yet."
link={`/team/${teamSlug}/${projectSlug}/bridge/configuration`}
metric="Fees"
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${teamSlug}/${projectSlug}/bridge/configuration`,
}}
title="No data available"
description="No bridge revenue generated in selected time period"
/>
),
isCurrency: true,
@@ -1,5 +1,6 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getInsightStatusCodeUsage } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import { RequestsByStatusGraph } from "../gateway/indexer/components/RequestsByStatusGraph";
export function IndexerRequestsChartCard(props: {
@@ -63,6 +64,16 @@ async function AsyncIndexerRequestsChartCard(props: {
data={requestsData && "data" in requestsData ? requestsData.data : []}
isPending={false}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`,
}}
title="No data available"
description="No indexer requests found in selected time period"
/>
}
/>
);
}
@@ -1,5 +1,6 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getRpcUsageByType } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import { RPCRequestsChartUI } from "../gateway/rpc/components/RequestsGraph";
async function AsyncRPCRequestsChartCard(props: {
@@ -23,11 +24,23 @@ async function AsyncRPCRequestsChartCard(props: {
props.authToken,
).catch(() => undefined);
const isEmpty = requestsData?.every((d) => d.count === 0);
return (
<RPCRequestsChartUI
isPending={false}
data={requestsData || []}
data={isEmpty ? [] : requestsData || []}
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View more details",
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`,
}}
title="No data available"
description="No RPC requests found in selected time period"
/>
}
/>
);
}
@@ -1,5 +1,6 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
import { X402RequestsChartCardUI } from "./x402-requests-chart";
async function AsyncX402RequestsChart(props: {
@@ -41,6 +42,16 @@ async function AsyncX402RequestsChart(props: {
isPending={false}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
emptyChartState={
<EmptyChartStateGetStartedCTA
link={{
label: "View Configuration",
href: `/team/${props.teamSlug}/${props.projectSlug}/x402/configuration`,
}}
title="No data available"
description="No X402 requests found in selected time period"
/>
}
/>
);
}
@@ -10,6 +10,7 @@ export function X402RequestsChartCardUI(props: {
isPending: boolean;
teamSlug: string;
projectSlug: string;
emptyChartState?: React.ReactElement | undefined;
}) {
const total = useMemo(() => {
return props.stats.reduce((acc, curr) => acc + curr.requests, 0);
@@ -41,6 +42,7 @@ export function X402RequestsChartCardUI(props: {
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/x402`}
/>
}
emptyChartState={props.emptyChartState}
/>
);
}
@@ -20,6 +20,7 @@ export function AiTokenUsageChartCardUI(props: {
isPending: boolean;
title: string;
viewMoreLink: string | undefined;
emptyChartState?: React.ReactElement | undefined;
}) {
const { aiUsageStats } = props;
@@ -93,7 +94,7 @@ export function AiTokenUsageChartCardUI(props: {
)
}
data={chartData}
emptyChartState={<AiTokenUsageEmptyChartState />}
emptyChartState={props.emptyChartState}
hideLabel={false}
isPending={props.isPending}
showLegend={false}
@@ -109,7 +110,7 @@ export function AiTokenUsageChartCardUI(props: {
);
}
function AiTokenUsageEmptyChartState() {
function _AiTokenUsageEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center px-4">
<h3 className="text-base font-semibold text-foreground mb-1 text-center">
@@ -60,7 +60,7 @@ export function QuickStartSection(props: {
id="webhooks"
features={["Instant events", "Transaction verification"]}
link={{
href: `/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`,
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/webhooks`,
label: "Setup Webhooks",
}}
/>
@@ -1,4 +1,3 @@
import { WebhookIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
@@ -43,13 +42,7 @@ export default async function Layout(props: {
instantly
</>
),
actions: {
secondary: {
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
label: "Webhooks",
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
},
actions: null,
links: [
{
type: "docs",
@@ -75,6 +68,10 @@ export default async function Layout(props: {
name: "Configuration",
path: `/team/${params.team_slug}/${params.project_slug}/bridge/configuration`,
},
{
name: "Webhooks",
path: `/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`,
},
]}
>
{props.children}
@@ -46,7 +46,7 @@ export default async function Page(props: {
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
href={`/team/${params.team_slug}/${params.project_slug}/webhooks/payments`}
href={`/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`}
>
Webhooks
</Link>
@@ -12,6 +12,7 @@ import {
PlusIcon,
TrashIcon,
WebhookIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import { type PropsWithChildren, useMemo, useState } from "react";
@@ -95,9 +96,14 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
if (!webhooksQuery.data?.length) {
return (
<div className="flex flex-col items-center gap-6 rounded-lg border border-border bg-card p-8 text-center">
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-xl">No webhooks configured</h2>
<p className="text-sm text-muted-foreground">
<div className="flex flex-col items-center">
<div className="rounded-full p-2 border bg-background inline-flex mb-3">
<XIcon className="size-4 text-muted-foreground" />
</div>
<h2 className="font-semibold text-base mb-1">
No webhooks configured
</h2>
<p className="text-muted-foreground text-sm">
Create a webhook to receive notifications for bridge, swap or onramp
events.
</p>
@@ -126,7 +132,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
webhook={webhook}
clientId={props.clientId}
teamId={props.teamId}
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`}
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/bridge/webhooks`}
/>
))}
</div>
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { PayWebhooksPage } from "../../payments/webhooks/components/webhooks.client";
import { PayWebhooksPage } from "./components/webhooks.client";
export default async function Page(props: {
params: Promise<{
@@ -6,7 +6,6 @@ import {
DoorOpenIcon,
HomeIcon,
Settings2Icon,
WebhookIcon,
} from "lucide-react";
import {
FullWidthSidebarLayout,
@@ -136,14 +135,6 @@ export function ProjectSidebarLayout(props: {
{
separator: true,
},
{
href: `${props.layoutPath}/webhooks/contracts`,
icon: WebhookIcon,
isActive: (pathname) => {
return pathname.startsWith(`${props.layoutPath}/webhooks`);
},
label: "Webhooks",
},
{
href: `${props.layoutPath}/settings`,
icon: Settings2Icon,
@@ -3,6 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeftIcon,
ArrowLeftRightIcon,
EllipsisVerticalIcon,
RefreshCcwIcon,
@@ -10,17 +11,22 @@ import {
ShuffleIcon,
WalletIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
createThirdwebClient,
Engine,
getContract,
readContract,
type ThirdwebClient,
toUnits,
} from "thirdweb";
import { useWalletBalance } from "thirdweb/react";
import type { Chain } from "thirdweb/chains";
import { SwapWidget, useWalletBalance } from "thirdweb/react";
import { isAddress, shortenAddress } from "thirdweb/utils";
import { createWalletAdapter } from "thirdweb/wallets";
import { z } from "zod";
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
import type { Project } from "@/api/project/projects";
@@ -69,6 +75,7 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
import { cn } from "@/lib/utils";
import { getSDKTheme } from "@/utils/sdk-component-theme";
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
type GetProjectServerWallets = (params: {
@@ -127,6 +134,11 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
const [isSendOpen, setIsSendOpen] = useState(false);
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
const [isSwapOpen, setIsSwapOpen] = useState(false);
// Persist swap credentials in memory so users don't have to re-enter them
const [swapSecretKey, setSwapSecretKey] = useState("");
const [swapVaultAccessToken, setSwapVaultAccessToken] = useState("");
// Initialize chain and token from localStorage or defaults
const [selectedChainId, setSelectedChainId] = useState(() => {
@@ -310,6 +322,15 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
<SendIcon className="size-4" />
Withdraw
</Button>
<Button
variant="outline"
size="sm"
className="gap-2 bg-background hover:bg-accent/50"
onClick={() => setIsSwapOpen(true)}
>
<ArrowLeftRightIcon className="size-4" />
Swap
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -394,6 +415,25 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
/>
</DialogContent>
</Dialog>
<Dialog onOpenChange={setIsSwapOpen} open={isSwapOpen}>
<DialogContent className="gap-0 p-0 overflow-hidden max-w-md">
<SwapProjectWalletModalContent
client={props.client}
chainId={selectedChainId}
tokenAddress={selectedTokenAddress}
walletAddress={projectWallet.address}
chain={chain}
isManagedVault={isManagedVault}
publishableKey={project.publishableKey}
secretKey={swapSecretKey}
setSecretKey={setSwapSecretKey}
vaultAccessToken={swapVaultAccessToken}
setVaultAccessToken={setSwapVaultAccessToken}
onClose={() => setIsSwapOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
);
}
@@ -573,6 +613,192 @@ function ChangeProjectWalletDialogContent(props: {
);
}
type SwapProjectWalletModalContentProps = {
client: ThirdwebClient;
chainId: number;
tokenAddress: string | undefined;
walletAddress: string;
chain: Chain;
isManagedVault: boolean;
publishableKey: string;
secretKey: string;
setSecretKey: (value: string) => void;
vaultAccessToken: string;
setVaultAccessToken: (value: string) => void;
onClose: () => void;
};
function SwapProjectWalletModalContent(
props: SwapProjectWalletModalContentProps,
) {
const {
client,
chainId,
tokenAddress,
walletAddress,
chain,
isManagedVault,
publishableKey,
secretKey,
setSecretKey,
vaultAccessToken,
setVaultAccessToken,
onClose,
} = props;
const [screen, setScreen] = useState<"credentials" | "swap">("credentials");
const { theme } = useTheme();
const t = theme === "light" ? "light" : "dark";
const hasRequiredCredentials = isManagedVault
? secretKey.trim().length > 0
: secretKey.trim().length > 0 && vaultAccessToken.trim().length > 0;
const swapClient = useMemo(() => {
if (!secretKey.trim()) {
return null;
}
// use the inputted secret key to create the client used for the serverWallet
return createThirdwebClient({
clientId: publishableKey,
secretKey: secretKey.trim(),
});
}, [secretKey, publishableKey]);
const activeWallet = useMemo(() => {
if (!swapClient) {
return undefined;
}
const vaultAccessTokenValue = vaultAccessToken.trim();
return createWalletAdapter({
adaptedAccount: Engine.serverWallet({
client: swapClient,
address: walletAddress,
...(vaultAccessTokenValue
? { vaultAccessToken: vaultAccessTokenValue }
: {}),
}),
chain: chain,
client: swapClient,
onDisconnect: () => {},
switchChain: () => {},
});
}, [swapClient, walletAddress, chain, vaultAccessToken]);
// Screen 1: Credentials
if (screen === "credentials") {
return (
<div>
<DialogHeader className="p-4 lg:p-6">
<DialogTitle>Swap Tokens</DialogTitle>
<DialogDescription>
Enter your credentials to swap tokens from your project wallet
</DialogDescription>
</DialogHeader>
<div className="px-4 pb-4 lg:px-6 lg:pb-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="swap-secret-key"
className="text-sm font-medium leading-none"
>
Project secret key
</label>
<Input
id="swap-secret-key"
type="password"
placeholder="Enter your project secret key"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">{secretKeyHelper}</p>
</div>
{!isManagedVault && (
<div className="space-y-2">
<label
htmlFor="swap-vault-access-token"
className="text-sm font-medium leading-none"
>
Vault access token
</label>
<Input
id="swap-vault-access-token"
type="password"
placeholder="Enter a vault access token"
value={vaultAccessToken}
onChange={(e) => setVaultAccessToken(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
{vaultAccessTokenHelper}
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t bg-card p-4">
<Button onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button
onClick={() => setScreen("swap")}
type="button"
disabled={!hasRequiredCredentials}
>
Continue
</Button>
</div>
</div>
);
}
// Screen 2: Swap Widget
return (
<div className="w-full">
<DialogHeader className="p-4 lg:p-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="p-1 h-auto w-auto"
onClick={() => setScreen("credentials")}
>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<DialogTitle>Swap Tokens</DialogTitle>
<DialogDescription>
Swap tokens from your project wallet
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="px-4 pb-4 lg:px-6 lg:pb-6 flex justify-center">
{activeWallet && (
<SwapWidget
client={props.client}
prefill={{
sellToken: {
chainId: chainId,
tokenAddress: tokenAddress,
},
}}
activeWallet={activeWallet}
theme={getSDKTheme(t)}
/>
)}
</div>
</div>
);
}
const createSendFormSchema = (secretKeyLabel: string) =>
z.object({
chainId: z.number({
@@ -98,7 +98,7 @@ export default async function Page(props: {
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/contracts",
href: "https://api.thirdweb.com/reference#tag/gateway",
},
{
type: "webhooks",
@@ -3,7 +3,6 @@ import { format } from "date-fns";
import { useMemo } from "react";
import { shortenLargeNumber } from "thirdweb/utils";
import type { InsightStatusCodeStats } from "@/api/analytics";
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
import type { ChartConfig } from "@/components/ui/chart";
@@ -17,6 +16,7 @@ export function RequestsByStatusGraph(props: {
data: InsightStatusCodeStats[];
isPending: boolean;
viewMoreLink: string | undefined;
emptyChartState?: React.ReactElement | undefined;
}) {
const topStatusCodesToShow = 10;
@@ -105,7 +105,7 @@ export function RequestsByStatusGraph(props: {
/>
}
data={chartData}
emptyChartState={<EmptyChartState type="bar" />}
emptyChartState={props.emptyChartState}
hideLabel={false}
isPending={props.isPending}
showLegend
@@ -10,6 +10,7 @@ export function RPCRequestsChartUI(props: {
data: RpcUsageTypeStats[];
viewMoreLink: string | undefined;
isPending: boolean;
emptyChartState?: React.ReactElement | undefined;
}) {
const total = useMemo(() => {
return props.data.reduce((acc, curr) => acc + curr.count, 0);
@@ -73,6 +74,7 @@ export function RPCRequestsChartUI(props: {
xAxis={{
showHour: showBreakdownByHour,
}}
emptyChartState={props.emptyChartState}
/>
);
}
@@ -39,7 +39,7 @@ export function AdvancedSection({
id="webhooks"
features={["Instant events", "Transaction verification"]}
link={{
href: `/team/${teamSlug}/${projectSlug}/webhooks/payments`,
href: `/team/${teamSlug}/${projectSlug}/bridge/webhooks`,
label: "Setup Webhooks",
}}
/>
@@ -66,7 +66,7 @@ export default async function Page(props: {
),
},
secondary: {
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
href: `/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`,
label: "Webhooks",
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
@@ -85,7 +85,7 @@ export default async function Page(props: {
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/payments",
href: "https://api.thirdweb.com/reference#tag/x402",
},
],
}}
@@ -1,16 +1,10 @@
import { Button } from "@workspace/ui/components/button";
import { PlusIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getAuthToken } from "@/api/auth-token";
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { ClientOnly } from "@/components/blocks/client-only";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ContractTable } from "@/components/contract-components/tables/contract-table";
import {
Dialog,
DialogContent,
@@ -21,10 +15,11 @@ import {
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TokenIcon } from "@/icons/TokenIcon";
import { loginRedirect } from "@/utils/redirects";
import { Cards, ImportTokenButton } from "./cards";
import { Cards, ImportTokenButton } from "../cards";
export default async function Page(props: {
export default async function Layout(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
children: React.ReactNode;
}) {
const params = await props.params;
@@ -110,48 +105,19 @@ export default async function Page(props: {
},
],
}}
tabs={[
{
name: "Overview",
path: `/team/${params.team_slug}/${params.project_slug}/tokens`,
exactMatch: true,
},
{
name: "Webhooks",
path: `/team/${params.team_slug}/${params.project_slug}/tokens/webhooks`,
},
]}
>
<Suspense fallback={<GenericLoadingPage />}>
<AssetsPageAsync
authToken={authToken}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
teamId={team.id}
teamSlug={params.team_slug}
/>
</Suspense>
{props.children}
</ProjectPage>
);
}
async function AssetsPageAsync(props: {
teamId: string;
projectId: string;
authToken: string;
client: ThirdwebClient;
teamSlug: string;
projectSlug: string;
}) {
const deployedContracts = await getFilteredProjectContracts({
authToken: props.authToken,
type: "token-contracts",
projectId: props.projectId,
teamId: props.teamId,
});
return (
<ClientOnly ssr={<GenericLoadingPage />}>
<ContractTable
client={props.client}
contracts={deployedContracts}
pageSize={10}
projectId={props.projectId}
projectSlug={props.projectSlug}
teamId={props.teamId}
teamSlug={props.teamSlug}
variant="asset"
/>
</ClientOnly>
);
}
@@ -0,0 +1,84 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getAuthToken } from "@/api/auth-token";
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { ClientOnly } from "@/components/blocks/client-only";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ContractTable } from "@/components/contract-components/tables/contract-table";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const [authToken, team, project] = await Promise.all([
getAuthToken(),
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/tokens`);
}
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
return (
<Suspense fallback={<GenericLoadingPage />}>
<AssetsPageAsync
authToken={authToken}
client={client}
projectId={project.id}
projectSlug={params.project_slug}
teamId={team.id}
teamSlug={params.team_slug}
/>
</Suspense>
);
}
async function AssetsPageAsync(props: {
teamId: string;
projectId: string;
authToken: string;
client: ThirdwebClient;
teamSlug: string;
projectSlug: string;
}) {
const deployedContracts = await getFilteredProjectContracts({
authToken: props.authToken,
type: "token-contracts",
projectId: props.projectId,
teamId: props.teamId,
});
return (
<ClientOnly ssr={<GenericLoadingPage />}>
<ContractTable
client={props.client}
contracts={deployedContracts}
pageSize={10}
projectId={props.projectId}
projectSlug={props.projectSlug}
teamId={props.teamId}
teamSlug={props.teamSlug}
variant="asset"
/>
</ClientOnly>
);
}
@@ -13,7 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
import type { WebhookFormValues } from "../utils/webhookTypes";
import type { WebhookFormValues } from "./utils/webhookTypes";
interface BasicInfoStepProps {
form: UseFormReturn<WebhookFormValues>;
@@ -16,26 +16,26 @@ import {
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useAbiMultiFetch } from "../hooks/useAbiProcessing";
import { useTestWebhook } from "../hooks/useTestWebhook";
import BasicInfoStep from "./BasicInfoStep";
import { FilterDetailsStep } from "./FilterDetailsStep";
import ReviewStep from "./ReviewStep";
import { useAbiMultiFetch } from "./useAbiProcessing";
import { useTestWebhook } from "./useTestWebhook";
import {
extractEventSignatures,
extractFunctionSignatures,
} from "../utils/abiUtils";
import type { WebhookPayload } from "../utils/webhookPayloadUtils";
} from "./utils/abiUtils";
import type { WebhookPayload } from "./utils/webhookPayloadUtils";
import {
buildEventWebhookPayload,
buildTransactionWebhookPayload,
} from "../utils/webhookPayloadUtils";
} from "./utils/webhookPayloadUtils";
import {
type WebhookFormStep,
WebhookFormSteps,
type WebhookFormValues,
webhookFormSchema,
} from "../utils/webhookTypes";
import BasicInfoStep from "./BasicInfoStep";
import { FilterDetailsStep } from "./FilterDetailsStep";
import ReviewStep from "./ReviewStep";
} from "./utils/webhookTypes";
interface CreateWebhookModalProps {
projectClientId: string;
@@ -17,13 +17,13 @@ import {
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { truncateMiddle } from "../utils/abiUtils";
import { truncateMiddle } from "./utils/abiUtils";
import type {
AbiData,
EventSignature,
FunctionSignature,
WebhookFormValues,
} from "../utils/webhookTypes";
} from "./utils/webhookTypes";
interface FilterDetailsStepProps {
form: UseFormReturn<WebhookFormValues>;
@@ -3,8 +3,8 @@ import { CheckIcon, PlayIcon, XIcon } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/Spinner";
import { truncateMiddle } from "../utils/abiUtils";
import type { WebhookFormValues } from "../utils/webhookTypes";
import { truncateMiddle } from "./utils/abiUtils";
import type { WebhookFormValues } from "./utils/webhookTypes";
interface WebhookTestResult {
status: string;
@@ -17,9 +17,9 @@ import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useTestWebhook } from "../hooks/useTestWebhook";
import { CreateContractWebhookButton } from "./CreateWebhookModal";
import { RelativeTime } from "./RelativeTime";
import { useTestWebhook } from "./useTestWebhook";
function getEventType(filters: WebhookFilters): string {
if (!filters || typeof filters !== "object") {
@@ -1,8 +1,9 @@
import { XIcon } from "lucide-react";
import { getWebhooks, type WebhookResponse } from "@/api/insight/webhooks";
import type { Project } from "@/api/project/projects";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { CreateContractWebhookButton } from "../components/CreateWebhookModal";
import { ContractsWebhooksTable } from "../components/WebhooksTable";
import { CreateContractWebhookButton } from "./CreateWebhookModal";
import { ContractsWebhooksTable } from "./WebhooksTable";
export async function ContractsWebhooksPageContent(props: {
project: Project;
@@ -50,11 +51,14 @@ export async function ContractsWebhooksPageContent(props: {
webhooks={webhooks}
/>
) : (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border p-12 text-center">
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border py-14 px-6 text-center">
<div>
<h3 className="mb-1 font-medium text-lg">No webhooks found</h3>
<p className="text-muted-foreground">
Create a webhook to get started.
<div className="rounded-full p-2 border bg-background inline-flex mb-3">
<XIcon className="size-4 text-muted-foreground" />
</div>
<h3 className="mb-1 font-semibold text-base">No webhooks found</h3>
<p className="text-muted-foreground text-sm">
Create a webhook to get started
</p>
</div>
<CreateContractWebhookButton
@@ -5,12 +5,12 @@ import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getContract, resolveAbiFromContractApi } from "thirdweb/contract";
import { useGetV5DashboardChain } from "@/hooks/chains/v5-adapter";
import { parseAddresses } from "../utils/webhookPayloadUtils";
import { parseAddresses } from "./utils/webhookPayloadUtils";
import type {
AbiData,
EventSignature,
FunctionSignature,
} from "../utils/webhookTypes";
} from "./utils/webhookTypes";
export function useAbiMultiFetch({
isOpen,
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getSupportedWebhookChains } from "@/api/insight/webhooks";
import { getProject } from "@/api/project/projects";
import { ContractsWebhooksPageContent } from "../contract-webhooks/contract-webhooks-page";
import { ContractsWebhooksPageContent } from "./components/contract-webhooks-page";
export default async function ContractsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -1,57 +0,0 @@
import { WebhookIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
export default async function Layout(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
children: React.ReactNode;
}) {
const [authToken, params] = await Promise.all([getAuthToken(), props.params]);
const project = await getProject(params.team_slug, params.project_slug);
if (!project || !authToken) {
notFound();
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
icon: WebhookIcon,
title: "Webhooks",
description: "Get notified for Bridge, Swap and Onramp events.",
actions: null,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/bridge/webhooks",
},
],
}}
tabs={[
{
name: "Contracts",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
},
{
name: "Bridge",
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -1,12 +0,0 @@
import { redirect } from "next/navigation";
export default async function WebhooksPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
// for the moment redirect to the "/contracts webhooks"
redirect(
`/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
);
}
@@ -22,7 +22,7 @@ export function X402EmptyState(props: { walletAddress?: string }) {
label: "View Docs",
},
]}
title="Start Monetizing your API"
title="Payment gate your API"
/>
);
}
@@ -49,7 +49,7 @@ export async function GET(request: Request) {
price: "$0.01",
facilitator: thirdwebX402Facilitator,
});
if (result.status === 200) {
// Payment successful, continue to app logic
return Response.json({ data: "premium content" });
@@ -4,7 +4,6 @@ import {
CartesianGrid,
BarChart as RechartsBarChart,
XAxis,
YAxis,
} from "recharts";
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
import {
@@ -13,6 +12,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { cn } from "@/lib/utils";
import { formatTickerNumber } from "@/utils/format-utils";
import { toUSD } from "@/utils/number";
@@ -23,6 +23,7 @@ export function BarChart({
tooltipLabel,
isCurrency = false,
emptyChartContent,
chartContentClassName,
}: {
chartConfig: ChartConfig;
data: { [key in string]: number | string }[];
@@ -30,10 +31,11 @@ export function BarChart({
tooltipLabel?: string;
isCurrency?: boolean;
emptyChartContent?: React.ReactNode;
chartContentClassName?: string;
}) {
return (
<ChartContainer
className="aspect-auto h-[250px] w-full pt-6"
className={cn("aspect-auto h-[275px] w-full pt-6", chartContentClassName)}
config={{
[activeKey]: {
label: tooltipLabel ?? chartConfig[activeKey]?.label,
@@ -42,16 +44,11 @@ export function BarChart({
}}
>
{data.length === 0 || data.every((d) => d[activeKey] === 0) ? (
<EmptyChartState type="bar"> {emptyChartContent} </EmptyChartState>
<div className="h-full">
<EmptyChartState content={emptyChartContent} />
</div>
) : (
<RechartsBarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
}}
>
<RechartsBarChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
@@ -67,13 +64,7 @@ export function BarChart({
tickLine={false}
tickMargin={8}
/>
<YAxis
axisLine={false}
dataKey={activeKey}
tickFormatter={(value: number) => formatTickerNumber(value)}
tickLine={false}
width={48}
/>
<ChartTooltip
content={
<ChartTooltipContent
@@ -83,7 +83,7 @@ export function CombinedBarChartCard<
</div>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6 sm:pl-0">
<CardContent className="">
<BarChart
activeKey={activeChart}
chartConfig={chartConfig}
@@ -82,7 +82,7 @@ export function PieChartCard({
{isPending ? (
<LoadingChartState />
) : isAllEmpty ? (
<EmptyChartState type="none"> {emptyChartState} </EmptyChartState>
<EmptyChartState content={emptyChartState} />
) : (
<PieChart
data={processedData}
+1 -1
View File
@@ -71,7 +71,7 @@ export const sidebar: SideBar = {
isCollapsible: false,
links: [
{
href: "/reference#tag/contracts",
href: "/reference#tag/gateway",
icon: <CodeIcon />,
name: "HTTP API",
},
+1 -1
View File
@@ -35,7 +35,7 @@ Server wallets are wallets that are managed by your own application, like a trea
### Use an existing Server Wallet
Once created, you can use your server wallet by passing it as the `from` field of the [thirdweb API](/reference#tag/transactions/post/v1/transactions).
Once created, you can use your server wallet by passing it as the `from` field of the [thirdweb API](/reference#tag/wallets/post/v1/transactions).
### Create a new Server Wallet
@@ -64,7 +64,7 @@ Send, monitor, and manage transactions. Send transactions from user or server wa
### Send raw transactions
Send a raw transaction from a [user wallet](/wallets/users) from the frontend, or [server wallet](/wallets/server) from the backend using the [thirdweb API](/reference#tag/transactions/post/v1/transactions).
Send a raw transaction from a [user wallet](/wallets/users) from the frontend, or [server wallet](/wallets/server) from the backend using the [thirdweb API](/reference#tag/wallets/post/v1/transactions).
- For server wallets, you can execute transactions with just your project secret key.
- For user wallets in React applications that use the SDK, you can obtain the user wallet auth token (JWT) with the [`useAuthToken()`](/references/typescript/v5/useAuthToken) hook.
@@ -74,7 +74,7 @@ Send, monitor, and manage transactions. Send transactions from user or server wa
### Send contract calls
Send a transaction to a contract from a [user wallet](/wallets/users) from the frontend, or [server wallet](/wallets/server) from the backend using the [thirdweb API](/reference#tag/contracts/post/v1/contracts/{chainId}/{address}/write).
Send a transaction to a contract from a [user wallet](/wallets/users) from the frontend, or [server wallet](/wallets/server) from the backend using the [thirdweb API](/reference#tag/wallets/post/v1/contracts/{chainId}/{address}/write).
- For server wallets, you can execute transactions with just your project secret key.
- For user wallets in React applications that use the SDK, you can obtain the user wallet auth token (JWT) with the [`useAuthToken()`](/references/typescript/v5/useAuthToken) hook.
@@ -0,0 +1,173 @@
import { Callout, createMetadata } from "@doc";
export const metadata = createMetadata({
image: {
title: "Solana Facilitator",
icon: "solana",
},
title: "Solana Facilitator",
description: "Verify and settle Solana transactions through your own facilitator wallets.",
});
# Solana Facilitator
Solana is now a supported network for x402 payments. You can gate, verify, and settle Solana transactions through your own facilitator wallets — fully end-to-end.
<Callout variant="info" title="Supported Networks">
We support Solana Mainnet and Devnet. Use the following network identifiers:
- **Mainnet**: `solana:mainnet`
- **Devnet**: `solana:devnet`
</Callout>
This enables you to spin up your own server wallet, prepare payment payloads, and handle verification + settlement directly on Solana (devnet or mainnet). No middle layers. No bloat.
## Features
- **Solana facilitator flow** — create or register a Solana server wallet, quote pricing with `/v1/payments/x402/accepts`, and settle signed transactions with `/v1/payments/x402/settle`.
- **Unified verify path** — `/v1/payments/x402/verify` now validates Solana payloads the same way it does for EVM, so you can reuse your middleware checks across chains.
- **End-to-end examples** — refreshed snippets for Node environments that show the exact headers, payloads, and success envelopes you should expect in production.
## Fast Start Guide
### 1. Create a facilitator wallet
First, create a server-side wallet that will act as the facilitator.
```ts
const response = await fetch("https://api.thirdweb.com/v1/solana/wallets", {
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
label: "my-solana-facilitator",
}),
});
const wallet = await response.json();
console.log(wallet.result.address);
```
The returned address is the server-side payer for settles, so double-check that it appears in your project’s server wallet list.
<Callout variant="info">
**Fund the wallet:** Top up SOL for fees on the target cluster (devnet or mainnet-beta).
</Callout>
### 2. Quote access
Use `/v1/payments/x402/accepts` to generate the payment requirements.
```ts
const acceptsResponse = await fetch(
"https://api.thirdweb.com/v1/payments/x402/accepts",
{
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
resourceUrl: "https://example.com/solana-protected",
method: "POST",
network: "solana:devnet",
price: {
amount: "0",
asset: {
address: "So11111111111111111111111111111111111111112",
decimals: 9,
tokenProgram: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
},
},
serverWalletAddress: "YOUR_SOLANA_FACILITATOR_ADDRESS",
}),
}
);
const { accepts } = await acceptsResponse.json();
const paymentRequirements = accepts[0];
```
Keep `paymentRequirements` around—clients will sign against it, and the facilitator will reuse it when settling.
### 3. Verify before you settle
Once you have the signed payment payload from the client, you can verify it.
```ts
const verifyResponse = await fetch(
"https://api.thirdweb.com/v1/payments/x402/verify",
{
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentPayload: SIGNED_PAYMENT_PAYLOAD,
paymentRequirements,
}),
}
);
const verifyData = await verifyResponse.json();
console.log(verifyData.isValid);
```
A truthy `isValid` means the signatures, blockhash, and amounts all check out.
### 4. Settle on-chain
Settle the transaction using your facilitator wallet.
```ts
const settleResponse = await fetch(
"https://api.thirdweb.com/v1/payments/x402/settle",
{
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentPayload: SIGNED_PAYMENT_PAYLOAD,
paymentRequirements,
waitUntil: "submitted",
}),
}
);
const settleData = await settleResponse.json();
console.log(settleData.transaction);
```
<Callout variant="info">
Use `waitUntil: "confirmed"` if you need finality before responding to the client.
</Callout>
Watch your server wallet dashboard or any Solana explorer for the transaction signature the settle endpoint returns.
### Fetch protected resources
Let payers fetch protected resources. If the client hits the protected endpoint first, have it replay the request through `/v1/payments/x402/fetch`. The API will answer with the payload you need for verification and settlement.
```ts
const fetchResponse = await fetch(
"https://api.thirdweb.com/v1/payments/x402/fetch?" +
new URLSearchParams({
url: "https://example.com/solana-protected",
from: "PAYER_SOLANA_ADDRESS",
chainId: "solana:devnet",
}),
{
method: "POST",
headers: {
"x-secret-key": "YOUR_SECRET_KEY",
},
}
);
const fetchData = await fetchResponse.json();
```
+4
View File
@@ -37,6 +37,10 @@ export const sidebar: SideBar = {
href: `${x402Slug}/facilitator`,
name: "Facilitator",
},
{
href: `${x402Slug}/facilitator/solana`,
name: "Solana",
},
],
name: "Guides",
},
+16
View File
@@ -1,5 +1,21 @@
# wagmi-inapp
## 0.0.42
### Patch Changes
- Updated dependencies [[`7b873d3`](https://github.com/thirdweb-dev/js/commit/7b873d38db373151ef42dfa7bc1e083568cb5432), [`b0eccf3`](https://github.com/thirdweb-dev/js/commit/b0eccf365a7521ec882fca9e680837c84147041e), [`a704c3f`](https://github.com/thirdweb-dev/js/commit/a704c3f2b00434f8f50653aa2825b4adf7cd4696)]:
- thirdweb@5.115.3
- @thirdweb-dev/wagmi-adapter@0.2.197
## 0.0.41
### Patch Changes
- Updated dependencies [[`859032d`](https://github.com/thirdweb-dev/js/commit/859032dc1761afdb7a4e12b40534e3a98b771498)]:
- thirdweb@5.115.2
- @thirdweb-dev/wagmi-adapter@0.2.196
## 0.0.40
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wagmi-inapp",
"private": true,
"version": "0.0.40",
"version": "0.0.42",
"type": "module",
"scripts": {
"dev": "vite",
+14
View File
@@ -1,5 +1,19 @@
# @thirdweb-dev/nebula
## 0.2.100
### Patch Changes
- Updated dependencies [[`7b873d3`](https://github.com/thirdweb-dev/js/commit/7b873d38db373151ef42dfa7bc1e083568cb5432), [`b0eccf3`](https://github.com/thirdweb-dev/js/commit/b0eccf365a7521ec882fca9e680837c84147041e), [`a704c3f`](https://github.com/thirdweb-dev/js/commit/a704c3f2b00434f8f50653aa2825b4adf7cd4696)]:
- thirdweb@5.115.3
## 0.2.99
### Patch Changes
- Updated dependencies [[`859032d`](https://github.com/thirdweb-dev/js/commit/859032dc1761afdb7a4e12b40534e3a98b771498)]:
- thirdweb@5.115.2
## 0.2.98
### 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.98"
"version": "0.2.100"
}
+16
View File
@@ -1,5 +1,21 @@
# thirdweb
## 5.115.3
### Patch Changes
- [#8509](https://github.com/thirdweb-dev/js/pull/8509) [`7b873d3`](https://github.com/thirdweb-dev/js/commit/7b873d38db373151ef42dfa7bc1e083568cb5432) Thanks [@MananTank](https://github.com/MananTank)! - Update success message shown in SwapWidget component
- [#8501](https://github.com/thirdweb-dev/js/pull/8501) [`b0eccf3`](https://github.com/thirdweb-dev/js/commit/b0eccf365a7521ec882fca9e680837c84147041e) Thanks [@MananTank](https://github.com/MananTank)! - Remove tabs from token selection UI in SwapWidget
- [#8504](https://github.com/thirdweb-dev/js/pull/8504) [`a704c3f`](https://github.com/thirdweb-dev/js/commit/a704c3f2b00434f8f50653aa2825b4adf7cd4696) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Allow passing an activeWallet to SwapWidget
## 5.115.2
### Patch Changes
- [#8496](https://github.com/thirdweb-dev/js/pull/8496) [`859032d`](https://github.com/thirdweb-dev/js/commit/859032dc1761afdb7a4e12b40534e3a98b771498) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Skip swap review step when coming from swap widget
## 5.115.1
### Patch Changes
+1 -1
View File
@@ -430,5 +430,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.115.1"
"version": "5.115.3"
}
@@ -723,6 +723,7 @@ function BridgeWidgetContent(
if (screen.id === "6:success") {
return (
<SuccessScreen
type="payment-success"
// from props
client={props.client}
hasPaymentId={!!props.paymentLinkId}
@@ -730,6 +730,7 @@ function CheckoutWidgetContent(
if (screen.id === "6:success") {
return (
<SuccessScreen
type="payment-success"
// from props
client={props.client}
hasPaymentId={!!props.paymentLinkId}
@@ -786,6 +786,7 @@ function TransactionWidgetContent(
<SuccessScreen
// from props
client={props.client}
type="payment-success"
hasPaymentId={!!props.paymentLinkId}
// others
completedStatuses={screen.completedStatuses}
@@ -46,6 +46,8 @@ type SuccessScreenProps = {
* Whether or not this payment is associated with a payment ID. If it does, we show a different message.
*/
hasPaymentId: boolean;
type: "swap-success" | "payment-success";
};
type ViewState = "success" | "detail";
@@ -58,6 +60,7 @@ export function SuccessScreen({
client,
hasPaymentId = false,
showContinueWithTx,
type,
}: SuccessScreenProps) {
const theme = useCustomTheme();
const [viewState, setViewState] = useState<ViewState>("success");
@@ -112,6 +115,13 @@ export function SuccessScreen({
);
}
const title =
type === "swap-success" ? "Swap Successful" : "Payment Successful";
const description =
type === "swap-success"
? "Your token swap has been completed successfully."
: "Your payment has been completed successfully.";
return (
<Container flex="column" fullHeight px="md" pb="md" pt="md+">
<Spacer y="3xl" />
@@ -151,7 +161,7 @@ export function SuccessScreen({
marginBottom: spacing.xxs,
}}
>
Payment Successful
{title}
</Text>
<Text center color="secondaryText" size="sm">
@@ -159,7 +169,7 @@ export function SuccessScreen({
? "You can now close this page and return to the application."
: showContinueWithTx
? "Click continue to execute your transaction."
: "Your payment has been completed successfully."}
: description}
</Text>
</div>
</Container>
@@ -173,7 +183,7 @@ export function SuccessScreen({
onClick={() => setViewState("detail")}
variant="secondary"
>
View Payment Receipt
View Transaction Receipt
</Button>
{!hasPaymentId && (
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { trackPayEvent } from "../../../../../analytics/track/pay.js";
import type { Buy, Sell } from "../../../../../bridge/index.js";
import type { TokenWithPrices } from "../../../../../bridge/types/Token.js";
@@ -8,6 +8,7 @@ import type { ThirdwebClient } from "../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { getAddress } from "../../../../../utils/address.js";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.js";
import { CustomThemeProvider } from "../../../../core/design-system/CustomThemeProvider.js";
import type { Theme } from "../../../../core/design-system/index.js";
import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js";
@@ -16,7 +17,6 @@ import { webWindowAdapter } from "../../../adapters/WindowAdapter.js";
import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js";
import { DynamicHeight } from "../../components/DynamicHeight.js";
import { ErrorBanner } from "../ErrorBanner.js";
import { PaymentDetails } from "../payment-details/PaymentDetails.js";
import { SuccessScreen } from "../payment-success/SuccessScreen.js";
import { StepRunner } from "../StepRunner.js";
import { useActiveWalletInfo } from "./hooks.js";
@@ -170,6 +170,10 @@ export type SwapWidgetProps = {
* Called when the user disconnects the active wallet
*/
onDisconnect?: () => void;
/**
* The wallet that should be pre-selected in the SwapWidget UI.
*/
activeWallet?: Wallet;
};
/**
@@ -321,7 +325,7 @@ function SwapWidgetContent(
},
) {
const [screen, setScreen] = useState<SwapWidgetScreen>({ id: "1:swap-ui" });
const activeWalletInfo = useActiveWalletInfo();
const activeWalletInfo = useActiveWalletInfo(props.activeWallet);
const isPersistEnabled = props.persistTokenSelections !== false;
const [amountSelection, setAmountSelection] = useState<{
@@ -364,19 +368,6 @@ function SwapWidgetContent(
// preload requests
useBridgeChains(props.client);
const handleError = useCallback(
(error: Error, quote: SwapPreparedQuote) => {
console.error(error);
props.onError?.(error, quote);
setScreen({
id: "error",
preparedQuote: quote,
error,
});
},
[props.onError],
);
// if wallet suddenly disconnects, show screen 1
if (screen.id === "1:swap-ui" || !activeWalletInfo) {
return (
@@ -399,19 +390,8 @@ function SwapWidgetContent(
amountSelection={amountSelection}
setAmountSelection={setAmountSelection}
onSwap={(data) => {
trackPayEvent({
chainId: data.result.intent.originChainId,
client: props.client,
event: "payment_details",
fromToken: data.result.intent.originTokenAddress,
toChainId: data.result.intent.destinationChainId,
toToken: data.result.intent.destinationTokenAddress,
walletAddress:
activeWalletInfo?.activeWallet?.getAccount()?.address,
walletType: activeWalletInfo?.activeWallet?.id,
});
setScreen({
id: "2:preview",
id: "3:execute",
buyToken: data.buyToken,
sellToken: data.sellToken,
sellTokenBalance: data.sellTokenBalance,
@@ -425,43 +405,6 @@ function SwapWidgetContent(
);
}
if (screen.id === "2:preview") {
return (
<PaymentDetails
metadata={{
title: "Review Swap",
description: undefined,
}}
confirmButtonLabel="Swap"
client={props.client}
onBack={() => {
setScreen({ id: "1:swap-ui" });
}}
onConfirm={() => {
setScreen({
...screen,
id: "3:execute",
});
}}
onError={(error) => handleError(error, screen.preparedQuote)}
paymentMethod={{
quote: screen.quote,
type: "wallet",
payerWallet: activeWalletInfo.activeWallet,
balance: screen.sellTokenBalance,
originToken: screen.sellToken,
action: screen.mode,
hasEnoughBalance: true,
}}
preparedQuote={screen.preparedQuote}
currency={props.currency}
modeInfo={{
mode: "fund_wallet",
}}
/>
);
}
if (screen.id === "3:execute") {
return (
<StepRunner
@@ -472,8 +415,7 @@ function SwapWidgetContent(
onBack={() => {
setScreen({
...screen,
id: "2:preview",
sellTokenBalance: screen.sellTokenBalance,
id: "1:swap-ui",
});
}}
onCancel={() => props.onCancel?.(screen.preparedQuote)}
@@ -498,6 +440,7 @@ function SwapWidgetContent(
if (screen.id === "4:success") {
return (
<SuccessScreen
type="swap-success"
client={props.client}
completedStatuses={screen.completedStatuses}
onDone={() => {
@@ -1,21 +1,27 @@
import { useMemo } from "react";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.js";
import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js";
import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js";
import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js";
import type { ActiveWalletInfo } from "./types.js";
export function useActiveWalletInfo(): ActiveWalletInfo | undefined {
export function useActiveWalletInfo(
activeWalletOverride?: Wallet,
): ActiveWalletInfo | undefined {
const activeAccount = useActiveAccount();
const activeWallet = useActiveWallet();
const activeChain = useActiveWalletChain();
return useMemo(() => {
return activeAccount && activeWallet && activeChain
const wallet = activeWalletOverride || activeWallet;
const chain = activeWalletOverride?.getChain() || activeChain;
const account = activeWalletOverride?.getAccount() || activeAccount;
return wallet && chain && account
? {
activeChain,
activeWallet,
activeAccount,
activeChain: chain,
activeWallet: wallet,
activeAccount: account,
}
: undefined;
}, [activeAccount, activeWallet, activeChain]);
}, [activeAccount, activeWallet, activeChain, activeWalletOverride]);
}
@@ -10,6 +10,8 @@ import {
radius,
spacing,
} from "../../../../core/design-system/index.js";
import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js";
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
import { Container, noScrollBar } from "../../components/basic.js";
import { Button } from "../../components/buttons.js";
import { Img } from "../../components/Img.js";
@@ -50,8 +52,7 @@ function findChain(chains: BridgeChain[], activeChainId: number | undefined) {
return chains.find((chain) => chain.chainId === activeChainId);
}
const ALL_TOKENS_LIMIT = 100;
const OWNED_TOKENS_LIMIT = 50;
const INITIAL_LIMIT = 100;
/**
* @internal
@@ -60,8 +61,12 @@ export function SelectToken(props: SelectTokenUIProps) {
const chainQuery = useBridgeChains(props.client);
const [search, _setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 500);
const [allTokensLimit, setAllTokensLimit] = useState(ALL_TOKENS_LIMIT);
const [ownedTokensLimit, setOwnedTokensLimit] = useState(OWNED_TOKENS_LIMIT);
const [limit, setLimit] = useState(INITIAL_LIMIT);
const setSearch = useCallback((search: string) => {
_setSearch(search);
setLimit(INITIAL_LIMIT);
}, []);
const [_selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
undefined,
@@ -79,15 +84,15 @@ export function SelectToken(props: SelectTokenUIProps) {
client: props.client,
chainId: selectedChain?.chainId,
search: debouncedSearch,
limit: allTokensLimit,
limit,
offset: 0,
});
// owned tokens
const ownedTokensQuery = useTokenBalances({
clientId: props.client.clientId,
client: props.client,
chainId: selectedChain?.chainId,
limit: ownedTokensLimit,
limit,
page: 1,
walletAddress: props.activeWalletInfo?.activeAccount.address,
});
@@ -104,62 +109,43 @@ export function SelectToken(props: SelectTokenUIProps) {
});
}, [ownedTokensQuery.data?.tokens, debouncedSearch]);
const setSearch = useCallback((search: string) => {
_setSearch(search);
setAllTokensLimit(ALL_TOKENS_LIMIT);
setOwnedTokensLimit(OWNED_TOKENS_LIMIT);
}, []);
const isFetching = tokensQuery.isFetching || ownedTokensQuery.isFetching;
return (
<SelectTokenUI
{...props}
ownedTokens={{
data: filteredOwnedTokens || [],
isFetching: ownedTokensQuery.isFetching,
showMore: ownedTokensQuery.data?.pagination.hasMore
? () => {
setOwnedTokensLimit(ownedTokensLimit + OWNED_TOKENS_LIMIT);
}
: undefined,
}}
allTokens={{
data: tokensQuery.data || [],
isFetching: tokensQuery.isFetching,
showMore:
tokensQuery.data?.length === allTokensLimit
? () => {
setAllTokensLimit(allTokensLimit + ALL_TOKENS_LIMIT);
}
: undefined,
}}
ownedTokens={filteredOwnedTokens || []}
allTokens={tokensQuery.data || []}
isFetching={isFetching}
selectedChain={selectedChain}
setSelectedChain={setSelectedChain}
search={search}
setSearch={setSearch}
selectedToken={props.selectedToken}
setSelectedToken={props.setSelectedToken}
showMore={
tokensQuery.data?.length === limit
? () => {
setLimit(limit + INITIAL_LIMIT);
}
: undefined
}
/>
);
}
function SelectTokenUI(
props: SelectTokenUIProps & {
allTokens: {
data: Token[];
isFetching: boolean;
showMore: (() => void) | undefined;
};
ownedTokens: {
data: TokenBalance[];
isFetching: boolean;
showMore: (() => void) | undefined;
};
ownedTokens: TokenBalance[];
allTokens: Token[];
isFetching: boolean;
selectedChain: BridgeChain | undefined;
setSelectedChain: (chain: BridgeChain) => void;
search: string;
setSearch: (search: string) => void;
selectedToken: TokenSelection | undefined;
setSelectedToken: (token: TokenSelection) => void;
showMore: (() => void) | undefined;
},
) {
const isMobile = useIsMobile();
@@ -169,7 +155,7 @@ function SelectTokenUI(
// show tokens with icons first
const sortedOwnedTokens = useMemo(() => {
return props.ownedTokens.data.sort((a, b) => {
return props.ownedTokens.sort((a, b) => {
if (a.icon_uri && !b.icon_uri) {
return -1;
}
@@ -178,11 +164,23 @@ function SelectTokenUI(
}
return 0;
});
}, [props.ownedTokens.data]);
}, [props.ownedTokens]);
const otherTokens = useMemo(() => {
const ownedTokenSet = new Set(
sortedOwnedTokens.map((t) =>
`${t.token_address}-${t.chain_id}`.toLowerCase(),
),
);
return props.allTokens.filter(
(token) =>
!ownedTokenSet.has(`${token.address}-${token.chainId}`.toLowerCase()),
);
}, [props.allTokens, sortedOwnedTokens]);
// show tokens with icons first
const sortedAllTokens = useMemo(() => {
return props.allTokens.data.sort((a, b) => {
const sortedOtherTokens = useMemo(() => {
return otherTokens.sort((a, b) => {
if (a.iconUri && !b.iconUri) {
return -1;
}
@@ -191,7 +189,7 @@ function SelectTokenUI(
}
return 0;
});
}, [props.allTokens.data]);
}, [otherTokens]);
// desktop
if (!isMobile) {
@@ -223,20 +221,15 @@ function SelectTokenUI(
}}
isMobile={false}
selectedToken={props.selectedToken}
ownedTokens={{
...props.ownedTokens,
data: sortedOwnedTokens,
}}
allTokens={{
...props.allTokens,
data: sortedAllTokens,
}}
isFetching={props.isFetching}
ownedTokens={props.ownedTokens}
otherTokens={sortedOtherTokens}
showMore={props.showMore}
selectedChain={props.selectedChain}
onSelectChain={() => setScreen("select-chain")}
client={props.client}
search={props.search}
setSearch={props.setSearch}
isWalletConnected={props.activeWalletInfo !== undefined}
/>
</Container>
</Container>
@@ -246,20 +239,15 @@ function SelectTokenUI(
if (screen === "select-token") {
return (
<TokenSelectionScreen
isWalletConnected={props.activeWalletInfo !== undefined}
onSelectToken={(token) => {
props.setSelectedToken(token);
props.onClose();
}}
selectedToken={props.selectedToken}
ownedTokens={{
...props.ownedTokens,
data: sortedOwnedTokens,
}}
allTokens={{
...props.allTokens,
data: sortedAllTokens,
}}
isFetching={props.isFetching}
ownedTokens={props.ownedTokens}
otherTokens={sortedOtherTokens}
showMore={props.showMore}
selectedChain={props.selectedChain}
isMobile={true}
onSelectChain={() => setScreen("select-chain")}
@@ -447,36 +435,17 @@ function TokenSelectionScreen(props: {
client: ThirdwebClient;
search: string;
setSearch: (search: string) => void;
isFetching: boolean;
ownedTokens: TokenBalance[];
otherTokens: Token[];
showMore: (() => void) | undefined;
selectedToken: TokenSelection | undefined;
onSelectToken: (token: TokenSelection) => void;
isWalletConnected: boolean;
allTokens: {
data: Token[];
isFetching: boolean;
showMore: (() => void) | undefined;
};
ownedTokens: {
data: TokenBalance[];
isFetching: boolean;
showMore: (() => void) | undefined;
};
}) {
const noAllTokensFound =
!props.allTokens.isFetching && props.allTokens.data.length === 0;
const noOwnedTokensFound =
!props.ownedTokens.isFetching && props.ownedTokens.data.length === 0;
const [activeTab, setActiveTab] = useState<"owned" | "all">("all");
const showSkeletons =
(props.allTokens.isFetching && activeTab === "all") ||
(props.ownedTokens.isFetching && activeTab === "owned");
const showNoTokensFound =
(activeTab === "all" && noAllTokensFound) ||
(activeTab === "owned" && noOwnedTokensFound);
const theme = useCustomTheme();
const noTokensFound =
!props.isFetching &&
props.otherTokens.length === 0 &&
props.ownedTokens.length === 0;
return (
<Container fullHeight flex="column">
@@ -534,28 +503,7 @@ function TokenSelectionScreen(props: {
/>
</Container>
<Spacer y="sm" />
{/* tabs */}
{props.isWalletConnected && (
<Container
flex="row"
gap="xs"
px="md"
style={{ borderBottom: `1px solid ${theme.colors.borderColor}` }}
>
<TabButton
isSelected={activeTab === "all"}
onSelect={() => setActiveTab("all")}
label="All Tokens"
/>
<TabButton
isSelected={activeTab === "owned"}
onSelect={() => setActiveTab("owned")}
label="Your Tokens"
/>
</Container>
)}
<Spacer y="xs" />
<Container
pb="md"
@@ -571,17 +519,36 @@ function TokenSelectionScreen(props: {
paddingBottom: spacing.md,
}}
>
{props.isWalletConnected && <Spacer y="xxs" />}
{showSkeletons &&
{props.isFetching &&
new Array(20).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<TokenButtonSkeleton key={i} />
))}
{activeTab === "owned" &&
!props.ownedTokens.isFetching &&
props.ownedTokens.data.map((token) => (
{!props.isFetching && props.ownedTokens.length > 0 && (
<Container
px="xs"
py="xs"
flex="row"
gap="xs"
center="y"
color="secondaryText"
>
<WalletDotIcon size="14" />
<Text
size="sm"
color="secondaryText"
style={{
overflow: "unset",
}}
>
Your Tokens
</Text>
</Container>
)}
{!props.isFetching &&
props.ownedTokens.map((token) => (
<TokenButton
key={token.token_address}
token={token}
@@ -596,9 +563,33 @@ function TokenSelectionScreen(props: {
/>
))}
{activeTab === "all" &&
!props.allTokens.isFetching &&
props.allTokens.data.map((token) => (
{!props.isFetching && props.ownedTokens.length > 0 && (
<Container
px="xs"
py="xs"
flex="row"
gap="xs"
center="y"
color="secondaryText"
style={{
marginTop: spacing.sm,
}}
>
<CoinsIcon size="14" />
<Text
size="sm"
color="secondaryText"
style={{
overflow: "unset",
}}
>
Other Tokens
</Text>
</Container>
)}
{!props.isFetching &&
props.otherTokens.map((token) => (
<TokenButton
key={token.address}
token={token}
@@ -613,31 +604,19 @@ function TokenSelectionScreen(props: {
/>
))}
{activeTab === "all" && props.allTokens.showMore && (
{props.showMore && (
<Button
variant="secondary"
fullWidth
onClick={() => {
props.allTokens.showMore?.();
props.showMore?.();
}}
>
Load More
</Button>
)}
{activeTab === "owned" && props.ownedTokens.showMore && (
<Button
variant="secondary"
fullWidth
onClick={() => {
props.ownedTokens.showMore?.();
}}
>
Load More
</Button>
)}
{showNoTokensFound && (
{noTokensFound && (
<div
style={{
flex: 1,
@@ -669,45 +648,3 @@ const LeftContainer = /* @__PURE__ */ StyledDiv((_) => {
position: "relative",
};
});
function TabButton(props: {
isSelected: boolean;
onSelect: () => void;
label: string;
}) {
const theme = useCustomTheme();
return (
<div
style={{
paddingBottom: "4px",
position: "relative",
}}
>
<Button
variant="ghost-solid"
onClick={props.onSelect}
style={{
fontSize: fontSize.sm,
padding: `10px ${spacing.xs}`,
color: props.isSelected
? theme.colors.primaryText
: theme.colors.secondaryText,
}}
>
{props.label}
</Button>
{props.isSelected && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: "-1.5px",
borderBottom: `2px solid ${theme.colors.primaryText}`,
}}
></div>
)}
</div>
);
}
@@ -4,6 +4,7 @@ import { tokens } from "../../../../../bridge/Token.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import { isAddress } from "../../../../../utils/address.js";
import { getThirdwebBaseUrl } from "../../../../../utils/domains.js";
import { getClientFetch } from "../../../../../utils/fetch.js";
export function useTokens(options: {
client: ThirdwebClient;
@@ -72,7 +73,7 @@ type TokenBalancesResponse = {
};
export function useTokenBalances(options: {
clientId: string;
client: ThirdwebClient;
page: number;
limit: number;
walletAddress: string | undefined;
@@ -101,11 +102,8 @@ export function useTokenBalances(options: {
url.searchParams.set("sortOrder", "desc");
url.searchParams.set("includeWithoutPrice", "false"); // filter out tokens with no price
const response = await fetch(url.toString(), {
headers: {
"x-client-id": options.clientId,
},
});
const clientFetch = getClientFetch(options.client);
const response = await clientFetch(url.toString());
if (!response.ok) {
throw new Error(
@@ -75,6 +75,7 @@ const meta: Meta<typeof SuccessScreen> = {
windowAdapter: webWindowAdapter,
client: storyClient,
hasPaymentId: false,
type: "payment-success",
},
component: SuccessScreen,
decorators: [
@@ -94,6 +95,12 @@ export const Basic: Story = {
args: {},
};
export const BasicSwapSuccess: Story = {
args: {
type: "swap-success",
},
};
export const OnrampPayment: Story = {
args: {
completedStatuses: mockOnrampCompletedStatuses,
+4
View File
@@ -1,5 +1,9 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.197
## 0.2.196
## 0.2.195
## 0.2.194
+1 -1
View File
@@ -56,5 +56,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.195"
"version": "0.2.197"
}