diff --git a/apps/web/package.json b/apps/web/package.json index 3997852..22edc17 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "prisma": "^5.11.0", "react": "18.2.0", "react-dom": "18.2.0", + "recharts": "^2.12.5", "server-only": "^0.0.1", "superjson": "^2.2.1", "tldts": "^6.1.16", diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index d5bbbcf..0d5e41c 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -137,14 +137,27 @@ model ApiKey { team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) } +enum EmailStatus { + SENT + OPENED + CLICKED + BOUNCED + COMPLAINED + DELIVERED + REJECTED + RENDERING_FAILURE + DELIVERY_DELAYED +} + model Email { - id String @id + id String @id @default(cuid()) + sesEmailId String? @unique to String from String subject String text String? html String? - latestStatus String? + latestStatus EmailStatus @default(SENT) teamId Int domainId Int? createdAt DateTime @default(now()) @@ -155,10 +168,10 @@ model Email { model EmailEvent { emailId String - status String + status EmailStatus data Json? - createdAt DateTime @default(now()) - email Email @relation(fields: [emailId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + email Email @relation(fields: [emailId], references: [id], onDelete: Cascade) @@unique([emailId, status]) } diff --git a/apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx b/apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx index 297f6c4..48ce34b 100644 --- a/apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx +++ b/apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx @@ -15,12 +15,15 @@ import { import { api } from "~/trpc/react"; import { useState } from "react"; +import { CheckIcon, ClipboardCopy, Plus } from "lucide-react"; +import { toast } from "@unsend/ui/src/toaster"; export default function AddApiKey() { const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [apiKey, setApiKey] = useState(""); const addDomainMutation = api.apiKey.createToken.useMutation(); + const [isCopied, setIsCopied] = useState(false); const utils = api.useUtils(); @@ -41,9 +44,18 @@ export default function AddApiKey() { function handleCopy() { navigator.clipboard.writeText(apiKey); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 2000); + } + + function copyAndClose() { + handleCopy(); setApiKey(""); setName(""); setOpen(false); + toast.success("API key copied to clipboard"); } return ( @@ -52,18 +64,34 @@ export default function AddApiKey() { onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} > - + {apiKey ? ( Copy API key -
{apiKey}
+
+

{apiKey}

+ +
diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx index 2cc6506..5d43d31 100644 --- a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx @@ -287,7 +287,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
-

Danger

+

Danger

Deleting a domain will stop sending emails with this domain. diff --git a/apps/web/src/app/(dashboard)/domains/add-domain.tsx b/apps/web/src/app/(dashboard)/domains/add-domain.tsx index 60abc83..3c94a2d 100644 --- a/apps/web/src/app/(dashboard)/domains/add-domain.tsx +++ b/apps/web/src/app/(dashboard)/domains/add-domain.tsx @@ -15,6 +15,7 @@ import { import { api } from "~/trpc/react"; import { useState } from "react"; +import { Plus } from "lucide-react"; export default function AddDomain() { const [open, setOpen] = useState(false); @@ -43,7 +44,10 @@ export default function AddDomain() { onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} > - + diff --git a/apps/web/src/app/(dashboard)/domains/domain-list.tsx b/apps/web/src/app/(dashboard)/domains/domain-list.tsx index f2cc993..407654a 100644 --- a/apps/web/src/app/(dashboard)/domains/domain-list.tsx +++ b/apps/web/src/app/(dashboard)/domains/domain-list.tsx @@ -20,7 +20,7 @@ export default function DomainsList() { )) ) : ( -

No domains
+
No domains Added
)}
diff --git a/apps/web/src/app/(dashboard)/emails/email-list.tsx b/apps/web/src/app/(dashboard)/emails/email-list.tsx index 57d6091..7adb015 100644 --- a/apps/web/src/app/(dashboard)/emails/email-list.tsx +++ b/apps/web/src/app/(dashboard)/emails/email-list.tsx @@ -21,6 +21,7 @@ import { MailX, } from "lucide-react"; import { formatDistance, formatDistanceToNow } from "date-fns"; +import { EmailStatus } from "@prisma/client"; export default function EmailsList() { const emailsQuery = api.email.emails.useQuery(); @@ -65,40 +66,40 @@ export default function EmailsList() { ); } -const EmailIcon: React.FC<{ status: string }> = ({ status }) => { +const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => { switch (status) { - case "Send": + case "SENT": return ( //
//
); - case "Delivery": - case "Delayed": + case "DELIVERED": return ( //
//
); - case "Bounced": + case "BOUNCED": return ( //
//
); - case "Clicked": + case "CLICKED": return ( //
//
); - case "Opened": + case "OPENED": return ( //
//
); - case "Complained": + case "DELIVERY_DELAYED": + case "COMPLAINED": return ( //
@@ -113,26 +114,27 @@ const EmailIcon: React.FC<{ status: string }> = ({ status }) => { } }; -const EmailStatusBadge: React.FC<{ status: string }> = ({ status }) => { +const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({ status }) => { let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color switch (status) { - case "Send": + case "SENT": badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; break; - case "Delivery": - case "Delayed": + case "DELIVERED": badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"; break; - case "Bounced": + case "BOUNCED": badgeColor = "bg-red-500/10 text-red-800 border-red-600/10"; break; - case "Clicked": + case "CLICKED": badgeColor = "bg-cyan-500/10 text-cyan-600 border-cyan-600/10"; break; - case "Opened": + case "OPENED": badgeColor = "bg-indigo-500/10 text-indigo-600 border-indigo-600/10"; break; - case "Complained": + case "DELIVERY_DELAYED": + badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10"; + case "COMPLAINED": badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10"; break; default: @@ -141,9 +143,9 @@ const EmailStatusBadge: React.FC<{ status: string }> = ({ status }) => { return (
- {status} + {status.toLowerCase().split("_").join(" ")}
); }; diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 66dd980..0419194 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { Bell, + BellRing, CircleUser, Globe, Home, @@ -10,6 +11,7 @@ import { LineChart, Mail, Menu, + MessageSquareMore, Package, Package2, Search, @@ -85,6 +87,16 @@ export default async function AuthenticatedDashboardLayout({ Domains + + + SMS + + + + + Push notification + + API keys diff --git a/apps/web/src/app/(dashboard)/nav-button.tsx b/apps/web/src/app/(dashboard)/nav-button.tsx index 3855092..fadbc88 100644 --- a/apps/web/src/app/(dashboard)/nav-button.tsx +++ b/apps/web/src/app/(dashboard)/nav-button.tsx @@ -7,15 +7,31 @@ import React from "react"; export const NavButton: React.FC<{ href: string; children: React.ReactNode; -}> = ({ href, children }) => { + comingSoon?: boolean; +}> = ({ href, children, comingSoon }) => { const pathname = usePathname(); const isActive = pathname?.startsWith(href); + if (comingSoon) { + return ( +
+
+ {children} +
+
+ soon +
+
+ ); + } + return ( {children} diff --git a/apps/web/src/app/api/ses_callback/route.ts b/apps/web/src/app/api/ses_callback/route.ts index f226bd1..1e08af0 100644 --- a/apps/web/src/app/api/ses_callback/route.ts +++ b/apps/web/src/app/api/ses_callback/route.ts @@ -1,6 +1,7 @@ import { headers } from "next/headers"; import { hashToken } from "~/server/auth"; import { db } from "~/server/db"; +import { parseSesHook } from "~/server/service/ses-hook-parser"; export async function GET(req: Request) { console.log("GET", req); @@ -10,67 +11,26 @@ export async function GET(req: Request) { export async function POST(req: Request) { const data = await req.json(); + console.log(data, data.Message); + if (data.Type === "SubscriptionConfirmation") { return handleSubscription(data); } - console.log(data, data.Message); - let message = null; try { message = JSON.parse(data.Message || "{}"); + const status = await parseSesHook(message); + if (!status) { + return Response.json({ data: "Error is parsing hook" }, { status: 400 }); + } + + return Response.json({ data: "Success" }); } catch (e) { - console.log(e); + console.error(e); + return Response.json({ data: "Error is parsing hook" }, { status: 400 }); } - - const emailId = message?.mail.messageId; - - console.log(emailId, message); - - if (!emailId) { - return Response.json({ data: "Email not found" }); - } - - const email = await db.email.findUnique({ - where: { - id: emailId, - }, - }); - - if (!email || !message.mail) { - return Response.json({ data: "Email not found" }); - } - - console.log("FOund email", email); - - await db.email.update({ - where: { - id: email.id, - }, - data: { - latestStatus: message.eventType, - }, - }); - - await db.emailEvent.upsert({ - where: { - emailId_status: { - emailId, - status: message.eventType, - }, - }, - update: { - data: message[message.eventType.toLowerCase()], - }, - create: { - emailId, - status: message.eventType, - data: message[message.eventType.toLowerCase()], - }, - }); - - return Response.json({ data: "Hello" }); } async function handleSubscription(message: any) { @@ -78,5 +38,5 @@ async function handleSubscription(message: any) { method: "GET", }); - return Response.json({ data: "Hello" }); + return Response.json({ data: "Success" }); } diff --git a/apps/web/src/server/api/routers/api.ts b/apps/web/src/server/api/routers/api.ts index 5145d16..27245e9 100644 --- a/apps/web/src/server/api/routers/api.ts +++ b/apps/web/src/server/api/routers/api.ts @@ -34,6 +34,7 @@ export const apiRouter = createTRPCRouter({ permission: true, partialToken: true, lastUsed: true, + createdAt: true, }, }); diff --git a/apps/web/src/server/ses.ts b/apps/web/src/server/aws/ses.ts similarity index 100% rename from apps/web/src/server/ses.ts rename to apps/web/src/server/aws/ses.ts diff --git a/apps/web/src/server/aws/setup.ts b/apps/web/src/server/aws/setup.ts index 24d664c..7044d33 100644 --- a/apps/web/src/server/aws/setup.ts +++ b/apps/web/src/server/aws/setup.ts @@ -4,7 +4,7 @@ import { APP_SETTINGS } from "~/utils/constants"; import { createTopic, subscribeEndpoint } from "./sns"; import { env } from "~/env"; import { AppSettingsService } from "~/server/service/app-settings-service"; -import { addWebhookConfiguration } from "../ses"; +import { addWebhookConfiguration } from "./ses"; import { EventType } from "@aws-sdk/client-sesv2"; const GENERAL_EVENTS: EventType[] = [ diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index ead67c5..e968d48 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -1,7 +1,7 @@ import dns from "dns"; import util from "util"; import * as tldts from "tldts"; -import * as ses from "~/server/ses"; +import * as ses from "~/server/aws/ses"; import { db } from "~/server/db"; const dnsResolveTxt = util.promisify(dns.resolveTxt); diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts index 267097c..d68a31f 100644 --- a/apps/web/src/server/service/email-service.ts +++ b/apps/web/src/server/service/email-service.ts @@ -1,6 +1,6 @@ import { EmailContent } from "~/types"; import { db } from "../db"; -import { sendEmailThroughSes } from "../ses"; +import { sendEmailThroughSes } from "../aws/ses"; import { APP_SETTINGS } from "~/utils/constants"; export async function sendEmail( @@ -45,7 +45,7 @@ export async function sendEmail( subject, text, html, - id: messageId, + sesEmailId: messageId, teamId, domainId: domain.id, }, diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts new file mode 100644 index 0000000..316605e --- /dev/null +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -0,0 +1,91 @@ +import { EmailStatus } from "@prisma/client"; +import { SesEvent, SesEventDataKey, SesEventType } from "~/types/aws-types"; +import { db } from "../db"; + +export async function parseSesHook(data: SesEvent) { + const mailStatus = getEmailStatus(data); + + if (!mailStatus) { + console.error("Unknown email status", data); + return false; + } + + const sesEmailId = data.mail.messageId; + + const mailData = getEmailData(data); + + const email = await db.email.findUnique({ + where: { + sesEmailId, + }, + }); + + if (!email) { + console.error("Email not found", data); + return false; + } + + await db.email.update({ + where: { + id: email.id, + }, + data: { + latestStatus: mailStatus, + }, + }); + + await db.emailEvent.upsert({ + where: { + emailId_status: { + emailId: email.id, + status: mailStatus, + }, + }, + update: { + data: mailData as any, + }, + create: { + emailId: email.id, + status: mailStatus, + data: mailData as any, + }, + }); + + return true; +} + +function getEmailStatus(data: SesEvent) { + const { eventType } = data; + + if (eventType === "Send") { + return EmailStatus.SENT; + } else if (eventType === "Delivery") { + return EmailStatus.DELIVERED; + } else if (eventType === "Bounce") { + return EmailStatus.BOUNCED; + } else if (eventType === "Complaint") { + return EmailStatus.COMPLAINED; + } else if (eventType === "Reject") { + return EmailStatus.REJECTED; + } else if (eventType === "Open") { + return EmailStatus.OPENED; + } else if (eventType === "Click") { + return EmailStatus.CLICKED; + } else if (eventType === "Rendering Failure") { + return EmailStatus.RENDERING_FAILURE; + } else if (eventType === "DeliveryDelay") { + return EmailStatus.DELIVERY_DELAYED; + } +} + +function getEmailData(data: SesEvent) { + const { eventType } = data; + + if (eventType === "Rendering Failure") { + return data.renderingFailure; + } else if (eventType === "DeliveryDelay") { + return data.deliveryDelay; + } else { + return data[eventType.toLowerCase() as SesEventDataKey]; + } +} diff --git a/apps/web/src/types/aws-types.ts b/apps/web/src/types/aws-types.ts new file mode 100644 index 0000000..4549502 --- /dev/null +++ b/apps/web/src/types/aws-types.ts @@ -0,0 +1,138 @@ +export interface SnsNotificationMessage { + Type: string; + MessageId: string; + TopicArn: string; + Subject?: string; + Message: string; // This is a JSON string that needs to be parsed into one of the SES event types below + Timestamp: string; + SignatureVersion: string; + Signature: string; + SigningCertURL: string; + UnsubscribeURL: string; +} + +export interface SesMail { + timestamp: string; + source: string; + messageId: string; + destination: string[]; + headersTruncated: boolean; + headers: Array<{ name: string; value: string }>; + commonHeaders: { + from: string[]; + to: string[]; + messageId: string; + subject?: string; + }; + tags: { [key: string]: string[] }; +} + +export interface SesBounce { + bounceType: string; + bounceSubType: string; + bouncedRecipients: Array<{ + emailAddress: string; + action: string; + status: string; + diagnosticCode?: string; + }>; + timestamp: string; + feedbackId: string; + reportingMTA: string; +} + +export interface SesComplaint { + complainedRecipients: Array<{ + emailAddress: string; + }>; + timestamp: string; + feedbackId: string; + complaintFeedbackType: string; + userAgent: string; + complaintSubType?: string; + arrivedDate?: string; +} + +export interface SesDelivery { + timestamp: string; + processingTimeMillis: number; + recipients: string[]; + smtpResponse: string; + reportingMTA: string; + remoteMtaIp?: string; +} + +export interface SesSend { + timestamp: string; + smtpResponse: string; + reportingMTA: string; + recipients: string[]; +} + +export interface SesReject { + reason: string; + timestamp: string; +} + +export interface SesOpen { + ipAddress: string; + timestamp: string; + userAgent: string; +} + +export interface SesClick { + ipAddress: string; + timestamp: string; + userAgent: string; + link: string; + linkTags: { [key: string]: string }; +} + +export interface SesRenderingFailure { + errorMessage: string; + templateName: string; +} + +export interface SesDeliveryDelay { + delayType: string; + expirationTime: string; + delayedRecipients: string[]; + timestamp: string; +} + +export type SesEventType = + | "Bounce" + | "Complaint" + | "Delivery" + | "Send" + | "Reject" + | "Open" + | "Click" + | "Rendering Failure" + | "DeliveryDelay"; + +export type SesEventDataKey = + | "bounce" + | "complaint" + | "delivery" + | "send" + | "reject" + | "open" + | "click" + | "renderingFailure" + | "deliveryDelay"; + +export interface SesEvent { + eventType: SesEventType; + mail: SesMail; + bounce?: SesBounce; + complaint?: SesComplaint; + delivery?: SesDelivery; + send?: SesSend; + reject?: SesReject; + open?: SesOpen; + click?: SesClick; + renderingFailure?: SesRenderingFailure; + deliveryDelay?: SesDeliveryDelay; + // Additional fields for other event types can be added here +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84dd738..c717b6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + recharts: + specifier: ^2.12.5 + version: 2.12.5(react-dom@18.2.0)(react@18.2.0) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -2922,6 +2925,48 @@ packages: resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} dev: false + /@types/d3-array@3.2.1: + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + dev: false + + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-ease@3.0.2: + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-path@3.1.0: + resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==} + dev: false + + /@types/d3-scale@4.0.8: + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + dependencies: + '@types/d3-time': 3.0.3 + dev: false + + /@types/d3-shape@3.1.6: + resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} + dependencies: + '@types/d3-path': 3.1.0 + dev: false + + /@types/d3-time@3.0.3: + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + dev: false + + /@types/d3-timer@3.0.2: + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + dev: false + /@types/eslint@8.56.5: resolution: {integrity: sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw==} dependencies: @@ -3836,6 +3881,77 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true @@ -3867,6 +3983,10 @@ packages: ms: 2.1.2 dev: true + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -3935,6 +4055,13 @@ packages: esutils: 2.0.3 dev: true + /dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dependencies: + '@babel/runtime': 7.24.0 + csstype: 3.1.3 + dev: false + /dotenv-cli@7.4.1: resolution: {integrity: sha512-fE1aywjRrWGxV3miaiUr3d2zC/VAiuzEGghi+QzgIA9fEf/M5hLMaRSXb4IxbUAwGmaLi0IozdZddnVU96acag==} hasBin: true @@ -4208,7 +4335,7 @@ packages: enhanced-resolve: 5.16.0 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.3 is-core-module: 2.13.1 @@ -4703,10 +4830,19 @@ packages: engines: {node: '>=0.10.0'} dev: true + /eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true + /fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + dev: false + /fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -5106,6 +5242,11 @@ packages: side-channel: 1.0.6 dev: true + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} dependencies: @@ -5480,7 +5621,6 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -6172,7 +6312,6 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 - dev: true /property-information@5.6.0: resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} @@ -6200,7 +6339,6 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - dev: true /react-remove-scroll-bar@2.3.6(@types/react@18.2.66)(react@18.2.0): resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} @@ -6237,6 +6375,19 @@ packages: use-sidecar: 1.1.2(@types/react@18.2.66)(react@18.2.0) dev: false + /react-smooth@4.0.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + fast-equals: 5.0.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + dev: false + /react-style-singleton@2.2.1(@types/react@18.2.66)(react@18.2.0): resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} @@ -6267,6 +6418,20 @@ packages: refractor: 3.6.0 dev: false + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + dependencies: + '@babel/runtime': 7.24.0 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -6303,6 +6468,31 @@ packages: dependencies: picomatch: 2.3.1 + /recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + dependencies: + decimal.js-light: 2.5.1 + dev: false + + /recharts@2.12.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Cy+BkqrFIYTHJCyKHJEPvbHE2kVQEP6PKbOHJ8ztRGTAhvHuUnCwDaKVb13OwRFZ0QNUk1QvGTDdgWSMbuMtKw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + clsx: 2.1.0 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 16.13.1 + react-smooth: 4.0.1(react-dom@18.2.0)(react@18.2.0) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + dev: false + /reflect.getprototypeof@1.0.5: resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==} engines: {node: '>= 0.4'} @@ -6783,6 +6973,10 @@ packages: dependencies: any-promise: 1.3.0 + /tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + dev: false + /tldts-core@6.1.16: resolution: {integrity: sha512-rxnuCux+zn3hMF57nBzr1m1qGZH7Od2ErbDZjVm04fk76cEynTg3zqvHjx5BsBl8lvRTjpzIhsEGMHDH/Hr2Vw==} dev: false @@ -7049,6 +7243,25 @@ packages: spdx-expression-parse: 3.0.1 dev: true + /victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.3 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + dev: false + /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: