diff --git a/apps/web/prisma/migrations/20250708222908_add_webhook/migration.sql b/apps/web/prisma/migrations/20250708222908_add_webhook/migration.sql
new file mode 100644
index 00000000..e6a0670f
--- /dev/null
+++ b/apps/web/prisma/migrations/20250708222908_add_webhook/migration.sql
@@ -0,0 +1,24 @@
+-- CreateEnum
+CREATE TYPE "WebhookEvent" AS ENUM ('DOMAIN_VERIFIED', 'EMAIL_SENT', 'EMAIL_DELIVERED', 'EMAIL_OPENED', 'EMAIL_CLICKED', 'EMAIL_BOUNCED', 'EMAIL_COMPLAINED');
+
+-- CreateTable
+CREATE TABLE "Webhook" (
+ "id" TEXT NOT NULL,
+ "url" TEXT NOT NULL,
+ "teamId" INTEGER NOT NULL,
+ "domainId" INTEGER,
+ "events" "WebhookEvent"[],
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Webhook_teamId_idx" ON "Webhook"("teamId");
+
+-- AddForeignKey
+ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma
index 269882ee..757588aa 100644
--- a/apps/web/prisma/schema.prisma
+++ b/apps/web/prisma/schema.prisma
@@ -116,6 +116,7 @@ model Team {
dailyEmailUsages DailyEmailUsage[]
subscription Subscription[]
invites TeamInvite[]
+ webhooks Webhook[]
}
model TeamInvite {
@@ -187,6 +188,7 @@ model Domain {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ webhooks Webhook[]
}
enum ApiPermission {
@@ -387,3 +389,31 @@ model CumulatedMetrics {
@@id([teamId, domainId])
}
+
+enum WebhookEvent {
+ // Events of Domains
+ DOMAIN_VERIFIED
+
+ // Events of Email (baseaded on EmailStatus)
+ EMAIL_SENT
+ EMAIL_DELIVERED
+ EMAIL_OPENED
+ EMAIL_CLICKED
+ EMAIL_BOUNCED
+ EMAIL_COMPLAINED
+}
+
+model Webhook {
+ id String @id @default(cuid())
+ url String
+ teamId Int
+ domainId Int?
+ events WebhookEvent[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
+
+ @@index([teamId])
+}
\ No newline at end of file
diff --git a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx
index ae2c3b57..2c66e6be 100644
--- a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx
+++ b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx
@@ -15,6 +15,7 @@ export default function ApiKeysPage({
API Keys
SMTP
+ Webhooks
{children}
diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx
new file mode 100644
index 00000000..e314384d
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { Button } from "@unsend/ui/src/button";
+import { Input } from "@unsend/ui/src/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@unsend/ui/src/dialog";
+import { api } from "~/trpc/react";
+import { useState } from "react";
+import { Plus } from "lucide-react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@unsend/ui/src/form";
+import { webhookSchema, webhookSchemaForm } from "~/lib/zod/webhook-schema";
+import { WebhookEvent } from "@prisma/client";
+import { Checkbox } from "@unsend/ui/src/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unsend/ui/src/select";
+
+
+export default function AddWebhook() {
+ const [open, setOpen] = useState(false);
+ const createWebhookMutation = api.webhook.create.useMutation();
+ const domainsQuery = api.domain.domains.useQuery();
+
+ const utils = api.useUtils();
+
+ const webhookForm = useForm>({
+ resolver: zodResolver(webhookSchemaForm),
+ defaultValues: {
+ url: "",
+ events: [],
+ domainId: undefined,
+ },
+ });
+
+ function handleSave(values: z.infer) {
+ const domainId = values.domainId && values.domainId !== 'all' ? Number(values.domainId) : undefined;
+
+ createWebhookMutation.mutate(
+ {
+ url: values.url,
+ events: values.events,
+ domainId,
+ },
+ {
+ onSuccess: () => {
+ utils.webhook.list.invalidate();
+ webhookForm.reset();
+ setOpen(false);
+ },
+ }
+ );
+ }
+
+ const eventKeys = Object.keys(WebhookEvent) as (keyof typeof WebhookEvent)[];
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx
new file mode 100644
index 00000000..1ba3260d
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { Button } from "@unsend/ui/src/button";
+import { Input } from "@unsend/ui/src/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@unsend/ui/src/dialog";
+import { api } from "~/trpc/react";
+import React, { useState } from "react";
+import { toast } from "@unsend/ui/src/toaster";
+import { Trash2 } from "lucide-react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@unsend/ui/src/form";
+import { Webhook } from "@prisma/client";
+
+const deleteWebhookSchema = z.object({
+ url: z.string(),
+});
+
+export const DeleteWebhook: React.FC<{
+ webhook: Partial & { id: string }
+}> = ({ webhook }) => {
+ const [open, setOpen] = useState(false);
+ const deleteWebhookMutation = api.webhook.delete.useMutation();
+
+ const utils = api.useUtils();
+
+ const webhookForm = useForm>({
+ resolver: zodResolver(deleteWebhookSchema),
+ });
+
+ async function onWebhookDelete(values: z.infer) {
+ if (values.url !== webhook.url) {
+ webhookForm.setError("url", {
+ message: "URL does not match",
+ });
+ return;
+ }
+
+ deleteWebhookMutation.mutate(
+ webhook.id,
+ {
+ onSuccess: () => {
+ utils.webhook.list.invalidate();
+ setOpen(false);
+ toast.success(`Webhook deleted`);
+ },
+ }
+ );
+ }
+
+ const url = webhookForm.watch("url");
+
+ return (
+
+ );
+};
+
+export default DeleteWebhook;
diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx
new file mode 100644
index 00000000..415a7095
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import AddWebhook from "./add-webhook";
+import WebhookList from "./webhook-list";
+
+export default function WebhooksPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-badge.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-badge.tsx
new file mode 100644
index 00000000..90ea6592
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-badge.tsx
@@ -0,0 +1,46 @@
+import { WebhookEvent } from "@prisma/client";
+import React from "react";
+
+
+export const WebhookEventBadge: React.FC<{ event: WebhookEvent }> = ({ event }) => {
+ let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
+
+ switch (event) {
+ case WebhookEvent.DOMAIN_VERIFIED:
+ case WebhookEvent.EMAIL_DELIVERED:
+ badgeColor =
+ "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25";
+ break;
+ case WebhookEvent.EMAIL_SENT:
+ case WebhookEvent.EMAIL_OPENED:
+ case WebhookEvent.EMAIL_CLICKED:
+ badgeColor =
+ "bg-blue-500/10 text-blue-600 dark:text-blue-500 border border-blue-500/20";
+ break;
+ case WebhookEvent.EMAIL_BOUNCED:
+ case WebhookEvent.EMAIL_COMPLAINED:
+ badgeColor =
+ "bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10";
+ break;
+ default:
+ badgeColor =
+ "bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20";
+ }
+
+ const formatLabel = (value: string) =>
+ value
+ .replace("EMAIL_", "")
+ .replace("_", " ")
+ .toLowerCase()
+ .replace(/^\w/, (c) => c.toUpperCase());
+
+ return (
+
+
+ {formatLabel(event)}
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx b/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx
new file mode 100644
index 00000000..30238222
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@unsend/ui/src/table";
+import { formatDistanceToNow } from "date-fns";
+import Spinner from "@unsend/ui/src/spinner";
+import { WebhookEventBadge } from "./webhook-badge";
+import { api } from "~/trpc/react";
+import DeleteWebhook from "./delete-webhook";
+
+export default function WebhookList() {
+ const webhooksQuery = api.webhook.list.useQuery();
+
+ const webhooks = webhooksQuery.data;
+ const isLoading = webhooksQuery.isLoading;
+
+ return (
+
+
+
+
+
+ URL
+ Events
+ Domain
+ Created at
+ Action
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : !webhooks || webhooks?.length === 0 ? (
+
+
+ No webhooks added
+
+
+ ) : (
+ webhooks.map((webhook) => (
+
+ {webhook.url}
+
+ {webhook.events.map((event) => (
+
+ ))}
+
+
+ {webhook.domain?.name ? webhook.domain?.name : "all domains"}
+
+
+ {formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/zod/webhook-schema.ts b/apps/web/src/lib/zod/webhook-schema.ts
new file mode 100644
index 00000000..b4860674
--- /dev/null
+++ b/apps/web/src/lib/zod/webhook-schema.ts
@@ -0,0 +1,14 @@
+import { z } from "zod";
+import { WebhookEvent } from "@prisma/client";
+
+export const webhookSchema = z.object({
+ url: z.string().url(),
+ domainId: z.number().optional(),
+ events: z.array(z.nativeEnum(WebhookEvent)),
+});
+
+export const webhookSchemaForm = z.object({
+ url: z.string().url(),
+ domainId: z.string().optional(),
+ events: z.array(z.nativeEnum(WebhookEvent)),
+});
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts
index 68c37add..bb38f424 100644
--- a/apps/web/src/server/api/root.ts
+++ b/apps/web/src/server/api/root.ts
@@ -10,6 +10,7 @@ import { templateRouter } from "./routers/template";
import { billingRouter } from "./routers/billing";
import { invitationRouter } from "./routers/invitiation";
import { dashboardRouter } from "./routers/dashboard";
+import { webhookRouter } from "./routers/webhook";
/**
* This is the primary router for your server.
@@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({
billing: billingRouter,
invitation: invitationRouter,
dashboard: dashboardRouter,
+ webhook: webhookRouter,
});
// export type definition of API
diff --git a/apps/web/src/server/api/routers/webhook.ts b/apps/web/src/server/api/routers/webhook.ts
new file mode 100644
index 00000000..f95f1745
--- /dev/null
+++ b/apps/web/src/server/api/routers/webhook.ts
@@ -0,0 +1,47 @@
+import { createTRPCRouter, protectedProcedure, teamProcedure } from "../trpc";
+import { webhookSchema } from "~/lib/zod/webhook-schema";
+import { z } from "zod";
+
+export const webhookRouter = createTRPCRouter({
+ create: teamProcedure.input(webhookSchema).mutation(async ({ ctx, input }) => {
+ return await ctx.db.webhook.create({
+ data: {
+ ...input,
+ teamId: ctx.team.id,
+ },
+ });
+ }),
+
+ list: teamProcedure.query(async ({ ctx }) => {
+ return await ctx.db.webhook.findMany({
+ where: {
+ teamId: ctx.team.id,
+ },
+ include: {
+ domain: true,
+ },
+ });
+ }),
+
+ update: teamProcedure
+ .input(webhookSchema.extend({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const { id, ...data } = input;
+ return await ctx.db.webhook.update({
+ where: {
+ id,
+ teamId: ctx.team.id,
+ },
+ data,
+ });
+ }),
+
+ delete: teamProcedure.input(z.string()).mutation(async ({ ctx, input }) => {
+ return await ctx.db.webhook.delete({
+ where: {
+ id: input,
+ teamId: ctx.team.id,
+ },
+ });
+ }),
+});
diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts
index 3342793f..ec12c766 100644
--- a/apps/web/src/server/service/domain-service.ts
+++ b/apps/web/src/server/service/domain-service.ts
@@ -4,6 +4,7 @@ import * as tldts from "tldts";
import * as ses from "~/server/aws/ses";
import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
+import { WebhookService } from "./webhook-service";
import { UnsendApiError } from "../public-api/api-error";
const dnsResolveTxt = util.promisify(dns.resolveTxt);
@@ -114,6 +115,20 @@ export async function getDomain(id: number) {
const _dmarcRecord = await getDmarcRecord(domain.name);
const dmarcRecord = _dmarcRecord?.[0]?.[0];
+ const isVerifying =
+ verificationStatus === "SUCCESS" &&
+ dkimStatus === "SUCCESS" &&
+ spfDetails === "SUCCESS"
+ ? false
+ : true;
+
+ if (domain.isVerifying && !isVerifying) {
+ WebhookService.triggerWebhook(domain.teamId, "DOMAIN_VERIFIED", {
+ domain: domain.name,
+ status: "SUCCESS",
+ });
+ }
+
domain = await db.domain.update({
where: {
id,
@@ -123,12 +138,7 @@ export async function getDomain(id: number) {
spfDetails,
status: verificationStatus ?? "NOT_STARTED",
dmarcAdded: dmarcRecord ? true : false,
- isVerifying:
- verificationStatus === "SUCCESS" &&
- dkimStatus === "SUCCESS" &&
- spfDetails === "SUCCESS"
- ? false
- : true,
+ isVerifying,
},
});
diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts
index 39acf186..d84d6b8e 100644
--- a/apps/web/src/server/service/ses-hook-parser.ts
+++ b/apps/web/src/server/service/ses-hook-parser.ts
@@ -12,6 +12,7 @@ import {
} from "./campaign-service";
import { env } from "~/env";
import { getRedis } from "../redis";
+import { WebhookService } from "./webhook-service";
import { Queue, Worker } from "bullmq";
import {
DEFAULT_QUEUE_OPTIONS,
@@ -172,9 +173,41 @@ export async function parseSesHook(data: SesEvent) {
},
});
+ try {
+ const webhookEvent = toWebhookEvent(mailStatus);
+ if (webhookEvent) {
+ await WebhookService.triggerWebhook(email.teamId, webhookEvent, {
+ emailId: email.id,
+ status: mailStatus,
+ data: mailData,
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
return true;
}
+function toWebhookEvent(status: EmailStatus) {
+ switch (status) {
+ case EmailStatus.SENT:
+ return "EMAIL_SENT";
+ case EmailStatus.DELIVERED:
+ return "EMAIL_DELIVERED";
+ case EmailStatus.OPENED:
+ return "EMAIL_OPENED";
+ case EmailStatus.CLICKED:
+ return "EMAIL_CLICKED";
+ case EmailStatus.BOUNCED:
+ return "EMAIL_BOUNCED";
+ case EmailStatus.COMPLAINED:
+ return "EMAIL_COMPLAINED";
+ default:
+ return null;
+ }
+}
+
async function checkUnsubscribe({
contactId,
campaignId,
diff --git a/apps/web/src/server/service/webhook-service.ts b/apps/web/src/server/service/webhook-service.ts
new file mode 100644
index 00000000..5cac453a
--- /dev/null
+++ b/apps/web/src/server/service/webhook-service.ts
@@ -0,0 +1,70 @@
+
+import { db } from '~/server/db';
+import { Webhook, WebhookEvent } from '@prisma/client';
+import { getRedis } from '../redis';
+
+const WEBHOOK_CACHE_SECONDS = 10 * 60; // 10 minutes
+
+export class WebhookService {
+ static async triggerWebhook(
+ teamId: number,
+ event: WebhookEvent,
+ payload: any,
+ ) {
+ const webhooks = await this.getWebhooks(teamId, event);
+
+ for (const webhook of webhooks) {
+ try {
+ await fetch(webhook.url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ event,
+ payload,
+ }),
+ });
+ } catch (error) {
+ console.error(`Error sending webhook to ${webhook.url}`, error);
+ }
+ }
+ }
+
+ private static async getWebhooks(
+ teamId: number,
+ event: WebhookEvent,
+ ): Promise {
+ const redis = getRedis();
+ const cacheKey = `webhooks:team:${teamId}`;
+
+ try {
+ const cachedWebhooks = await redis.get(cacheKey);
+ if (cachedWebhooks) {
+ const webhooks: Webhook[] = JSON.parse(cachedWebhooks);
+ return webhooks.filter((webhook) => webhook.events.includes(event));
+ }
+ } catch (error) {
+ console.error('Error getting webhooks from cache', error);
+ }
+
+ const webhooksFromDb = await db.webhook.findMany({
+ where: {
+ teamId,
+ },
+ });
+
+ try {
+ await redis.set(
+ cacheKey,
+ JSON.stringify(webhooksFromDb),
+ 'EX',
+ WEBHOOK_CACHE_SECONDS,
+ );
+ } catch (error) {
+ console.error('Error setting webhooks in cache', error);
+ }
+
+ return webhooksFromDb.filter((webhook) => webhook.events.includes(event));
+ }
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8139aeb4..6f87613d 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -32,6 +32,7 @@
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-avatar": "^1.1.9",
+ "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-label": "^2.1.4",
diff --git a/packages/ui/src/checkbox.tsx b/packages/ui/src/checkbox.tsx
new file mode 100644
index 00000000..23d3be0f
--- /dev/null
+++ b/packages/ui/src/checkbox.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "../lib/utils";
+
+export interface CheckboxProps
+ extends React.ComponentProps {}
+
+const Checkbox = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+
+
+
+
+ )
+})
+
+Checkbox.displayName = "Checkbox";
+
+export { Checkbox };
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d5600c21..764e5db4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -539,6 +539,9 @@ importers:
'@radix-ui/react-avatar':
specifier: ^1.1.9
version: 1.1.9(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0)
+ '@radix-ui/react-checkbox':
+ specifier: ^1.3.2
+ version: 1.3.2(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0)
'@radix-ui/react-dialog':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0)
@@ -4060,6 +4063,33 @@ packages:
react-dom: 19.1.0(react@19.1.0)
dev: false
+ /@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0):
+ resolution: {integrity: sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@radix-ui/primitive': 1.1.2
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+ '@types/react': 19.1.2
+ '@types/react-dom': 19.1.2(@types/react@19.1.2)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ dev: false
+
/@radix-ui/react-collapsible@1.1.1(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0):
resolution: {integrity: sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==}
peerDependencies:
@@ -4782,6 +4812,26 @@ packages:
react-dom: 19.1.0(react@19.1.0)
dev: false
+ /@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0):
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.2)(react@19.1.0)
+ '@types/react': 19.1.2
+ '@types/react-dom': 19.1.2(@types/react@19.1.2)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ dev: false
+
/@radix-ui/react-progress@1.1.4(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0):
resolution: {integrity: sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==}
peerDependencies:
@@ -4960,6 +5010,20 @@ packages:
react: 19.1.0
dev: false
+ /@radix-ui/react-slot@1.2.3(@types/react@19.1.2)(react@19.1.0):
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+ '@types/react': 19.1.2
+ react: 19.1.0
+ dev: false
+
/@radix-ui/react-switch@1.2.2(@types/react-dom@19.1.2)(@types/react@19.1.2)(react-dom@19.1.0)(react@19.1.0):
resolution: {integrity: sha512-7Z8n6L+ifMIIYZ83f28qWSceUpkXuslI2FJ34+kDMTiyj91ENdpdQ7VCidrzj5JfwfZTeano/BnGBbu/jqa5rQ==}
peerDependencies: