Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77063e5c85 | ||
|
|
585eac2a6f | ||
|
|
c54db522b3 | ||
|
|
d62e9971ee | ||
|
|
7d796f5aa5 | ||
|
|
a5bb36141d | ||
|
|
f3234c3c41 | ||
|
|
91e3781e83 | ||
|
|
86e3aa8c04 | ||
|
|
aa1035d16c | ||
|
|
bd395a3cff | ||
|
|
446a69b1d1 | ||
|
|
826d9c80a8 | ||
|
|
772eb0cc53 | ||
|
|
5c6dd874e0 | ||
|
|
a9e47e4759 | ||
|
|
bfd728a9d1 | ||
|
|
381a2823d7 | ||
|
|
2111b8b12c | ||
|
|
1665e4d534 | ||
|
|
692cedf921 | ||
|
|
e1f4901b80 | ||
|
|
028f871d40 | ||
|
|
2821b4eeb7 | ||
|
|
adfa56b2a2 | ||
|
|
415ad90986 | ||
|
|
afcb7bba84 | ||
|
|
10c3b6f27f | ||
|
|
c64446306e | ||
|
|
2136c7ba4a | ||
|
|
67c3d861fe | ||
|
|
d2c12cc6ef |
@@ -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",
|
||||
@@ -81,7 +82,7 @@
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.71"
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
let _posthogClient: PostHog | null = null;
|
||||
|
||||
function getPostHogServer(): PostHog | null {
|
||||
if (!_posthogClient && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
_posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
host: "https://us.i.posthog.com",
|
||||
});
|
||||
}
|
||||
return _posthogClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 const isFeatureFlagEnabled = unstable_cache(
|
||||
async (params: {
|
||||
flagKey: string;
|
||||
accountId: string;
|
||||
email: string | undefined;
|
||||
}): Promise<boolean> => {
|
||||
const posthogClient = getPostHogServer();
|
||||
if (!posthogClient) {
|
||||
console.warn("Posthog client not set");
|
||||
return true;
|
||||
}
|
||||
|
||||
const { flagKey, accountId, email } = params;
|
||||
|
||||
try {
|
||||
if (posthogClient && accountId) {
|
||||
const isEnabled = await posthogClient.isFeatureEnabled(
|
||||
flagKey,
|
||||
accountId,
|
||||
{
|
||||
personProperties: email ? { email } : undefined,
|
||||
},
|
||||
);
|
||||
if (isEnabled !== undefined) {
|
||||
return isEnabled;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking feature flag ${flagKey}:`, error);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
["is-feature-flag-enabled"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
@@ -362,7 +362,11 @@ export function reportAssetCreationFailed(
|
||||
properties: { contractType: AssetContractType; error: string } & (
|
||||
| {
|
||||
assetType: "nft";
|
||||
step: "deploy-contract" | "mint-nfts" | "set-claim-conditions";
|
||||
step:
|
||||
| "deploy-contract"
|
||||
| "mint-nfts"
|
||||
| "set-claim-conditions"
|
||||
| "set-admins";
|
||||
}
|
||||
| {
|
||||
assetType: "coin";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createProjectClient } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
|
||||
import { toArrFromList } from "@/utils/string";
|
||||
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
|
||||
const ALL_PROJECT_SERVICES = SERVICES.filter(
|
||||
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
|
||||
@@ -63,6 +64,10 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
});
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { createVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
|
||||
|
||||
// FIXME: We keep repeating types, API server should provide them
|
||||
@@ -311,26 +312,35 @@ export type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function rotateSecretKeyClient(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
export async function rotateSecretKeyClient(params: { project: Project }) {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
|
||||
pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
try {
|
||||
// if the project has a vault admin key, rotate it as well
|
||||
await createVaultAccountAndAccessToken({
|
||||
project: params.project,
|
||||
projectSecretKey: res.data.data.secret,
|
||||
projectSecretHash: res.data.data.secretHash,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to rotate vault admin key", error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import { defineChain, type ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { BuyWidget } from "thirdweb/react";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
export function BuyFundsSection(props: {
|
||||
chain: ChainMetadata;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<section className="flex flex-col gap-4 items-center justify-center">
|
||||
<BuyWidget
|
||||
@@ -14,6 +17,7 @@ export function BuyFundsSection(props: {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineChain(props.chain.chainId)}
|
||||
client={props.client}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
+17
-13
@@ -9,19 +9,23 @@ export async function getContractCreator(
|
||||
contract: ThirdwebContract,
|
||||
functionSelectors: string[],
|
||||
) {
|
||||
if (isOwnerSupported(functionSelectors)) {
|
||||
return owner({
|
||||
contract,
|
||||
});
|
||||
}
|
||||
try {
|
||||
if (isOwnerSupported(functionSelectors)) {
|
||||
return await owner({
|
||||
contract,
|
||||
});
|
||||
}
|
||||
|
||||
if (isGetRoleAdminSupported(functionSelectors)) {
|
||||
return getRoleMember({
|
||||
contract,
|
||||
index: BigInt(0),
|
||||
role: "admin",
|
||||
});
|
||||
}
|
||||
if (isGetRoleAdminSupported(functionSelectors)) {
|
||||
return await getRoleMember({
|
||||
contract,
|
||||
index: BigInt(0),
|
||||
role: "admin",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-3
@@ -62,9 +62,8 @@ function IntegrateAPIKeySection({
|
||||
<ClientIDSection clientId={clientId} />
|
||||
{secretKeyMasked && (
|
||||
<SecretKeySection
|
||||
projectId={project.id}
|
||||
project={project}
|
||||
secretKeyMasked={secretKeyMasked}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -257,7 +256,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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+3
-4
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { rotateSecretKeyClient } from "@/hooks/useApi";
|
||||
import { RotateSecretKeyButton } from "../../settings/ProjectGeneralSettingsPage";
|
||||
|
||||
export function SecretKeySection(props: {
|
||||
secretKeyMasked: string;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
project: Project;
|
||||
}) {
|
||||
const [secretKeyMasked, setSecretKeyMasked] = useState(props.secretKeyMasked);
|
||||
|
||||
@@ -31,8 +31,7 @@ export function SecretKeySection(props: {
|
||||
}}
|
||||
rotateSecretKey={async () => {
|
||||
return rotateSecretKeyClient({
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
project: props.project,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
+14
-3
@@ -22,8 +22,14 @@ export function ProjectSidebarLayout(props: {
|
||||
layoutPath: string;
|
||||
engineLinkType: "cloud" | "dedicated";
|
||||
children: React.ReactNode;
|
||||
isCentralizedWebhooksFeatureFlagEnabled: boolean;
|
||||
}) {
|
||||
const { layoutPath, engineLinkType, children } = props;
|
||||
const {
|
||||
layoutPath,
|
||||
engineLinkType,
|
||||
children,
|
||||
isCentralizedWebhooksFeatureFlagEnabled,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<FullWidthSidebarLayout
|
||||
@@ -75,7 +81,7 @@ export function ProjectSidebarLayout(props: {
|
||||
{
|
||||
href: `${layoutPath}/universal-bridge`,
|
||||
icon: PayIcon,
|
||||
label: "Universal Bridge",
|
||||
label: "Payments",
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/tokens`,
|
||||
@@ -119,8 +125,13 @@ export function ProjectSidebarLayout(props: {
|
||||
]}
|
||||
footerSidebarLinks={[
|
||||
{
|
||||
href: `${layoutPath}/webhooks`,
|
||||
href: isCentralizedWebhooksFeatureFlagEnabled
|
||||
? `${layoutPath}/webhooks`
|
||||
: `${layoutPath}/webhooks/contracts`,
|
||||
icon: BellIcon,
|
||||
isActive: (pathname) => {
|
||||
return pathname.startsWith(`${layoutPath}/webhooks`);
|
||||
},
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Webhooks <Badge>New</Badge>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "@/api/auth-token";
|
||||
import { getProject, getProjects, type Project } from "@/api/projects";
|
||||
import { getTeamBySlug, getTeams } from "@/api/team";
|
||||
@@ -56,10 +57,18 @@ export default async function ProjectLayout(props: {
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const engineLinkType = await getEngineLinkType({
|
||||
authToken,
|
||||
project,
|
||||
});
|
||||
const [engineLinkType, isCentralizedWebhooksFeatureFlagEnabled] =
|
||||
await Promise.all([
|
||||
getEngineLinkType({
|
||||
authToken,
|
||||
project,
|
||||
}),
|
||||
isFeatureFlagEnabled({
|
||||
flagKey: "centralized-webhooks",
|
||||
accountId: account.id,
|
||||
email: account.email,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isStaffMode = !teams.some((t) => t.slug === team.slug);
|
||||
|
||||
@@ -81,6 +90,9 @@ export default async function ProjectLayout(props: {
|
||||
<ProjectSidebarLayout
|
||||
engineLinkType={engineLinkType}
|
||||
layoutPath={layoutPath}
|
||||
isCentralizedWebhooksFeatureFlagEnabled={
|
||||
isCentralizedWebhooksFeatureFlagEnabled
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ function Story(props: { isOwnerAccount: boolean }) {
|
||||
data: {
|
||||
secret: `sk_${new Array(86).fill("x").join("")}`,
|
||||
secretMasked: "sk_123...4567",
|
||||
secretHash: "sk_123...4567",
|
||||
},
|
||||
};
|
||||
}}
|
||||
|
||||
+1
-2
@@ -135,8 +135,7 @@ export function ProjectGeneralSettingsPage(props: {
|
||||
project={props.project}
|
||||
rotateSecretKey={async () => {
|
||||
return rotateSecretKeyClient({
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
project: props.project,
|
||||
});
|
||||
}}
|
||||
showNebulaSettings={props.showNebulaSettings}
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export function SocialUrlsFieldset<T extends WithSocialUrls>(props: {
|
||||
<h2 className="mb-2 font-medium text-sm">Social URLs</h2>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="mb-5 space-y-4">
|
||||
<div className="mb-4 space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { type UseFormReturn, useFieldArray } from "react-hook-form";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type WithAdmins = {
|
||||
admins: {
|
||||
address: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function AdminAddressesFieldset<T extends WithAdmins>(props: {
|
||||
form: UseFormReturn<T>;
|
||||
}) {
|
||||
// T contains all properties of WithAdmins, so this is ok
|
||||
const form = props.form as unknown as UseFormReturn<WithAdmins>;
|
||||
const account = useActiveAccount();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "admins",
|
||||
});
|
||||
|
||||
const handleAddAddress = () => {
|
||||
append({ address: "" });
|
||||
};
|
||||
|
||||
const handleRemoveAddress = (index: number) => {
|
||||
const field = fields[index];
|
||||
if (field?.address === account?.address) {
|
||||
return; // Don't allow removing the connected address
|
||||
}
|
||||
remove(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-dashed px-4 py-6 lg:px-6">
|
||||
<div className="mb-3">
|
||||
<h2 className="mb-1 font-medium text-sm">Admins</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These wallets will have authority on the token
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="mb-4 space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
|
||||
key={field.id}
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`admins.${index}.address`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
aria-label="Admin Address"
|
||||
disabled={field.value === account?.address}
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="rounded-full"
|
||||
disabled={field.address === account?.address}
|
||||
onClick={() => handleRemoveAddress(index)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="h-auto gap-1.5 rounded-full px-3 py-1.5 text-xs"
|
||||
onClick={handleAddAddress}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
Add Admin
|
||||
</Button>
|
||||
|
||||
{form.watch("admins").length === 0 && (
|
||||
<p className="text-sm text-destructive mt-2">
|
||||
At least one admin address is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+16
@@ -22,6 +22,22 @@ export const socialUrlsSchema = z.array(
|
||||
}),
|
||||
);
|
||||
|
||||
export const addressArraySchema = z.array(
|
||||
z.object({
|
||||
address: z.string().refine(
|
||||
(value) => {
|
||||
if (isAddress(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{
|
||||
message: "Invalid address",
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const addressSchema = z.string().refine(
|
||||
(value) => {
|
||||
if (isAddress(value)) {
|
||||
|
||||
+38
-15
@@ -1,9 +1,15 @@
|
||||
import { isAddress } from "thirdweb";
|
||||
import * as z from "zod";
|
||||
import { socialUrlsSchema } from "../../_common/schema";
|
||||
import {
|
||||
addressArraySchema,
|
||||
addressSchema,
|
||||
socialUrlsSchema,
|
||||
} from "../../_common/schema";
|
||||
import type { NFTMetadataWithPrice } from "../upload-nfts/batch-upload/process-files";
|
||||
|
||||
export const nftCollectionInfoFormSchema = z.object({
|
||||
admins: addressArraySchema.refine((addresses) => addresses.length > 0, {
|
||||
message: "At least one admin is required",
|
||||
}),
|
||||
chain: z.string().min(1, "Chain is required"),
|
||||
description: z.string().optional(),
|
||||
image: z.instanceof(File).optional(),
|
||||
@@ -12,14 +18,6 @@ export const nftCollectionInfoFormSchema = z.object({
|
||||
symbol: z.string(),
|
||||
});
|
||||
|
||||
const addressSchema = z.string().refine((value) => {
|
||||
if (isAddress(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
export const nftSalesSettingsFormSchema = z.object({
|
||||
primarySaleRecipient: addressSchema,
|
||||
royaltyBps: z.coerce.number().min(0).max(10000),
|
||||
@@ -37,15 +35,36 @@ export type CreateNFTCollectionAllValues = {
|
||||
};
|
||||
|
||||
export type CreateNFTCollectionFunctions = {
|
||||
setAdmins: (values: {
|
||||
contractAddress: string;
|
||||
contractType: "DropERC721" | "DropERC1155";
|
||||
admins: {
|
||||
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: {
|
||||
@@ -54,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>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+3
@@ -10,6 +10,7 @@ import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminAddressesFieldset } from "../../_common/admin-addresses-fieldset";
|
||||
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
|
||||
import { StepCard } from "../../_common/step-card";
|
||||
import type { NFTCollectionInfoFormValues } from "../_common/form";
|
||||
@@ -126,6 +127,8 @@ export function NFTCollectionInfoFieldset(props: {
|
||||
</div>
|
||||
|
||||
<SocialUrlsFieldset form={form} />
|
||||
|
||||
<AdminAddressesFieldset form={form} />
|
||||
</StepCard>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
+12
-5
@@ -8,7 +8,7 @@ import {
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
} from "thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
|
||||
import { reportAssetCreationStepConfigured } from "@/analytics/report";
|
||||
import type { Team } from "@/api/team";
|
||||
import {
|
||||
@@ -163,11 +163,16 @@ export function CreateNFTPageUI(props: {
|
||||
}
|
||||
|
||||
function useNFTCollectionInfoForm() {
|
||||
const chain = useActiveWalletChain();
|
||||
const account = useActiveAccount();
|
||||
return useForm<NFTCollectionInfoFormValues>({
|
||||
resolver: zodResolver(nftCollectionInfoFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
values: {
|
||||
chain: "1",
|
||||
defaultValues: {
|
||||
admins: [
|
||||
{
|
||||
address: account?.address || "",
|
||||
},
|
||||
],
|
||||
chain: chain?.id.toString() || "1",
|
||||
description: "",
|
||||
image: undefined,
|
||||
name: "",
|
||||
@@ -183,5 +188,7 @@ function useNFTCollectionInfoForm() {
|
||||
],
|
||||
symbol: "",
|
||||
},
|
||||
resolver: zodResolver(nftCollectionInfoFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
}
|
||||
|
||||
+121
-38
@@ -18,8 +18,10 @@ import {
|
||||
lazyMint as lazyMint1155,
|
||||
setClaimConditions as setClaimConditions1155,
|
||||
} from "thirdweb/extensions/erc1155";
|
||||
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,
|
||||
@@ -41,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));
|
||||
|
||||
@@ -59,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));
|
||||
@@ -92,7 +101,7 @@ export function CreateNFTPage(props: {
|
||||
|
||||
if (ercType === "erc721") {
|
||||
contractAddress = await deployERC721Contract({
|
||||
account: activeAccount,
|
||||
account,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
params: {
|
||||
@@ -109,7 +118,7 @@ export function CreateNFTPage(props: {
|
||||
});
|
||||
} else {
|
||||
contractAddress = await deployERC1155Contract({
|
||||
account: activeAccount,
|
||||
account,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
params: {
|
||||
@@ -165,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) {
|
||||
@@ -204,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");
|
||||
}
|
||||
@@ -234,7 +253,7 @@ export function CreateNFTPage(props: {
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account: activeAccount,
|
||||
account,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -258,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);
|
||||
|
||||
@@ -314,7 +338,7 @@ export function CreateNFTPage(props: {
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account: activeAccount,
|
||||
account,
|
||||
transaction: tx,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -332,47 +356,106 @@ export function CreateNFTPage(props: {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetAdmins(params: {
|
||||
contractAddress: string;
|
||||
contractType: "DropERC721" | "DropERC1155";
|
||||
admins: {
|
||||
address: string;
|
||||
}[];
|
||||
gasless: boolean;
|
||||
chain: string;
|
||||
}) {
|
||||
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 !== account.address,
|
||||
);
|
||||
|
||||
const encodedTxs = await Promise.all(
|
||||
adminsToAdd.map((admin) => {
|
||||
const tx = grantRole({
|
||||
contract,
|
||||
role: "admin",
|
||||
targetAccountAddress: admin.address,
|
||||
});
|
||||
|
||||
return encode(tx);
|
||||
}),
|
||||
);
|
||||
|
||||
const tx = multicall({
|
||||
contract,
|
||||
data: encodedTxs,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account,
|
||||
transaction: tx,
|
||||
});
|
||||
} catch (e) {
|
||||
const errorMessage = parseError(e);
|
||||
console.error(errorMessage);
|
||||
|
||||
reportAssetCreationFailed({
|
||||
assetType: "nft",
|
||||
contractType: params.contractType,
|
||||
error: errorMessage,
|
||||
step: "set-admins",
|
||||
});
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateNFTPageUI
|
||||
{...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) => {
|
||||
return handleSetClaimConditionsERC1155(params);
|
||||
},
|
||||
},
|
||||
setAdmins: handleSetAdmins,
|
||||
}}
|
||||
onLaunchSuccess={() => {
|
||||
revalidatePathAction(
|
||||
|
||||
+109
-12
@@ -8,12 +8,18 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { defineChain, type ThirdwebClient } from "thirdweb";
|
||||
import { TokenProvider, TokenSymbol, useActiveWallet } from "thirdweb/react";
|
||||
import {
|
||||
TokenProvider,
|
||||
TokenSymbol,
|
||||
useActiveAccount,
|
||||
useActiveWallet,
|
||||
} from "thirdweb/react";
|
||||
import {
|
||||
reportAssetCreationFailed,
|
||||
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";
|
||||
@@ -41,6 +47,7 @@ import type {
|
||||
const stepIds = {
|
||||
"deploy-contract": "deploy-contract",
|
||||
"mint-nfts": "mint-nfts",
|
||||
"set-admins": "set-admins",
|
||||
"set-claim-conditions": "set-claim-conditions",
|
||||
} as const;
|
||||
|
||||
@@ -61,7 +68,8 @@ export function LaunchNFT(props: {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const activeWallet = useActiveWallet();
|
||||
const walletRequiresApproval = activeWallet?.id !== "inApp";
|
||||
const [contractLink, setContractLink] = useState<string | null>(null);
|
||||
const account = useActiveAccount();
|
||||
const contractAddressRef = useRef<string | null>(null);
|
||||
|
||||
function updateStatus(
|
||||
index: number,
|
||||
@@ -108,6 +116,19 @@ export function LaunchNFT(props: {
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
account &&
|
||||
formValues.collectionInfo.admins.some(
|
||||
(admin) => admin.address !== account.address,
|
||||
)
|
||||
) {
|
||||
initialSteps.push({
|
||||
id: stepIds["set-admins"],
|
||||
label: "Set admins",
|
||||
status: { type: "idle" },
|
||||
});
|
||||
}
|
||||
|
||||
setSteps(initialSteps);
|
||||
setIsModalOpen(true);
|
||||
executeSteps(initialSteps, 0);
|
||||
@@ -134,16 +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);
|
||||
setContractLink(
|
||||
`/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${result.contractAddress}`,
|
||||
);
|
||||
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;
|
||||
@@ -168,6 +201,7 @@ export function LaunchNFT(props: {
|
||||
count: batchSize,
|
||||
startIndex: batchIndex * batchSize,
|
||||
},
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
});
|
||||
|
||||
@@ -179,12 +213,29 @@ 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) {
|
||||
throw new Error("Contract address not set");
|
||||
}
|
||||
|
||||
await props.createNFTFunctions.setAdmins({
|
||||
admins: formValues.collectionInfo.admins,
|
||||
chain: formValues.collectionInfo.chain,
|
||||
contractAddress: contractAddressRef.current,
|
||||
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
|
||||
gasless: isGasless,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +328,7 @@ export function LaunchNFT(props: {
|
||||
custom: (
|
||||
<TransactionButton
|
||||
client={props.client}
|
||||
disableNoFundsPopup={isGasless}
|
||||
isLoggedIn={true}
|
||||
isPending={false}
|
||||
onClick={handleSubmitClick}
|
||||
@@ -285,7 +337,7 @@ export function LaunchNFT(props: {
|
||||
variant="default"
|
||||
>
|
||||
<ArrowUpFromLineIcon className="size-4" />
|
||||
Launch NFT
|
||||
Launch NFT Collection
|
||||
</TransactionButton>
|
||||
),
|
||||
type: "custom",
|
||||
@@ -293,7 +345,7 @@ export function LaunchNFT(props: {
|
||||
prevButton={{
|
||||
onClick: props.onPrevious,
|
||||
}}
|
||||
title="Launch NFT"
|
||||
title="Launch NFT Collection"
|
||||
>
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
@@ -304,7 +356,7 @@ export function LaunchNFT(props: {
|
||||
dialogCloseClassName="hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<DialogHeader>
|
||||
<DialogHeader className="space-y-0.5">
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Status
|
||||
</DialogTitle>
|
||||
@@ -412,10 +464,29 @@ export function LaunchNFT(props: {
|
||||
}
|
||||
/>
|
||||
</OverviewField>
|
||||
|
||||
<OverviewField
|
||||
name={
|
||||
formValues.collectionInfo.admins.length > 1 ? "Admins" : "Admin"
|
||||
}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{formValues.collectionInfo.admins.map((admin) => (
|
||||
<WalletAddress
|
||||
address={admin.address}
|
||||
className="text-foreground text-sm h-auto py-1"
|
||||
client={props.client}
|
||||
iconClassName="size-3.5"
|
||||
key={admin.address}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</OverviewField>
|
||||
</div>
|
||||
</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">
|
||||
@@ -457,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">
|
||||
@@ -485,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>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-1
@@ -4,7 +4,11 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, AsteriskIcon } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
getAddress,
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
} from "thirdweb";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { TokenSelector } from "@/components/blocks/TokenSelector";
|
||||
@@ -32,6 +36,8 @@ import type { NFTMetadataWithPrice } from "../batch-upload/process-files";
|
||||
import { nftWithPriceSchema } from "../schema";
|
||||
import { AttributesFieldset } from "./attributes";
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
export function SingleUploadNFT(props: {
|
||||
client: ThirdwebClient;
|
||||
onNext: () => void;
|
||||
@@ -46,6 +52,9 @@ export function SingleUploadNFT(props: {
|
||||
description: "",
|
||||
image: undefined,
|
||||
name: "",
|
||||
price_amount: "1",
|
||||
price_currency: nativeTokenAddress,
|
||||
supply: "1",
|
||||
},
|
||||
resolver: zodResolver(nftWithPriceSchema),
|
||||
reValidateMode: "onChange",
|
||||
|
||||
+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(),
|
||||
},
|
||||
];
|
||||
|
||||
+15
-8
@@ -8,6 +8,7 @@ import {
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
} from "thirdweb";
|
||||
import { useActiveWalletChain } from "thirdweb/react";
|
||||
import { reportAssetCreationStepConfigured } from "@/analytics/report";
|
||||
import type { Team } from "@/api/team";
|
||||
import {
|
||||
@@ -21,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);
|
||||
@@ -44,12 +50,11 @@ export function CreateTokenAssetPageUI(props: {
|
||||
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
|
||||
"token-info",
|
||||
);
|
||||
const activeWalletChain = useActiveWalletChain();
|
||||
|
||||
const tokenInfoForm = useForm<TokenInfoFormValues>({
|
||||
resolver: zodResolver(tokenInfoFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
values: {
|
||||
chain: "1",
|
||||
defaultValues: {
|
||||
chain: activeWalletChain?.id.toString() || "1",
|
||||
description: "",
|
||||
image: undefined,
|
||||
name: "",
|
||||
@@ -65,6 +70,8 @@ export function CreateTokenAssetPageUI(props: {
|
||||
],
|
||||
symbol: "",
|
||||
},
|
||||
resolver: zodResolver(tokenInfoFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const tokenDistributionForm = useForm<TokenDistributionFormValues>({
|
||||
|
||||
+80
-12
@@ -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
|
||||
@@ -262,7 +303,7 @@ export function LaunchTokenStatus(props: {
|
||||
dialogCloseClassName="hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<DialogHeader>
|
||||
<DialogHeader className="space-y-0.5">
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Status
|
||||
</DialogTitle>
|
||||
@@ -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}
|
||||
|
||||
+7
-28
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { type Step, StepsCard } from "@/components/blocks/StepsCard";
|
||||
@@ -9,7 +9,6 @@ import { CreateVaultAccountButton } from "../../vault/components/create-vault-ac
|
||||
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import { SendTestTransaction } from "./send-test-tx.client";
|
||||
import { deleteUserAccessToken } from "./utils";
|
||||
|
||||
interface Props {
|
||||
managementAccessToken: string | undefined;
|
||||
@@ -19,27 +18,12 @@ interface Props {
|
||||
teamSlug: string;
|
||||
testTxWithWallet?: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
isManagedVault: boolean;
|
||||
}
|
||||
|
||||
export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
const [userAccessToken, setUserAccessToken] = useState<string | undefined>();
|
||||
|
||||
const finalSteps = useMemo(() => {
|
||||
const steps: Step[] = [];
|
||||
steps.push({
|
||||
children: (
|
||||
<CreateVaultAccountStep
|
||||
onUserAccessTokenCreated={(token) => setUserAccessToken(token)}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
),
|
||||
completed: !!props.managementAccessToken,
|
||||
description:
|
||||
"Vault is thirdweb's key management system. It allows you to create secure server wallets and manage access tokens.",
|
||||
showCompletedChildren: false,
|
||||
title: "Create a Vault Admin Account",
|
||||
});
|
||||
steps.push({
|
||||
children: (
|
||||
<CreateServerWalletStep
|
||||
@@ -48,7 +32,7 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
),
|
||||
completed: props.wallets.length > 0,
|
||||
completed: props.wallets.length > 0 || props.hasTransactions,
|
||||
description:
|
||||
"Server wallets are smart wallets, they don't require any gas funds to send transactions.",
|
||||
showCompletedChildren: false,
|
||||
@@ -58,10 +42,10 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
steps.push({
|
||||
children: (
|
||||
<SendTestTransaction
|
||||
isManagedVault={props.isManagedVault}
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
userAccessToken={userAccessToken}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
),
|
||||
@@ -78,9 +62,9 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
props.project,
|
||||
props.wallets,
|
||||
props.hasTransactions,
|
||||
userAccessToken,
|
||||
props.teamSlug,
|
||||
props.client,
|
||||
props.isManagedVault,
|
||||
]);
|
||||
|
||||
const isComplete = useMemo(
|
||||
@@ -91,10 +75,10 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
if (props.testTxWithWallet) {
|
||||
return (
|
||||
<SendTestTransaction
|
||||
isManagedVault={props.isManagedVault}
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
userAccessToken={userAccessToken}
|
||||
walletId={props.testTxWithWallet}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
@@ -102,8 +86,6 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
}
|
||||
|
||||
if (finalSteps.length === 0 || isComplete) {
|
||||
// clear token from local storage after FTUX is complete
|
||||
deleteUserAccessToken(props.project.id);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@@ -122,10 +104,7 @@ function CreateVaultAccountStep(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-row gap-4">
|
||||
<CreateVaultAccountButton
|
||||
onUserAccessTokenCreated={props.onUserAccessTokenCreated}
|
||||
project={props.project}
|
||||
/>
|
||||
<CreateVaultAccountButton project={props.project} />
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/vault"
|
||||
|
||||
+32
-42
@@ -11,7 +11,6 @@ import { engineCloudProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -24,10 +23,9 @@ import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Wallet } from "../server-wallets/wallet-table/types";
|
||||
import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client";
|
||||
import { deleteUserAccessToken, getUserAccessToken } from "./utils";
|
||||
|
||||
const formSchema = z.object({
|
||||
accessToken: z.string().min(1, "Access token is required"),
|
||||
secretKey: z.string().min(1, "Secret key is required"),
|
||||
chainId: z.number(),
|
||||
walletIndex: z.string(),
|
||||
});
|
||||
@@ -38,9 +36,9 @@ export function SendTestTransaction(props: {
|
||||
wallets?: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
userAccessToken?: string;
|
||||
expanded?: boolean;
|
||||
walletId?: string;
|
||||
isManagedVault: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -49,12 +47,9 @@ export function SendTestTransaction(props: {
|
||||
|
||||
const chainsQuery = useAllChainsData();
|
||||
|
||||
const userAccessToken =
|
||||
props.userAccessToken ?? getUserAccessToken(props.project.id) ?? "";
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
accessToken: userAccessToken,
|
||||
secretKey: "",
|
||||
chainId: 84532,
|
||||
walletIndex:
|
||||
props.wallets && props.walletId
|
||||
@@ -73,7 +68,7 @@ export function SendTestTransaction(props: {
|
||||
const sendDummyTxMutation = useMutation({
|
||||
mutationFn: async (args: {
|
||||
walletAddress: string;
|
||||
accessToken: string;
|
||||
secretKey: string;
|
||||
chainId: number;
|
||||
}) => {
|
||||
const response = await engineCloudProxy({
|
||||
@@ -93,7 +88,9 @@ export function SendTestTransaction(props: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
"x-team-id": props.project.teamId,
|
||||
"x-vault-access-token": args.accessToken,
|
||||
...(props.isManagedVault
|
||||
? { "x-secret-key": args.secretKey }
|
||||
: { "x-vault-access-token": args.secretKey }),
|
||||
},
|
||||
method: "POST",
|
||||
pathname: "/v1/write/transaction",
|
||||
@@ -123,7 +120,7 @@ export function SendTestTransaction(props: {
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await sendDummyTxMutation.mutateAsync({
|
||||
accessToken: data.accessToken,
|
||||
secretKey: data.secretKey,
|
||||
chainId: data.chainId,
|
||||
walletAddress: selectedWallet.address,
|
||||
});
|
||||
@@ -141,44 +138,39 @@ export function SendTestTransaction(props: {
|
||||
)}
|
||||
<p className="flex items-center gap-2 text-sm text-warning-text">
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{userAccessToken
|
||||
? "Copy your Vault access token, you'll need it for every HTTP call to Engine."
|
||||
: "Every wallet action requires your Vault access token."}
|
||||
Every server wallet action requires your{" "}
|
||||
{props.isManagedVault ? "project secret key" : "vault access token"}.
|
||||
</p>
|
||||
<div className="h-4" />
|
||||
{/* Responsive container */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
|
||||
<div className="flex-grow">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Vault Access Token</p>
|
||||
{userAccessToken ? (
|
||||
<div className="flex flex-col gap-2 ">
|
||||
<CopyTextButton
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
copyIconPosition="right"
|
||||
textToCopy={userAccessToken}
|
||||
textToShow={userAccessToken}
|
||||
tooltip="Copy Vault Access Token"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This is a project-wide access token to access your server
|
||||
wallets. You can create more access tokens using your admin
|
||||
key, with granular scopes and permissions.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="vt_act_1234....ABCD"
|
||||
type={userAccessToken ? "text" : "password"}
|
||||
{...form.register("accessToken")}
|
||||
className="text-xs"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{props.isManagedVault
|
||||
? "Project Secret Key"
|
||||
: "Vault Access Token"}
|
||||
</p>
|
||||
<Input
|
||||
placeholder={
|
||||
props.isManagedVault
|
||||
? "Enter your project secret key"
|
||||
: "Enter your vault access token"
|
||||
}
|
||||
type={"password"}
|
||||
{...form.register("secretKey")}
|
||||
className="text-xs"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{props.isManagedVault
|
||||
? "Your project secret key was generated when you created your project. If you lost it, you can regenerate one in the project settings."
|
||||
: "Your vault access token was generated when you created your vault. If you lost it, you can regenerate one in the vault settings."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4" />
|
||||
<div className="h-6" />
|
||||
{/* Wallet Selector */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
|
||||
@@ -268,8 +260,6 @@ export function SendTestTransaction(props: {
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
// clear token from local storage after FTUX is complete
|
||||
deleteUserAccessToken(props.project.id);
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
|
||||
-1
@@ -64,7 +64,6 @@ export function TransactionsTableUI(props: {
|
||||
|
||||
const pageSize = 10;
|
||||
const transactionsQuery = useQuery({
|
||||
enabled: !!props.wallets && props.wallets.length > 0,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () => props.getData({ page, status }),
|
||||
queryKey: ["transactions", props.project.id, page, status],
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
export function storeUserAccessToken(projectId: string, accessToken: string) {
|
||||
localStorage.setItem(
|
||||
`thirdweb:engine-cloud-user-access-token-${projectId}`,
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserAccessToken(projectId: string) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(
|
||||
`thirdweb:engine-cloud-user-access-token-${projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteUserAccessToken(projectId: string) {
|
||||
localStorage.removeItem(
|
||||
`thirdweb:engine-cloud-user-access-token-${projectId}`,
|
||||
);
|
||||
}
|
||||
+200
-26
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { encrypt } from "@thirdweb-dev/service-utils";
|
||||
import {
|
||||
createAccessToken,
|
||||
createServiceAccount,
|
||||
createVaultClient,
|
||||
rotateServiceAccount,
|
||||
type VaultClient,
|
||||
} from "@thirdweb-dev/vault-sdk";
|
||||
import type { Project } from "@/api/projects";
|
||||
@@ -27,6 +30,183 @@ export async function initVaultClient() {
|
||||
return vc;
|
||||
}
|
||||
|
||||
export async function createVaultAccountAndAccessToken(props: {
|
||||
project: Project;
|
||||
projectSecretKey?: string;
|
||||
projectSecretHash?: string;
|
||||
}) {
|
||||
try {
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
const service = props.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
const storedRotationCode = service?.rotationCode;
|
||||
const storedEncryptedAdminKey = service?.encryptedAdminKey;
|
||||
|
||||
let adminKey: string | null = null;
|
||||
let rotationCode: string | null = null;
|
||||
|
||||
if (storedRotationCode && storedEncryptedAdminKey) {
|
||||
// if the project has a managed vault admin key, rotate it
|
||||
const rotateServiceAccountRes = await rotateServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
rotationCode: storedRotationCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (rotateServiceAccountRes.error) {
|
||||
throw new Error(rotateServiceAccountRes.error.message);
|
||||
}
|
||||
adminKey = rotateServiceAccountRes.data.newAdminKey;
|
||||
rotationCode = rotateServiceAccountRes.data.newRotationCode;
|
||||
} else {
|
||||
// otherwise create a new service account
|
||||
const serviceAccountResult = await createServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
options: {
|
||||
metadata: {
|
||||
projectId: props.project.id,
|
||||
purpose: "Thirdweb Project Server Wallet Service Account",
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (serviceAccountResult.success === false) {
|
||||
throw new Error(
|
||||
`Failed to create service account: ${serviceAccountResult.error}`,
|
||||
);
|
||||
}
|
||||
const serviceAccount = serviceAccountResult.data;
|
||||
adminKey = serviceAccount.adminKey;
|
||||
rotationCode = serviceAccount.rotationCode;
|
||||
}
|
||||
|
||||
const { managementToken, walletToken } =
|
||||
await createAndEncryptVaultAccessTokens({
|
||||
project: props.project,
|
||||
projectSecretKey: props.projectSecretKey,
|
||||
projectSecretHash: props.projectSecretHash,
|
||||
vaultClient,
|
||||
adminKey,
|
||||
rotationCode,
|
||||
});
|
||||
|
||||
return {
|
||||
adminKey,
|
||||
managementToken,
|
||||
walletToken,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create vault account and access token: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndEncryptVaultAccessTokens(props: {
|
||||
project: Project;
|
||||
vaultClient: VaultClient;
|
||||
projectSecretKey?: string;
|
||||
projectSecretHash?: string;
|
||||
adminKey: string;
|
||||
rotationCode: string;
|
||||
}) {
|
||||
const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
|
||||
props;
|
||||
|
||||
const [managementTokenResult, walletTokenResult] = await Promise.all([
|
||||
createManagementAccessToken({ project, adminKey, vaultClient }),
|
||||
createWalletAccessToken({ project, adminKey, vaultClient }),
|
||||
]);
|
||||
|
||||
if (!managementTokenResult.success) {
|
||||
throw new Error(
|
||||
`Failed to create management token: ${managementTokenResult.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!walletTokenResult.success) {
|
||||
throw new Error(
|
||||
`Failed to create wallet token: ${walletTokenResult.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
const managementToken = managementTokenResult.data;
|
||||
const walletToken = walletTokenResult.data;
|
||||
|
||||
if (projectSecretKey) {
|
||||
// verify that the project secret key is valid
|
||||
const projectSecretKeyHash = await hashSecretKey(projectSecretKey);
|
||||
const secretKeysHashed = [
|
||||
...project.secretKeys,
|
||||
// for newly rotated secret keys, we don't have the secret key in the project secret keys yet
|
||||
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
|
||||
];
|
||||
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
|
||||
throw new Error("Invalid project secret key");
|
||||
}
|
||||
|
||||
// encrypt admin key and wallet token with project secret key
|
||||
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
|
||||
encrypt(adminKey, projectSecretKey),
|
||||
encrypt(walletToken.accessToken, projectSecretKey),
|
||||
]);
|
||||
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services,
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
maskedAdminKey: maskSecret(adminKey),
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
rotationCode: rotationCode,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// no secret key, only store the management token, remove any encrypted keys
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services,
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
maskedAdminKey: maskSecret(adminKey),
|
||||
encryptedAdminKey: null,
|
||||
encryptedWalletAccessToken: null,
|
||||
rotationCode: rotationCode,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
managementToken,
|
||||
walletToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWalletAccessToken(props: {
|
||||
project: Project;
|
||||
adminKey: string;
|
||||
@@ -243,13 +423,12 @@ export async function createWalletAccessToken(props: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function createManagementAccessToken(props: {
|
||||
async function createManagementAccessToken(props: {
|
||||
project: Project;
|
||||
adminKey: string;
|
||||
rotationCode: string;
|
||||
vaultClient: VaultClient;
|
||||
}) {
|
||||
const res = await createAccessToken({
|
||||
return createAccessToken({
|
||||
client: props.vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
@@ -333,31 +512,26 @@ export async function createManagementAccessToken(props: {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (res.success) {
|
||||
const data = res.data;
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services,
|
||||
{
|
||||
actions: [],
|
||||
managementAccessToken: data.accessToken,
|
||||
maskedAdminKey: maskSecret(props.adminKey),
|
||||
name: "engineCloud",
|
||||
rotationCode: props.rotationCode,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
return res;
|
||||
}
|
||||
throw new Error(`Failed to create management access token: ${res.error}`);
|
||||
}
|
||||
|
||||
export function maskSecret(secret: string) {
|
||||
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
|
||||
}
|
||||
|
||||
async function hashSecretKey(secretKey: string) {
|
||||
if (typeof window === "undefined" || !window.crypto?.subtle) {
|
||||
throw new Error(
|
||||
"This function can only be used in the browser environment with Web Crypto API support.",
|
||||
);
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(secretKey);
|
||||
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
|
||||
return bufferToHex(hashBuffer);
|
||||
}
|
||||
|
||||
function bufferToHex(buffer: ArrayBuffer) {
|
||||
return [...new Uint8Array(buffer)]
|
||||
.map((x) => x.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
+7
-12
@@ -7,10 +7,7 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
|
||||
import { EngineChecklist } from "./analytics/ftux.client";
|
||||
import { TransactionAnalyticsSummary } from "./analytics/summary";
|
||||
import {
|
||||
getTransactionAnalyticsSummary,
|
||||
type TransactionSummaryData,
|
||||
} from "./lib/analytics";
|
||||
import { getTransactionAnalyticsSummary } from "./lib/analytics";
|
||||
import type { Wallet } from "./server-wallets/wallet-table/types";
|
||||
|
||||
export default async function TransactionsAnalyticsPage(props: {
|
||||
@@ -53,6 +50,7 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
|
||||
const managementAccessToken =
|
||||
projectEngineCloudService?.managementAccessToken;
|
||||
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
|
||||
|
||||
const eoas = managementAccessToken
|
||||
? await listEoas({
|
||||
@@ -68,14 +66,10 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
|
||||
const wallets = eoas.data?.items as Wallet[] | undefined;
|
||||
|
||||
let initialData: TransactionSummaryData | undefined;
|
||||
if (wallets && wallets.length > 0) {
|
||||
const summary = await getTransactionAnalyticsSummary({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
}).catch(() => undefined);
|
||||
initialData = summary;
|
||||
}
|
||||
const initialData = await getTransactionAnalyticsSummary({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
}).catch(() => undefined);
|
||||
const hasTransactions = initialData ? initialData.totalCount > 0 : false;
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
@@ -86,6 +80,7 @@ export default async function TransactionsAnalyticsPage(props: {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<EngineChecklist
|
||||
isManagedVault={isManagedVault}
|
||||
client={client}
|
||||
hasTransactions={hasTransactions}
|
||||
managementAccessToken={managementAccessToken ?? undefined}
|
||||
|
||||
-6
@@ -203,7 +203,6 @@ const client = createThirdwebClient({
|
||||
const serverWallet = Engine.serverWallet({
|
||||
client,
|
||||
address: "<your-server-wallet-address>",
|
||||
vaultAccessToken: "<your-vault-access-token>",
|
||||
});
|
||||
|
||||
// Prepare the transaction
|
||||
@@ -241,7 +240,6 @@ const curlExample = () => `\
|
||||
curl -X POST "${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/write/contract" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "x-secret-key: <your-project-secret-key>" \\
|
||||
-H "x-vault-access-token: <your-vault-access-token>" \\
|
||||
-d '{
|
||||
"executionOptions": {
|
||||
"from": "<your-server-wallet-address>",
|
||||
@@ -264,7 +262,6 @@ const response = await fetch(
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-secret-key": "<your-project-secret-key>",
|
||||
"x-vault-access-token": "<your-vault-access-token>"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"executionOptions": {
|
||||
@@ -290,7 +287,6 @@ url = "${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/write/contract"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-secret-key": "<your-project-secret-key>",
|
||||
"x-vault-access-token": "<your-vault-access-token>"
|
||||
}
|
||||
payload = {
|
||||
"executionOptions": {
|
||||
@@ -355,7 +351,6 @@ func main() {
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-secret-key", "<your-project-secret-key>")
|
||||
req.Header.Set("x-vault-access-token", "<your-vault-access-token>")
|
||||
|
||||
// Send the request
|
||||
client := &http.Client{}
|
||||
@@ -408,7 +403,6 @@ class Program
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.DefaultRequestHeaders.Add("x-secret-key", "<your-project-secret-key>");
|
||||
httpClient.DefaultRequestHeaders.Add("x-vault-access-token", "<your-vault-access-token>");
|
||||
|
||||
var response = await httpClient.PostAsync(url, content);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
+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>
|
||||
|
||||
+137
-96
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createServiceAccount } from "@thirdweb-dev/vault-sdk";
|
||||
import {
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
@@ -21,91 +20,74 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { storeUserAccessToken } from "../../transactions/analytics/utils";
|
||||
import {
|
||||
createManagementAccessToken,
|
||||
createWalletAccessToken,
|
||||
initVaultClient,
|
||||
createVaultAccountAndAccessToken,
|
||||
maskSecret,
|
||||
} from "../../transactions/lib/vault.client";
|
||||
|
||||
export function CreateVaultAccountButton(props: {
|
||||
project: Project;
|
||||
onUserAccessTokenCreated?: (userAccessToken: string) => void;
|
||||
}) {
|
||||
export function CreateVaultAccountButton(props: { project: Project }) {
|
||||
const [secretKeyModalOpen, setSecretKeyModalOpen] = useState(false);
|
||||
const [secretKey, setSecretKey] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [keysConfirmed, setKeysConfirmed] = useState(false);
|
||||
const [keysDownloaded, setKeysDownloaded] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [manageSelfChecked, setManageSelfChecked] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const initialiseProjectWithVaultMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (projectSecretKey: string | undefined) => {
|
||||
setModalOpen(true);
|
||||
setSecretKeyModalOpen(false);
|
||||
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
const serviceAccount = await createServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
options: {
|
||||
metadata: {
|
||||
projectId: props.project.id,
|
||||
purpose: "Thirdweb Project Server Wallet Service Account",
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!serviceAccount.success) {
|
||||
throw new Error("Failed to create service account");
|
||||
}
|
||||
|
||||
const managementAccessTokenPromise = createManagementAccessToken({
|
||||
adminKey: serviceAccount.data.adminKey,
|
||||
const result = await createVaultAccountAndAccessToken({
|
||||
project: props.project,
|
||||
rotationCode: serviceAccount.data.rotationCode,
|
||||
vaultClient,
|
||||
projectSecretKey,
|
||||
});
|
||||
|
||||
const userAccesTokenPromise = createWalletAccessToken({
|
||||
adminKey: serviceAccount.data.adminKey,
|
||||
project: props.project,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
|
||||
userAccesTokenPromise,
|
||||
managementAccessTokenPromise,
|
||||
]);
|
||||
|
||||
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
|
||||
throw new Error("Failed to create access token");
|
||||
}
|
||||
|
||||
// save in local storage in case the user refreshes the page during FTUX
|
||||
storeUserAccessToken(
|
||||
props.project.id,
|
||||
userAccessTokenRes.data.accessToken,
|
||||
);
|
||||
props.onUserAccessTokenCreated?.(userAccessTokenRes.data.accessToken);
|
||||
|
||||
return {
|
||||
serviceAccount: serviceAccount.data,
|
||||
userAccessToken: userAccessTokenRes.data,
|
||||
};
|
||||
return result;
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
setErrorMessage(error.message);
|
||||
setModalOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateServerWallet = async () => {
|
||||
await initialiseProjectWithVaultMutation.mutateAsync();
|
||||
const handleInitializeVault = async () => {
|
||||
setSecretKeyModalOpen(true);
|
||||
setErrorMessage("");
|
||||
setSecretKey("");
|
||||
setManageSelfChecked(false);
|
||||
};
|
||||
|
||||
const handleSecretKeySubmit = async () => {
|
||||
if (!manageSelfChecked && !secretKey.trim()) {
|
||||
setErrorMessage(
|
||||
"Please enter your project secret key or check the option to manage keys yourself",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setErrorMessage("");
|
||||
await initialiseProjectWithVaultMutation.mutateAsync(
|
||||
manageSelfChecked ? undefined : secretKey,
|
||||
);
|
||||
} catch (error) {
|
||||
// Error will be handled by the mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSecretKey = () => {
|
||||
setSecretKeyModalOpen(false);
|
||||
setSecretKey("");
|
||||
setErrorMessage("");
|
||||
setManageSelfChecked(false);
|
||||
};
|
||||
|
||||
const handleDownloadKeys = () => {
|
||||
@@ -113,7 +95,7 @@ export function CreateVaultAccountButton(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.serviceAccount.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.userAccessToken.accessToken}\n`;
|
||||
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.walletToken.accessToken}\n`;
|
||||
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -147,11 +129,11 @@ export function CreateVaultAccountButton(props: {
|
||||
const isLoading = initialiseProjectWithVaultMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Button
|
||||
className="flex flex-row items-center gap-2"
|
||||
disabled={isLoading}
|
||||
onClick={handleCreateServerWallet}
|
||||
onClick={handleInitializeVault}
|
||||
variant={"primary"}
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -162,6 +144,93 @@ export function CreateVaultAccountButton(props: {
|
||||
{"Create Vault Admin Account"}
|
||||
</Button>
|
||||
|
||||
{/* Secret Key Input Modal */}
|
||||
<Dialog
|
||||
modal={true}
|
||||
onOpenChange={handleCancelSecretKey}
|
||||
open={secretKeyModalOpen}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="pt-6 px-6">
|
||||
<DialogTitle>Create Your Vault</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 p-6 pt-0">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="secretKey">
|
||||
Let thirdweb manage your Vault keys for you
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Lets you use your project secret key to access your vault like
|
||||
any other thirdweb service (recommended).
|
||||
</p>
|
||||
<Input
|
||||
id="secretKey"
|
||||
type="password"
|
||||
placeholder="Enter your project secret key"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
disabled={
|
||||
initialiseProjectWithVaultMutation.isPending ||
|
||||
manageSelfChecked
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Your project secret key was generated when you created your
|
||||
project. If you lost it, you can regenerate one in your project
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<hr className="mb-4" />
|
||||
<CheckboxWithLabel className="text-foreground">
|
||||
<Checkbox
|
||||
checked={manageSelfChecked}
|
||||
onCheckedChange={(v) => setManageSelfChecked(!!v)}
|
||||
disabled={initialiseProjectWithVaultMutation.isPending}
|
||||
/>
|
||||
I want to manage my Vault keys myself (advanced)
|
||||
</CheckboxWithLabel>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
|
||||
<Button
|
||||
onClick={handleCancelSecretKey}
|
||||
variant="outline"
|
||||
disabled={initialiseProjectWithVaultMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSecretKeySubmit}
|
||||
variant="primary"
|
||||
disabled={
|
||||
(!secretKey.trim() && !manageSelfChecked) ||
|
||||
initialiseProjectWithVaultMutation.isPending
|
||||
}
|
||||
>
|
||||
{initialiseProjectWithVaultMutation.isPending ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
Creating vault...
|
||||
</>
|
||||
) : (
|
||||
"Create vault"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Keys Display Modal */}
|
||||
<Dialog modal={true} onOpenChange={handleCloseModal} open={modalOpen}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
@@ -197,12 +266,10 @@ export function CreateVaultAccountButton(props: {
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
copyIconPosition="right"
|
||||
textToCopy={
|
||||
initialiseProjectWithVaultMutation.data.serviceAccount
|
||||
.adminKey
|
||||
initialiseProjectWithVaultMutation.data.adminKey
|
||||
}
|
||||
textToShow={maskSecret(
|
||||
initialiseProjectWithVaultMutation.data.serviceAccount
|
||||
.adminKey,
|
||||
initialiseProjectWithVaultMutation.data.adminKey,
|
||||
)}
|
||||
tooltip="Copy Admin Key"
|
||||
/>
|
||||
@@ -212,32 +279,6 @@ export function CreateVaultAccountButton(props: {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div>
|
||||
<h3 className="mb-2 font-medium text-sm">
|
||||
Vault Access Token
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2 ">
|
||||
<CopyTextButton
|
||||
textToCopy={
|
||||
initialiseProjectWithVaultMutation.data
|
||||
.userAccessToken.accessToken
|
||||
}
|
||||
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
|
||||
textToShow={maskSecret(
|
||||
initialiseProjectWithVaultMutation.data
|
||||
.userAccessToken.accessToken,
|
||||
)}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Vault Access Token"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This access token is used to sign transactions and
|
||||
messages from your backend. Can be revoked and recreated
|
||||
with your admin key.
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Secure your admin key</AlertTitle>
|
||||
@@ -285,6 +326,6 @@ export function CreateVaultAccountButton(props: {
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+33
-2
@@ -9,15 +9,17 @@ import RotateAdminKeyButton from "./rotate-admin-key.client";
|
||||
export function KeyManagement({
|
||||
maskedAdminKey,
|
||||
project,
|
||||
isManagedVault,
|
||||
}: {
|
||||
maskedAdminKey?: string;
|
||||
project: Project;
|
||||
isManagedVault: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{!maskedAdminKey && <CreateVaultAccountAlert project={project} />}
|
||||
|
||||
{maskedAdminKey && (
|
||||
{maskedAdminKey && !isManagedVault && (
|
||||
<>
|
||||
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col p-6">
|
||||
@@ -37,13 +39,42 @@ export function KeyManagement({
|
||||
{maskedAdminKey}
|
||||
</p>
|
||||
</div>
|
||||
<RotateAdminKeyButton project={project} />
|
||||
<RotateAdminKeyButton
|
||||
project={project}
|
||||
isManagedVault={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListAccessTokens project={project} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isManagedVault && (
|
||||
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col p-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Managed Vault
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your vault is currently managed by Thirdweb so you can access it
|
||||
via you project secret key. You can eject and manage your own
|
||||
vault keys at any time. Doing so means you'll need to pass your
|
||||
own vault access token to the transactions API additionally to
|
||||
your project secret key.
|
||||
</p>
|
||||
<div className="h-4" />
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex min-w-[300px] flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{maskedAdminKey}
|
||||
</p>
|
||||
</div>
|
||||
<RotateAdminKeyButton project={project} isManagedVault={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-51
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { rotateServiceAccount } from "@thirdweb-dev/vault-sdk";
|
||||
import {
|
||||
CheckIcon,
|
||||
CircleAlertIcon,
|
||||
DownloadIcon,
|
||||
Loader2Icon,
|
||||
LogOutIcon,
|
||||
RefreshCcwIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -27,13 +27,14 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
createManagementAccessToken,
|
||||
createWalletAccessToken,
|
||||
initVaultClient,
|
||||
createVaultAccountAndAccessToken,
|
||||
maskSecret,
|
||||
} from "../../transactions/lib/vault.client";
|
||||
|
||||
export default function RotateAdminKeyButton(props: { project: Project }) {
|
||||
export default function RotateAdminKeyButton(props: {
|
||||
project: Project;
|
||||
isManagedVault: boolean;
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [keysConfirmed, setKeysConfirmed] = useState(false);
|
||||
const [keysDownloaded, setKeysDownloaded] = useState(false);
|
||||
@@ -41,55 +42,15 @@ export default function RotateAdminKeyButton(props: { project: Project }) {
|
||||
|
||||
const rotateAdminKeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const vaultClient = await initVaultClient();
|
||||
const rotationCode = props.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
)?.rotationCode;
|
||||
|
||||
if (!rotationCode) {
|
||||
throw new Error("Rotation code not found");
|
||||
}
|
||||
|
||||
const rotateServiceAccountRes = await rotateServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
rotationCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (rotateServiceAccountRes.error) {
|
||||
throw new Error(rotateServiceAccountRes.error.message);
|
||||
}
|
||||
|
||||
// need to recreate the management access token with the new admin key
|
||||
const managementAccessTokenPromise = createManagementAccessToken({
|
||||
adminKey: rotateServiceAccountRes.data.newAdminKey,
|
||||
// passing no secret key means we're rotating the admin key and deleting any stored keys
|
||||
const result = await createVaultAccountAndAccessToken({
|
||||
project: props.project,
|
||||
rotationCode: rotateServiceAccountRes.data.newRotationCode,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
const userAccesTokenPromise = createWalletAccessToken({
|
||||
adminKey: rotateServiceAccountRes.data.newAdminKey,
|
||||
project: props.project,
|
||||
vaultClient,
|
||||
});
|
||||
|
||||
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
|
||||
userAccesTokenPromise,
|
||||
managementAccessTokenPromise,
|
||||
]);
|
||||
|
||||
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
|
||||
throw new Error("Failed to create access token");
|
||||
}
|
||||
|
||||
return {
|
||||
adminKey: rotateServiceAccountRes.data.newAdminKey,
|
||||
adminKey: result.adminKey,
|
||||
success: true,
|
||||
userAccessToken: userAccessTokenRes.data,
|
||||
userAccessToken: result.walletToken,
|
||||
};
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -144,8 +105,12 @@ export default function RotateAdminKeyButton(props: { project: Project }) {
|
||||
variant="outline"
|
||||
>
|
||||
{isLoading && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{!isLoading && <RefreshCcwIcon className="size-4" />}
|
||||
Rotate Admin Key
|
||||
{!isLoading && props.isManagedVault ? (
|
||||
<LogOutIcon className="size-4" />
|
||||
) : (
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
)}
|
||||
{props.isManagedVault ? "Eject From Managed Vault" : "Rotate Admin Key"}
|
||||
</Button>
|
||||
|
||||
<Dialog modal={true} onOpenChange={handleCloseModal} open={modalOpen}>
|
||||
|
||||
@@ -22,11 +22,13 @@ export default async function VaultPage(props: {
|
||||
);
|
||||
|
||||
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
|
||||
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<KeyManagement
|
||||
maskedAdminKey={maskedAdminKey ?? undefined}
|
||||
isManagedVault={isManagedVault}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
|
||||
export default function Loading() {
|
||||
return <GenericLoadingPage className="border-none" />;
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
|
||||
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 { getValidAccount } from "../../../../../../account/settings/getAccount";
|
||||
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, account] = await Promise.all([
|
||||
getAuthToken(),
|
||||
props.params,
|
||||
getValidAccount(),
|
||||
]);
|
||||
|
||||
if (!account || !authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [isFeatureEnabled, project] = await Promise.all([
|
||||
isFeatureFlagEnabled({
|
||||
flagKey: "centralized-webhooks",
|
||||
accountId: account.id,
|
||||
email: account.email,
|
||||
}),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!isFeatureEnabled) {
|
||||
redirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
+11
-3
@@ -109,7 +109,9 @@ export function CreateContractWebhookButton({
|
||||
await handleSubmit(data);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to process webhook: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to process webhook: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -142,7 +144,9 @@ export function CreateContractWebhookButton({
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to create webhook: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to create webhook: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -173,7 +177,11 @@ export function CreateContractWebhookButton({
|
||||
if (filterType === "event") {
|
||||
result = await formHook.trigger(["chainIds", "addresses"]);
|
||||
} else {
|
||||
result = await formHook.trigger(["chainIds", "toAddresses"]);
|
||||
result = await formHook.trigger([
|
||||
"chainIds",
|
||||
"toAddresses",
|
||||
"fromAddresses",
|
||||
]);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
|
||||
+6
-2
@@ -131,7 +131,9 @@ export function FilterDetailsStep({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<FormLabel>Contract Addresses</FormLabel>
|
||||
<FormLabel>
|
||||
Contract Addresses <span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<p className="text-muted-foreground">
|
||||
Enter a contract address
|
||||
</p>
|
||||
@@ -196,7 +198,9 @@ export function FilterDetailsStep({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormLabel>
|
||||
From Address <span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<p className="text-muted-foreground">
|
||||
Enter a from address
|
||||
</p>
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
|
||||
export default function Loading() {
|
||||
return <GenericLoadingPage className="border-none" />;
|
||||
}
|
||||
+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>
|
||||
);
|
||||
}
|
||||
+49
-14
@@ -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,13 @@ export default async function WebhooksLayout(props: {
|
||||
project_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const account = await getValidAccount();
|
||||
const isFeatureEnabled = await isFeatureFlagEnabled({
|
||||
flagKey: "centralized-webhooks",
|
||||
accountId: account.id,
|
||||
email: account.email,
|
||||
});
|
||||
|
||||
const params = await props.params;
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
@@ -21,20 +30,46 @@ export default async function WebhooksLayout(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabPathLinks
|
||||
links={[
|
||||
{
|
||||
exactMatch: true,
|
||||
name: "Contracts",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/webhooks`,
|
||||
},
|
||||
{
|
||||
name: "Universal Bridge",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
|
||||
},
|
||||
]}
|
||||
scrollableClassName="container max-w-7xl"
|
||||
/>
|
||||
{isFeatureEnabled ? (
|
||||
<TabPathLinks
|
||||
links={[
|
||||
{
|
||||
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`,
|
||||
},
|
||||
{
|
||||
name: "Contracts",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
|
||||
},
|
||||
{
|
||||
name: "Payments",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
|
||||
},
|
||||
]}
|
||||
scrollableClassName="container max-w-7xl"
|
||||
/>
|
||||
) : (
|
||||
<TabPathLinks
|
||||
links={[
|
||||
{
|
||||
name: "Contracts",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
|
||||
},
|
||||
{
|
||||
name: "Payments",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`,
|
||||
},
|
||||
]}
|
||||
scrollableClassName="container max-w-7xl"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="container flex max-w-7xl grow flex-col">
|
||||
{props.children}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
|
||||
export default function Loading() {
|
||||
return <GenericLoadingPage className="border-none" />;
|
||||
}
|
||||
+81
-29
@@ -1,45 +1,97 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { isFeatureFlagEnabled } from "@/analytics/posthog-server";
|
||||
import { getWebhookSummary } from "@/api/analytics";
|
||||
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 { getAvailableTopics, getWebhookConfigs } from "@/api/webhook-configs";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { WebhooksOverview } from "./components/overview";
|
||||
|
||||
export default async function WebhooksPage({
|
||||
params,
|
||||
}: {
|
||||
export default async function WebhooksPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const [authToken, resolvedParams] = await Promise.all([
|
||||
const [authToken, params, account] = await Promise.all([
|
||||
getAuthToken(),
|
||||
params,
|
||||
props.params,
|
||||
getValidAccount(),
|
||||
]);
|
||||
|
||||
const project = await getProject(
|
||||
resolvedParams.team_slug,
|
||||
resolvedParams.project_slug,
|
||||
);
|
||||
if (!account || !authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [isFeatureEnabled, project] = await Promise.all([
|
||||
isFeatureFlagEnabled({
|
||||
flagKey: "centralized-webhooks",
|
||||
accountId: account.id,
|
||||
email: account.email,
|
||||
}),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!project || !authToken) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!isFeatureEnabled) {
|
||||
redirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch webhook configs and topics in parallel
|
||||
const [webhookConfigsResult, topicsResult] = await Promise.all([
|
||||
getWebhookConfigs({
|
||||
projectIdOrSlug: params.project_slug,
|
||||
teamIdOrSlug: params.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={params.project_slug}
|
||||
teamId={project.teamId}
|
||||
teamSlug={params.team_slug}
|
||||
topics={topics}
|
||||
webhookConfigs={webhookConfigs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
|
||||
export default function Loading() {
|
||||
return <GenericLoadingPage className="border-none" />;
|
||||
}
|
||||
+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.{" "}
|
||||
|
||||
+102
-69
@@ -24,75 +24,108 @@ const inputAbi = z.object({
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export const webhookFormSchema = z.object({
|
||||
abi: z.string().optional(),
|
||||
addresses: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val === undefined || val.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
return val
|
||||
.split(/[,\s]+/)
|
||||
.filter(Boolean)
|
||||
.every((a) => isAddress(a.trim()));
|
||||
},
|
||||
{
|
||||
message: "Enter valid addresses (comma-separated) or leave empty",
|
||||
},
|
||||
),
|
||||
chainIds: z
|
||||
.array(z.string())
|
||||
.min(1, { message: "Select at least one chain" }),
|
||||
eventTypes: z.array(z.string()).optional(),
|
||||
filterType: z.enum(["event", "transaction"]),
|
||||
fromAddresses: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val === undefined || val.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
return val
|
||||
.split(/[,\s]+/)
|
||||
.filter(Boolean)
|
||||
.every((a) => isAddress(a.trim()));
|
||||
},
|
||||
{
|
||||
message: "Enter valid addresses (comma-separated) or leave empty",
|
||||
},
|
||||
),
|
||||
inputAbi: z.array(inputAbi).optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(3, { message: "Name must be at least 3 characters long" })
|
||||
.max(100, { message: "Name must be at most 100 characters long" }),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
secret: z.string().optional(),
|
||||
sigHash: z.string().optional(),
|
||||
sigHashAbi: z.string().optional(),
|
||||
toAddresses: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val === undefined || val.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
return val
|
||||
.split(/[,\s]+/)
|
||||
.filter(Boolean)
|
||||
.every((a) => isAddress(a.trim()));
|
||||
},
|
||||
{
|
||||
message: "Enter valid addresses (comma-separated) or leave empty",
|
||||
},
|
||||
),
|
||||
webhookUrl: z.string().url({ message: "Must be a valid URL" }),
|
||||
});
|
||||
export const webhookFormSchema = z
|
||||
.object({
|
||||
abi: z.string().optional(),
|
||||
addresses: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val: string | undefined) => {
|
||||
if (val === undefined || val.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
return val
|
||||
.split(/[,\s]+/)
|
||||
.filter(Boolean)
|
||||
.every((a: string) => isAddress(a.trim()));
|
||||
},
|
||||
{
|
||||
message: "Enter valid addresses (comma-separated)",
|
||||
},
|
||||
),
|
||||
chainIds: z
|
||||
.array(z.string())
|
||||
.min(1, { message: "Select at least one chain" }),
|
||||
eventTypes: z.array(z.string()).optional(),
|
||||
filterType: z.enum(["event", "transaction"]),
|
||||
fromAddresses: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val: string | undefined) => {
|
||||
if (val === undefined || val.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
return val
|
||||
.split(/[,\s]+/)
|
||||
.filter(Boolean)
|
||||
.every((a: string) => isAddress(a.trim()));
|
||||
},
|
||||
{
|
||||
message: "Enter valid addresses (comma-separated)",
|
||||
},
|
||||
),
|
||||
inputAbi: z.array(inputAbi).optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(3, { message: "Name must be at least 3 characters long" })
|
||||
.max(100, { message: "Name must be at most 100 characters long" }),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
secret: z.string().optional(),
|
||||
sigHash: z.string().optional(),
|
||||
sigHashAbi: z.string().optional(),
|
||||
toAddresses: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val: string | undefined) => {
|
||||
if (val === undefined || val.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
return val
|
||||
.split(/[,\s]+/)
|
||||
.filter(Boolean)
|
||||
.every((a: string) => isAddress(a.trim()));
|
||||
},
|
||||
{
|
||||
message: "Enter valid addresses (comma-separated) or leave empty",
|
||||
},
|
||||
),
|
||||
webhookUrl: z.string().url({ message: "Must be a valid URL" }),
|
||||
})
|
||||
.refine(
|
||||
(data: {
|
||||
filterType: "event" | "transaction";
|
||||
addresses?: string;
|
||||
fromAddresses?: string;
|
||||
}) => {
|
||||
if (data.filterType === "event") {
|
||||
return data.addresses && data.addresses.trim() !== "";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Contract address is required for event webhooks",
|
||||
path: ["addresses"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data: {
|
||||
filterType: "event" | "transaction";
|
||||
addresses?: string;
|
||||
fromAddresses?: string;
|
||||
}) => {
|
||||
if (data.filterType === "transaction") {
|
||||
return data.fromAddresses && data.fromAddresses.trim() !== "";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "From address is required for transaction webhooks",
|
||||
path: ["fromAddresses"],
|
||||
},
|
||||
);
|
||||
|
||||
export type WebhookFormValues = z.infer<typeof webhookFormSchema>;
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
"zod": "3.25.71"
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"thirdweb": "workspace:*",
|
||||
"use-debounce": "^10.0.5",
|
||||
"zod": "3.25.71"
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -5533,60 +5533,7 @@ let returnType: Promise<string>;
|
||||
The deployed contract address.
|
||||
---
|
||||
|
||||
## DEPLOY.deployPackContract
|
||||
|
||||
Deprecated
|
||||
|
||||
\[Pack contract is incompatible with Pectra update. Support for this contract is being removed in next release.\]
|
||||
|
||||
Deploy a thirdweb Pack contract
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
import { deployPackContract } from "thirdweb/extensions/deploy";
|
||||
|
||||
const packAddress = await deployPackContract({
|
||||
account,
|
||||
client,
|
||||
chain,
|
||||
params: {
|
||||
name: "Pack contract name",
|
||||
symbol: "PACK1155",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
function deployPackContract(options: {
|
||||
account: Account;
|
||||
chain: Readonly;
|
||||
client: ThirdwebClient;
|
||||
params: PackContractParams;
|
||||
}): Promise<string>;
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
params for deploying [Pack contract](https://thirdweb.com/thirdweb.eth/Pack)
|
||||
|
||||
#### Type
|
||||
|
||||
```ts
|
||||
let options: {
|
||||
account: Account;
|
||||
chain: Readonly;
|
||||
client: ThirdwebClient;
|
||||
params: PackContractParams;
|
||||
};
|
||||
```
|
||||
|
||||
### Returns
|
||||
|
||||
```ts
|
||||
let returnType: Promise<string>;
|
||||
```
|
||||
---
|
||||
|
||||
## DEPLOY.deployPublishedContract
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
* [DEPLOY.deployERC1155Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC1155Contract): Deploys an thirdweb ERC1155 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
|
||||
* [DEPLOY.deployERC20Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC20Contract): Deploys an thirdweb ERC20 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
|
||||
* [DEPLOY.deployERC721Contract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployERC721Contract): Deploys an thirdweb ERC721 contract of the given type. On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
|
||||
* [DEPLOY.deployPackContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployPackContract): \[Pack contract is incompatible with Pectra update. Support for this contract is being removed in next release.\]
|
||||
|
||||
* [DEPLOY.deployPublishedContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deployPublishedContract): Deploy an instance of a published contract on a given chain
|
||||
* [DEPLOY.deploySplitContract](https://portal.thirdweb.com/references/typescript/v5/deploy/deploySplitContract): Deploys a thirdweb [Split contract](https://thirdweb.com/thirdweb.eth/Split)On chains where the thirdweb infrastructure contracts are not deployed, this function will deploy them as well.
|
||||
* [DEPLOY.prepareDeterministicDeployTransaction](https://portal.thirdweb.com/references/typescript/v5/deploy/prepareDeterministicDeployTransaction): Deploy a contract deterministically - will maintain the same address across chains. This is meant to be used with published contracts configured with the 'direct deploy' method. Under the hood, this uses a keyless transaction with a common create2 factory.
|
||||
|
||||
@@ -369,6 +369,8 @@ const typescriptRedirects = {
|
||||
"/typescript/sdk.thirdwebsdk.fromwallet":
|
||||
"/references/typescript/v4/ThirdwebSDK#fromWallet",
|
||||
"/typescript/sdk.thirdwebsdk.smartcontract": "/typescript/v4/extensions",
|
||||
// v5 deploy redirects
|
||||
"/references/typescript/v5/deploy/deployPackContract": "/contracts",
|
||||
};
|
||||
|
||||
const reactNativeRedirects = {
|
||||
@@ -803,7 +805,7 @@ const contractRedirects = {
|
||||
"/contracts/explore/pre-built-contracts/nft-drop",
|
||||
"/pre-built-contracts/open-edition-erc721":
|
||||
"/contracts/explore/pre-built-contracts/open-edition",
|
||||
"/pre-built-contracts/pack": "/contracts/explore/pre-built-contracts/pack",
|
||||
"/pre-built-contracts/pack": "/contracts",
|
||||
"/pre-built-contracts/signature-drop": "/contracts",
|
||||
"/pre-built-contracts/split": "/contracts/explore/pre-built-contracts/split",
|
||||
"/pre-built-contracts/stake-erc20":
|
||||
@@ -816,6 +818,8 @@ const contractRedirects = {
|
||||
"/pre-built-contracts/token-drop":
|
||||
"/contracts/explore/pre-built-contracts/token-drop",
|
||||
"/pre-built-contracts/vote": "/contracts/explore/pre-built-contracts/vote",
|
||||
// pack contract deprecation redirect
|
||||
"/contracts/explore/pre-built-contracts/pack": "/contracts",
|
||||
//publish
|
||||
"/publish": "/contracts/publish/overview",
|
||||
"/publish/deployment-options": "/contracts/publish/publish-options",
|
||||
@@ -940,7 +944,7 @@ const otherRedirects = {
|
||||
// solidity sdk
|
||||
"/contracts/nft-drop": "/contracts/explore/pre-built-contracts/nft-drop",
|
||||
"/contracts/nfts-and-tokens": "/contracts",
|
||||
"/contracts/pack": "/contracts/explore/pre-built-contracts/pack",
|
||||
"/contracts/pack": "/contracts",
|
||||
"/contracts/split": "/contracts/explore/pre-built-contracts/split",
|
||||
"/contracts/token": "/contracts/explore/pre-built-contracts/token",
|
||||
"/contracts/token-drop": "/contracts/explore/pre-built-contracts/token-drop",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GithubTemplateCard,
|
||||
Grid,
|
||||
ExpandableGrid,
|
||||
FeatureCard,
|
||||
createMetadata,
|
||||
OpenSourceCard,
|
||||
Stack,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
ArticleIconCard,
|
||||
DocImage
|
||||
} from "@doc";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import {
|
||||
TypeScriptIcon,
|
||||
ReactIcon,
|
||||
@@ -24,6 +24,8 @@ import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import SupportedChains from "../_images/supported-chains.png";
|
||||
import {FuelIcon,MonitorCheckIcon,WalletCardsIcon,UserLockIcon, ExternalLinkIcon, WalletIcon, PencilIcon, ShieldCheckIcon, PiggyBankIcon, GlobeIcon, ComponentIcon, CodeIcon, ApiIcon, WebhookIcon, IdCardIcon, HandCoinsIcon, MonitorSmartphoneIcon} from "lucide-react";
|
||||
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "thirdweb Connect",
|
||||
@@ -36,26 +38,79 @@ export const metadata = createMetadata({
|
||||
|
||||
# Wallets
|
||||
|
||||
Wallets is the complete toolkit for connecting every user to your application. It features customizable onboarding flows, self-custodial in-app wallets, account abstraction, onramps, and a performant API to interact with the blockchain.
|
||||
Wallets is a complete toolkit for connecting any user to your application. It includes **customizable components** for onboarding, **embedded wallets** , **account abstraction**
|
||||
for gasless transactions, **server wallets** for developers to carry out transactions, and a secure **non-custodial key management system**.
|
||||
|
||||
## Live Playground
|
||||
|
||||
You can interact with the React SDK in the [Live Playground](https://playground.thirdweb.com/).
|
||||
|
||||
<Stack>
|
||||
|
||||
<ArticleIconCard
|
||||
title="Try the demo"
|
||||
icon={ExternalLink}
|
||||
title="Try Wallets"
|
||||
icon={ExternalLinkIcon}
|
||||
href="https://playground.thirdweb.com"
|
||||
description="See the SDK in action on the live playground"
|
||||
description="Try out the wallet components live in the interactive playground"
|
||||
/>
|
||||
|
||||
</Stack>
|
||||
|
||||
## Quick starts
|
||||
## Features
|
||||
|
||||
Get started with Wallets in your preferred language.
|
||||
<div
|
||||
className="my-4 grid gap-2 md:grid-cols-2 lg:grid-cols-2"
|
||||
>
|
||||
<FeatureCard
|
||||
title="Non-Custodial Wallets"
|
||||
description="Secure non-custodial wallets only accessible by the owner."
|
||||
iconUrl={<UserLockIcon />}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="EOA Support"
|
||||
description="Connect to over 500+ EOA wallet providers for crypto native users."
|
||||
iconUrl={<WalletCardsIcon />}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Gas Sponsorship"
|
||||
description="Support for gasless transactions with account abstraction."
|
||||
iconUrl={<FuelIcon />}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Ecosystem Wallets"
|
||||
description="Global wallets to connect your users to any application in an ecosystem."
|
||||
iconUrl={<MonitorCheckIcon />}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Cross Platform Support"
|
||||
description="Create and manage wallets for web, mobile, games, and agents."
|
||||
iconUrl={<MonitorSmartphoneIcon />}
|
||||
/>
|
||||
|
||||
|
||||
<FeatureCard
|
||||
title="One-Click Funding"
|
||||
description="Buy crypto or swap tokens directly in the wallet modal."
|
||||
iconUrl={<HandCoinsIcon />}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Authentication"
|
||||
description="Enable SIWE standard for blockchain-based ID verification."
|
||||
iconUrl={<IdCardIcon />}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Hardware-Level Security"
|
||||
description="Built with AWS Enclaves (TEE) to isolate and protect keys."
|
||||
iconUrl={<ShieldCheckIcon />}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
## Quick Starts
|
||||
|
||||
<Grid>
|
||||
<SDKCard
|
||||
@@ -78,37 +133,15 @@ Get started with Wallets in your preferred language.
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
## With Wallets, you can
|
||||
|
||||
- **Connect to 500+ different wallet providers** with support for every EVM network
|
||||
- **Log in and authenticate your users** with customizable and secure [email, phone, passkeys and social login](https://portal.thirdweb.com/connect/in-app-wallet/overview) flows.
|
||||
- **Sponsor transactions** to seamlessly onboard non-native or new crypto users with [Account Abstraction](https://portal.thirdweb.com/connect/account-abstraction/overview).
|
||||
- **Perform wallet actions** like connecting and disconnecting wallets, viewing balance, displaying ENS names, and execute transactions with our [performant, reliable and type safe API](https://portal.thirdweb.com/connect/blockchain-api)
|
||||
- **Easily integrate with thirdweb's contracts** to enable users to interact with your application.
|
||||
- **Facilitate payments** by letting user top up their wallets or do onchain purchases with a credit card with [Pay](https://portal.thirdweb.com/connect/pay/overview).
|
||||
|
||||
<Callout variant="info" title="Have you generated a client ID?">
|
||||
<div className="flex flex-col gap-4 items-start">
|
||||
<p>
|
||||
You'll need a client ID to access the Wallet APIs, storage, and more.
|
||||
</p>
|
||||
|
||||
<Button asChild variant='outline'>
|
||||
<Link href="https://thirdweb.com/create-api-key" target="_blank"> Generate </Link>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</Callout>
|
||||
|
||||
## Supported Chains
|
||||
|
||||
Wallets are supported on every EVM compatible chain. To view the full list, visit [thirdweb chainlist](https://thirdweb.com/chainlist).
|
||||
Wallets are supported on EVM compatible chains. To view the full list, visit [thirdweb chainlist](https://thirdweb.com/chainlist?service=connect-sdk).
|
||||
|
||||
<DocImage src={SupportedChains} />
|
||||
|
||||
## Starter Kits & demos
|
||||
|
||||
View all available starter kits, demos and templates on Github.
|
||||
View all available starter kits, demos, and templates on Github.
|
||||
|
||||
<GithubTemplateCard
|
||||
title="View all templates"
|
||||
|
||||
@@ -17,10 +17,6 @@ export const sidebar: SideBar = {
|
||||
href: "/connect",
|
||||
name: "Introduction",
|
||||
},
|
||||
{
|
||||
href: "/connect/why-thirdweb",
|
||||
name: "Why thirdweb?",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/",
|
||||
icon: <ExternalLinkIcon />,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { createMetadata } from "@doc";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
image: {
|
||||
title: "Pack contract",
|
||||
icon: "contract",
|
||||
},
|
||||
title: "Pack contract | thirdweb",
|
||||
description: `The packs are ERC1155 NFTs themselves, which can have the metadata you'd expect of an NFT, such as a name, image, description, etc.`,
|
||||
});
|
||||
|
||||
# Pack
|
||||
|
||||
The packs are ERC1155 NFTs themselves, which can have the metadata you'd expect of an NFT,
|
||||
such as a name, image, description, etc.
|
||||
|
||||
When a pack is opened, a pre-defined quantity of tokens are randomly selected from the ones
|
||||
that were used to create the packs _(that haven't already been selected)_, and awarded to the opener.
|
||||
The pack NFT is burned as it's opened.
|
||||
|
||||
You can configure how many tokens get selected for the pack:
|
||||
|
||||
- How many tokens are in each "unit" (for ERC20 & ERC1155 tokens - one unit could be multiple tokens).
|
||||
- How many units are in each pack.
|
||||
|
||||
> **IMPORTANT**: _Pack functions, such as opening of packs, can be costly in terms of gas usage due to random selection of rewards. Please check your gas estimates/usage, and do a trial on testnets before any mainnet deployment._
|
||||
|
||||
## Use Cases & Examples
|
||||
|
||||
You could use the Pack contract to:
|
||||
|
||||
- Create randomized NFT loot boxes
|
||||
- Run a randomized raffle where some users open a winning ticket
|
||||
- Reward your community with token gift boxes
|
||||
|
||||
## Smart Contract Design
|
||||
|
||||
For a detailed exploration of how the pack smart contract works, why it was built,
|
||||
and it's limitations, visit our [Pack Design Doc](/contracts/design-docs/pack)!
|
||||
|
||||
## Creating & Configuring the Pack
|
||||
|
||||
Learn how to create and configure your smart contract using the [dashboard](https://thirdweb.com/team).
|
||||
|
||||
### Creating a Pack Contract
|
||||
|
||||
Deploy the pack contract to any of our
|
||||
[supported networks](https://blog.thirdweb.com/guides/which-network-should-you-use) using the button below.
|
||||
|
||||
### Contract Metadata
|
||||
|
||||
To configure the image, name, and description of the contract, click the **Settings** tab:
|
||||
|
||||
### Royalty & Platform Fees
|
||||
|
||||
From the **Settings** tab, you also configure the royalty fee.
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createMetadata, Details } from "@doc";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title: "Contracts FAQs",
|
||||
description: "Frequently asked questions about thirdweb contracts",
|
||||
image: {
|
||||
title: "Contracts FAQs",
|
||||
icon: "contract",
|
||||
},
|
||||
});
|
||||
|
||||
# Contracts FAQs
|
||||
|
||||
<Details summary="Why can I no longer deploy a pack contract?">
|
||||
Pack contracts were officially deprecated after the Pectra release from Ethereum. The pack contract implementation is incompatible with the Pectra update, which introduced breaking changes to the Ethereum protocol. As a result, support for pack contracts has been removed from thirdweb's deployment tools to ensure compatibility and security.
|
||||
|
||||
If you need similar functionality, consider using:
|
||||
- **Edition Drop contracts** for distributing multiple quantities of NFTs
|
||||
- **Marketplace contracts** for trading and bundle functionality
|
||||
- **Custom smart contracts** built with our modular contract framework
|
||||
|
||||
For existing pack contracts, they will continue to function on-chain, but new deployments are no longer supported.
|
||||
</Details>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user