Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/dashboard/src/@/api/insight/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,7 +19,7 @@ interface WebhookResponse {
updated_at: string | null;
}

interface WebhookFilters {
export interface WebhookFilters {
"v1.events"?: {
chain_ids?: string[];
addresses?: string[];
Expand Down Expand Up @@ -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<WebhookSingleResponse> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
import { Badge } from "@/components/ui/badge";
import {
BellIcon,
BookTextIcon,
BoxIcon,
CoinsIcon,
Expand Down Expand Up @@ -94,6 +95,16 @@ export function ProjectSidebarLayout(props: {
icon: NebulaIcon,
tracking: tracking("nebula"),
},
{
href: `${layoutPath}/webhooks`,
label: (
<span className="flex items-center gap-2">
Webhooks <Badge>New</Badge>
</span>
),
icon: BellIcon,
tracking: tracking("webhooks"),
},
]}
footerSidebarLinks={[
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Button } from "@/components/ui/button";
// Implementation is going to be added in the next PR
export function CreateWebhookModal() {
return <Button>New Webhook</Button>;
}
Original file line number Diff line number Diff line change
@@ -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 <span className={cn(className)}>{content}</span>;
}
Original file line number Diff line number Diff line change
@@ -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<Record<string, boolean>>({});
// 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<WebhookResponse>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="max-w-40 truncate" title={row.original.name}>
{row.original.name}
</span>
</div>
),
},
{
accessorKey: "filters",
header: "Event Type",
cell: ({ getValue }) => {
const filters = getValue() as WebhookFilters;
if (!filters) return <span>-</span>;
const eventType = getEventType(filters);
return <span>{eventType}</span>;
},
},
{
accessorKey: "webhook_url",
header: "Webhook URL",
cell: ({ getValue }) => {
const url = getValue() as string;
return (
<div className="flex items-center gap-2">
<span className="max-w-60 truncate">{url}</span>
<CopyTextButton
textToCopy={url}
textToShow=""
tooltip="Copy URL"
variant="ghost"
copyIconPosition="right"
className="flex h-6 w-6 items-center justify-center"
iconClassName="h-3 w-3"
/>
</div>
);
},
},
{
accessorKey: "created_at",
header: "Created",
cell: ({ getValue }) => {
const date = getValue() as string;
return (
<div className="flex flex-col">
<RelativeTime date={date} />
<span className="text-muted-foreground text-xs">
{format(new Date(date), "MMM d, yyyy")}
</span>
</div>
);
},
},
{
id: "status",
accessorKey: "suspended_at",
header: "Status",
cell: ({ row }) => {
const webhook = row.original;
const isSuspended = Boolean(webhook.suspended_at);
return (
<Badge variant={isSuspended ? "destructive" : "default"}>
{isSuspended ? "Suspended" : "Active"}
</Badge>
);
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const webhook = row.original;

return (
<div className="flex justify-end gap-2">
<Button
size="icon"
variant="outline"
className="h-8 w-8 text-red-500 hover:border-red-700 hover:text-red-700"
onClick={() => handleDeleteWebhook(webhook.id)}
disabled={isDeleting[webhook.id]}
aria-label={`Delete webhook ${webhook.name}`}
>
{isDeleting[webhook.id] ? (
<Spinner className="h-4 w-4" />
) : (
<TrashIcon className="h-4 w-4" />
)}
</Button>
</div>
);
},
},
];

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 (
<div className="w-full">
<div className="mb-4 flex items-center justify-end">
<Button type="button">New Webhook</Button>
</div>
<TWTable
data={sortedWebhooks}
columns={columns}
isPending={false}
isFetched={true}
title="Webhooks"
tableContainerClassName="mt-4"
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 <div>{JSON.stringify({ webhooks, error })}</div>;
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 (
<div className="flex grow flex-col">
<div className="border-b py-10">
<div className="container max-w-7xl">
<h1 className="mb-1 font-semibold text-3xl tracking-tight">
Webhooks
</h1>
<p className="text-muted-foreground text-sm">
Create and manage webhooks to get notified about blockchain events,
transactions and more.{" "}
<TrackedUnderlineLink
category="webhooks"
label="learn-more"
target="_blank"
href="https://portal.thirdweb.com/insight/webhooks"
>
Learn more about webhooks.
</TrackedUnderlineLink>
</p>
</div>
</div>
<div className="h-6" />
<div className="container max-w-7xl">
{errorMessage ? (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-destructive bg-destructive/10 p-12 text-center">
<div>
<h3 className="mb-1 font-medium text-destructive text-lg">
Unable to load webhooks
</h3>
<p className="text-muted-foreground">{errorMessage}</p>
</div>
</div>
) : webhooks.length > 0 ? (
<WebhooksTable webhooks={webhooks} clientId={clientId} />
) : (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border p-12 text-center">
<div>
<h3 className="mb-1 font-medium text-lg">No webhooks found</h3>
<p className="text-muted-foreground">
Create a webhook to get started.
</p>
</div>
<CreateWebhookModal />
</div>
)}
</div>
<div className="h-20" />
</div>
);
}
Loading