diff --git a/apps/dashboard/src/@/api/universal-bridge/developer.ts b/apps/dashboard/src/@/api/universal-bridge/developer.ts index 85b2c502017..05a4c9709be 100644 --- a/apps/dashboard/src/@/api/universal-bridge/developer.ts +++ b/apps/dashboard/src/@/api/universal-bridge/developer.ts @@ -1,4 +1,5 @@ "use server"; +import type { Address } from "thirdweb"; import { getAuthToken } from "@/api/auth-token"; import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs"; @@ -96,6 +97,139 @@ export async function deleteWebhook(props: { return; } +type PaymentLink = { + id: string; + link: string; + title: string; + imageUrl: string; + createdAt: string; + updatedAt: string; + destinationToken: { + chainId: number; + address: Address; + symbol: string; + name: string; + decimals: number; + iconUri: string; + }; + receiver: Address; + amount: bigint; +}; + +export async function getPaymentLinks(props: { + clientId: string; + teamId: string; +}): Promise> { + const authToken = await getAuthToken(); + const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": props.clientId, + "x-team-id": props.teamId, + }, + method: "GET", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = (await res.json()) as { + data: Array; + }; + return json.data.map((link) => ({ + id: link.id, + link: link.link, + title: link.title, + imageUrl: link.imageUrl, + createdAt: link.createdAt, + updatedAt: link.updatedAt, + destinationToken: { + chainId: link.destinationToken.chainId, + address: link.destinationToken.address, + symbol: link.destinationToken.symbol, + name: link.destinationToken.name, + decimals: link.destinationToken.decimals, + iconUri: link.destinationToken.iconUri, + }, + receiver: link.receiver, + amount: BigInt(link.amount), + })); +} + +export async function createPaymentLink(props: { + clientId: string; + teamId: string; + title: string; + imageUrl?: string; + intent: { + destinationChainId: number; + destinationTokenAddress: Address; + receiver: Address; + amount: bigint; + purchaseData?: unknown; + }; +}) { + const authToken = await getAuthToken(); + + const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, { + body: JSON.stringify({ + title: props.title, + imageUrl: props.imageUrl, + intent: { + destinationChainId: props.intent.destinationChainId, + destinationTokenAddress: props.intent.destinationTokenAddress, + receiver: props.intent.receiver, + amount: props.intent.amount.toString(), + purchaseData: props.intent.purchaseData, + }, + }), + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": props.clientId, + "x-team-id": props.teamId, + }, + method: "POST", + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + return; +} + +export async function deletePaymentLink(props: { + clientId: string; + teamId: string; + paymentLinkId: string; +}) { + const authToken = await getAuthToken(); + const res = await fetch( + `${UB_BASE_URL}/v1/developer/links/${props.paymentLinkId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-client-id": props.clientId, + "x-team-id": props.teamId, + }, + method: "DELETE", + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + return; +} + export type Fee = { feeRecipient: string; feeBps: number; @@ -195,30 +329,28 @@ export type Payment = { export async function getPayments(props: { clientId: string; teamId: string; + paymentLinkId?: string; limit?: number; offset?: number; }) { const authToken = await getAuthToken(); // Build URL with query parameters if provided - let url = `${UB_BASE_URL}/v1/developer/payments`; - const queryParams = new URLSearchParams(); + const url = new URL(`${UB_BASE_URL}/v1/developer/payments`); if (props.limit) { - queryParams.append("limit", props.limit.toString()); + url.searchParams.append("limit", props.limit.toString()); } if (props.offset) { - queryParams.append("offset", props.offset.toString()); + url.searchParams.append("offset", props.offset.toString()); } - // Append query params to URL if any exist - const queryString = queryParams.toString(); - if (queryString) { - url = `${url}?${queryString}`; + if (props.paymentLinkId) { + url.searchParams.append("paymentLinkId", props.paymentLinkId); } - const res = await fetch(url, { + const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${authToken}`, "Content-Type": "application/json", diff --git a/apps/dashboard/src/@/api/universal-bridge/tokens.ts b/apps/dashboard/src/@/api/universal-bridge/tokens.ts index 60f32c785d8..989afb7f03f 100644 --- a/apps/dashboard/src/@/api/universal-bridge/tokens.ts +++ b/apps/dashboard/src/@/api/universal-bridge/tokens.ts @@ -13,12 +13,18 @@ export type TokenMetadata = { iconUri?: string; }; -export async function getUniversalBridgeTokens(props: { chainId?: number }) { +export async function getUniversalBridgeTokens(props: { + chainId?: number; + address?: string; +}) { const url = new URL(`${UB_BASE_URL}/v1/tokens`); if (props.chainId) { url.searchParams.append("chainId", String(props.chainId)); } + if (props.address) { + url.searchParams.append("tokenAddress", props.address); + } url.searchParams.append("limit", "1000"); const res = await fetch(url.toString(), { diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx index 872a9e46663..f42a704bf3f 100644 --- a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx @@ -14,6 +14,7 @@ import { useTokensData } from "@/hooks/tokens"; import { replaceIpfsUrl } from "@/lib/sdk"; import { cn } from "@/lib/utils"; import { fallbackChainIcon } from "@/utils/chain-icons"; +import { Spinner } from "../ui/Spinner/Spinner"; type Option = { label: string; value: string }; @@ -186,9 +187,14 @@ export function TokenSelector(props: { options={options} overrideSearchFn={searchFn} placeholder={ - tokensQuery.isPending - ? "Loading Tokens" - : props.placeholder || "Select Token" + tokensQuery.isPending ? ( +
+ + Loading tokens +
+ ) : ( + props.placeholder || "Select Token" + ) } popoverContentClassName={props.popoverContentClassName} renderOption={renderOption} diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.tsx index 56349f9982b..7c8fb40b75a 100644 --- a/apps/dashboard/src/@/components/blocks/select-with-search.tsx +++ b/apps/dashboard/src/@/components/blocks/select-with-search.tsx @@ -22,7 +22,7 @@ interface SelectWithSearchProps }[]; value: string | undefined; onValueChange: (value: string) => void; - placeholder: string; + placeholder: string | React.ReactNode; searchPlaceholder?: string; className?: string; overrideSearchFn?: ( diff --git a/apps/dashboard/src/@/components/ui/CopyButton.tsx b/apps/dashboard/src/@/components/ui/CopyButton.tsx index 99f702f952b..ecaf0a3b054 100644 --- a/apps/dashboard/src/@/components/ui/CopyButton.tsx +++ b/apps/dashboard/src/@/components/ui/CopyButton.tsx @@ -8,29 +8,42 @@ import { ToolTipLabel } from "./tooltip"; export function CopyButton(props: { text: string; + label?: string; className?: string; iconClassName?: string; - variant?: "ghost" | "primary" | "secondary"; + tooltip?: boolean; + variant?: "ghost" | "primary" | "secondary" | "default" | "outline"; }) { const { hasCopied, onCopy } = useClipboard(props.text, 1000); - return ( - - - + const showTooltip = props.tooltip ?? true; + + const button = ( + ); + + if (!showTooltip) { + return button; + } + + return {button}; } diff --git a/apps/dashboard/src/@/components/ui/tabs.tsx b/apps/dashboard/src/@/components/ui/tabs.tsx index 47acea12b5d..99fcc0bda58 100644 --- a/apps/dashboard/src/@/components/ui/tabs.tsx +++ b/apps/dashboard/src/@/components/ui/tabs.tsx @@ -56,7 +56,7 @@ export function TabLinks(props: { ; + title: string; + description: string; + buttons: Array; +}) { + return ( + +
+ +
+
+

{props.title}

+

+ {props.description} +

+
+
{props.buttons.map((button) => button)}
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/ErrorState.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/ErrorState.tsx new file mode 100644 index 00000000000..ffba9af7907 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/ErrorState.tsx @@ -0,0 +1,25 @@ +import { OctagonAlertIcon } from "lucide-react"; +import { Card } from "@/components/ui/card"; + +export function ErrorState(props: { + title: string; + description: string; + buttons: Array; +}) { + return ( + + +
+

{props.title}

+

+ {props.description} +

+
+ {props.buttons && ( +
+ {props.buttons.map((button) => button)} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/FeatureCard.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/FeatureCard.client.tsx index 213fbddb8d0..6b606bbafc6 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/FeatureCard.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/FeatureCard.client.tsx @@ -23,11 +23,11 @@ export function FeatureCard(props: {
- +
{props.badge && ( - - - +
@@ -178,9 +177,5 @@ function GridWithSeparator(props: { children: React.ReactNode }) { } function CardContainer(props: { children: React.ReactNode }) { - return ( -
- {props.children} -
- ); + return {props.children}; } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.client.tsx similarity index 66% rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.tsx rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.client.tsx index 40710aea991..daf30f35ac4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentHistory.client.tsx @@ -10,17 +10,11 @@ import { } from "@/api/universal-bridge/developer"; import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; import { PaginationButtons } from "@/components/blocks/pagination-buttons"; -import { WalletAddress } from "@/components/blocks/wallet-address"; -import { Badge } from "@/components/ui/badge"; import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/utils"; -import { - CardHeading, - TableData, - TableHeading, - TableHeadingRow, -} from "./common"; +import { TableData, TableHeading, TableHeadingRow } from "./common"; +import { formatTokenAmount } from "./format"; +import { TableRow } from "./PaymentsTableRow"; const pageSize = 50; @@ -54,7 +48,14 @@ export function PaymentHistory(props: { return (
- Transaction History +
+

+ Transaction History +

+

+ Past transactions from your project. +

+
{ @@ -118,110 +119,6 @@ export function PaymentHistory(props: { ); } -export function TableRow(props: { purchase: Payment; client: ThirdwebClient }) { - const { purchase } = props; - const originAmount = toTokens( - purchase.originAmount, - purchase.originToken.decimals, - ); - const destinationAmount = toTokens( - purchase.destinationAmount, - purchase.destinationToken.decimals, - ); - const type = (() => { - if (purchase.originToken.chainId !== purchase.destinationToken.chainId) { - return "Bridge"; - } - if (purchase.originToken.address !== purchase.destinationToken.address) { - return "Swap"; - } - return "Transfer"; - })(); - - return ( - - {/* Paid */} - {`${formatTokenAmount(originAmount)} ${purchase.originToken.symbol}`} - - {/* Bought */} - - {`${formatTokenAmount(destinationAmount)} ${purchase.destinationToken.symbol}`} - - - {/* Type */} - - - {type} - - - - {/* Status */} - - - {purchase.status} - - - - {/* Address */} - - - - - {/* Date */} - -

- {format(new Date(purchase.createdAt), "LLL dd, y h:mm a")} -

-
- - ); -} - -export function SkeletonTableRow() { - return ( - - - - - - - - - - - - - - - - - - - - - ); -} - function getCSVData(data: Payment[]) { const header = ["Type", "Bought", "Paid", "Status", "Recipient", "Date"]; @@ -263,12 +160,27 @@ function getCSVData(data: Payment[]) { return { header, rows }; } -function formatTokenAmount(value: string) { - // have at max 3 decimal places - const strValue = Number(`${Number(value).toFixed(3)}`); - - if (Number(strValue) === 0) { - return "~0"; - } - return strValue; +function SkeletonTableRow() { + return ( + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentsTableRow.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentsTableRow.tsx new file mode 100644 index 00000000000..25fd54e0d6d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/PaymentsTableRow.tsx @@ -0,0 +1,87 @@ +import { format } from "date-fns"; +import { type ThirdwebClient, toTokens } from "thirdweb"; +import type { Payment } from "@/api/universal-bridge/developer"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { TableData } from "./common"; +import { formatTokenAmount } from "./format"; + +export function TableRow(props: { purchase: Payment; client: ThirdwebClient }) { + const { purchase } = props; + const originAmount = toTokens( + purchase.originAmount, + purchase.originToken.decimals, + ); + const destinationAmount = toTokens( + purchase.destinationAmount, + purchase.destinationToken.decimals, + ); + const type = (() => { + if (purchase.originToken.chainId !== purchase.destinationToken.chainId) { + return "Bridge"; + } + if (purchase.originToken.address !== purchase.destinationToken.address) { + return "Swap"; + } + return "Transfer"; + })(); + + return ( + + {/* Paid */} + {`${formatTokenAmount(originAmount)} ${purchase.originToken.symbol}`} + + {/* Bought */} + + {`${formatTokenAmount(destinationAmount)} ${purchase.destinationToken.symbol}`} + + + {/* Type */} + + + {type.toLowerCase()} + + + + {/* Status */} + + + {purchase.status.toLowerCase()} + + + + {/* Address */} + + + + + {/* Date */} + +

+ {format(new Date(purchase.createdAt), "LLL dd, y h:mm a")} +

+
+ + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx index 2886c9c3626..5106f1997c0 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/QuickstartSection.client.tsx @@ -36,7 +36,7 @@ export function QuickStartSection({ "Send instantly", ]} link={{ - href: `/pay`, + href: `/team/${teamSlug}/${projectSlug}/payments/links`, label: "Create Link", }} /> @@ -47,10 +47,6 @@ export function QuickStartSection({ id="fees" setupTime={1} color="violet" - badge={{ - label: "Recommended", - variant: "outline", - }} features={[ "Fees on every purchase", "Custom percentage", @@ -68,10 +64,6 @@ export function QuickStartSection({ id="components" color="violet" setupTime={2} - badge={{ - label: "Popular", - variant: "outline", - }} features={[ "Drop-in components", "Supports custom user data", diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/RecentPaymentsSection.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/RecentPaymentsSection.client.tsx index 078d1d4d151..3dfdd2e0f1e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/RecentPaymentsSection.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/RecentPaymentsSection.client.tsx @@ -1,5 +1,6 @@ "use client"; +import { Skeleton } from "@chakra-ui/react"; import { useQuery } from "@tanstack/react-query"; import { ArrowRightIcon, CreditCardIcon } from "lucide-react"; import Link from "next/link"; @@ -11,8 +12,9 @@ import { } from "@/api/universal-bridge/developer"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { TableHeading, TableHeadingRow } from "./common"; -import { SkeletonTableRow, TableRow } from "./PaymentHistory"; +import { TableData, TableHeading, TableHeadingRow } from "./common"; +import { EmptyState } from "./EmptyState"; +import { TableRow } from "./PaymentsTableRow"; export function RecentPaymentsSection(props: { client: ThirdwebClient; @@ -54,8 +56,8 @@ export function RecentPaymentsSection(props: { - Sent - Received + Sent + Received Type Status Recipient @@ -81,21 +83,13 @@ export function RecentPaymentsSection(props: {
) : ( - -
- -
-
-

- No payments yet -

-

- Start accepting crypto payments with payment links, prebuilt - components, or custom branded experiences. -

-
-
+ - - , + -
-
+ , + ]} + /> )} ); } + +function SkeletonTableRow() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/format.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/format.ts new file mode 100644 index 00000000000..ec72bfc8a6c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/format.ts @@ -0,0 +1,9 @@ +export function formatTokenAmount(value: string) { + // have at max 3 decimal places + const strValue = Number(`${Number(value).toFixed(3)}`); + + if (Number(strValue) === 0) { + return "~0"; + } + return strValue; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/layout.tsx index eb52b5a34bc..254e94d9841 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/layout.tsx @@ -48,6 +48,11 @@ export default async function Layout(props: { name: "Overview", path: `${payLayoutPath}`, }, + { + exactMatch: true, + name: "Links", + path: `${payLayoutPath}/links`, + }, { exactMatch: true, name: "Analytics", diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/CreatePaymentLinkButton.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/CreatePaymentLinkButton.client.tsx new file mode 100644 index 00000000000..76db422e868 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/CreatePaymentLinkButton.client.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { resolveAddressAndEns } from "@app/(dashboard)/profile/[addressOrEns]/resolveAddressAndEns"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { LinkIcon } from "lucide-react"; +import { type PropsWithChildren, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Bridge, toUnits } from "thirdweb"; +import { checksumAddress } from "thirdweb/utils"; +import z from "zod"; +import { createPaymentLink } from "@/api/universal-bridge/developer"; +import { getUniversalBridgeTokens } from "@/api/universal-bridge/tokens"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { TokenSelector } from "@/components/blocks/TokenSelector"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormDescription, + FormField, + FormItem, + FormMessage, + RequiredFormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { parseErrorToMessage } from "@/utils/errorParser"; + +const formSchema = z.object({ + chainId: z.number(), + tokenAddress: z.string(), + recipient: z.string(), + amount: z.coerce.number(), + title: z.string(), +}); + +export function CreatePaymentLinkButton( + props: PropsWithChildren<{ clientId: string; teamId: string }>, +) { + const [open, setOpen] = useState(false); + // const [image, setImage] = useState(); + const client = getClientThirdwebClient(); + + const form = useForm>({ + defaultValues: { + chainId: 1, + tokenAddress: undefined, + recipient: undefined, + amount: undefined, + title: undefined, + }, + resolver: zodResolver(formSchema), + }); + + const queryClient = useQueryClient(); + const chainsQuery = useQuery({ + queryFn: async () => { + return await Bridge.chains({ client }); + }, + queryKey: ["payments-chains"], + }); + const createMutation = useMutation({ + mutationFn: async (values: z.infer) => { + const tokens = await getUniversalBridgeTokens({ + chainId: values.chainId, + address: values.tokenAddress, + }); + + const token = tokens[0]; + if (!token) { + throw new Error("Token not found"); + } + + const addressResult = await resolveAddressAndEns( + values.recipient, + client, + ); + + if (!addressResult) { + throw new Error("Invalid recipient address."); + } + + await createPaymentLink({ + clientId: props.clientId, + teamId: props.teamId, + intent: { + destinationChainId: values.chainId, + destinationTokenAddress: checksumAddress(values.tokenAddress), + receiver: checksumAddress(addressResult.address), + amount: toUnits(values.amount.toString(), token.decimals), + }, + title: values.title, + }); + return null; + }, + onSuccess: () => { + toast.success("Payment link created successfully."); + return queryClient.invalidateQueries({ + queryKey: ["payment-links", props.clientId, props.teamId], + }); + }, + onError: (err) => { + console.error(err); + toast.error(parseErrorToMessage(err), { duration: 5000 }); + }, + }); + // const uploadMutation = useMutation({ + // mutationFn: async (file: File) => { + // const uploadClient = createThirdwebClient({ + // clientId: "f958464759859da7a1c6f9d905c90a43", //7ae789153cf9ecde8f35649f2d8a4333", // Special client ID for uploads only on thirdweb.com + // }); + // const uri = await upload({ + // client: uploadClient, + // files: [file], + // }); + // + // // eslint-disable-next-line no-restricted-syntax + // const resolvedUrl = resolveScheme({ + // client: uploadClient, + // uri, + // }); + // + // form.setValue("imageUrl", resolvedUrl); + // return; + // }, + // onSuccess: () => { + // toast.success("Image uploaded successfully."); + // }, + // onError: (e) => { + // console.error(e); + // // setImage(undefined); + // toast.error(parseErrorToMessage(e), { duration: 5000 }); + // }, + // }); + + return ( + + {props.children} + +
+ + createMutation.mutateAsync(values, { + onSuccess: () => { + setOpen(false); + form.reset(); + form.clearErrors(); + }, + }), + )} + > + + Create a Payment Link + + Get paid in any token on any chain. + + + +
+ ( + + Title + + + + )} + /> + + ( + + Recipient Address + + Address or ENS + + + )} + /> +
+ + ( + + Chain + x.chainId)} + chainId={field.value} + className="w-full" + client={client} + disableTestnets + onChange={field.onChange} + /> + + + )} + /> + + ( + + Token + { + field.onChange(token.address); + }} + selectedToken={ + field.value + ? { + address: field.value, + chainId: form.getValues().chainId, + } + : undefined + } + showCheck={false} + /> + + + )} + /> + + ( + + Amount + + + + )} + /> + + +
+ +
+
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/PaymentLinksTable.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/PaymentLinksTable.client.tsx new file mode 100644 index 00000000000..54da87f7750 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/components/PaymentLinksTable.client.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { LinkIcon, PlusIcon, TrashIcon } from "lucide-react"; +import { type PropsWithChildren, useState } from "react"; +import { toast } from "sonner"; +import { toTokens } from "thirdweb"; +import { + deletePaymentLink, + getPaymentLinks, + getPayments, +} from "@/api/universal-bridge/developer"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Button } from "@/components/ui/button"; +import { CopyButton } from "@/components/ui/CopyButton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { TableData } from "../../components/common"; +import { EmptyState } from "../../components/EmptyState"; +import { ErrorState } from "../../components/ErrorState"; +import { formatTokenAmount } from "../../components/format"; +import { CreatePaymentLinkButton } from "./CreatePaymentLinkButton.client"; + +export function PaymentLinksTable(props: { clientId: string; teamId: string }) { + const paymentLinksQuery = useQuery({ + queryFn: async () => { + return getPaymentLinks({ + clientId: props.clientId, + teamId: props.teamId, + }); + }, + queryKey: ["payment-links", props.clientId, props.teamId], + }); + + const paymentLinkUsagesQuery = useQuery({ + queryFn: async () => { + const paymentLinks = paymentLinksQuery.data || []; + return await Promise.all( + paymentLinks.map(async (paymentLink) => { + const { data } = await getPayments({ + clientId: props.clientId, + teamId: props.teamId, + paymentLinkId: paymentLink.id, + }); + return { + paymentLink, + usages: data, + }; + }), + ); + }, + queryKey: [ + "payment-link-usages", + paymentLinksQuery.dataUpdatedAt, + props.clientId, + props.teamId, + ], + }); + + const client = getClientThirdwebClient(); + + if (paymentLinksQuery.error) { + return ( + paymentLinksQuery.refetch()} + > + Retry + , + ]} + /> + ); + } + + if (!paymentLinksQuery.isLoading && paymentLinksQuery.data?.length === 0) { + return ( + + + , + ]} + /> + ); + } + + return ( +
+ + + + + Payment Link + Recipient + Amount + Usages + Revenue + Link + Delete + + + + {paymentLinksQuery.data && !paymentLinksQuery.isLoading + ? paymentLinksQuery.data.map((paymentLink) => ( + + + {paymentLink.title} + + + + + + {formatTokenAmount( + toTokens( + paymentLink.amount, + paymentLink.destinationToken.decimals, + ), + )}{" "} + {paymentLink.destinationToken.symbol} + + + {paymentLinkUsagesQuery.isLoading ? ( + + ) : ( + paymentLinkUsagesQuery.data?.find( + (x) => x.paymentLink.id === paymentLink.id, + )?.usages?.length || 0 + )} + + + {paymentLinkUsagesQuery.isLoading ? ( + + ) : ( + `${( + paymentLinkUsagesQuery.data + ?.find((x) => x.paymentLink.id === paymentLink.id) + ?.usages?.reduce( + (acc, curr) => + acc + + Number( + toTokens( + curr.destinationAmount, + curr.destinationToken.decimals, + ), + ), + 0, + ) || 0 + ).toString()} ${paymentLink.destinationToken.symbol}` + )} + + + + + + + + + + + )) + : new Array(10).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + + ))} + +
+
+
+ ); +} + +function SkeletonTableRow() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +function DeletePaymentLinkButton( + props: PropsWithChildren<{ + paymentLinkId: string; + clientId: string; + teamId: string; + }>, +) { + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await deletePaymentLink({ + clientId: props.clientId, + teamId: props.teamId, + paymentLinkId: id, + }); + return null; + }, + }); + + return ( + + {props.children} + + + Are you sure? + + This action cannot be undone. This will permanently delete the + payment link. + + + + + + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/loading.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/loading.tsx new file mode 100644 index 00000000000..6c54ef15def --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/loading.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/page.tsx new file mode 100644 index 00000000000..31acf2d1baf --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/links/page.tsx @@ -0,0 +1,58 @@ +import { PlusIcon } from "lucide-react"; +import { redirect } from "next/navigation"; +import { getProject } from "@/api/projects"; +import { Button } from "@/components/ui/button"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { CreatePaymentLinkButton } from "./components/CreatePaymentLinkButton.client"; +import { PaymentLinksTable } from "./components/PaymentLinksTable.client"; + +export default async function Page(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + }>; +}) { + const params = await props.params; + const project = await getProject(params.team_slug, params.project_slug); + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + return ( +
+
+
+

+ Payment Links +

+

+ Get notified for Bridge, Swap and Onramp events.{" "} + + Learn more + +

+
+ + + +
+
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/webhooks/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/webhooks/page.tsx index 4b643e627e3..3e6bb9919c6 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/webhooks/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/webhooks/page.tsx @@ -22,7 +22,7 @@ export default async function Page(props: {

Get notified for Bridge, Swap and Onramp events.{" "}