Skip to content

Commit a1b14b4

Browse files
committed
Add webhooks page to project dashboard
1 parent 0b15d5e commit a1b14b4

File tree

7 files changed

+323
-2
lines changed

7 files changed

+323
-2
lines changed

apps/dashboard/src/@/api/insight/webhooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,4 @@ export async function testWebhook(
197197
error: `Network or parsing error: ${error instanceof Error ? error.message : "Unknown error"}`,
198198
};
199199
}
200-
}
200+
}

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
33
import { Badge } from "@/components/ui/badge";
44
import {
5+
BellIcon,
56
BookTextIcon,
67
BoxIcon,
78
CoinsIcon,
@@ -88,6 +89,16 @@ export function ProjectSidebarLayout(props: {
8889
icon: InsightIcon,
8990
tracking: tracking("insight"),
9091
},
92+
{
93+
href: `${layoutPath}/webhooks`,
94+
label: (
95+
<span className="flex items-center gap-2">
96+
Webhooks <Badge>New</Badge>
97+
</span>
98+
),
99+
icon: BellIcon,
100+
tracking: tracking("webhooks"),
101+
},
91102
{
92103
href: `${layoutPath}/nebula`,
93104
label: "Nebula",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Button } from "@/components/ui/button";
2+
// Implementation is going to be added in the next PR
3+
export function CreateWebhookModal() {
4+
return <Button>New Webhook</Button>;
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use client";
2+
import { cn } from "@/lib/utils";
3+
import { formatDistanceToNowStrict } from "date-fns";
4+
import { useEffect, useState } from "react";
5+
6+
export function RelativeTime({
7+
date,
8+
className,
9+
}: { date: string; className?: string }) {
10+
const [, forceUpdate] = useState({});
11+
12+
// eslint-disable-next-line no-restricted-syntax
13+
useEffect(() => {
14+
// Update every 10 seconds for better performance
15+
const interval = setInterval(() => forceUpdate({}), 10000);
16+
return () => clearInterval(interval);
17+
}, []);
18+
let content: string;
19+
const parsedDate = new Date(date);
20+
if (Number.isNaN(parsedDate.getTime())) {
21+
content = "-";
22+
} else {
23+
try {
24+
content = formatDistanceToNowStrict(parsedDate, { addSuffix: true });
25+
} catch {
26+
content = "-";
27+
}
28+
}
29+
return <span className={cn(className)}>{content}</span>;
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"use client";
2+
3+
import {
4+
type WebhookFilters,
5+
type WebhookResponse,
6+
deleteWebhook,
7+
} from "@/api/insight/webhooks";
8+
import { CopyTextButton } from "@/components/ui/CopyTextButton";
9+
import { Spinner } from "@/components/ui/Spinner/Spinner";
10+
import { Badge } from "@/components/ui/badge";
11+
import { Button } from "@/components/ui/button";
12+
import { useDashboardRouter } from "@/lib/DashboardRouter";
13+
import type { ColumnDef } from "@tanstack/react-table";
14+
import { TWTable } from "components/shared/TWTable";
15+
import { format } from "date-fns";
16+
import { TrashIcon } from "lucide-react";
17+
import { useMemo, useState } from "react";
18+
import { toast } from "sonner";
19+
import { RelativeTime } from "./RelativeTime";
20+
21+
function getEventType(filters: WebhookFilters): string {
22+
if (filters["v1.events"]) return "Event";
23+
if (filters["v1.transactions"]) return "Transaction";
24+
return "Unknown";
25+
}
26+
27+
interface WebhooksTableProps {
28+
webhooks: WebhookResponse[];
29+
clientId: string;
30+
}
31+
32+
export function WebhooksTable({ webhooks, clientId }: WebhooksTableProps) {
33+
const [isDeleting, setIsDeleting] = useState<Record<string, boolean>>({});
34+
// const { testWebhookEndpoint, isTestingMap } = useTestWebhook(clientId);
35+
const router = useDashboardRouter();
36+
37+
const handleDeleteWebhook = async (webhookId: string) => {
38+
if (isDeleting[webhookId]) return;
39+
40+
try {
41+
setIsDeleting((prev) => ({ ...prev, [webhookId]: true }));
42+
await deleteWebhook(webhookId, clientId);
43+
toast.success("Webhook deleted successfully");
44+
router.refresh();
45+
} catch (error) {
46+
console.error("Error deleting webhook:", error);
47+
toast.error("Failed to delete webhook", {
48+
description:
49+
error instanceof Error
50+
? error.message
51+
: "An unexpected error occurred",
52+
});
53+
} finally {
54+
setIsDeleting((prev) => ({ ...prev, [webhookId]: false }));
55+
}
56+
};
57+
58+
const columns: ColumnDef<WebhookResponse>[] = [
59+
{
60+
accessorKey: "name",
61+
header: "Name",
62+
cell: ({ row }) => (
63+
<div className="flex items-center gap-2">
64+
<span className="max-w-40 truncate" title={row.original.name}>
65+
{row.original.name}
66+
</span>
67+
</div>
68+
),
69+
},
70+
{
71+
accessorKey: "filters",
72+
header: "Event Type",
73+
cell: ({ getValue }) => {
74+
const filters = getValue() as WebhookFilters;
75+
if (!filters) return <span>-</span>;
76+
const eventType = getEventType(filters);
77+
return <span>{eventType}</span>;
78+
},
79+
},
80+
{
81+
accessorKey: "webhook_url",
82+
header: "Webhook URL",
83+
cell: ({ getValue }) => {
84+
const url = getValue() as string;
85+
return (
86+
<div className="flex items-center gap-2">
87+
<span className="max-w-60 truncate">{url}</span>
88+
<CopyTextButton
89+
textToCopy={url}
90+
textToShow=""
91+
tooltip="Copy URL"
92+
variant="ghost"
93+
copyIconPosition="right"
94+
className="flex h-6 w-6 items-center justify-center"
95+
iconClassName="h-3 w-3"
96+
/>
97+
</div>
98+
);
99+
},
100+
},
101+
{
102+
accessorKey: "created_at",
103+
header: "Created",
104+
cell: ({ getValue }) => {
105+
const date = getValue() as string;
106+
return (
107+
<div className="flex flex-col">
108+
<RelativeTime date={date} />
109+
<span className="text-muted-foreground text-xs">
110+
{format(new Date(date), "MMM d, yyyy")}
111+
</span>
112+
</div>
113+
);
114+
},
115+
},
116+
{
117+
id: "status",
118+
accessorKey: "suspended_at",
119+
header: "Status",
120+
cell: ({ row }) => {
121+
const webhook = row.original;
122+
const isSuspended = Boolean(webhook.suspended_at);
123+
return (
124+
<Badge variant={isSuspended ? "destructive" : "default"}>
125+
{isSuspended ? "Suspended" : "Active"}
126+
</Badge>
127+
);
128+
},
129+
},
130+
{
131+
id: "actions",
132+
header: "Actions",
133+
cell: ({ row }) => {
134+
const webhook = row.original;
135+
136+
return (
137+
<div className="flex justify-end gap-2">
138+
<Button
139+
size="icon"
140+
variant="outline"
141+
className="h-8 w-8 text-red-500 hover:border-red-700 hover:text-red-700"
142+
onClick={() => handleDeleteWebhook(webhook.id)}
143+
disabled={isDeleting[webhook.id]}
144+
aria-label={`Delete webhook ${webhook.name}`}
145+
>
146+
{isDeleting[webhook.id] ? (
147+
<Spinner className="h-4 w-4" />
148+
) : (
149+
<TrashIcon className="h-4 w-4" />
150+
)}
151+
</Button>
152+
</div>
153+
);
154+
},
155+
},
156+
];
157+
158+
const sortedWebhooks = useMemo(() => {
159+
return [...webhooks].sort((a, b) => {
160+
const dateA = new Date(a.created_at);
161+
const dateB = new Date(b.created_at);
162+
163+
// Handle invalid dates by treating them as epoch (0)
164+
const timeA = Number.isNaN(dateA.getTime()) ? 0 : dateA.getTime();
165+
const timeB = Number.isNaN(dateB.getTime()) ? 0 : dateB.getTime();
166+
167+
return timeB - timeA;
168+
});
169+
}, [webhooks]);
170+
171+
return (
172+
<div className="w-full">
173+
<div className="mb-4 flex items-center justify-end">
174+
<Button type="button">New Webhook</Button>
175+
</div>
176+
<TWTable
177+
data={sortedWebhooks}
178+
columns={columns}
179+
isPending={false}
180+
isFetched={true}
181+
title="Webhooks"
182+
tableContainerClassName="mt-4"
183+
/>
184+
</div>
185+
);
186+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { type WebhookResponse, getWebhooks } from "@/api/insight/webhooks";
2+
import { getProject } from "@/api/projects";
3+
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
4+
import { notFound } from "next/navigation";
5+
import { CreateWebhookModal } from "./components/CreateWebhookModal";
6+
import { WebhooksTable } from "./components/WebhooksTable";
7+
8+
export default async function WebhooksPage({
9+
params,
10+
}: { params: Promise<{ team_slug: string; project_slug: string }> }) {
11+
let webhooks: WebhookResponse[] = [];
12+
let clientId = "";
13+
let errorMessage = "";
14+
15+
try {
16+
// Await params before accessing properties
17+
const resolvedParams = await params;
18+
const team_slug = resolvedParams.team_slug;
19+
const project_slug = resolvedParams.project_slug;
20+
21+
const project = await getProject(team_slug, project_slug);
22+
23+
if (!project) {
24+
notFound();
25+
}
26+
27+
clientId = project.publishableKey;
28+
29+
const webhooksRes = await getWebhooks(clientId);
30+
if (webhooksRes.error) {
31+
errorMessage = webhooksRes.error;
32+
} else if (webhooksRes.data) {
33+
webhooks = webhooksRes.data;
34+
}
35+
} catch (error) {
36+
errorMessage = "Failed to load webhooks. Please try again later.";
37+
console.error("Error loading project or webhooks", error);
38+
}
39+
40+
return (
41+
<div className="flex grow flex-col">
42+
<div className="border-b py-10">
43+
<div className="container max-w-7xl">
44+
<h1 className="mb-1 font-semibold text-3xl tracking-tight">
45+
Webhooks
46+
</h1>
47+
<p className="text-muted-foreground text-sm">
48+
Create and manage webhooks to get notified about blockchain events,
49+
transactions and more.{" "}
50+
<TrackedUnderlineLink
51+
category="webhooks"
52+
label="learn-more"
53+
target="_blank"
54+
href="https://portal.thirdweb.com/insight/webhooks"
55+
>
56+
Learn more about webhooks.
57+
</TrackedUnderlineLink>
58+
</p>
59+
</div>
60+
</div>
61+
<div className="h-6" />
62+
<div className="container max-w-7xl">
63+
{errorMessage ? (
64+
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-destructive bg-destructive/10 p-12 text-center">
65+
<div>
66+
<h3 className="mb-1 font-medium text-destructive text-lg">
67+
Unable to load webhooks
68+
</h3>
69+
<p className="text-muted-foreground">{errorMessage}</p>
70+
</div>
71+
</div>
72+
) : webhooks.length > 0 ? (
73+
<WebhooksTable webhooks={webhooks} clientId={clientId} />
74+
) : (
75+
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border p-12 text-center">
76+
<div>
77+
<h3 className="mb-1 font-medium text-lg">No webhooks found</h3>
78+
<p className="text-muted-foreground">
79+
Create a webhook to get started.
80+
</p>
81+
</div>
82+
<CreateWebhookModal />
83+
</div>
84+
)}
85+
</div>
86+
<div className="h-20" />
87+
</div>
88+
);
89+
}

apps/portal/src/app/Header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ export function Header() {
334334

335335
{/* Mobile menu */}
336336
{showBurgerMenu && (
337-
<div className="fixed inset-0 top-sticky-top-height z-50 overflow-auto bg-card p-6 xl:hidden">
337+
<div className="fixed inset-0 top-sticky-top-height overflow-auto bg-card p-6 xl:hidden">
338338
<div className="flex flex-col gap-6">
339339
<div className="flex flex-col gap-4">
340340
<h3 className="font-semibold text-lg">Products</h3>

0 commit comments

Comments
 (0)