Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5bb36141d | ||
|
|
f3234c3c41 | ||
|
|
91e3781e83 | ||
|
|
86e3aa8c04 | ||
|
|
aa1035d16c | ||
|
|
bd395a3cff | ||
|
|
446a69b1d1 | ||
|
|
826d9c80a8 | ||
|
|
772eb0cc53 | ||
|
|
5c6dd874e0 | ||
|
|
a9e47e4759 | ||
|
|
bfd728a9d1 | ||
|
|
381a2823d7 | ||
|
|
2111b8b12c |
@@ -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=
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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'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,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+4
@@ -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}/#`,
|
||||
|
||||
+40
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-45
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
/>
|
||||
|
||||
+22
-5
@@ -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>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+68
-40
@@ -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) => {
|
||||
|
||||
+51
-6
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+92
-88
@@ -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(),
|
||||
},
|
||||
];
|
||||
|
||||
+9
-4
@@ -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);
|
||||
|
||||
+79
-11
@@ -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}
|
||||
|
||||
+9
-2
@@ -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) => `\
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-3
@@ -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>
|
||||
|
||||
+95
@@ -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>
|
||||
);
|
||||
}
|
||||
-22
@@ -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>
|
||||
);
|
||||
}
|
||||
+149
@@ -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>
|
||||
);
|
||||
}
|
||||
+111
-80
@@ -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>
|
||||
|
||||
+56
@@ -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[];
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type SearchType = "email" | "phone" | "id" | "address";
|
||||
+1
@@ -34,6 +34,7 @@ export default async function Page(props: {
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectClientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+231
@@ -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>
|
||||
);
|
||||
}
|
||||
+106
@@ -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>
|
||||
);
|
||||
}
|
||||
+29
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+29
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+111
@@ -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>
|
||||
);
|
||||
}
|
||||
+23
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+101
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+50
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+195
@@ -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>
|
||||
);
|
||||
}
|
||||
+346
@@ -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>
|
||||
);
|
||||
}
|
||||
+428
@@ -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>
|
||||
);
|
||||
}
|
||||
+38
@@ -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>
|
||||
);
|
||||
}
|
||||
+45
@@ -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>
|
||||
);
|
||||
}
|
||||
+24
-3
@@ -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`,
|
||||
},
|
||||
]}
|
||||
|
||||
+59
-19
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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: {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -404,5 +404,5 @@
|
||||
}
|
||||
},
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "5.105.11"
|
||||
"version": "5.105.14"
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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={() => {}}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
+445
-445
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -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;
|
||||
|
||||
+17
-17
@@ -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;
|
||||
|
||||
+17
-17
@@ -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;
|
||||
|
||||
+16
-16
@@ -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
Reference in New Issue
Block a user