diff --git a/apps/dashboard/src/@/api/universal-bridge/tokens.ts b/apps/dashboard/src/@/api/universal-bridge/tokens.ts index 5f0c89609ef..a7e60b5d884 100644 --- a/apps/dashboard/src/@/api/universal-bridge/tokens.ts +++ b/apps/dashboard/src/@/api/universal-bridge/tokens.ts @@ -1,5 +1,7 @@ "use server"; import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs"; +import type { ProjectResponse } from "@thirdweb-dev/service-utils"; +import { getAuthToken } from "app/(app)/api/lib/getAuthToken"; import { UB_BASE_URL } from "./constants"; export type TokenMetadata = { @@ -37,3 +39,33 @@ export async function getUniversalBridgeTokens(props: { const json = await res.json(); return json.data as Array; } + +export async function addUniversalBridgeTokenRoute(props: { + chainId: number; + tokenAddress: string; + project: ProjectResponse; +}) { + const authToken = await getAuthToken(); + const url = new URL(`${UB_BASE_URL}/v1/tokens`); + + const res = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + "x-client-id": props.project.publishableKey, + } as Record, + body: JSON.stringify({ + chainId: props.chainId, + tokenAddress: props.tokenAddress, + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json.data as Array; +} diff --git a/apps/dashboard/src/@/components/blocks/RouteDiscoveryCard.tsx b/apps/dashboard/src/@/components/blocks/RouteDiscoveryCard.tsx new file mode 100644 index 00000000000..2afdd28fa3c --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/RouteDiscoveryCard.tsx @@ -0,0 +1,84 @@ +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type React from "react"; + +export function RouteDiscoveryCard( + props: React.PropsWithChildren<{ + bottomText: React.ReactNode; + header?: { + description: string | undefined; + title: string; + }; + errorText: string | undefined; + noPermissionText: string | undefined; + saveButton?: { + onClick?: () => void; + disabled: boolean; + isPending: boolean; + type?: "submit"; + variant?: + | "ghost" + | "default" + | "primary" + | "destructive" + | "outline" + | "secondary"; + className?: string; + label?: string; + }; + }>, +) { + return ( +
+
+ {props.header && ( + <> +

+ {props.header.title} +

+ {props.header.description && ( +

+ {props.header.description} +

+ )} + + )} + + {props.children} +
+ +
+ {props.noPermissionText ? ( +

+ {props.noPermissionText} +

+ ) : props.errorText ? ( +

{props.errorText}

+ ) : ( +

{props.bottomText}

+ )} + + {props.saveButton && !props.noPermissionText && ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/connect/universal-bridge/settings/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/connect/universal-bridge/settings/page.tsx index c538aec5976..200657b95fc 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/connect/universal-bridge/settings/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/connect/universal-bridge/settings/page.tsx @@ -2,6 +2,8 @@ import { getProject } from "@/api/projects"; import { getTeamBySlug } from "@/api/team"; import { getFees } from "@/api/universal-bridge/developer"; import { PayConfig } from "components/pay/PayConfig"; +import { RouteDiscovery } from "components/pay/RouteDiscovery"; + import { redirect } from "next/navigation"; export default async function Page(props: { @@ -47,11 +49,17 @@ export default async function Page(props: { } return ( - +
+ + +
+ +
+
); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx index f6048818f6d..0c43f08417e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx @@ -349,32 +349,26 @@ export function ProjectGeneralSettingsPageUI(props: { isUpdatingProject={updateProject.isPending} handleSubmit={handleSubmit} /> - - - - - - - - { + const form = useForm({ + resolver: zodResolver(routeDiscoveryValidationSchema), + defaultValues: { + chainId: 1, + tokenAddress: undefined, + }, + }); + + const trackEvent = useTrack(); + + const submitDiscoveryMutation = useMutation({ + mutationFn: async (values: { + chainId: number; + tokenAddress: string; + }) => { + // Call the API to add the route + const result = await addUniversalBridgeTokenRoute({ + chainId: values.chainId, + tokenAddress: values.tokenAddress, + project, + }); + + return result; + }, + }); + + const handleSubmit = form.handleSubmit( + ({ chainId, tokenAddress }) => { + submitDiscoveryMutation.mutate( + { + chainId, + tokenAddress, + }, + { + onSuccess: (data) => { + toast.success("Token submitted successfully!", { + description: + "Thank you for your submission. Contact support if your token doesn't appear after some time.", + }); + trackEvent({ + category: TRACKING_CATEGORY, + action: "token-discovery-submit", + label: "success", + data: { + tokenAddress, + tokenCount: data?.length || 0, + }, + }); + }, + onError: () => { + toast.error("Token submission failed!", { + description: + "Please double check the network and token address. If issues persist, please reach out to our support team.", + }); + + // Get appropriate error message + const errorMessage = "An unknown error occurred"; + + trackEvent({ + category: TRACKING_CATEGORY, + action: "token-discovery-submit", + label: "error", + error: errorMessage, + }); + }, + }, + ); + }, + () => { + toast.error("Please fix the form errors before submitting"); + }, + ); + + return ( +
+ + +
+

+ Don't see your token listed? +

+

+ Select your chain and input the token address to automatically + kick-off the token route discovery process. Please check back on + this page within 20-40 minutes of submitting this form. +

+ +
+ ( + + Blockchain + + { + // Update the form field value + field.onChange(chain.chainId); + }} + /> + + + )} + /> + ( + + Token Address + +
+ +
+
+
+ )} + /> +
+
+
+
+ + ); +}; diff --git a/apps/dashboard/src/components/settings/ApiKeys/validations.ts b/apps/dashboard/src/components/settings/ApiKeys/validations.ts index 9ce385b6e3e..8e091917121 100644 --- a/apps/dashboard/src/components/settings/ApiKeys/validations.ts +++ b/apps/dashboard/src/components/settings/ApiKeys/validations.ts @@ -1,3 +1,4 @@ +import { isAddress } from "thirdweb"; import { RE_DOMAIN } from "utils/regex"; import { validStrList } from "utils/validations"; import { z } from "zod"; @@ -123,6 +124,20 @@ export const apiKeyPayConfigValidationSchema = z.object({ .optional(), }); +export const routeDiscoveryValidationSchema = z.object({ + chainId: z.number(), + tokenAddress: z + .string({ + required_error: "Token address is required", + }) + .refine((value) => isAddress(value), { + message: "Invalid Ethereum address format", + }), +}); +export type RouteDiscoveryValidationSchema = z.infer< + typeof routeDiscoveryValidationSchema +>; + export type ApiKeyEmbeddedWalletsValidationSchema = z.infer< typeof apiKeyEmbeddedWalletsValidationSchema >;