diff --git a/apps/dashboard/src/@/api/insight/webhooks.ts b/apps/dashboard/src/@/api/insight/webhooks.ts index 51aeeda4890..2d219a4b460 100644 --- a/apps/dashboard/src/@/api/insight/webhooks.ts +++ b/apps/dashboard/src/@/api/insight/webhooks.ts @@ -4,7 +4,7 @@ import { getAuthToken } from "app/(app)/api/lib/getAuthToken"; import { THIRDWEB_INSIGHT_API_DOMAIN } from "constants/urls"; -interface WebhookResponse { +export interface WebhookResponse { id: string; name: string; team_id: string; @@ -19,7 +19,7 @@ interface WebhookResponse { updated_at: string | null; } -interface WebhookFilters { +export interface WebhookFilters { "v1.events"?: { chain_ids?: string[]; addresses?: string[]; @@ -130,8 +130,8 @@ export async function getWebhooks( }; } } -// biome-ignore lint/correctness/noUnusedVariables: will be used in the next PR -async function deleteWebhook( + +export async function deleteWebhook( webhookId: string, clientId: string, ): Promise { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx index 8c7bbf1edb7..8ce108a67d0 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx @@ -2,6 +2,7 @@ import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout"; import { Badge } from "@/components/ui/badge"; import { + BellIcon, BookTextIcon, BoxIcon, CoinsIcon, @@ -94,6 +95,16 @@ export function ProjectSidebarLayout(props: { icon: NebulaIcon, tracking: tracking("nebula"), }, + { + href: `${layoutPath}/webhooks`, + label: ( + + Webhooks New + + ), + icon: BellIcon, + tracking: tracking("webhooks"), + }, ]} footerSidebarLinks={[ { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/CreateWebhookModal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/CreateWebhookModal.tsx new file mode 100644 index 00000000000..4d8b9c8380f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/CreateWebhookModal.tsx @@ -0,0 +1,5 @@ +import { Button } from "@/components/ui/button"; +// Implementation is going to be added in the next PR +export function CreateWebhookModal() { + return ; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/RelativeTime.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/RelativeTime.tsx new file mode 100644 index 00000000000..1304deac032 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/RelativeTime.tsx @@ -0,0 +1,42 @@ +"use client"; +import { cn } from "@/lib/utils"; +import { formatDistanceToNowStrict } from "date-fns"; +import { useEffect, useState } from "react"; + +export function RelativeTime({ + date, + className, +}: { date: string; className?: string }) { + const [content, setContent] = useState(() => { + const parsedDate = new Date(date); + if (Number.isNaN(parsedDate.getTime())) return "-"; + try { + return formatDistanceToNowStrict(parsedDate, { addSuffix: true }); + } catch { + return "-"; + } + }); + + // eslint-disable-next-line + useEffect(() => { + const updateContent = () => { + const parsedDate = new Date(date); + if (Number.isNaN(parsedDate.getTime())) { + setContent("-"); + } else { + try { + setContent( + formatDistanceToNowStrict(parsedDate, { addSuffix: true }), + ); + } catch { + setContent("-"); + } + } + }; + updateContent(); + const interval = setInterval(updateContent, 10000); + return () => clearInterval(interval); + }, [date]); + + return {content}; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/WebhooksTable.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/WebhooksTable.tsx new file mode 100644 index 00000000000..244d0d67b9b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/WebhooksTable.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { + type WebhookFilters, + type WebhookResponse, + deleteWebhook, +} from "@/api/insight/webhooks"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import type { ColumnDef } from "@tanstack/react-table"; +import { TWTable } from "components/shared/TWTable"; +import { format } from "date-fns"; +import { TrashIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { RelativeTime } from "./RelativeTime"; + +function getEventType(filters: WebhookFilters): string { + if (filters["v1.events"]) return "Event"; + if (filters["v1.transactions"]) return "Transaction"; + return "Unknown"; +} + +interface WebhooksTableProps { + webhooks: WebhookResponse[]; + clientId: string; +} + +export function WebhooksTable({ webhooks, clientId }: WebhooksTableProps) { + const [isDeleting, setIsDeleting] = useState>({}); + // const { testWebhookEndpoint, isTestingMap } = useTestWebhook(clientId); + const router = useDashboardRouter(); + + const handleDeleteWebhook = async (webhookId: string) => { + if (isDeleting[webhookId]) return; + + try { + setIsDeleting((prev) => ({ ...prev, [webhookId]: true })); + await deleteWebhook(webhookId, clientId); + toast.success("Webhook deleted successfully"); + router.refresh(); + } catch (error) { + console.error("Error deleting webhook:", error); + toast.error("Failed to delete webhook", { + description: + error instanceof Error + ? error.message + : "An unexpected error occurred", + }); + } finally { + setIsDeleting((prev) => ({ ...prev, [webhookId]: false })); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => ( +
+ + {row.original.name} + +
+ ), + }, + { + accessorKey: "filters", + header: "Event Type", + cell: ({ getValue }) => { + const filters = getValue() as WebhookFilters; + if (!filters) return -; + const eventType = getEventType(filters); + return {eventType}; + }, + }, + { + accessorKey: "webhook_url", + header: "Webhook URL", + cell: ({ getValue }) => { + const url = getValue() as string; + return ( +
+ {url} + +
+ ); + }, + }, + { + accessorKey: "created_at", + header: "Created", + cell: ({ getValue }) => { + const date = getValue() as string; + return ( +
+ + + {format(new Date(date), "MMM d, yyyy")} + +
+ ); + }, + }, + { + id: "status", + accessorKey: "suspended_at", + header: "Status", + cell: ({ row }) => { + const webhook = row.original; + const isSuspended = Boolean(webhook.suspended_at); + return ( + + {isSuspended ? "Suspended" : "Active"} + + ); + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const webhook = row.original; + + return ( +
+ +
+ ); + }, + }, + ]; + + const sortedWebhooks = useMemo(() => { + return [...webhooks].sort((a, b) => { + const dateA = new Date(a.created_at); + const dateB = new Date(b.created_at); + + // Handle invalid dates by treating them as epoch (0) + const timeA = Number.isNaN(dateA.getTime()) ? 0 : dateA.getTime(); + const timeB = Number.isNaN(dateB.getTime()) ? 0 : dateB.getTime(); + + return timeB - timeA; + }); + }, [webhooks]); + + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx index 7149a59a94f..8b6af7e668b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx @@ -1,8 +1,89 @@ -// This is a temporary page to supress linting errors and will be implemented up in the stack -import { getWebhooks } from "@/api/insight/webhooks"; +import { type WebhookResponse, getWebhooks } from "@/api/insight/webhooks"; +import { getProject } from "@/api/projects"; +import { TrackedUnderlineLink } from "@/components/ui/tracked-link"; +import { notFound } from "next/navigation"; +import { CreateWebhookModal } from "./components/CreateWebhookModal"; +import { WebhooksTable } from "./components/WebhooksTable"; -export default async function WebhooksPage() { - const { data: webhooks, error } = await getWebhooks("123"); +export default async function WebhooksPage({ + params, +}: { params: Promise<{ team_slug: string; project_slug: string }> }) { + let webhooks: WebhookResponse[] = []; + let clientId = ""; + let errorMessage = ""; - return
{JSON.stringify({ webhooks, error })}
; + try { + // Await params before accessing properties + const resolvedParams = await params; + const team_slug = resolvedParams.team_slug; + const project_slug = resolvedParams.project_slug; + + const project = await getProject(team_slug, project_slug); + + if (!project) { + notFound(); + } + + clientId = project.publishableKey; + + const webhooksRes = await getWebhooks(clientId); + if (webhooksRes.error) { + errorMessage = webhooksRes.error; + } else if (webhooksRes.data) { + webhooks = webhooksRes.data; + } + } catch (error) { + errorMessage = "Failed to load webhooks. Please try again later."; + console.error("Error loading project or webhooks", error); + } + + return ( +
+
+
+

+ Webhooks +

+

+ Create and manage webhooks to get notified about blockchain events, + transactions and more.{" "} + + Learn more about webhooks. + +

+
+
+
+
+ {errorMessage ? ( +
+
+

+ Unable to load webhooks +

+

{errorMessage}

+
+
+ ) : webhooks.length > 0 ? ( + + ) : ( +
+
+

No webhooks found

+

+ Create a webhook to get started. +

+
+ +
+ )} +
+
+
+ ); }