From 71857c7d72fbb30b5754ebff706ebf03c84a6575 Mon Sep 17 00:00:00 2001 From: Rakshat28 Date: Wed, 23 Jul 2025 08:55:13 +0530 Subject: [PATCH 1/2] show past send email fully #187 --- .../dashboard/src/pages/contacts/[id].tsx | 110 ++++++++++-------- 1 file changed, 63 insertions(+), 47 deletions(-) diff --git a/packages/dashboard/src/pages/contacts/[id].tsx b/packages/dashboard/src/pages/contacts/[id].tsx index a6084d4..b739707 100644 --- a/packages/dashboard/src/pages/contacts/[id].tsx +++ b/packages/dashboard/src/pages/contacts/[id].tsx @@ -33,7 +33,7 @@ interface EventValues { */ export default function Index() { const router = useRouter(); - + const [selectedEmail, setSelectedEmail] = useState(null); if (!router.isReady) { return ; } @@ -400,53 +400,69 @@ export default function Index() { {contact.triggers.length > 0 || contact.emails.length > 0 ? (
    - {[...contact.triggers, ...contact.emails] - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .map((t, index) => { - if (t.messageId) { - const email = t as Email; +{[...contact.triggers, ...contact.emails] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map((t, index) => { + if (t.messageId) { + const email = t as Email; + const expanded = selectedEmail?.id === email.id; - return ( -
  • -
    - {contact.triggers.length + contact.emails.length - 1 !== index && ( -
    -
  • - ); - } + return ( +
  • + {contact.triggers.length + contact.emails.length - 1 !== index && ( +
  • + ); + } if (t.action) { return ( From f886ff80e0dc01310fb7ba7a917cb54a539aef65 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Fri, 22 Aug 2025 14:45:44 +0200 Subject: [PATCH 2/2] Sanitise body --- package.json | 2 +- packages/dashboard/package.json | 1 + .../dashboard/src/pages/contacts/[id].tsx | 1197 +++++++++-------- yarn.lock | 20 + 4 files changed, 646 insertions(+), 574 deletions(-) diff --git a/package.json b/package.json index da9f646..7e4dbf6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plunk", - "version": "1.1.0", + "version": "1.3.0", "private": true, "license": "agpl-3.0", "workspaces": { diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index dac9241..9abfbc0 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -31,6 +31,7 @@ "@uiball/loaders": "^1.3.1", "classnames": "^2.5.1", "dayjs": "^1.11.12", + "dompurify": "^3.2.6", "framer-motion": "^11.3.7", "jotai": "2.9.0", "lucide-react": "^0.408.0", diff --git a/packages/dashboard/src/pages/contacts/[id].tsx b/packages/dashboard/src/pages/contacts/[id].tsx index b739707..f5a9f3d 100644 --- a/packages/dashboard/src/pages/contacts/[id].tsx +++ b/packages/dashboard/src/pages/contacts/[id].tsx @@ -1,622 +1,673 @@ // @ts-nocheck // React Hook Form messes up our types, ignore the entire file -import { zodResolver } from "@hookform/resolvers/zod"; -import { ContactSchemas, EventSchemas, type UtilitySchemas } from "@plunk/shared"; -import type { Contact, Email, Template } from "@prisma/client"; +import {zodResolver} from "@hookform/resolvers/zod"; +import {ContactSchemas, EventSchemas, type UtilitySchemas} from "@plunk/shared"; +import type {Contact, Email, Template} from "@prisma/client"; import dayjs from "dayjs"; -import { motion } from "framer-motion"; -import { Save } from "lucide-react"; -import { useRouter } from "next/router"; -import React, { useEffect, useState } from "react"; -import { useFieldArray, useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; -import { Card, Empty, FullscreenLoader, Input, Modal, Toggle } from "../../components"; -import { Dashboard } from "../../layouts"; -import { useContact } from "../../lib/hooks/contacts"; -import { useActiveProject } from "../../lib/hooks/projects"; -import { network } from "../../lib/network"; +import {motion} from "framer-motion"; +import {Save} from "lucide-react"; +import {useRouter} from "next/router"; +import React, {useEffect, useState} from "react"; +import {useFieldArray, useForm} from "react-hook-form"; +import {toast} from "sonner"; +import {z} from "zod"; +import {Badge, Card, Empty, FullscreenLoader, Input, Modal, Toggle} from "../../components"; +import {Dashboard} from "../../layouts"; +import {useContact} from "../../lib/hooks/contacts"; +import {useActiveProject} from "../../lib/hooks/projects"; +import {network} from "../../lib/network"; +import DOMPurify from "dompurify"; interface ContactValues { - email: string; - data: string | null; - subscribed: boolean; + email: string; + data: string | null; + subscribed: boolean; } interface EventValues { - event: string; + event: string; } /** * */ export default function Index() { - const router = useRouter(); - const [selectedEmail, setSelectedEmail] = useState(null); - if (!router.isReady) { - return ; - } + const router = useRouter(); + const [selectedEmail, setSelectedEmail] = useState(null); + if (!router.isReady) { + return ; + } - const [eventModal, setEventModal] = useState(false); + const [eventModal, setEventModal] = useState(false); - const project = useActiveProject(); - const { data: contact, mutate } = useContact({ - id: router.query.id as string, - }); + const project = useActiveProject(); + const {data: contact, mutate} = useContact({ + id: router.query.id as string, + }); - const { handleSubmit, watch, setValue, reset } = useForm({ - resolver: zodResolver(ContactSchemas.manage), - }); + const {handleSubmit, watch, setValue, reset} = useForm({ + resolver: zodResolver(ContactSchemas.manage), + }); - const { - register: dataRegister, - control, - getValues: getDataValues, - reset: dataReset, - } = useForm({ - defaultValues: { - data: Object.entries(JSON.parse(contact?.data ? contact.data : "{}")).map(([key]) => ({ - value: { key }, - })), - }, - resolver: zodResolver( - z.object({ - data: z - .array( - z.object({ - value: z.object({ key: z.string(), value: z.string() }), - }), - ) - .min(0), - }), - ), - }); + const { + register: dataRegister, + control, + getValues: getDataValues, + reset: dataReset, + } = useForm({ + defaultValues: { + data: Object.entries(JSON.parse(contact?.data ? contact.data : "{}")).map(([key]) => ({ + value: {key}, + })), + }, + resolver: zodResolver( + z.object({ + data: z + .array( + z.object({ + value: z.object({key: z.string(), value: z.string()}), + }), + ) + .min(0), + }), + ), + }); - const { fields, append: fieldAppend, remove: fieldRemove } = useFieldArray({ control, name: "data" }); + const {fields, append: fieldAppend, remove: fieldRemove} = useFieldArray({control, name: "data"}); - const { - register: eventRegister, - handleSubmit: eventHandleSubmit, - formState: { errors: eventErrors }, - reset: eventReset, - } = useForm({ - resolver: zodResolver(EventSchemas.post.pick({ event: true })), - }); + const { + register: eventRegister, + handleSubmit: eventHandleSubmit, + formState: {errors: eventErrors}, + reset: eventReset, + } = useForm({ + resolver: zodResolver(EventSchemas.post.pick({event: true})), + }); - useEffect(() => { - if (!contact) { - return; - } + useEffect(() => { + if (!contact) { + return; + } - reset({ - email: contact.email, - subscribed: contact.subscribed, - }); - dataReset({ - data: Object.entries(JSON.parse(contact.data ? contact.data : "{}")).map(([key, value]) => ({ - value: { key, value }, - })), - }); - }, [dataReset, reset, contact]); + reset({ + email: contact.email, + subscribed: contact.subscribed, + }); + dataReset({ + data: Object.entries(JSON.parse(contact.data ? contact.data : "{}")).map(([key, value]) => ({ + value: {key, value}, + })), + }); + }, [dataReset, reset, contact]); - if (!contact) { - return ; - } + if (!contact) { + return ; + } - const create = (data: EventValues) => { - toast.promise( - network.mock(project.secret, "POST", "/v1", { - ...data, - email: contact.email, - }), - { - loading: "Creating new event", - success: () => { - void mutate(); - eventReset(); - return "Created new event"; - }, - error: "Could not create new event!", - }, - ); + const create = (data: EventValues) => { + toast.promise( + network.mock(project.secret, "POST", "/v1", { + ...data, + email: contact.email, + }), + { + loading: "Creating new event", + success: () => { + void mutate(); + eventReset(); + return "Created new event"; + }, + error: "Could not create new event!", + }, + ); - setEventModal(false); - }; + setEventModal(false); + }; - const update = (data: ContactValues) => { - const entries = getDataValues().data.map(({ value }) => [value.key, value.value]); - let dataObject = {}; + const update = (data: ContactValues) => { + const entries = getDataValues().data.map(({value}) => [value.key, value.value]); + let dataObject = {}; - entries.forEach(([key, value]) => { - Object.assign(dataObject, { [key]: value }); - }); + entries.forEach(([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 !== "")); - toast.promise( - network.mock(project.secret, "PUT", "/v1/contacts", { - ...data, - data: dataObject, - }), - { - loading: "Saving your changes", - success: () => { - void mutate(); - return "Saved your changes"; - }, - error: "Could not save your changes!", - }, - ); - }; + toast.promise( + network.mock(project.secret, "PUT", "/v1/contacts", { + ...data, + data: dataObject, + }), + { + loading: "Saving your changes", + success: () => { + void mutate(); + return "Saved your changes"; + }, + error: "Could not save your changes!", + }, + ); + }; - const remove = async (e: { preventDefault: () => void }) => { - e.preventDefault(); - toast.promise( - network.mock(project.secret, "DELETE", "/v1/contacts", { - id: contact.id, - }), - { - loading: "Deleting contact", - success: "Deleted contact", - error: "Could not delete contact!", - }, - ); + const remove = async (e: { preventDefault: () => void }) => { + e.preventDefault(); + toast.promise( + network.mock(project.secret, "DELETE", "/v1/contacts", { + id: contact.id, + }), + { + loading: "Deleting contact", + success: "Deleted contact", + error: "Could not delete contact!", + }, + ); - await router.push("/contacts"); - }; + await router.push("/contacts"); + }; - return ( - <> - setEventModal(!eventModal)} - onAction={eventHandleSubmit(create)} - type={"info"} - action={"Trigger"} - title={"Trigger event"} - description={`Trigger an event for ${contact.email}`} - icon={ - <> - - - - } - > - - - - - - - - } - > -
    -
    - - {contact.email[0].toUpperCase()} + return ( + <> + setEventModal(!eventModal)} + onAction={eventHandleSubmit(create)} + type={"info"} + action={"Trigger"} + title={"Trigger event"} + description={`Trigger an event for ${contact.email}`} + icon={ + <> + + + + } + > + + + + + + + + } + > + +
    + + {contact.email[0].toUpperCase()} -

    - {contact.email[0].toUpperCase()} - {contact.email.slice(1)} -

    -
    +

    + {contact.email[0].toUpperCase()} + {contact.email.slice(1)} +

    +
    -
    -
    - - -
    +
    +
    + + +
    - {fields.length > 0 ? ( - fields.map((field, index) => { - return ( - <> -
    -
    -
    - - -
    - -
    - - -
    - -
    -
    - - ); - }) - ) : ( -

    No fields added

    - )} -
    - -
    - setValue("subscribed", !watch("subscribed"))} - /> -
    - -
    - - - Save - -
    - - - - {contact.triggers.length > 0 || contact.emails.length > 0 ? ( -
    -
      -{[...contact.triggers, ...contact.emails] - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .map((t, index) => { - if (t.messageId) { - const email = t as Email; - const expanded = selectedEmail?.id === email.id; - - return ( -
    • - {contact.triggers.length + contact.emails.length - 1 !== index && ( -
    • -
      - {contact.triggers.length + contact.emails.length - 1 !== index && ( -
    • + ) + } -
      -
      - + if (t.action) { + return ( +
    • +
      + {contact.triggers.length + contact.emails.length - 1 !== index && ( +
      -
    • - ); - } +
      +
      +
      +

      {t.action.name} triggered

      +
      +
      + +
      +
      +
      +
    + + ); + } - if (t.event) { - return ( -
  • -
    - {contact.triggers.length + contact.emails.length - 1 !== index && ( -
  • +
    + {contact.triggers.length + contact.emails.length - 1 !== index && ( +
    -
  • - ); - } - })} -
-
- ) : ( - - )} - - - - ); + +
+
+

+ {t.event.templateId || t.event.campaignId + ? `${t.event.name.charAt(0).toUpperCase()}${t.event.name + .replaceAll("-", " ") + .slice(1) + .replace(/(delivered|opened)$/, "")}` + : t.event.name}{" "} + {t.event.templateId + ? t.event.name.endsWith("delivered") + ? "delivered" + : "opened" + : t.event.campaignId + ? t.event.name.endsWith("delivered") + ? "delivered" + : "opened" + : "triggered"} +

+
+
+ +
+
+ + + + ); + } + })} + + + ) : ( + + )} + + + + ); } diff --git a/yarn.lock b/yarn.lock index 4621824..1dc2cf1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3041,6 +3041,7 @@ __metadata: autoprefixer: "npm:^10.4.19" classnames: "npm:^2.5.1" dayjs: "npm:^1.11.12" + dompurify: "npm:^3.2.6" framer-motion: "npm:^11.3.7" jotai: "npm:2.9.0" lucide-react: "npm:^0.408.0" @@ -4777,6 +4778,13 @@ __metadata: languageName: node 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": version: 2.0.11 resolution: "@types/unist@npm:2.0.11" @@ -6565,6 +6573,18 @@ __metadata: languageName: node 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": version: 2.8.0 resolution: "domutils@npm:2.8.0"