Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6a953933 | ||
|
|
33b0b6dcb4 | ||
|
|
cdc7d95db2 | ||
|
|
592bf6f1fd | ||
|
|
8973174ad5 | ||
|
|
c160e91c08 | ||
|
|
f33e31ad6e | ||
|
|
4111d0e638 | ||
|
|
ffb7b6487b | ||
|
|
77063e5c85 | ||
|
|
585eac2a6f | ||
|
|
c54db522b3 | ||
|
|
d62e9971ee | ||
|
|
7d796f5aa5 | ||
|
|
a5bb36141d | ||
|
|
f3234c3c41 | ||
|
|
91e3781e83 | ||
|
|
86e3aa8c04 | ||
|
|
aa1035d16c | ||
|
|
bd395a3cff | ||
|
|
446a69b1d1 | ||
|
|
826d9c80a8 | ||
|
|
772eb0cc53 |
@@ -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=
|
||||
|
||||
@@ -9,7 +9,7 @@ module.exports = [
|
||||
// -- build category
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/universal-bridge",
|
||||
"/payments",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -416,7 +416,7 @@ async function redirects() {
|
||||
source: "/connect/account-abstraction",
|
||||
},
|
||||
{
|
||||
destination: "/universal-bridge",
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/connect/universal-bridge",
|
||||
},
|
||||
@@ -440,6 +440,11 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/rpc-edge",
|
||||
},
|
||||
{
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/universal-bridge",
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
...teamPageRedirects,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
headerClassName?: string;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
@@ -52,6 +53,12 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
emptyChartState?: React.ReactElement;
|
||||
margin?: {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
@@ -62,7 +69,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
return (
|
||||
<Card className={props.className}>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardHeader className={props.header.headerClassName}>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
@@ -85,7 +92,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
right: props.margin?.right ?? 0,
|
||||
left: props.margin?.left ?? 0,
|
||||
bottom: props.margin?.bottom ?? 10,
|
||||
top: props.margin?.top ?? 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{props.yAxis && <YAxis axisLine={false} tickLine={false} />}
|
||||
<XAxis
|
||||
|
||||
@@ -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,16 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
console.error(
|
||||
"Failed to create vault account and access token",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -7,6 +7,7 @@ export function CopyAddressButton(props: {
|
||||
address: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltip?: string;
|
||||
variant?:
|
||||
| "primary"
|
||||
| "default"
|
||||
@@ -24,7 +25,7 @@ export function CopyAddressButton(props: {
|
||||
copyIconPosition={props.copyIconPosition}
|
||||
textToCopy={props.address}
|
||||
textToShow={shortenedAddress}
|
||||
tooltip="Copy Address"
|
||||
tooltip={props.tooltip || "Copy Address"}
|
||||
variant={props.variant}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
@@ -16,3 +17,70 @@ export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
squares?: Array<[x: number, y: number]>;
|
||||
strokeDasharray?: string;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function GridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = "0",
|
||||
squares,
|
||||
className,
|
||||
...props
|
||||
}: GridPatternProps) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-current stroke-current",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible" role="presentation">
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={x * width + 1}
|
||||
y={y * height + 1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border bg-card text-card-foreground",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -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 { rotateVaultAccountAndAccessToken } 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,40 @@ 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 an encrypted vault admin key, rotate it as well
|
||||
const service = params.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (service?.encryptedAdminKey) {
|
||||
await rotateVaultAccountAndAccessToken({
|
||||
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,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SVGProps } from "react";
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
enable-background="new 0 0 100 100"
|
||||
enableBackground="new 0 0 100 100"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<div className="border-b bg-card">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-8xl justify-between py-3",
|
||||
|
||||
+35
-6
@@ -17,28 +17,57 @@ export const Variants: Story = {
|
||||
function StoryVariants() {
|
||||
return (
|
||||
<div className="container max-w-md space-y-10 py-10">
|
||||
<BadgeContainer label="5.5 / Unlimited Supply">
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={5.5}
|
||||
totalSupplyTokens="unlimited"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 / Unlimited Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply="unlimited" />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens="unlimited"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="500/1000 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={500}
|
||||
totalSupplyTokens={1000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="123.45/1000 Supply">
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={123.45}
|
||||
totalSupplyTokens={1000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens={1000000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={0}
|
||||
totalSupplyTokens={1000000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/10 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens={10}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 Supply - don't show anything">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
|
||||
<SupplyClaimedProgress claimedSupplyTokens={0} totalSupplyTokens={0} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
+12
-12
@@ -3,16 +3,16 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { supplyFormatter } from "../nft/format";
|
||||
|
||||
export function SupplyClaimedProgress(props: {
|
||||
claimedSupply: bigint;
|
||||
totalSupply: bigint | "unlimited";
|
||||
claimedSupplyTokens: number;
|
||||
totalSupplyTokens: number | "unlimited";
|
||||
}) {
|
||||
// if total supply is unlimited
|
||||
if (props.totalSupply === "unlimited") {
|
||||
if (props.totalSupplyTokens === "unlimited") {
|
||||
return (
|
||||
<p className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">Claimed Supply </span>
|
||||
<span className="flex items-center gap-1 font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
|
||||
<InfinityIcon aria-label="Unlimited" className="size-4" />
|
||||
</span>
|
||||
</p>
|
||||
@@ -20,29 +20,29 @@ export function SupplyClaimedProgress(props: {
|
||||
}
|
||||
|
||||
// if total supply is 0 - don't show anything
|
||||
if (props.totalSupply === 0n) {
|
||||
if (props.totalSupplyTokens === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// multiply by 10k to have precision up to 2 decimal places in percentage value
|
||||
const soldFractionTimes10KBigInt =
|
||||
(props.claimedSupply * 10000n) / props.totalSupply;
|
||||
|
||||
const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;
|
||||
const soldPercentage =
|
||||
(props.claimedSupplyTokens / props.totalSupplyTokens) * 100;
|
||||
|
||||
const percentToShow = soldPercentage.toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Supply Claimed</span>
|
||||
<span className="font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupply)}
|
||||
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupplyTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress className="h-2.5" value={soldPercentage} />
|
||||
<p className="font-medium text-muted-foreground text-xs">
|
||||
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
|
||||
{soldPercentage.toFixed(2)}% Sold
|
||||
{percentToShow === "0.00" ? "~0.00%" : `${percentToShow}%`} Sold
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
export type TokenPriceData = {
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
percent_change_24h: number;
|
||||
market_cap_usd: number;
|
||||
volume_24h_usd: number;
|
||||
volume_change_24h: number;
|
||||
holders: number;
|
||||
historical_prices: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getTokenPriceData(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
try {
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
|
||||
);
|
||||
|
||||
url.searchParams.set("include_historical_prices", "true");
|
||||
url.searchParams.set("chain_id", params.chainId.toString());
|
||||
url.searchParams.set("address", params.contractAddress);
|
||||
url.searchParams.set("include_holders", "true");
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch token price data", await res.text());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const priceData = json.data[0] as TokenPriceData | undefined;
|
||||
|
||||
return priceData;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
|
||||
export function ContractCreatorBadge(props: {
|
||||
contractCreator: string;
|
||||
clientContract: ThirdwebContract;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 bg-card rounded-full px-2.5 py-1.5 border hover:bg-accent">
|
||||
<span className="text-xs text-foreground">By</span>
|
||||
<WalletAddress
|
||||
address={props.contractCreator}
|
||||
className="py-0 text-xs h-auto !no-underline"
|
||||
client={props.clientContract.client}
|
||||
iconClassName="size-3.5 hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+65
-49
@@ -1,4 +1,9 @@
|
||||
import { ExternalLinkIcon, GlobeIcon, Settings2Icon } from "lucide-react";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
Settings2Icon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
@@ -19,7 +24,6 @@ import { YoutubeIcon } from "@/icons/brand-icons/YoutubeIcon";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { ContractCreatorBadge } from "./ContractCreatorBadge";
|
||||
|
||||
const platformToIcons: Record<string, React.FC<{ className?: string }>> = {
|
||||
discord: DiscordIcon,
|
||||
@@ -44,6 +48,7 @@ export function ContractHeaderUI(props: {
|
||||
socialUrls: object;
|
||||
imageClassName?: string;
|
||||
contractCreator: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const socialUrls = useMemo(() => {
|
||||
const socialUrlsValue: { name: string; href: string }[] = [];
|
||||
@@ -64,41 +69,45 @@ export function ContractHeaderUI(props: {
|
||||
?.replace("Mainnet", "")
|
||||
.trim();
|
||||
|
||||
const explorersToShow = getExplorersToShow(props.chainMetadata);
|
||||
const validBlockExplorer = getExplorerToShow(props.chainMetadata);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-4 border-b border-dashed py-8 lg:flex-row lg:items-center">
|
||||
{props.image && (
|
||||
<Img
|
||||
className={cn(
|
||||
"size-20 shrink-0 rounded-full border bg-muted",
|
||||
props.imageClassName,
|
||||
)}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center font-bold text-3xl text-muted-foreground/80">
|
||||
{props.name[0]}
|
||||
</div>
|
||||
}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.clientContract.client,
|
||||
uri: props.image,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="border p-1 rounded-full bg-card">
|
||||
<Img
|
||||
className={cn("size-28 shrink-0 rounded-full bg-muted")}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center font-bold text-5xl text-muted-foreground/50 capitalize">
|
||||
{props.name[0]}
|
||||
</div>
|
||||
}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.clientContract.client,
|
||||
uri: props.image,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2.5 flex-1">
|
||||
{/* top row */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col flex-wrap gap-3 lg:flex-row lg:items-center">
|
||||
<h1 className="line-clamp-2 font-bold text-2xl tracking-tight lg:text-3xl">
|
||||
<h1 className="font-semibold text-3xl tracking-tighter lg:text-5xl">
|
||||
{props.name}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2 ">
|
||||
<Link
|
||||
className="flex w-fit shrink-0 items-center gap-2 rounded-3xl border border-border bg-card px-2.5 py-1.5 hover:bg-accent"
|
||||
href={`/${props.chainMetadata.slug}`}
|
||||
@@ -113,12 +122,15 @@ export function ContractHeaderUI(props: {
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<CopyAddressButton
|
||||
address={props.clientContract.address}
|
||||
className="rounded-full bg-card w-[30px] h-[30px] p-0 [&>span]:hidden [&>svg]:text-foreground"
|
||||
copyIconPosition="left"
|
||||
variant="outline"
|
||||
/>
|
||||
{props.contractCreator &&
|
||||
validBlockExplorer &&
|
||||
props.contractCreator !== ZERO_ADDRESS && (
|
||||
<SocialLink
|
||||
href={`${validBlockExplorer.url}/address/${props.contractCreator}`}
|
||||
name="Contract Owner"
|
||||
icon={UserIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{socialUrls
|
||||
.toSorted((a, b) => {
|
||||
@@ -148,13 +160,14 @@ export function ContractHeaderUI(props: {
|
||||
</div>
|
||||
|
||||
{/* bottom row */}
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
{props.contractCreator && props.contractCreator !== ZERO_ADDRESS && (
|
||||
<ContractCreatorBadge
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={props.contractCreator}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-end sm:flex-row flex-wrap sm:items-center gap-2 absolute top-6 right-0 sm:static w-1/2 sm:w-full">
|
||||
<CopyAddressButton
|
||||
address={props.clientContract.address}
|
||||
className="rounded-full bg-card px-2.5 py-1.5 text-xs"
|
||||
copyIconPosition="left"
|
||||
tooltip="Copy contract address"
|
||||
variant="outline"
|
||||
/>
|
||||
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[300px]"
|
||||
@@ -180,13 +193,13 @@ export function ContractHeaderUI(props: {
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
{explorersToShow?.map((validBlockExplorer) => (
|
||||
{validBlockExplorer && (
|
||||
<BadgeLink
|
||||
href={`${validBlockExplorer.url.endsWith("/") ? validBlockExplorer.url : `${validBlockExplorer.url}/`}address/${props.clientContract.address}`}
|
||||
key={validBlockExplorer.url}
|
||||
name={validBlockExplorer.name}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* TODO - render social links here */}
|
||||
</div>
|
||||
@@ -204,19 +217,22 @@ function isValidUrl(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getExplorersToShow(chainMetadata: ChainMetadata) {
|
||||
const validBlockExplorers = chainMetadata.explorers
|
||||
?.filter((e) => e.standard === "EIP3091")
|
||||
?.slice(0, 2);
|
||||
function getExplorerToShow(chainMetadata: ChainMetadata) {
|
||||
const validBlockExplorers = chainMetadata.explorers?.filter(
|
||||
(e) => e.standard === "EIP3091",
|
||||
);
|
||||
|
||||
return validBlockExplorers?.slice(0, 1);
|
||||
return validBlockExplorers?.[0];
|
||||
}
|
||||
|
||||
function BadgeLink(props: { name: string; href: string }) {
|
||||
function BadgeLink(props: { name: string; href: string; className?: string }) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize"
|
||||
className={cn(
|
||||
"!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize",
|
||||
props.className,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
<Link href={props.href} rel="noopener noreferrer" target="_blank">
|
||||
|
||||
+63
-112
@@ -6,13 +6,11 @@ import { useMemo, useState } from "react";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTokenPriceData } from "../_hooks/useTokenPriceData";
|
||||
import type { TokenPriceData } from "../_apis/token-price-data";
|
||||
|
||||
function PriceChartUI(props: {
|
||||
isPending: boolean;
|
||||
showTimeOfDay: boolean;
|
||||
data: Array<{
|
||||
date: string;
|
||||
@@ -29,7 +27,7 @@ function PriceChartUI(props: {
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
className="border-none bg-background p-0"
|
||||
className="border-none bg-transparent p-0"
|
||||
config={{
|
||||
price: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
@@ -38,7 +36,7 @@ function PriceChartUI(props: {
|
||||
}}
|
||||
data={data}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
isPending={false}
|
||||
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
|
||||
toolTipValueFormatter={(value) => {
|
||||
return tokenPriceUSDFormatter.format(value as number);
|
||||
@@ -49,7 +47,7 @@ function PriceChartUI(props: {
|
||||
|
||||
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 10,
|
||||
maximumFractionDigits: 8,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
roundingMode: "halfEven",
|
||||
@@ -92,20 +90,14 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
|
||||
export function TokenStats(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
tokenPriceData: TokenPriceData;
|
||||
}) {
|
||||
const tokenPriceQuery = useTokenPriceData(params);
|
||||
const [interval, setInterval] = useState<Interval>("max");
|
||||
|
||||
const tokenPriceData = tokenPriceQuery.data;
|
||||
|
||||
const filteredHistoricalPrices = useMemo(() => {
|
||||
const currentDate = new Date();
|
||||
|
||||
if (tokenPriceData?.type === "no-data") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tokenPriceData?.data?.historical_prices.filter((item) => {
|
||||
return params.tokenPriceData.historical_prices.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
const maxDiff =
|
||||
interval === "24h"
|
||||
@@ -120,106 +112,75 @@ export function TokenStats(params: {
|
||||
|
||||
return differenceInCalendarDays(currentDate, date) <= maxDiff;
|
||||
});
|
||||
}, [tokenPriceData, interval]);
|
||||
}, [params.tokenPriceData, interval]);
|
||||
|
||||
const priceUsd =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.price_usd;
|
||||
const priceUsd = params.tokenPriceData.price_usd;
|
||||
const percentChange24h = params.tokenPriceData.percent_change_24h;
|
||||
const marketCap = params.tokenPriceData.market_cap_usd;
|
||||
const holders = params.tokenPriceData.holders;
|
||||
|
||||
const percentChange24h =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.percent_change_24h;
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(percentChange24h),
|
||||
);
|
||||
|
||||
const marketCap =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.market_cap_usd;
|
||||
const isAlmostZeroChange =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
const holders =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.holders;
|
||||
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-card rounded-lg border">
|
||||
{/* price and change */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between p-4 lg:p-6">
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonContainer
|
||||
loadedData={priceUsd}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-bold text-4xl tracking-tight">
|
||||
{typeof v === "number"
|
||||
? tokenPriceUSDFormatter.format(v)
|
||||
: v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData={10.0001}
|
||||
/>
|
||||
<SkeletonContainer
|
||||
loadedData={percentChange24h}
|
||||
render={(data) => {
|
||||
if (typeof data === "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(data),
|
||||
);
|
||||
|
||||
const isAlmostZero =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className="gap-2 text-sm"
|
||||
variant={
|
||||
isAlmostZero
|
||||
? "default"
|
||||
: data > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZero ? null : data > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZero ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
);
|
||||
}}
|
||||
skeletonData={0.001}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"font-bold text-3xl lg:text-4xl tracking-tight",
|
||||
formattedPriceUSD.length > 8 && "text-2xl lg:text-4xl",
|
||||
)}
|
||||
>
|
||||
{typeof priceUsd === "number" ? formattedPriceUSD : priceUsd}
|
||||
</p>
|
||||
<Badge
|
||||
className="gap-2 lg:text-sm text-xs px-1.5"
|
||||
variant={
|
||||
isAlmostZeroChange
|
||||
? "default"
|
||||
: percentChange24h > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZeroChange ? null : percentChange24h > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntervalSelector interval={interval} setInterval={setInterval} />
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
isPending={tokenPriceQuery.isPending}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
<div className="px-0">
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex gap-6 border-y border-dashed py-4 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:border-dashed [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<div className="px-4 lg:px-6 py-4 mt-6 border-t border-dashed flex gap-6 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<TokenStat
|
||||
label="Market Cap"
|
||||
skeletonData={"1000000000"}
|
||||
tooltip="Market capitalization is the total value of all tokens in circulation, calculated by multiplying the current price by the circulating supply"
|
||||
value={
|
||||
typeof marketCap === "number"
|
||||
@@ -230,7 +191,6 @@ export function TokenStats(params: {
|
||||
|
||||
<TokenStat
|
||||
label="Number of Holders"
|
||||
skeletonData={"1000000000"}
|
||||
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
|
||||
value={
|
||||
// show 0 value as N/A
|
||||
@@ -246,9 +206,8 @@ export function TokenStats(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function TokenStat<T extends string | number>(props: {
|
||||
value: T | undefined;
|
||||
skeletonData: T;
|
||||
function TokenStat(props: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}) {
|
||||
@@ -261,17 +220,9 @@ function TokenStat<T extends string | number>(props: {
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<SkeletonContainer
|
||||
loadedData={props.value}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData={props.skeletonData}
|
||||
/>
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{props.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -296,7 +247,7 @@ function IntervalSelector(props: {
|
||||
{Object.entries(intervals).map(([key, value]) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
"rounded-full px-2.5 py-1.5 h-auto bg-background text-xs lg:text-sm",
|
||||
props.interval === key && "border-active-border bg-accent",
|
||||
)}
|
||||
key={key}
|
||||
|
||||
+21
-23
@@ -47,8 +47,8 @@ function RecentTransfersUI(props: {
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
|
||||
Recent Transfers
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -57,7 +57,7 @@ function RecentTransfersUI(props: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-b-none">
|
||||
<TableContainer className="rounded-b-none rounded-t-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -159,7 +159,7 @@ function RecentTransfersUI(props: {
|
||||
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
|
||||
<Button
|
||||
className="gap-1.5 bg-background"
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.page === 0 || props.isPending}
|
||||
onClick={() => props.setPage(props.page - 1)}
|
||||
size="sm"
|
||||
@@ -169,7 +169,7 @@ function RecentTransfersUI(props: {
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-1.5 bg-background"
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.isPending || props.data.length < props.rowsPerPage}
|
||||
onClick={() => props.setPage(props.page + 1)}
|
||||
size="sm"
|
||||
@@ -222,23 +222,21 @@ export function RecentTransfers(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RecentTransfersUI
|
||||
client={props.clientContract.client}
|
||||
data={tokenQuery.data ?? []}
|
||||
explorerUrl={
|
||||
props.chainMetadata.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.chainMetadata.slug}`
|
||||
}
|
||||
isPending={tokenQuery.isPending}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setPage={setPage}
|
||||
tokenMetadata={{
|
||||
decimals: props.decimals,
|
||||
symbol: props.tokenSymbol,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RecentTransfersUI
|
||||
client={props.clientContract.client}
|
||||
data={tokenQuery.data ?? []}
|
||||
explorerUrl={
|
||||
props.chainMetadata.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.chainMetadata.slug}`
|
||||
}
|
||||
isPending={tokenQuery.isPending}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setPage={setPage}
|
||||
tokenMetadata={{
|
||||
decimals: props.decimals,
|
||||
symbol: props.tokenSymbol,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-3
@@ -325,13 +325,13 @@ export function TokenDropClaim(props: {
|
||||
<div className="h-4" />
|
||||
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={BigInt(
|
||||
claimedSupplyTokens={Number(
|
||||
toTokens(props.claimCondition.supplyClaimed, props.decimals),
|
||||
)}
|
||||
totalSupply={
|
||||
totalSupplyTokens={
|
||||
props.claimCondition.maxClaimableSupply === maxUint256
|
||||
? "unlimited"
|
||||
: BigInt(
|
||||
: Number(
|
||||
toTokens(
|
||||
props.claimCondition.maxClaimableSupply,
|
||||
props.decimals,
|
||||
|
||||
+33
-33
@@ -29,38 +29,38 @@ export function ContractAnalyticsOverview(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Analytics</h2>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
View trends in unique wallets, transactions, and events over time for
|
||||
this contract
|
||||
</p>
|
||||
</div>
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.2] lg:aspect-[2.6]"
|
||||
className="border-none bg-background p-0"
|
||||
config={{
|
||||
events: {
|
||||
color: "hsl(var(--chart-3))",
|
||||
label: "Events",
|
||||
},
|
||||
transactions: {
|
||||
color: "hsl(var(--chart-2))",
|
||||
label: "Transactions",
|
||||
},
|
||||
wallets: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Unique Wallets",
|
||||
},
|
||||
}}
|
||||
data={data || []}
|
||||
hideLabel={false}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
|
||||
/>
|
||||
</div>
|
||||
<ThirdwebAreaChart
|
||||
chartClassName="aspect-[1.2] lg:aspect-[3]"
|
||||
cardContentClassName="px-0 pb-0"
|
||||
margin={{
|
||||
bottom: 20,
|
||||
}}
|
||||
header={{
|
||||
headerClassName: "p-4 lg:p-6",
|
||||
title: "Analytics",
|
||||
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
|
||||
description:
|
||||
"View trends of transactions, events and unique wallets interacting with this contract over time",
|
||||
}}
|
||||
config={{
|
||||
events: {
|
||||
color: "hsl(var(--chart-5))",
|
||||
label: "Events",
|
||||
},
|
||||
transactions: {
|
||||
color: "hsl(var(--chart-8))",
|
||||
label: "Transactions",
|
||||
},
|
||||
wallets: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Unique Wallets",
|
||||
},
|
||||
}}
|
||||
data={data || []}
|
||||
hideLabel={false}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
|
||||
type TokenPriceData = {
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
percent_change_24h: number;
|
||||
market_cap_usd: number;
|
||||
volume_24h_usd: number;
|
||||
volume_change_24h: number;
|
||||
holders: number;
|
||||
historical_prices: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function useTokenPriceData(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryFn: async () => {
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
|
||||
);
|
||||
|
||||
url.searchParams.set("include_historical_prices", "true");
|
||||
url.searchParams.set("chain_id", params.chainId.toString());
|
||||
url.searchParams.set("address", params.contractAddress);
|
||||
url.searchParams.set("include_holders", "true");
|
||||
url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID);
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const priceData = json.data[0] as TokenPriceData | undefined;
|
||||
return priceData
|
||||
? {
|
||||
data: priceData,
|
||||
type: "data-found" as const,
|
||||
}
|
||||
: { type: "no-data" as const };
|
||||
},
|
||||
queryKey: ["token-price-chart", params.chainId, params.contractAddress],
|
||||
refetchInterval: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
});
|
||||
}
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
export async function fetchTokenInfoFromBridge(params: {
|
||||
chainId: number;
|
||||
@@ -10,7 +12,7 @@ export async function fetchTokenInfoFromBridge(params: {
|
||||
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
|
||||
{
|
||||
headers: {
|
||||
"x-client-id": params.clientId,
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+62
-44
@@ -2,9 +2,11 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20";
|
||||
import { GridPattern } from "@/components/ui/background-patterns";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { getContractCreator } from "../_components/getContractCreator";
|
||||
import { PageHeader } from "../_components/PageHeader";
|
||||
import { getTokenPriceData } from "./_apis/token-price-data";
|
||||
import { ContractHeaderUI } from "./_components/ContractHeader";
|
||||
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
|
||||
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
|
||||
@@ -25,6 +27,7 @@ export async function ERC20PublicPage(props: {
|
||||
tokenDecimals,
|
||||
tokenInfo,
|
||||
functionSelectors,
|
||||
tokenPriceData,
|
||||
] = await Promise.all([
|
||||
getContractMetadata({
|
||||
contract: props.serverContract,
|
||||
@@ -39,6 +42,10 @@ export async function ERC20PublicPage(props: {
|
||||
tokenAddress: props.serverContract.address,
|
||||
}),
|
||||
resolveFunctionSelectors(props.serverContract),
|
||||
getTokenPriceData({
|
||||
chainId: props.serverContract.chain.id,
|
||||
contractAddress: props.serverContract.address,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!contractMetadata.image && tokenInfo) {
|
||||
@@ -78,62 +85,73 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<PageHeader />
|
||||
<div className="container flex max-w-8xl grow flex-col">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={contractCreator}
|
||||
image={contractMetadata.image}
|
||||
name={contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof contractMetadata.social_urls === "object" &&
|
||||
contractMetadata.social_urls !== null
|
||||
? contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={contractMetadata.symbol}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-5xl" />
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-5xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={contractCreator}
|
||||
image={contractMetadata.image}
|
||||
name={contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof contractMetadata.social_urls === "object" &&
|
||||
contractMetadata.social_urls !== null
|
||||
? contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8 pb-20 xl:flex-row">
|
||||
<div className="flex grow flex-col gap-8 overflow-hidden">
|
||||
{activeClaimCondition ? (
|
||||
<div className="border-b border-dashed pb-6">
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="container flex max-w-5xl grow flex-col pt-8 pb-10">
|
||||
<div className="flex grow flex-col gap-8">
|
||||
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className="text-border dark:text-border/70"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom right,white,transparent,transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="sm:w-[420px] z-10">{buyEmbed}</div>
|
||||
</div>
|
||||
|
||||
{tokenPriceData ? (
|
||||
<>
|
||||
<TokenStats
|
||||
chainId={props.chainMetadata.chainId}
|
||||
contractAddress={props.clientContract.address}
|
||||
tokenPriceData={tokenPriceData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="xl:hidden">{buyEmbed}</div>
|
||||
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
decimals={tokenDecimals}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
|
||||
{!activeClaimCondition && (
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden xl:block xl:w-96">
|
||||
<div className="-mt-6 sticky top-0 pt-6">{buyEmbed}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
decimals={tokenDecimals}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+22
-19
@@ -17,26 +17,29 @@ export function NFTPublicPageLayout(props: {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<PageHeader containerClassName="max-w-8xl" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-8xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={props.contractCreator}
|
||||
image={
|
||||
typeof props.contractMetadata.image === "string"
|
||||
? props.contractMetadata.image
|
||||
: undefined
|
||||
}
|
||||
name={props.contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof props.contractMetadata.social_urls === "object" &&
|
||||
props.contractMetadata.social_urls !== null
|
||||
? props.contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={props.contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container flex max-w-8xl grow flex-col">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={props.contractCreator}
|
||||
image={
|
||||
typeof props.contractMetadata.image === "string"
|
||||
? props.contractMetadata.image
|
||||
: undefined
|
||||
}
|
||||
imageClassName="rounded-lg"
|
||||
name={props.contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof props.contractMetadata.social_urls === "object" &&
|
||||
props.contractMetadata.social_urls !== null
|
||||
? props.contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={props.contractMetadata.symbol}
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-2
@@ -10,6 +10,7 @@ import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getApprovalForTransaction } from "thirdweb/extensions/erc20";
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
reportAssetBuyFailed,
|
||||
@@ -225,8 +226,12 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
|
||||
{claimCondition.data ? (
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={claimCondition.data.supplyClaimed}
|
||||
totalSupply={claimCondition.data.maxClaimableSupply}
|
||||
claimedSupplyTokens={Number(claimCondition.data.supplyClaimed)}
|
||||
totalSupplyTokens={
|
||||
claimCondition.data.maxClaimableSupply === maxUint256
|
||||
? "unlimited"
|
||||
: Number(claimCondition.data.maxClaimableSupply)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-[62px] w-full" />
|
||||
|
||||
+4
-2
@@ -231,8 +231,10 @@ export function BuyNFTDropUI(props: BuyNFTDropUIProps) {
|
||||
)}
|
||||
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={BigInt(props.totalNFTs) - props.totalUnclaimedSupply}
|
||||
totalSupply={BigInt(props.totalNFTs)}
|
||||
claimedSupplyTokens={Number(
|
||||
BigInt(props.totalNFTs) - props.totalUnclaimedSupply,
|
||||
)}
|
||||
totalSupplyTokens={Number(props.totalNFTs)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5 rounded-lg bg-muted/50 p-3">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getStripeBalance } from "@/actions/stripe-actions";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug, type Team } from "@/api/team";
|
||||
import { getMemberByAccountId } from "@/api/team-members";
|
||||
import { getTeamSubscriptions } from "@/api/team-subscription";
|
||||
import { CreditsInfoCard } from "@/components/billing/PlanCard";
|
||||
import { Coupons } from "@/components/billing/SubscriptionCoupons/Coupons";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getValidAccount } from "../../../../../account/settings/getAccount";
|
||||
import { CreditBalanceSection } from "./components/credit-balance-section.client";
|
||||
import { PlanInfoCardClient } from "./components/PlanInfoCard.client";
|
||||
@@ -28,9 +26,8 @@ export default async function Page(props: {
|
||||
|
||||
const account = await getValidAccount(pagePath);
|
||||
|
||||
const [team, authToken, teamMember] = await Promise.all([
|
||||
const [team, teamMember] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getAuthToken(),
|
||||
getMemberByAccountId(params.team_slug, account.id),
|
||||
]);
|
||||
|
||||
@@ -50,11 +47,6 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const highlightPlan =
|
||||
typeof searchParams.highlight === "string"
|
||||
? (searchParams.highlight as Team["billingPlan"])
|
||||
@@ -84,11 +76,7 @@ export default async function Page(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreditsInfoCard
|
||||
client={client}
|
||||
teamSlug={team.slug}
|
||||
twAccount={account}
|
||||
/>
|
||||
<CreditsInfoCard />
|
||||
<Coupons isPaymentSetup={validPayment} teamId={team.id} />
|
||||
</div>
|
||||
);
|
||||
|
||||
+4
@@ -62,6 +62,10 @@ export async function EcosystemLayoutSlug({
|
||||
name: "Analytics",
|
||||
path: `${ecosystemLayoutPath}/${ecosystem.slug}/analytics`,
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
path: `${ecosystemLayoutPath}/${ecosystem.slug}/users`,
|
||||
},
|
||||
{
|
||||
name: "Design (coming soon)",
|
||||
path: `${ecosystemLayoutPath}/${ecosystem.slug}/#`,
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { loginRedirect } from "@app/login/loginRedirect";
|
||||
import { InAppWalletUsersPageContent } from "@app/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { fetchEcosystem } from "@/api/ecosystems";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
|
||||
export default async function EcosystemUsersPage(props: {
|
||||
params: Promise<{ team_slug: string; slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [authToken, ecosystem, team] = await Promise.all([
|
||||
getAuthToken(),
|
||||
fetchEcosystem(params.slug, params.team_slug),
|
||||
getTeamBySlug(params.team_slug),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/~/ecosystem/${params.slug}/users`);
|
||||
}
|
||||
|
||||
if (!ecosystem || !team) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<InAppWalletUsersPageContent
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
ecosystemSlug={ecosystem.slug}
|
||||
teamId={team.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-45
@@ -1,10 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SmartWalletsBillingAlert } from "@app/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/Alerts";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { AAFooter } from "./AAFooterSection";
|
||||
@@ -44,10 +40,6 @@ export function AccountAbstractionLayout(props: {
|
||||
{props.hasSmartWalletsWithoutBilling && (
|
||||
<SmartWalletsBillingAlert teamSlug={props.teamSlug} />
|
||||
)}
|
||||
<GasCreditAlert
|
||||
projectId={props.projectId}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
@@ -89,40 +81,3 @@ export function AccountAbstractionLayout(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GasCreditAlert(props: { teamSlug: string; projectId: string }) {
|
||||
return (
|
||||
<DismissibleAlert
|
||||
description={
|
||||
<>
|
||||
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
|
||||
<UnderlineLink
|
||||
href="https://thirdweb.com/superchain"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
</UnderlineLink>
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-background"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Link
|
||||
className="gap-2"
|
||||
href={`/team/${props.teamSlug}/~/settings/credits`}
|
||||
target="_blank"
|
||||
>
|
||||
Claim your credits <ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
localStorageId={`${props.projectId}-gas-credit-alert`}
|
||||
title="OP Gas Credit Program"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-2
@@ -62,9 +62,8 @@ function IntegrateAPIKeySection({
|
||||
<ClientIDSection clientId={clientId} />
|
||||
{secretKeyMasked && (
|
||||
<SecretKeySection
|
||||
projectId={project.id}
|
||||
project={project}
|
||||
secretKeyMasked={secretKeyMasked}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
+13
-2
@@ -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
|
||||
@@ -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>
|
||||
|
||||
+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}
|
||||
|
||||
+22
-5
@@ -42,16 +42,29 @@ export type CreateNFTCollectionFunctions = {
|
||||
address: string;
|
||||
}[];
|
||||
chain: string;
|
||||
gasless: boolean;
|
||||
}) => Promise<void>;
|
||||
erc721: {
|
||||
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
|
||||
deployContract: (params: {
|
||||
values: CreateNFTCollectionAllValues;
|
||||
gasless: boolean;
|
||||
}) => Promise<{
|
||||
contractAddress: string;
|
||||
}>;
|
||||
setClaimConditions: (values: CreateNFTCollectionAllValues) => Promise<void>;
|
||||
lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise<void>;
|
||||
setClaimConditions: (params: {
|
||||
values: CreateNFTCollectionAllValues;
|
||||
gasless: boolean;
|
||||
}) => Promise<void>;
|
||||
lazyMintNFTs: (params: {
|
||||
values: CreateNFTCollectionAllValues;
|
||||
gasless: boolean;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
erc1155: {
|
||||
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
|
||||
deployContract: (params: {
|
||||
values: CreateNFTCollectionAllValues;
|
||||
gasless: boolean;
|
||||
}) => Promise<{
|
||||
contractAddress: string;
|
||||
}>;
|
||||
setClaimConditions: (params: {
|
||||
@@ -60,8 +73,12 @@ export type CreateNFTCollectionFunctions = {
|
||||
startIndex: number;
|
||||
count: number;
|
||||
};
|
||||
gasless: boolean;
|
||||
}) => Promise<void>;
|
||||
lazyMintNFTs: (params: {
|
||||
values: CreateNFTCollectionAllValues;
|
||||
gasless: boolean;
|
||||
}) => Promise<void>;
|
||||
lazyMintNFTs: (values: CreateNFTCollectionAllValues) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+68
-40
@@ -21,6 +21,7 @@ import {
|
||||
import { grantRole } from "thirdweb/extensions/permissions";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
|
||||
import { revalidatePathAction } from "@/actions/revalidate";
|
||||
import {
|
||||
reportAssetCreationFailed,
|
||||
@@ -42,15 +43,26 @@ export function CreateNFTPage(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
}) {
|
||||
const activeAccount = useActiveAccount();
|
||||
|
||||
const addContractToProject = useAddContractToProject();
|
||||
const contractAddressRef = useRef<string | undefined>(undefined);
|
||||
|
||||
function getContractAndAccount(params: { chain: string }) {
|
||||
function getAccount(params: { gasless: boolean }) {
|
||||
if (!activeAccount) {
|
||||
throw new Error("Wallet is not connected");
|
||||
}
|
||||
|
||||
if (params.gasless) {
|
||||
return create7702MinimalAccount({
|
||||
adminAccount: activeAccount,
|
||||
client: props.client,
|
||||
sponsorGas: true,
|
||||
});
|
||||
}
|
||||
|
||||
return activeAccount;
|
||||
}
|
||||
|
||||
function getDeployedContract(params: { chain: string }) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(Number(params.chain));
|
||||
|
||||
@@ -60,30 +72,26 @@ export function CreateNFTPage(props: {
|
||||
throw new Error("Contract not found");
|
||||
}
|
||||
|
||||
const contract = getContract({
|
||||
return getContract({
|
||||
address: contractAddress,
|
||||
chain,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
return {
|
||||
activeAccount,
|
||||
contract,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleContractDeploy(params: {
|
||||
values: CreateNFTCollectionAllValues;
|
||||
ercType: "erc721" | "erc1155";
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values: formValues, ercType } = params;
|
||||
const { collectionInfo, sales } = formValues;
|
||||
const contractType =
|
||||
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
|
||||
|
||||
if (!activeAccount) {
|
||||
throw new Error("Wallet is not connected");
|
||||
}
|
||||
const account = getAccount({
|
||||
gasless: params.gasless,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(Number(collectionInfo.chain));
|
||||
@@ -93,7 +101,7 @@ export function CreateNFTPage(props: {
|
||||
|
||||
if (ercType === "erc721") {
|
||||
contractAddress = await deployERC721Contract({
|
||||
account: activeAccount,
|
||||
account,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
params: {
|
||||
@@ -110,7 +118,7 @@ export function CreateNFTPage(props: {
|
||||
});
|
||||
} else {
|
||||
contractAddress = await deployERC1155Contract({
|
||||
account: activeAccount,
|
||||
account,
|
||||
chain: chain,
|
||||
client: props.client,
|
||||
params: {
|
||||
@@ -166,27 +174,32 @@ export function CreateNFTPage(props: {
|
||||
}
|
||||
|
||||
async function handleLazyMintNFTs(params: {
|
||||
formValues: CreateNFTCollectionAllValues;
|
||||
values: CreateNFTCollectionAllValues;
|
||||
ercType: "erc721" | "erc1155";
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { formValues, ercType } = params;
|
||||
const { values, ercType } = params;
|
||||
const contractType =
|
||||
ercType === "erc721" ? ("DropERC721" as const) : ("DropERC1155" as const);
|
||||
|
||||
const { contract, activeAccount } = getContractAndAccount({
|
||||
chain: formValues.collectionInfo.chain,
|
||||
const contract = getDeployedContract({
|
||||
chain: values.collectionInfo.chain,
|
||||
});
|
||||
|
||||
const account = getAccount({
|
||||
gasless: params.gasless,
|
||||
});
|
||||
|
||||
const lazyMintFn = ercType === "erc721" ? lazyMint721 : lazyMint1155;
|
||||
|
||||
const transaction = lazyMintFn({
|
||||
contract,
|
||||
nfts: formValues.nfts,
|
||||
nfts: values.nfts,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account: activeAccount,
|
||||
account,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -205,14 +218,19 @@ export function CreateNFTPage(props: {
|
||||
}
|
||||
|
||||
async function handleSetClaimConditionsERC721(params: {
|
||||
formValues: CreateNFTCollectionAllValues;
|
||||
values: CreateNFTCollectionAllValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { formValues } = params;
|
||||
const { contract, activeAccount } = getContractAndAccount({
|
||||
chain: formValues.collectionInfo.chain,
|
||||
const { values } = params;
|
||||
const contract = getDeployedContract({
|
||||
chain: values.collectionInfo.chain,
|
||||
});
|
||||
|
||||
const firstNFT = formValues.nfts[0];
|
||||
const account = getAccount({
|
||||
gasless: params.gasless,
|
||||
});
|
||||
|
||||
const firstNFT = values.nfts[0];
|
||||
if (!firstNFT) {
|
||||
throw new Error("No NFTs found");
|
||||
}
|
||||
@@ -235,7 +253,7 @@ export function CreateNFTPage(props: {
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account: activeAccount,
|
||||
account,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -259,12 +277,17 @@ export function CreateNFTPage(props: {
|
||||
startIndex: number;
|
||||
count: number;
|
||||
};
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values, batch } = params;
|
||||
const { contract, activeAccount } = getContractAndAccount({
|
||||
const contract = getDeployedContract({
|
||||
chain: values.collectionInfo.chain,
|
||||
});
|
||||
|
||||
const account = getAccount({
|
||||
gasless: params.gasless,
|
||||
});
|
||||
|
||||
const endIndexExclusive = batch.startIndex + batch.count;
|
||||
const nfts = values.nfts.slice(batch.startIndex, endIndexExclusive);
|
||||
|
||||
@@ -315,7 +338,7 @@ export function CreateNFTPage(props: {
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account: activeAccount,
|
||||
account,
|
||||
transaction: tx,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -339,15 +362,20 @@ export function CreateNFTPage(props: {
|
||||
admins: {
|
||||
address: string;
|
||||
}[];
|
||||
gasless: boolean;
|
||||
chain: string;
|
||||
}) {
|
||||
const { contract, activeAccount } = getContractAndAccount({
|
||||
const contract = getDeployedContract({
|
||||
chain: params.chain,
|
||||
});
|
||||
|
||||
const account = getAccount({
|
||||
gasless: params.gasless,
|
||||
});
|
||||
|
||||
// remove the current account from the list - its already an admin, don't have to add it again
|
||||
const adminsToAdd = params.admins.filter(
|
||||
(admin) => admin.address !== activeAccount.address,
|
||||
(admin) => admin.address !== account.address,
|
||||
);
|
||||
|
||||
const encodedTxs = await Promise.all(
|
||||
@@ -369,7 +397,7 @@ export function CreateNFTPage(props: {
|
||||
|
||||
try {
|
||||
await sendAndConfirmTransaction({
|
||||
account: activeAccount,
|
||||
account,
|
||||
transaction: tx,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -392,35 +420,35 @@ export function CreateNFTPage(props: {
|
||||
{...props}
|
||||
createNFTFunctions={{
|
||||
erc721: {
|
||||
deployContract: (formValues) => {
|
||||
deployContract: (params) => {
|
||||
return handleContractDeploy({
|
||||
ercType: "erc721",
|
||||
values: formValues,
|
||||
...params,
|
||||
});
|
||||
},
|
||||
lazyMintNFTs: (formValues) => {
|
||||
lazyMintNFTs: (params) => {
|
||||
return handleLazyMintNFTs({
|
||||
ercType: "erc721",
|
||||
formValues,
|
||||
...params,
|
||||
});
|
||||
},
|
||||
setClaimConditions: async (formValues) => {
|
||||
setClaimConditions: async (params) => {
|
||||
return handleSetClaimConditionsERC721({
|
||||
formValues,
|
||||
...params,
|
||||
});
|
||||
},
|
||||
},
|
||||
erc1155: {
|
||||
deployContract: (formValues) => {
|
||||
deployContract: (params) => {
|
||||
return handleContractDeploy({
|
||||
ercType: "erc1155",
|
||||
values: formValues,
|
||||
...params,
|
||||
});
|
||||
},
|
||||
lazyMintNFTs: (formValues) => {
|
||||
lazyMintNFTs: (params) => {
|
||||
return handleLazyMintNFTs({
|
||||
ercType: "erc1155",
|
||||
formValues,
|
||||
...params,
|
||||
});
|
||||
},
|
||||
setClaimConditions: async (params) => {
|
||||
|
||||
+51
-6
@@ -19,6 +19,7 @@ import {
|
||||
reportAssetCreationSuccessful,
|
||||
} from "@/analytics/report";
|
||||
import type { Team } from "@/api/team";
|
||||
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
|
||||
import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status";
|
||||
import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
@@ -154,18 +155,28 @@ export function LaunchNFT(props: {
|
||||
return shouldDeployERC721 ? "erc721" : "erc1155";
|
||||
}, [formValues.nfts]);
|
||||
|
||||
const canEnableGasless =
|
||||
props.teamPlan !== "free" && activeWallet?.id === "inApp";
|
||||
const [isGasless, setIsGasless] = useState(canEnableGasless);
|
||||
const showGaslessSection = activeWallet?.id === "inApp";
|
||||
|
||||
const contractLink = contractAddressRef.current
|
||||
? `/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.collectionInfo.chain}/${contractAddressRef.current}`
|
||||
: null;
|
||||
|
||||
async function executeStep(steps: MultiStepState<StepId>[], stepId: StepId) {
|
||||
if (stepId === "deploy-contract") {
|
||||
const result =
|
||||
await props.createNFTFunctions[ercType].deployContract(formValues);
|
||||
const result = await props.createNFTFunctions[ercType].deployContract({
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
});
|
||||
contractAddressRef.current = result.contractAddress;
|
||||
} else if (stepId === "set-claim-conditions") {
|
||||
if (ercType === "erc721") {
|
||||
await props.createNFTFunctions.erc721.setClaimConditions(formValues);
|
||||
await props.createNFTFunctions.erc721.setClaimConditions({
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
});
|
||||
} else {
|
||||
if (batchCount > 1) {
|
||||
const batchStartIndex = batchesProcessedRef.current;
|
||||
@@ -190,6 +201,7 @@ export function LaunchNFT(props: {
|
||||
count: batchSize,
|
||||
startIndex: batchIndex * batchSize,
|
||||
},
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
});
|
||||
|
||||
@@ -201,12 +213,16 @@ export function LaunchNFT(props: {
|
||||
count: formValues.nfts.length,
|
||||
startIndex: 0,
|
||||
},
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (stepId === "mint-nfts") {
|
||||
await props.createNFTFunctions[ercType].lazyMintNFTs(formValues);
|
||||
await props.createNFTFunctions[ercType].lazyMintNFTs({
|
||||
gasless: isGasless,
|
||||
values: formValues,
|
||||
});
|
||||
} else if (stepId === "set-admins") {
|
||||
// this is type guard, this can never happen
|
||||
if (!contractAddressRef.current) {
|
||||
@@ -218,6 +234,7 @@ export function LaunchNFT(props: {
|
||||
chain: formValues.collectionInfo.chain,
|
||||
contractAddress: contractAddressRef.current,
|
||||
contractType: ercType === "erc721" ? "DropERC721" : "DropERC1155",
|
||||
gasless: isGasless,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -311,6 +328,7 @@ export function LaunchNFT(props: {
|
||||
custom: (
|
||||
<TransactionButton
|
||||
client={props.client}
|
||||
disableNoFundsPopup={isGasless}
|
||||
isLoggedIn={true}
|
||||
isPending={false}
|
||||
onClick={handleSubmitClick}
|
||||
@@ -319,7 +337,7 @@ export function LaunchNFT(props: {
|
||||
variant="default"
|
||||
>
|
||||
<ArrowUpFromLineIcon className="size-4" />
|
||||
Launch NFT
|
||||
Launch NFT Collection
|
||||
</TransactionButton>
|
||||
),
|
||||
type: "custom",
|
||||
@@ -327,7 +345,7 @@ export function LaunchNFT(props: {
|
||||
prevButton={{
|
||||
onClick: props.onPrevious,
|
||||
}}
|
||||
title="Launch NFT"
|
||||
title="Launch NFT Collection"
|
||||
>
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
@@ -468,6 +486,7 @@ export function LaunchNFT(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NFTs */}
|
||||
<div className="border-b border-dashed px-4 py-6 pb-6 md:px-6">
|
||||
<h2 className="font-semibold text-base">NFTs</h2>
|
||||
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:gap-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
|
||||
@@ -509,6 +528,7 @@ export function LaunchNFT(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* sales and fees */}
|
||||
<div className="px-4 py-6 pb-6 md:px-6">
|
||||
<h2 className="font-semibold text-base">Sales and Fees</h2>
|
||||
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:gap-4 lg:[&>*:not(:first-child)]:border-l lg:[&>*:not(:first-child)]:border-dashed lg:[&>*:not(:first-child)]:pl-5">
|
||||
@@ -537,6 +557,31 @@ export function LaunchNFT(props: {
|
||||
</OverviewField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* gasless */}
|
||||
{showGaslessSection && (
|
||||
<div className="px-4 py-6 pb-6 md:px-6 border-t border-dashed">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Sponsor Gas</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sponsor gas fees for launching your NFT collection. <br /> This
|
||||
allows you to launch the NFT collection without requiring any
|
||||
balance in your wallet
|
||||
</p>
|
||||
</div>
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan="starter"
|
||||
switchProps={{
|
||||
checked: isGasless,
|
||||
onCheckedChange: setIsGasless,
|
||||
}}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
+92
-88
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
defineChain,
|
||||
getAddress,
|
||||
getContract,
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
transferBatch,
|
||||
} from "thirdweb/extensions/erc20";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { create7702MinimalAccount } from "thirdweb/wallets/smart";
|
||||
import { revalidatePathAction } from "@/actions/revalidate";
|
||||
import {
|
||||
reportAssetCreationFailed,
|
||||
@@ -45,18 +46,52 @@ export function CreateTokenAssetPage(props: {
|
||||
projectSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
}) {
|
||||
const account = useActiveAccount();
|
||||
const activeAccount = useActiveAccount();
|
||||
const { idToChain } = useAllChainsData();
|
||||
const addContractToProject = useAddContractToProject();
|
||||
const contractAddressRef = useRef<string | undefined>(undefined);
|
||||
|
||||
async function deployContract(formValues: CreateAssetFormValues) {
|
||||
if (!account) {
|
||||
toast.error("No Connected Wallet");
|
||||
function getAccount(gasless: boolean) {
|
||||
if (!activeAccount) {
|
||||
throw new Error("No Connected Wallet");
|
||||
}
|
||||
|
||||
const socialUrls = formValues.socialUrls.reduce(
|
||||
if (gasless) {
|
||||
return create7702MinimalAccount({
|
||||
adminAccount: activeAccount,
|
||||
client: props.client,
|
||||
sponsorGas: true,
|
||||
});
|
||||
}
|
||||
return activeAccount;
|
||||
}
|
||||
|
||||
function getDeployedContract(params: { chain: string }) {
|
||||
const contractAddress = contractAddressRef.current;
|
||||
|
||||
if (!contractAddress) {
|
||||
throw new Error("Contract address not set");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(Number(params.chain));
|
||||
|
||||
return getContract({
|
||||
address: contractAddress,
|
||||
chain,
|
||||
client: props.client,
|
||||
});
|
||||
}
|
||||
|
||||
async function deployContract(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values, gasless } = params;
|
||||
|
||||
const account = getAccount(gasless);
|
||||
|
||||
const socialUrls = values.socialUrls.reduce(
|
||||
(acc, url) => {
|
||||
if (url.url && url.platform) {
|
||||
acc[url.platform] = url.url;
|
||||
@@ -71,29 +106,31 @@ export function CreateTokenAssetPage(props: {
|
||||
account,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineDashboardChain(
|
||||
Number(formValues.chain),
|
||||
idToChain.get(Number(formValues.chain)),
|
||||
Number(values.chain),
|
||||
idToChain.get(Number(values.chain)),
|
||||
),
|
||||
client: props.client,
|
||||
params: {
|
||||
description: formValues.description,
|
||||
image: formValues.image,
|
||||
description: values.description,
|
||||
image: values.image,
|
||||
// metadata
|
||||
name: formValues.name,
|
||||
name: values.name,
|
||||
// platform fees
|
||||
platformFeeBps: BigInt(DEFAULT_FEE_BPS_NEW),
|
||||
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
|
||||
// primary sale
|
||||
saleRecipient: account.address,
|
||||
social_urls: socialUrls,
|
||||
symbol: formValues.symbol,
|
||||
symbol: values.symbol,
|
||||
},
|
||||
type: "DropERC20",
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
|
||||
// add contract to project in background
|
||||
addContractToProject.mutateAsync({
|
||||
chainId: formValues.chain,
|
||||
chainId: values.chain,
|
||||
contractAddress: contractAddress,
|
||||
contractType: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
@@ -103,18 +140,17 @@ export function CreateTokenAssetPage(props: {
|
||||
|
||||
reportContractDeployed({
|
||||
address: contractAddress,
|
||||
chainId: Number(formValues.chain),
|
||||
chainId: Number(values.chain),
|
||||
contractName: "DropERC20",
|
||||
deploymentType: "asset",
|
||||
publisher: "deployer.thirdweb.eth",
|
||||
});
|
||||
|
||||
contractAddressRef.current = contractAddress;
|
||||
|
||||
return {
|
||||
contractAddress: contractAddress,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const parsedError = parseError(e);
|
||||
const errorMessage =
|
||||
typeof parsedError === "string" ? parsedError : "Unknown error";
|
||||
@@ -131,32 +167,25 @@ export function CreateTokenAssetPage(props: {
|
||||
}
|
||||
}
|
||||
|
||||
async function airdropTokens(formValues: CreateAssetFormValues) {
|
||||
const contractAddress = contractAddressRef.current;
|
||||
async function airdropTokens(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values, gasless } = params;
|
||||
|
||||
if (!contractAddress) {
|
||||
throw new Error("No contract address");
|
||||
}
|
||||
const contract = getDeployedContract({
|
||||
chain: values.chain,
|
||||
});
|
||||
|
||||
const account = getAccount(gasless);
|
||||
|
||||
if (!account) {
|
||||
throw new Error("No connected account");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineDashboardChain(
|
||||
Number(formValues.chain),
|
||||
idToChain.get(Number(formValues.chain)),
|
||||
);
|
||||
|
||||
const contract = getContract({
|
||||
address: contractAddress,
|
||||
chain,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
try {
|
||||
const airdropTx = transferBatch({
|
||||
batch: formValues.airdropAddresses.map((recipient) => ({
|
||||
batch: values.airdropAddresses.map((recipient) => ({
|
||||
amount: recipient.quantity,
|
||||
to: recipient.address,
|
||||
})),
|
||||
@@ -181,28 +210,18 @@ export function CreateTokenAssetPage(props: {
|
||||
}
|
||||
}
|
||||
|
||||
async function mintTokens(formValues: CreateAssetFormValues) {
|
||||
const contractAddress = contractAddressRef.current;
|
||||
if (!contractAddress) {
|
||||
throw new Error("No contract address");
|
||||
}
|
||||
async function mintTokens(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values, gasless } = params;
|
||||
|
||||
if (!account) {
|
||||
throw new Error("No connected account");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineDashboardChain(
|
||||
Number(formValues.chain),
|
||||
idToChain.get(Number(formValues.chain)),
|
||||
);
|
||||
|
||||
const contract = getContract({
|
||||
address: contractAddress,
|
||||
chain,
|
||||
client: props.client,
|
||||
const contract = getDeployedContract({
|
||||
chain: values.chain,
|
||||
});
|
||||
|
||||
const account = getAccount(gasless);
|
||||
|
||||
// poll until claim conditions are set before moving on to minting
|
||||
await pollWithTimeout({
|
||||
shouldStop: async () => {
|
||||
@@ -214,9 +233,9 @@ export function CreateTokenAssetPage(props: {
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
const totalSupply = Number(formValues.supply);
|
||||
const salePercent = formValues.saleEnabled
|
||||
? Number(formValues.saleAllocationPercentage)
|
||||
const totalSupply = Number(values.supply);
|
||||
const salePercent = values.saleEnabled
|
||||
? Number(values.saleAllocationPercentage)
|
||||
: 0;
|
||||
|
||||
const ownerAndAirdropPercent = 100 - salePercent;
|
||||
@@ -248,48 +267,36 @@ export function CreateTokenAssetPage(props: {
|
||||
}
|
||||
}
|
||||
|
||||
async function setClaimConditions(formValues: CreateAssetFormValues) {
|
||||
const contractAddress = contractAddressRef.current;
|
||||
|
||||
if (!contractAddress) {
|
||||
throw new Error("No contract address");
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
throw new Error("No connected account");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineDashboardChain(
|
||||
Number(formValues.chain),
|
||||
idToChain.get(Number(formValues.chain)),
|
||||
);
|
||||
|
||||
const contract = getContract({
|
||||
address: contractAddress,
|
||||
chain,
|
||||
client: props.client,
|
||||
async function setClaimConditions(params: {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
}) {
|
||||
const { values, gasless } = params;
|
||||
const contract = getDeployedContract({
|
||||
chain: values.chain,
|
||||
});
|
||||
|
||||
const salePercent = formValues.saleEnabled
|
||||
? Number(formValues.saleAllocationPercentage)
|
||||
const account = getAccount(gasless);
|
||||
|
||||
const salePercent = values.saleEnabled
|
||||
? Number(values.saleAllocationPercentage)
|
||||
: 0;
|
||||
|
||||
const totalSupply = Number(formValues.supply);
|
||||
const totalSupply = Number(values.supply);
|
||||
const totalSupplyWei = toUnits(totalSupply.toString(), 18);
|
||||
|
||||
const phases: ClaimConditionsInput[] = [
|
||||
{
|
||||
currencyAddress:
|
||||
getAddress(formValues.saleTokenAddress) ===
|
||||
getAddress(values.saleTokenAddress) ===
|
||||
getAddress(NATIVE_TOKEN_ADDRESS)
|
||||
? undefined
|
||||
: formValues.saleTokenAddress,
|
||||
maxClaimablePerWallet: formValues.saleEnabled ? undefined : 0n,
|
||||
: values.saleTokenAddress,
|
||||
maxClaimablePerWallet: values.saleEnabled ? undefined : 0n,
|
||||
maxClaimableSupply: totalSupplyWei,
|
||||
metadata: {
|
||||
name:
|
||||
formValues.saleEnabled && salePercent > 0
|
||||
values.saleEnabled && salePercent > 0
|
||||
? "Coin Sale phase"
|
||||
: "Only Owner phase",
|
||||
},
|
||||
@@ -300,10 +307,7 @@ export function CreateTokenAssetPage(props: {
|
||||
price: "0",
|
||||
},
|
||||
],
|
||||
price:
|
||||
formValues.saleEnabled && salePercent > 0
|
||||
? formValues.salePrice
|
||||
: "0",
|
||||
price: values.saleEnabled && salePercent > 0 ? values.salePrice : "0",
|
||||
startTime: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
+9
-4
@@ -22,13 +22,18 @@ import { TokenDistributionFieldset } from "./distribution/token-distribution";
|
||||
import { LaunchTokenStatus } from "./launch/launch-token";
|
||||
import { TokenInfoFieldset } from "./token-info/token-info-fieldset";
|
||||
|
||||
type CreateTokenFunctionsParams = {
|
||||
values: CreateAssetFormValues;
|
||||
gasless: boolean;
|
||||
};
|
||||
|
||||
export type CreateTokenFunctions = {
|
||||
deployContract: (values: CreateAssetFormValues) => Promise<{
|
||||
deployContract: (params: CreateTokenFunctionsParams) => Promise<{
|
||||
contractAddress: string;
|
||||
}>;
|
||||
setClaimConditions: (values: CreateAssetFormValues) => Promise<void>;
|
||||
mintTokens: (values: CreateAssetFormValues) => Promise<void>;
|
||||
airdropTokens: (values: CreateAssetFormValues) => Promise<void>;
|
||||
setClaimConditions: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
mintTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
airdropTokens: (params: CreateTokenFunctionsParams) => Promise<void>;
|
||||
};
|
||||
|
||||
const checksummedNativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
+79
-11
@@ -13,6 +13,7 @@ import {
|
||||
reportAssetCreationSuccessful,
|
||||
} from "@/analytics/report";
|
||||
import type { Team } from "@/api/team";
|
||||
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
|
||||
import {
|
||||
type MultiStepState,
|
||||
MultiStepStatus,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { ChainOverview } from "../../_common/chain-overview";
|
||||
import { FilePreview } from "../../_common/file-preview";
|
||||
@@ -62,6 +64,13 @@ export function LaunchTokenStatus(props: {
|
||||
const activeWallet = useActiveWallet();
|
||||
const walletRequiresApproval = activeWallet?.id !== "inApp";
|
||||
|
||||
const canEnableGasless =
|
||||
props.teamPlan !== "free" && activeWallet?.id === "inApp";
|
||||
const [isGasless, setIsGasless] = useState(canEnableGasless);
|
||||
const showGaslessSection = activeWallet?.id === "inApp";
|
||||
const { idToChain } = useAllChainsData();
|
||||
const chainMetadata = idToChain.get(Number(formValues.chain));
|
||||
|
||||
function updateStatus(
|
||||
index: number,
|
||||
newStatus: MultiStepState<StepId>["status"],
|
||||
@@ -104,30 +113,36 @@ export function LaunchTokenStatus(props: {
|
||||
|
||||
setSteps(initialSteps);
|
||||
setIsModalOpen(true);
|
||||
executeSteps(initialSteps, 0);
|
||||
executeSteps(initialSteps, 0, isGasless);
|
||||
}
|
||||
|
||||
const isComplete = steps.every((step) => step.status.type === "completed");
|
||||
const isPending = steps.some((step) => step.status.type === "pending");
|
||||
|
||||
async function executeStep(stepId: StepId) {
|
||||
async function executeStep(stepId: StepId, gasless: boolean) {
|
||||
const params = {
|
||||
gasless,
|
||||
values: formValues,
|
||||
};
|
||||
|
||||
if (stepId === "deploy-contract") {
|
||||
const result = await createTokenFunctions.deployContract(formValues);
|
||||
const result = await createTokenFunctions.deployContract(params);
|
||||
setContractLink(
|
||||
`/team/${props.teamSlug}/${props.projectSlug}/contract/${formValues.chain}/${result.contractAddress}`,
|
||||
);
|
||||
} else if (stepId === "set-claim-conditions") {
|
||||
await createTokenFunctions.setClaimConditions(formValues);
|
||||
await createTokenFunctions.setClaimConditions(params);
|
||||
} else if (stepId === "mint-tokens") {
|
||||
await createTokenFunctions.mintTokens(formValues);
|
||||
await createTokenFunctions.mintTokens(params);
|
||||
} else if (stepId === "airdrop-tokens") {
|
||||
await createTokenFunctions.airdropTokens(formValues);
|
||||
await createTokenFunctions.airdropTokens(params);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSteps(
|
||||
steps: MultiStepState<StepId>[],
|
||||
startIndex: number,
|
||||
gasless: boolean,
|
||||
) {
|
||||
for (let i = startIndex; i < steps.length; i++) {
|
||||
const currentStep = steps[i];
|
||||
@@ -140,7 +155,7 @@ export function LaunchTokenStatus(props: {
|
||||
type: "pending",
|
||||
});
|
||||
|
||||
await executeStep(currentStep.id);
|
||||
await executeStep(currentStep.id, gasless);
|
||||
|
||||
updateStatus(i, {
|
||||
type: "completed",
|
||||
@@ -172,20 +187,22 @@ export function LaunchTokenStatus(props: {
|
||||
props.onLaunchSuccess();
|
||||
}
|
||||
|
||||
async function handleRetry(step: MultiStepState<StepId>) {
|
||||
async function handleRetry(step: MultiStepState<StepId>, gasless: boolean) {
|
||||
const startIndex = steps.findIndex((s) => s.id === step.id);
|
||||
if (startIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
await executeSteps(steps, startIndex);
|
||||
await executeSteps(steps, startIndex, gasless);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepCard
|
||||
nextButton={{
|
||||
custom: (
|
||||
<TransactionButton
|
||||
client={props.client}
|
||||
disableNoFundsPopup={isGasless}
|
||||
isLoggedIn={true}
|
||||
isPending={false}
|
||||
onClick={handleSubmitClick}
|
||||
@@ -253,6 +270,30 @@ export function LaunchTokenStatus(props: {
|
||||
<TokenDistributionBarChart distributionFormValues={formValues} />
|
||||
</div>
|
||||
|
||||
{/* gasless */}
|
||||
{showGaslessSection && (
|
||||
<div className="px-4 py-6 pb-6 md:px-6 border-t border-dashed">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Sponsor Gas</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sponsor gas fees for launching your coin. <br /> This allows you
|
||||
to launch the coin without requiring any balance in your wallet
|
||||
</p>
|
||||
</div>
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan="starter"
|
||||
switchProps={{
|
||||
checked: isGasless,
|
||||
onCheckedChange: setIsGasless,
|
||||
}}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
// do not set onOpenChange
|
||||
@@ -274,7 +315,7 @@ export function LaunchTokenStatus(props: {
|
||||
</DialogHeader>
|
||||
|
||||
<MultiStepStatus
|
||||
onRetry={handleRetry}
|
||||
onRetry={(step) => handleRetry(step, isGasless)}
|
||||
renderError={(step, errorMessage) => {
|
||||
if (
|
||||
props.teamPlan === "free" &&
|
||||
@@ -282,13 +323,40 @@ export function LaunchTokenStatus(props: {
|
||||
) {
|
||||
return (
|
||||
<StorageErrorPlanUpsell
|
||||
onRetry={() => handleRetry(step)}
|
||||
onRetry={() => handleRetry(step, isGasless)}
|
||||
teamSlug={props.teamSlug}
|
||||
trackingCampaign="create-coin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage
|
||||
.toLowerCase()
|
||||
.includes("does not support eip-7702")
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-red-500 text-sm mb-2">
|
||||
Gas Sponsorship is not supported on{" "}
|
||||
{chainMetadata?.name || "selected chain"}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
setIsGasless(false);
|
||||
handleRetry(step, false);
|
||||
}}
|
||||
>
|
||||
Continue without gas sponsorship
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
steps={steps}
|
||||
|
||||
+6
-51
@@ -1,15 +1,11 @@
|
||||
"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";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CreateVaultAccountButton } from "../../vault/components/create-vault-account.client";
|
||||
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 +15,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 +29,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 +39,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 +59,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 +72,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 +83,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 (
|
||||
@@ -115,30 +94,6 @@ export const EngineChecklist: React.FC<Props> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
function CreateVaultAccountStep(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
onUserAccessTokenCreated: (userAccessToken: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-row gap-4">
|
||||
<CreateVaultAccountButton
|
||||
onUserAccessTokenCreated={props.onUserAccessTokenCreated}
|
||||
project={props.project}
|
||||
/>
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/vault"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Vault
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateServerWalletStep(props: {
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
|
||||
+187
-146
@@ -11,7 +11,14 @@ 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -22,39 +29,35 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { TryItOut } from "../server-wallets/components/try-it-out";
|
||||
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(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function SendTestTransaction(props: {
|
||||
function SendTestTransactionModal(props: {
|
||||
wallets?: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
userAccessToken?: string;
|
||||
expanded?: boolean;
|
||||
walletId?: string;
|
||||
isManagedVault: boolean;
|
||||
client: ThirdwebClient;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [hasSentTx, setHasSentTx] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
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 +76,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 +96,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",
|
||||
@@ -111,6 +116,10 @@ export function SendTestTransaction(props: {
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Test transaction sent successfully!");
|
||||
// Close the modal after successful transaction
|
||||
setTimeout(() => {
|
||||
props.onOpenChange(false);
|
||||
}, 1000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -123,161 +132,193 @@ export function SendTestTransaction(props: {
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await sendDummyTxMutation.mutateAsync({
|
||||
accessToken: data.accessToken,
|
||||
secretKey: data.secretKey,
|
||||
chainId: data.chainId,
|
||||
walletAddress: selectedWallet.address,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["transactions", props.project.id],
|
||||
});
|
||||
setHasSentTx(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full rounded-md border bg-background p-6">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send a test transaction</DialogTitle>
|
||||
<DialogDescription>
|
||||
Test your server wallet configuration by sending a transaction
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{props.walletId && (
|
||||
<h3 className="mb-4 font-medium text-lg">Send a test transaction</h3>
|
||||
)}
|
||||
<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."}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<p className="flex items-center gap-2 text-sm text-warning-text">
|
||||
<LockIcon className="h-4 w-4" />
|
||||
Every server wallet action requires your{" "}
|
||||
{props.isManagedVault ? "project secret key" : "vault access token"}
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Secret Key Input */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<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 className="h-4" />
|
||||
{/* 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">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<p className="text-sm">Server Wallet</p>
|
||||
<Select
|
||||
onValueChange={(value) => form.setValue("walletIndex", value)}
|
||||
value={form.watch("walletIndex")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
|
||||
{/* Wallet Selector */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Server Wallet</p>
|
||||
<Select
|
||||
onValueChange={(value) => form.setValue("walletIndex", value)}
|
||||
value={form.watch("walletIndex")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<SmartAccountCell
|
||||
client={props.client}
|
||||
wallet={selectedWallet}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{selectedWallet.metadata.label}
|
||||
</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.wallets.map((wallet, index) => (
|
||||
<SelectItem key={wallet.address} value={index.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SmartAccountCell
|
||||
client={props.client}
|
||||
wallet={selectedWallet}
|
||||
/>
|
||||
<SmartAccountCell client={props.client} wallet={wallet} />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{selectedWallet.metadata.label}
|
||||
{wallet.metadata.label}
|
||||
</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.wallets.map((wallet, index) => (
|
||||
<SelectItem key={wallet.address} value={index.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SmartAccountCell
|
||||
client={props.client}
|
||||
wallet={wallet}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{wallet.metadata.label}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<p className="text-sm">Select Testnet</p>
|
||||
<SingleNetworkSelector
|
||||
chainId={form.watch("chainId")}
|
||||
chainIds={chainsQuery.allChains
|
||||
.filter(
|
||||
(chain) =>
|
||||
chain.testnet === true &&
|
||||
chain.stackType !== "zksync_stack",
|
||||
)
|
||||
.map((chain) => chain.chainId)}
|
||||
className="bg-background"
|
||||
client={props.client}
|
||||
onChange={(chainId) => {
|
||||
form.setValue("chainId", chainId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6" />
|
||||
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
|
||||
<Button
|
||||
className="w-full min-w-[200px] md:w-auto"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
variant={hasSentTx ? "secondary" : "primary"}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : hasSentTx ? (
|
||||
"Send More Transactions"
|
||||
) : (
|
||||
"Send Transaction"
|
||||
)}
|
||||
</Button>
|
||||
{hasSentTx && (
|
||||
|
||||
{/* Testnet Selector */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm">Select Testnet</p>
|
||||
<SingleNetworkSelector
|
||||
chainId={form.watch("chainId")}
|
||||
chainIds={chainsQuery.allChains
|
||||
.filter(
|
||||
(chain) =>
|
||||
chain.testnet === true &&
|
||||
chain.stackType !== "zksync_stack",
|
||||
)
|
||||
.map((chain) => chain.chainId)}
|
||||
className="bg-background"
|
||||
client={props.client}
|
||||
onChange={(chainId) => {
|
||||
form.setValue("chainId", chainId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
|
||||
<Button
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => {
|
||||
if (props.walletId) {
|
||||
router.replace(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
// clear token from local storage after FTUX is complete
|
||||
deleteUserAccessToken(props.project.id);
|
||||
}}
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full min-w-[200px] md:w-auto"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
{props.walletId ? "Close" : "Complete Setup"}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Send Transaction"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendTestTransaction(props: {
|
||||
wallets?: Wallet[];
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
expanded?: boolean;
|
||||
walletId?: string;
|
||||
isManagedVault: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
|
||||
// Early return in render phase
|
||||
if (!props.wallets || props.wallets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full rounded-md border bg-background p-6">
|
||||
<TryItOut />
|
||||
<div className="mt-6 flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" className="w-full md:w-auto">
|
||||
Test Request
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<SendTestTransactionModal
|
||||
{...props}
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
/>
|
||||
</Dialog>
|
||||
<Button
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => {
|
||||
if (props.walletId) {
|
||||
router.replace(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
{props.walletId ? "Close" : "Complete Setup"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-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}`,
|
||||
);
|
||||
}
|
||||
+241
-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,201 @@ export async function initVaultClient() {
|
||||
return vc;
|
||||
}
|
||||
|
||||
export async function rotateVaultAccountAndAccessToken(props: {
|
||||
project: Project;
|
||||
projectSecretKey?: string;
|
||||
projectSecretHash?: string;
|
||||
}) {
|
||||
const vaultClient = await initVaultClient();
|
||||
const service = props.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
const storedRotationCode = service?.rotationCode;
|
||||
if (!storedRotationCode) {
|
||||
throw new Error("No rotation code found");
|
||||
}
|
||||
|
||||
const rotateServiceAccountRes = await rotateServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
rotationCode: storedRotationCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (rotateServiceAccountRes.error) {
|
||||
throw new Error(rotateServiceAccountRes.error.message);
|
||||
}
|
||||
const adminKey = rotateServiceAccountRes.data.newAdminKey;
|
||||
const rotationCode = rotateServiceAccountRes.data.newRotationCode;
|
||||
|
||||
const { managementToken, walletToken } =
|
||||
await createAndEncryptVaultAccessTokens({
|
||||
project: props.project,
|
||||
projectSecretKey: props.projectSecretKey,
|
||||
projectSecretHash: props.projectSecretHash,
|
||||
vaultClient,
|
||||
adminKey,
|
||||
rotationCode,
|
||||
});
|
||||
|
||||
return {
|
||||
adminKey,
|
||||
managementToken,
|
||||
walletToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createVaultAccountAndAccessToken(props: {
|
||||
project: Project;
|
||||
projectSecretKey?: string;
|
||||
projectSecretHash?: string;
|
||||
}) {
|
||||
try {
|
||||
const vaultClient = await initVaultClient();
|
||||
|
||||
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;
|
||||
const adminKey = serviceAccount.adminKey;
|
||||
const 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;
|
||||
@@ -164,6 +362,29 @@ export async function createWalletAccessToken(props: {
|
||||
],
|
||||
type: "eoa:signTypedData",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
key: "projectId",
|
||||
rule: {
|
||||
pattern: props.project.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teamId",
|
||||
rule: {
|
||||
pattern: props.project.teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
rule: {
|
||||
pattern: "server-wallet",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "eoa:signAuthorization",
|
||||
},
|
||||
{
|
||||
metadataPatterns: [
|
||||
{
|
||||
@@ -243,13 +464,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 +553,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}
|
||||
|
||||
+10
-67
@@ -1,19 +1,17 @@
|
||||
"use client";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
|
||||
|
||||
export function TryItOut() {
|
||||
const [activeTab, setActiveTab] = useState<string>("sdk");
|
||||
const [activeTab, setActiveTab] = useState<string>("curl");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 overflow-hidden rounded-lg border border-border bg-card p-4">
|
||||
<>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between pb-2">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Usage from your backend
|
||||
@@ -30,16 +28,16 @@ export function TryItOut() {
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
{
|
||||
isActive: activeTab === "sdk",
|
||||
name: "thirdweb SDK",
|
||||
onClick: () => setActiveTab("sdk"),
|
||||
},
|
||||
{
|
||||
isActive: activeTab === "curl",
|
||||
name: "Curl",
|
||||
onClick: () => setActiveTab("curl"),
|
||||
},
|
||||
{
|
||||
isActive: activeTab === "sdk",
|
||||
name: "thirdweb SDK",
|
||||
onClick: () => setActiveTab("sdk"),
|
||||
},
|
||||
{
|
||||
isActive: activeTab === "js",
|
||||
name: "JavaScript",
|
||||
@@ -63,59 +61,10 @@ export function TryItOut() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-2" />
|
||||
<div className="h-4" />
|
||||
|
||||
{activeTab === "sdk" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Alert className="bg-background" variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Using the thirdweb SDK on the backend</AlertTitle>
|
||||
<AlertDescription className="leading-loose">
|
||||
<p className="text-foreground text-sm">
|
||||
You can use the full TypeScript thirdweb SDK in your backend,
|
||||
allowing you to use:{" "}
|
||||
<ul className="ml-2 list-disc py-2 pl-4">
|
||||
<li>
|
||||
The full catalog of{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="https://portal.thirdweb.com/references/typescript/v5/functions#extensions"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
extension functions
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
The{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="https://portal.thirdweb.com/references/typescript/v5/prepareContractCall"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
prepareContractCall
|
||||
</Link>{" "}
|
||||
function to encode your transactions
|
||||
</li>
|
||||
<li>
|
||||
The full{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="https://portal.thirdweb.com/references/typescript/v5/Account"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
account
|
||||
</Link>{" "}
|
||||
interface, predefined chains, and more
|
||||
</li>
|
||||
</ul>
|
||||
The SDK handles encoding your transactions, signing them to
|
||||
Engine and polling for status.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
Installation
|
||||
@@ -185,7 +134,7 @@ export function TryItOut() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +152,6 @@ const client = createThirdwebClient({
|
||||
const serverWallet = Engine.serverWallet({
|
||||
client,
|
||||
address: "<your-server-wallet-address>",
|
||||
vaultAccessToken: "<your-vault-access-token>",
|
||||
});
|
||||
|
||||
// Prepare the transaction
|
||||
@@ -241,7 +189,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 +211,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 +236,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 +300,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 +352,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();
|
||||
|
||||
+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 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,
|
||||
maskSecret,
|
||||
rotateVaultAccountAndAccessToken,
|
||||
} 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 rotateVaultAccountAndAccessToken({
|
||||
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>
|
||||
);
|
||||
}
|
||||
+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: "Payments",
|
||||
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,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { PayEmbed, type TokenInfo } from "thirdweb/react";
|
||||
import type { Address } from "thirdweb";
|
||||
import { BuyWidget, type TokenInfo } from "thirdweb/react";
|
||||
import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { bridgeAppThirdwebClient } from "../../constants";
|
||||
@@ -19,19 +20,11 @@ export function UniversalBridgeEmbed({
|
||||
const chain = useV5DashboardChain(chainId || 1);
|
||||
|
||||
return (
|
||||
<PayEmbed
|
||||
<BuyWidget
|
||||
client={bridgeAppThirdwebClient}
|
||||
payOptions={{
|
||||
mode: "fund_wallet",
|
||||
prefillBuy:
|
||||
chainId && token
|
||||
? {
|
||||
amount,
|
||||
chain,
|
||||
token,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
amount={amount || "0"}
|
||||
chain={chain}
|
||||
tokenAddress={token?.address as Address | undefined}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -26,6 +54,8 @@ export type BridgeComponentsPlaygroundOptions = {
|
||||
|
||||
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" ||
|
||||
|
||||
@@ -64,6 +64,7 @@ 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}
|
||||
/>
|
||||
);
|
||||
@@ -88,6 +89,7 @@ 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}
|
||||
/>
|
||||
);
|
||||
@@ -108,6 +110,7 @@ export function RightSection(props: {
|
||||
to: account?.address || "",
|
||||
tokenId: 2n,
|
||||
})}
|
||||
currency={props.options.payOptions.currency}
|
||||
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ const defaultConnectOptions: BridgeComponentsPlaygroundOptions = {
|
||||
title: "",
|
||||
transactionData: "",
|
||||
widget: "buy",
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
},
|
||||
theme: {
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# @thirdweb-dev/nebula
|
||||
|
||||
## 0.2.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`f33e31a`](https://github.com/thirdweb-dev/js/commit/f33e31ad6e045fe751c2d7a51874f8a13e99861f), [`592bf6f`](https://github.com/thirdweb-dev/js/commit/592bf6f1fdd4f1a2babbef94a37b93881d28b6f5)]:
|
||||
- thirdweb@5.105.16
|
||||
|
||||
## 0.2.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`585eac2`](https://github.com/thirdweb-dev/js/commit/585eac2a6f49877318cb275f6fff5910ceabdbfb), [`d62e997`](https://github.com/thirdweb-dev/js/commit/d62e9971ee7707211b513509de80a5b0abdaa4d0)]:
|
||||
- thirdweb@5.105.15
|
||||
|
||||
## 0.2.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`f3234c3`](https://github.com/thirdweb-dev/js/commit/f3234c3c414f2fb4ee41c2394bf52a56fa081f73)]:
|
||||
- thirdweb@5.105.14
|
||||
|
||||
## 0.2.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`772eb0c`](https://github.com/thirdweb-dev/js/commit/772eb0cc53982951dc6b6baed1c4411bd3e39934), [`446a69b`](https://github.com/thirdweb-dev/js/commit/446a69b1d18b8711324b24377abecdbda10b8a1f)]:
|
||||
- thirdweb@5.105.13
|
||||
|
||||
## 0.2.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -57,5 +57,5 @@
|
||||
"type": "module",
|
||||
"types": "./dist/types/exports/thirdweb.d.ts",
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "0.2.12"
|
||||
"version": "0.2.16"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @thirdweb-dev/service-utils
|
||||
|
||||
## 0.10.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7570](https://github.com/thirdweb-dev/js/pull/7570) [`585eac2`](https://github.com/thirdweb-dev/js/commit/585eac2a6f49877318cb275f6fff5910ceabdbfb) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Add encryption utilities
|
||||
|
||||
## 0.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user