Merge pull request #210 from useplunk/email-view

Add ability to view transactional email in contacts page
This commit is contained in:
Dries Augustyns
2025-08-22 14:49:20 +02:00
committed by GitHub
4 changed files with 647 additions and 559 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "plunk", "name": "plunk",
"version": "1.2.1", "version": "1.3.0",
"private": true, "private": true,
"license": "agpl-3.0", "license": "agpl-3.0",
"workspaces": { "workspaces": {
+1
View File
@@ -31,6 +31,7 @@
"@uiball/loaders": "^1.3.1", "@uiball/loaders": "^1.3.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"dayjs": "^1.11.12", "dayjs": "^1.11.12",
"dompurify": "^3.2.6",
"framer-motion": "^11.3.7", "framer-motion": "^11.3.7",
"jotai": "2.9.0", "jotai": "2.9.0",
"lucide-react": "^0.408.0", "lucide-react": "^0.408.0",
+168 -101
View File
@@ -1,22 +1,23 @@
// @ts-nocheck // @ts-nocheck
// React Hook Form messes up our types, ignore the entire file // React Hook Form messes up our types, ignore the entire file
import { zodResolver } from "@hookform/resolvers/zod"; import {zodResolver} from "@hookform/resolvers/zod";
import { ContactSchemas, EventSchemas, type UtilitySchemas } from "@plunk/shared"; import {ContactSchemas, EventSchemas, type UtilitySchemas} from "@plunk/shared";
import type { Contact, Email, Template } from "@prisma/client"; import type {Contact, Email, Template} from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { motion } from "framer-motion"; import {motion} from "framer-motion";
import { Save } from "lucide-react"; import {Save} from "lucide-react";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import React, { useEffect, useState } from "react"; import React, {useEffect, useState} from "react";
import { useFieldArray, useForm } from "react-hook-form"; import {useFieldArray, useForm} from "react-hook-form";
import { toast } from "sonner"; import {toast} from "sonner";
import { z } from "zod"; import {z} from "zod";
import { Card, Empty, FullscreenLoader, Input, Modal, Toggle } from "../../components"; import {Badge, Card, Empty, FullscreenLoader, Input, Modal, Toggle} from "../../components";
import { Dashboard } from "../../layouts"; import {Dashboard} from "../../layouts";
import { useContact } from "../../lib/hooks/contacts"; import {useContact} from "../../lib/hooks/contacts";
import { useActiveProject } from "../../lib/hooks/projects"; import {useActiveProject} from "../../lib/hooks/projects";
import { network } from "../../lib/network"; import {network} from "../../lib/network";
import DOMPurify from "dompurify";
interface ContactValues { interface ContactValues {
email: string; email: string;
@@ -33,19 +34,19 @@ interface EventValues {
*/ */
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
const [selectedEmail, setSelectedEmail] = useState(null);
if (!router.isReady) { if (!router.isReady) {
return <FullscreenLoader />; return <FullscreenLoader/>;
} }
const [eventModal, setEventModal] = useState(false); const [eventModal, setEventModal] = useState(false);
const project = useActiveProject(); const project = useActiveProject();
const { data: contact, mutate } = useContact({ const {data: contact, mutate} = useContact({
id: router.query.id as string, id: router.query.id as string,
}); });
const { handleSubmit, watch, setValue, reset } = useForm<ContactValues>({ const {handleSubmit, watch, setValue, reset} = useForm<ContactValues>({
resolver: zodResolver(ContactSchemas.manage), resolver: zodResolver(ContactSchemas.manage),
}); });
@@ -57,7 +58,7 @@ export default function Index() {
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
data: Object.entries(JSON.parse(contact?.data ? contact.data : "{}")).map(([key]) => ({ data: Object.entries(JSON.parse(contact?.data ? contact.data : "{}")).map(([key]) => ({
value: { key }, value: {key},
})), })),
}, },
resolver: zodResolver( resolver: zodResolver(
@@ -65,7 +66,7 @@ export default function Index() {
data: z data: z
.array( .array(
z.object({ z.object({
value: z.object({ key: z.string(), value: z.string() }), value: z.object({key: z.string(), value: z.string()}),
}), }),
) )
.min(0), .min(0),
@@ -73,15 +74,15 @@ export default function Index() {
), ),
}); });
const { fields, append: fieldAppend, remove: fieldRemove } = useFieldArray({ control, name: "data" }); const {fields, append: fieldAppend, remove: fieldRemove} = useFieldArray({control, name: "data"});
const { const {
register: eventRegister, register: eventRegister,
handleSubmit: eventHandleSubmit, handleSubmit: eventHandleSubmit,
formState: { errors: eventErrors }, formState: {errors: eventErrors},
reset: eventReset, reset: eventReset,
} = useForm<EventValues>({ } = useForm<EventValues>({
resolver: zodResolver(EventSchemas.post.pick({ event: true })), resolver: zodResolver(EventSchemas.post.pick({event: true})),
}); });
useEffect(() => { useEffect(() => {
@@ -95,13 +96,13 @@ export default function Index() {
}); });
dataReset({ dataReset({
data: Object.entries(JSON.parse(contact.data ? contact.data : "{}")).map(([key, value]) => ({ data: Object.entries(JSON.parse(contact.data ? contact.data : "{}")).map(([key, value]) => ({
value: { key, value }, value: {key, value},
})), })),
}); });
}, [dataReset, reset, contact]); }, [dataReset, reset, contact]);
if (!contact) { if (!contact) {
return <FullscreenLoader />; return <FullscreenLoader/>;
} }
const create = (data: EventValues) => { const create = (data: EventValues) => {
@@ -125,11 +126,11 @@ export default function Index() {
}; };
const update = (data: ContactValues) => { const update = (data: ContactValues) => {
const entries = getDataValues().data.map(({ value }) => [value.key, value.value]); const entries = getDataValues().data.map(({value}) => [value.key, value.value]);
let dataObject = {}; let dataObject = {};
entries.forEach(([key, value]) => { entries.forEach(([key, value]) => {
Object.assign(dataObject, { [key]: value }); Object.assign(dataObject, {[key]: value});
}); });
dataObject = Object.fromEntries(Object.entries(dataObject).filter(([, value]) => value !== "")); dataObject = Object.fromEntries(Object.entries(dataObject).filter(([, value]) => value !== ""));
@@ -178,12 +179,13 @@ export default function Index() {
description={`Trigger an event for ${contact.email}`} description={`Trigger an event for ${contact.email}`}
icon={ icon={
<> <>
<rect strokeWidth={2} width="14.5" height="14.5" x="4.75" y="4.75" rx="2" /> <rect strokeWidth={2} width="14.5" height="14.5" x="4.75" y="4.75" rx="2"/>
<path strokeWidth={2} d="M8.75 10.75L11.25 13L8.75 15.25" /> <path strokeWidth={2} d="M8.75 10.75L11.25 13L8.75 15.25"/>
</> </>
} }
> >
<Input register={eventRegister("event")} label={"Event"} placeholder={"signup"} error={eventErrors.event} /> <Input register={eventRegister("event")} label={"Event"} placeholder={"signup"}
error={eventErrors.event}/>
</Modal> </Modal>
<Dashboard> <Dashboard>
<Card <Card
@@ -239,17 +241,21 @@ export default function Index() {
strokeWidth="1.5" strokeWidth="1.5"
d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5" d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5"
/> />
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M5 7.75H19" /> <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"
strokeWidth="1.5" d="M5 7.75H19"/>
</svg> </svg>
Delete Delete
</button> </button>
</> </>
} }
> >
<form onSubmit={handleSubmit(update)} className="space-y-6 sm:grid sm:gap-x-5 sm:space-y-9 sm:grid-cols-2"> <form onSubmit={handleSubmit(update)}
className="space-y-6 sm:grid sm:gap-x-5 sm:space-y-9 sm:grid-cols-2">
<div className={"col-span-2 flex items-center gap-6"}> <div className={"col-span-2 flex items-center gap-6"}>
<span className="inline-flex h-20 w-20 items-center justify-center rounded-full bg-neutral-100"> <span
<span className="text-xl font-semibold leading-none text-neutral-800">{contact.email[0].toUpperCase()}</span> className="inline-flex h-20 w-20 items-center justify-center rounded-full bg-neutral-100">
<span
className="text-xl font-semibold leading-none text-neutral-800">{contact.email[0].toUpperCase()}</span>
</span> </span>
<h1 className={"text-2xl font-semibold text-neutral-800"}> <h1 className={"text-2xl font-semibold text-neutral-800"}>
{contact.email[0].toUpperCase()} {contact.email[0].toUpperCase()}
@@ -259,13 +265,14 @@ export default function Index() {
<div className={"grid sm:col-span-2"}> <div className={"grid sm:col-span-2"}>
<div className={"grid items-center gap-3 sm:grid-cols-9"}> <div className={"grid items-center gap-3 sm:grid-cols-9"}>
<label htmlFor={"data"} className="block text-sm font-medium text-neutral-700 sm:col-span-8"> <label htmlFor={"data"}
className="block text-sm font-medium text-neutral-700 sm:col-span-8">
Metadata Metadata
</label> </label>
<button <button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
fieldAppend({ value: { key: "", value: "" } }); fieldAppend({value: {key: "", value: ""}});
}} }}
className={ className={
"ml-auto flex w-full items-center justify-center gap-x-0.5 rounded border border-neutral-200 bg-white py-1 text-center text-sm text-neutral-700 transition ease-in-out hover:bg-neutral-50 sm:col-span-1" "ml-auto flex w-full items-center justify-center gap-x-0.5 rounded border border-neutral-200 bg-white py-1 text-center text-sm text-neutral-700 transition ease-in-out hover:bg-neutral-50 sm:col-span-1"
@@ -384,13 +391,13 @@ export default function Index() {
<div className={"col-span-2 ml-auto flex justify-end gap-x-5"}> <div className={"col-span-2 ml-auto flex justify-end gap-x-5"}>
<motion.button <motion.button
whileHover={{ scale: 1.05 }} whileHover={{scale: 1.05}}
whileTap={{ scale: 0.9 }} whileTap={{scale: 0.9}}
className={ className={
"ml-auto mt-6 flex items-center gap-x-2 rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white" "ml-auto mt-6 flex items-center gap-x-2 rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white"
} }
> >
<Save strokeWidth={1.5} size={18} /> <Save strokeWidth={1.5} size={18}/>
Save Save
</motion.button> </motion.button>
</div> </div>
@@ -398,67 +405,101 @@ export default function Index() {
</Card> </Card>
<Card title={"Journey"}> <Card title={"Journey"}>
{contact.triggers.length > 0 || contact.emails.length > 0 ? ( {contact.triggers.length > 0 || contact.emails.length > 0 ? (
<div className="scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-neutral-100 scrollbar-thumb-rounded-full scrollbar-track-rounded-full flow-root h-96 max-h-96 overflow-y-auto pr-6"> <div
className="scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-neutral-100 scrollbar-thumb-rounded-full scrollbar-track-rounded-full flow-root h-96 max-h-96 overflow-y-auto pr-6">
<ul className="-mb-8"> <ul className="-mb-8">
{[...contact.triggers, ...contact.emails] {[...contact.triggers, ...contact.emails]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((t, index) => { .map((t, index) => {
if (t.messageId) { if (t.messageId) {
const email = t as Email; const email = t as Email;
const expanded = selectedEmail?.id === email.id;
return ( return (
<li> <li key={email.id} className="mb-8">
<div className="relative pb-8">
{contact.triggers.length + contact.emails.length - 1 !== index && ( {contact.triggers.length + contact.emails.length - 1 !== index && (
<span className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200" aria-hidden="true" /> <span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
aria-hidden="true"/>
)} )}
<div className="relative flex space-x-3"> <div className="relative flex space-x-3">
<div> <div>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white"> <span
<svg className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white">
className={"h-5 w-5"} <svg className={"h-5 w-5"} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" strokeWidth="1.5" stroke="currentColor" fill="none" strokeLinecap="round"
viewBox="0 0 24 24" strokeLinejoin="round">
strokeWidth="1.5"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<> <>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2"/>
<polyline points="3 7 12 13 21 7" /> <polyline points="3 7 12 13 21 7"/>
</> </>
</svg> </svg>
</span> </span>
</div> </div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> <div
className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div className="relative">
<p
className="text-sm text-neutral-500 cursor-pointer"
onClick={() => setSelectedEmail(expanded ? null : email)}
>
Transactional email {email.subject} delivered
</p>
</div>
<div
className="whitespace-nowrap text-right text-sm text-neutral-500">
<time
dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time>
</div>
</div>
</div>
{/* Expanded panel for all screen sizes */}
{expanded && (
<div
className="mt-4 mb-2 ml-10 rounded border border-neutral-200 bg-neutral-50 p-4 text-sm">
<div className="space-y-4">
<div className="flex items-center gap-2">
<span
className="font-semibold text-neutral-700 w-20">Status</span>
<Badge type={'info'}><span
className={'capitalize'}>{email.status.toLowerCase()}</span></Badge>
</div>
<div className="flex items-center gap-2">
<span
className="font-semibold text-neutral-700 w-20">Sent</span>
<span
className="text-neutral-800">{dayjs(email.createdAt).format("YYYY-MM-DD HH:mm")}</span>
</div>
<div> <div>
<p className="text-sm text-neutral-500">Transactional email {email.subject} delivered</p> <span
</div> className="font-semibold text-neutral-700 block mb-1">Body</span>
<div className="whitespace-nowrap text-right text-sm text-neutral-500"> <div
<time dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time> dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(email.body)}}
</div> className="rounded bg-white p-3 text-neutral-800 whitespace-pre-line max-h-64 overflow-y-auto"/>
</div> </div>
</div> </div>
</div> </div>
)}
</li> </li>
); )
} }
if (t.action) { if (t.action) {
return ( return (
<li> <li className="mb-8">
<div className="relative pb-8"> <div className="relative pb-8">
{contact.triggers.length + contact.emails.length - 1 !== index && ( {contact.triggers.length + contact.emails.length - 1 !== index && (
<span className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200" aria-hidden="true" /> <span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
aria-hidden="true"/>
)} )}
<div className="relative flex space-x-3"> <div className="relative flex space-x-3">
<div> <div>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white"> <span
className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white">
<svg <svg
className={"h-5 w-5"} className={"h-5 w-5"}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -469,20 +510,27 @@ export default function Index() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
<path d="M16 21h3c.81 0 1.48 -.67 1.48 -1.48l.02 -.02c0 -.82 -.69 -1.5 -1.5 -1.5h-3v3z" /> <path
<path d="M16 15h2.5c.84 -.01 1.5 .66 1.5 1.5s-.66 1.5 -1.5 1.5h-2.5v-3z" /> d="M16 21h3c.81 0 1.48 -.67 1.48 -1.48l.02 -.02c0 -.82 -.69 -1.5 -1.5 -1.5h-3v3z"/>
<path d="M4 9v-4c0 -1.036 .895 -2 2 -2s2 .964 2 2v4" /> <path
<path d="M2.99 11.98a9 9 0 0 0 9 9m9 -9a9 9 0 0 0 -9 -9" /> d="M16 15h2.5c.84 -.01 1.5 .66 1.5 1.5s-.66 1.5 -1.5 1.5h-2.5v-3z"/>
<path d="M8 7h-4" /> <path
d="M4 9v-4c0 -1.036 .895 -2 2 -2s2 .964 2 2v4"/>
<path
d="M2.99 11.98a9 9 0 0 0 9 9m9 -9a9 9 0 0 0 -9 -9"/>
<path d="M8 7h-4"/>
</svg> </svg>
</span> </span>
</div> </div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> <div
className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div> <div>
<p className="text-sm text-neutral-500">{t.action.name} triggered</p> <p className="text-sm text-neutral-500">{t.action.name} triggered</p>
</div> </div>
<div className="whitespace-nowrap text-right text-sm text-neutral-500"> <div
<time dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time> className="whitespace-nowrap text-right text-sm text-neutral-500">
<time
dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time>
</div> </div>
</div> </div>
</div> </div>
@@ -493,14 +541,17 @@ export default function Index() {
if (t.event) { if (t.event) {
return ( return (
<li> <li className="mb-8">
<div className="relative pb-8"> <div className="relative pb-8">
{contact.triggers.length + contact.emails.length - 1 !== index && ( {contact.triggers.length + contact.emails.length - 1 !== index && (
<span className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200" aria-hidden="true" /> <span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
aria-hidden="true"/>
)} )}
<div className="relative flex space-x-3"> <div className="relative flex space-x-3">
<div> <div>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white"> <span
className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white">
{t.event.templateId ? ( {t.event.templateId ? (
<svg <svg
className={"h-5 w-5"} className={"h-5 w-5"}
@@ -514,17 +565,26 @@ export default function Index() {
> >
{t.event.name.includes("delivered") ? ( {t.event.name.includes("delivered") ? (
<> <>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none"
<rect x="3" y="5" width="18" height="14" rx="2" /> d="M0 0h24v24H0z"
<polyline points="3 7 12 13 21 7" /> fill="none"/>
<rect x="3" y="5" width="18"
height="14" rx="2"/>
<polyline points="3 7 12 13 21 7"/>
</> </>
) : ( ) : (
<> <>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none"
<polyline points="3 9 12 15 21 9 12 3 3 9" /> d="M0 0h24v24H0z"
<path d="M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10" /> fill="none"/>
<line x1="3" y1="19" x2="9" y2="13" /> <polyline
<line x1="15" y1="13" x2="21" y2="19" /> points="3 9 12 15 21 9 12 3 3 9"/>
<path
d="M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10"/>
<line x1="3" y1="19" x2="9"
y2="13"/>
<line x1="15" y1="13" x2="21"
y2="19"/>
</> </>
)} )}
</svg> </svg>
@@ -539,12 +599,14 @@ export default function Index() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
<path d="M13 5h8" /> <path d="M13 5h8"/>
<path d="M13 9h5" /> <path d="M13 9h5"/>
<path d="M13 15h8" /> <path d="M13 15h8"/>
<path d="M13 19h5" /> <path d="M13 19h5"/>
<rect x="3" y="4" width="6" height="6" rx="1" /> <rect x="3" y="4" width="6" height="6"
<rect x="3" y="14" width="6" height="6" rx="1" /> rx="1"/>
<rect x="3" y="14" width="6" height="6"
rx="1"/>
</svg> </svg>
) : ( ) : (
<svg <svg
@@ -557,14 +619,16 @@ export default function Index() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
> >
<path d="M8 9l3 3l-3 3" /> <path d="M8 9l3 3l-3 3"/>
<line x1="13" y1="15" x2="16" y2="15" /> <line x1="13" y1="15" x2="16" y2="15"/>
<rect x="3" y="4" width="18" height="16" rx="2" /> <rect x="3" y="4" width="18" height="16"
rx="2"/>
</svg> </svg>
)} )}
</span> </span>
</div> </div>
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> <div
className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div> <div>
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">
{t.event.templateId || t.event.campaignId {t.event.templateId || t.event.campaignId
@@ -584,8 +648,10 @@ export default function Index() {
: "triggered"} : "triggered"}
</p> </p>
</div> </div>
<div className="whitespace-nowrap text-right text-sm text-neutral-500"> <div
<time dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time> className="whitespace-nowrap text-right text-sm text-neutral-500">
<time
dateTime={dayjs(t.createdAt).format("YYYY-MM-DD")}>{dayjs().to(t.createdAt)}</time>
</div> </div>
</div> </div>
</div> </div>
@@ -597,7 +663,8 @@ export default function Index() {
</ul> </ul>
</div> </div>
) : ( ) : (
<Empty title={"No triggers"} description={"This contact has not yet triggered any events or actions"} /> <Empty title={"No triggers"}
description={"This contact has not yet triggered any events or actions"}/>
)} )}
</Card> </Card>
</Dashboard> </Dashboard>
+20
View File
@@ -3041,6 +3041,7 @@ __metadata:
autoprefixer: "npm:^10.4.19" autoprefixer: "npm:^10.4.19"
classnames: "npm:^2.5.1" classnames: "npm:^2.5.1"
dayjs: "npm:^1.11.12" dayjs: "npm:^1.11.12"
dompurify: "npm:^3.2.6"
framer-motion: "npm:^11.3.7" framer-motion: "npm:^11.3.7"
jotai: "npm:2.9.0" jotai: "npm:2.9.0"
lucide-react: "npm:^0.408.0" lucide-react: "npm:^0.408.0"
@@ -4777,6 +4778,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/trusted-types@npm:^2.0.7":
version: 2.0.7
resolution: "@types/trusted-types@npm:2.0.7"
checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c
languageName: node
linkType: hard
"@types/unist@npm:^2": "@types/unist@npm:^2":
version: 2.0.11 version: 2.0.11
resolution: "@types/unist@npm:2.0.11" resolution: "@types/unist@npm:2.0.11"
@@ -6565,6 +6573,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"dompurify@npm:^3.2.6":
version: 3.2.6
resolution: "dompurify@npm:3.2.6"
dependencies:
"@types/trusted-types": "npm:^2.0.7"
dependenciesMeta:
"@types/trusted-types":
optional: true
checksum: 10c0/c8f8e5b0879a0d93c84a2e5e78649a47d0c057ed0f7850ca3d573d2cca64b84fb1ff85bd4b20980ade69c4e5b80ae73011340f1c2ff375c7ef98bb8268e1d13a
languageName: node
linkType: hard
"domutils@npm:^2.4.2": "domutils@npm:^2.4.2":
version: 2.8.0 version: 2.8.0
resolution: "domutils@npm:2.8.0" resolution: "domutils@npm:2.8.0"