Add unsend campaign feature (#45)
* Add unsend email editor Add email editor Add more email editor Add renderer partial Add more marketing email features * Add more campaign feature * Add variables * Getting there * campaign is there mfs * Add migration
This commit is contained in:
@@ -20,7 +20,6 @@
|
||||
"@auth/prisma-adapter": "^1.4.0",
|
||||
"@aws-sdk/client-sesv2": "^3.535.0",
|
||||
"@aws-sdk/client-sns": "^3.540.0",
|
||||
"@hono/node-server": "^1.9.1",
|
||||
"@hono/swagger-ui": "^0.2.1",
|
||||
"@hono/zod-openapi": "^0.10.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
@@ -32,6 +31,7 @@
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"@unsend/email-editor": "workspace:*",
|
||||
"@unsend/ui": "workspace:*",
|
||||
"bullmq": "^5.8.2",
|
||||
"date-fns": "^3.6.0",
|
||||
@@ -56,6 +56,7 @@
|
||||
"tldts": "^6.1.16",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"unsend": "workspace:*",
|
||||
"use-debounce": "^10.0.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@@ -0,0 +1,81 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CampaignStatus" AS ENUM ('DRAFT', 'SCHEDULED', 'SENT');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Email" ADD COLUMN "campaignId" TEXT,
|
||||
ADD COLUMN "contactId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "SesSetting" ADD COLUMN "transactionalQuota" INTEGER NOT NULL DEFAULT 50;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContactBook" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"properties" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ContactBook_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Contact" (
|
||||
"id" TEXT NOT NULL,
|
||||
"firstName" TEXT,
|
||||
"lastName" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"subscribed" BOOLEAN NOT NULL DEFAULT true,
|
||||
"properties" JSONB NOT NULL,
|
||||
"contactBookId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Contact_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Campaign" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"from" TEXT NOT NULL,
|
||||
"cc" TEXT[],
|
||||
"bcc" TEXT[],
|
||||
"replyTo" TEXT[],
|
||||
"domainId" INTEGER NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"previewText" TEXT,
|
||||
"html" TEXT,
|
||||
"content" TEXT,
|
||||
"contactBookId" TEXT,
|
||||
"total" INTEGER NOT NULL DEFAULT 0,
|
||||
"sent" INTEGER NOT NULL DEFAULT 0,
|
||||
"delivered" INTEGER NOT NULL DEFAULT 0,
|
||||
"opened" INTEGER NOT NULL DEFAULT 0,
|
||||
"clicked" INTEGER NOT NULL DEFAULT 0,
|
||||
"unsubscribed" INTEGER NOT NULL DEFAULT 0,
|
||||
"bounced" INTEGER NOT NULL DEFAULT 0,
|
||||
"complained" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" "CampaignStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Campaign_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContactBook_teamId_idx" ON "ContactBook"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Contact_contactBookId_email_key" ON "Contact"("contactBookId", "email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContactBook" ADD CONSTRAINT "ContactBook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Contact" ADD CONSTRAINT "Contact_contactBookId_fkey" FOREIGN KEY ("contactBookId") REFERENCES "ContactBook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@@ -2,7 +2,7 @@
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["tracing"]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ model SesSetting {
|
||||
idPrefix String @unique
|
||||
topic String
|
||||
topicArn String?
|
||||
transactionalQuota Int @default(50)
|
||||
callbackUrl String
|
||||
callbackSuccess Boolean @default(false)
|
||||
configGeneral String?
|
||||
@@ -90,14 +91,16 @@ model User {
|
||||
}
|
||||
|
||||
model Team {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
teamUsers TeamUser[]
|
||||
domains Domain[]
|
||||
apiKeys ApiKey[]
|
||||
emails Email[]
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
teamUsers TeamUser[]
|
||||
domains Domain[]
|
||||
apiKeys ApiKey[]
|
||||
emails Email[]
|
||||
contactBooks ContactBook[]
|
||||
campaigns Campaign[]
|
||||
}
|
||||
|
||||
enum Role {
|
||||
@@ -193,6 +196,8 @@ model Email {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
attachments String?
|
||||
campaignId String?
|
||||
contactId String?
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
emailEvents EmailEvent[]
|
||||
}
|
||||
@@ -205,3 +210,67 @@ model EmailEvent {
|
||||
createdAt DateTime @default(now())
|
||||
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ContactBook {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
teamId Int
|
||||
properties Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contacts Contact[]
|
||||
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model Contact {
|
||||
id String @id @default(cuid())
|
||||
firstName String?
|
||||
lastName String?
|
||||
email String
|
||||
subscribed Boolean @default(true)
|
||||
properties Json
|
||||
contactBookId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([contactBookId, email])
|
||||
}
|
||||
|
||||
enum CampaignStatus {
|
||||
DRAFT
|
||||
SCHEDULED
|
||||
SENT
|
||||
}
|
||||
|
||||
model Campaign {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
teamId Int
|
||||
from String
|
||||
cc String[]
|
||||
bcc String[]
|
||||
replyTo String[]
|
||||
domainId Int
|
||||
subject String
|
||||
previewText String?
|
||||
html String?
|
||||
content String?
|
||||
contactBookId String?
|
||||
total Int @default(0)
|
||||
sent Int @default(0)
|
||||
delivered Int @default(0)
|
||||
opened Int @default(0)
|
||||
clicked Int @default(0)
|
||||
unsubscribed Int @default(0)
|
||||
bounced Int @default(0)
|
||||
complained Int @default(0)
|
||||
status CampaignStatus @default(DRAFT)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
168
apps/web/src/app/(dashboard)/admin/edit-ses-configuration.tsx
Normal file
168
apps/web/src/app/(dashboard)/admin/edit-ses-configuration.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
|
||||
import { Edit } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Input } from "@unsend/ui/src/input";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { SesSetting } from "@prisma/client";
|
||||
|
||||
const FormSchema = z.object({
|
||||
settingsId: z.string(),
|
||||
sendRate: z.preprocess((val) => Number(val), z.number()),
|
||||
transactionalQuota: z.preprocess(
|
||||
(val) => Number(val),
|
||||
z.number().min(0).max(100)
|
||||
),
|
||||
});
|
||||
|
||||
export default function EditSesConfiguration({
|
||||
setting,
|
||||
}: {
|
||||
setting: SesSetting;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit SES configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<EditSesSettingsForm
|
||||
setting={setting}
|
||||
onSuccess={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type SesSettingsProps = {
|
||||
setting: SesSetting;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
|
||||
setting,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const updateSesSettings = api.admin.updateSesSettings.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
settingsId: setting.id,
|
||||
sendRate: setting.sesEmailRateLimit,
|
||||
transactionalQuota: setting.transactionalQuota,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
updateSesSettings.mutate(data, {
|
||||
onSuccess: () => {
|
||||
utils.admin.invalidate();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error("Failed to update", {
|
||||
description: e.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className=" flex flex-col gap-8 w-full"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendRate"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Send Rate</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="1" className="w-full" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.sendRate ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription>
|
||||
The number of emails to send per second.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="transactionalQuota"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Transactional Quota</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0" className="w-full" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.transactionalQuota ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription>
|
||||
The percentage of the quota to be used for transactional
|
||||
emails (0-100%).
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateSesSettings.isPending}
|
||||
className="w-[200px] mx-auto"
|
||||
>
|
||||
{updateSesSettings.isPending ? (
|
||||
<Spinner className="w-5 h-5" />
|
||||
) : (
|
||||
"Update"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
@@ -11,6 +11,8 @@ import {
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import EditSesConfiguration from "./edit-ses-configuration";
|
||||
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
|
||||
|
||||
export default function SesConfigurations() {
|
||||
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
|
||||
@@ -25,6 +27,9 @@ export default function SesConfigurations() {
|
||||
<TableHead>Callback URL</TableHead>
|
||||
<TableHead>Callback status</TableHead>
|
||||
<TableHead>Created at</TableHead>
|
||||
<TableHead>Send rate</TableHead>
|
||||
<TableHead>Transactional quota</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -47,13 +52,25 @@ export default function SesConfigurations() {
|
||||
sesSettingsQuery.data?.map((sesSetting) => (
|
||||
<TableRow key={sesSetting.id}>
|
||||
<TableCell>{sesSetting.region}</TableCell>
|
||||
<TableCell>{sesSetting.callbackUrl}</TableCell>
|
||||
<TableCell>
|
||||
<div className="w-[200px] overflow-hidden text-ellipsis">
|
||||
<TextWithCopyButton
|
||||
value={sesSetting.callbackUrl}
|
||||
className="w-[200px] overflow-hidden text-ellipsis"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{sesSetting.callbackSuccess ? "Success" : "Failed"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(sesSetting.createdAt)} ago
|
||||
</TableCell>
|
||||
<TableCell>{sesSetting.sesEmailRateLimit}</TableCell>
|
||||
<TableCell>{sesSetting.transactionalQuota}%</TableCell>
|
||||
<TableCell>
|
||||
<EditSesConfiguration setting={sesSetting} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useInterval } from "~/hooks/useInterval";
|
||||
import { Spinner } from "@unsend/ui/src/spinner";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Input } from "@unsend/ui/src/input";
|
||||
import { Editor } from "@unsend/email-editor";
|
||||
import { useState } from "react";
|
||||
import { Campaign } from "@prisma/client";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@unsend/ui/src/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
const sendSchema = z.object({
|
||||
confirmation: z.string(),
|
||||
});
|
||||
|
||||
export default function EditCampaignPage({
|
||||
params,
|
||||
}: {
|
||||
params: { campaignId: string };
|
||||
}) {
|
||||
const {
|
||||
data: campaign,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.campaign.getCampaign.useQuery(
|
||||
{ campaignId: params.campaignId },
|
||||
{
|
||||
enabled: !!params.campaignId,
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Spinner className="w-6 h-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<p className="text-red-500">Failed to load campaign</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return <div>Campaign not found</div>;
|
||||
}
|
||||
|
||||
return <CampaignEditor campaign={campaign} />;
|
||||
}
|
||||
|
||||
function CampaignEditor({ campaign }: { campaign: Campaign }) {
|
||||
const contactBooksQuery = api.contacts.getContactBooks.useQuery();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [json, setJson] = useState<Record<string, any> | undefined>(
|
||||
campaign.content ? JSON.parse(campaign.content) : undefined
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [name, setName] = useState(campaign.name);
|
||||
const [subject, setSubject] = useState(campaign.subject);
|
||||
const [from, setFrom] = useState(campaign.from);
|
||||
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
|
||||
const [openSendDialog, setOpenSendDialog] = useState(false);
|
||||
|
||||
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.campaign.getCampaign.invalidate();
|
||||
setIsSaving(false);
|
||||
},
|
||||
});
|
||||
const sendCampaignMutation = api.campaign.sendCampaign.useMutation();
|
||||
|
||||
const sendForm = useForm<z.infer<typeof sendSchema>>({
|
||||
resolver: zodResolver(sendSchema),
|
||||
});
|
||||
|
||||
function updateEditorContent() {
|
||||
updateCampaignMutation.mutate({
|
||||
campaignId: campaign.id,
|
||||
content: JSON.stringify(json),
|
||||
});
|
||||
}
|
||||
|
||||
const deboucedUpdateCampaign = useDebouncedCallback(
|
||||
updateEditorContent,
|
||||
1000
|
||||
);
|
||||
|
||||
async function onSendCampaign(values: z.infer<typeof sendSchema>) {
|
||||
if (
|
||||
values.confirmation?.toLocaleLowerCase() !== "Send".toLocaleLowerCase()
|
||||
) {
|
||||
sendForm.setError("confirmation", {
|
||||
message: "Please type 'Send' to confirm",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpenSendDialog(false);
|
||||
toast.success(`Campaign sent successfully`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to send campaign: ${error.message}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const confirmation = sendForm.watch("confirmation");
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="w-[600px] mx-auto">
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
|
||||
onBlur={() => {
|
||||
if (name === campaign.name || !name) {
|
||||
return;
|
||||
}
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
name,
|
||||
},
|
||||
{
|
||||
onError: (e) => {
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setName(campaign.name);
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
{isSaving ? (
|
||||
<div className="h-2 w-2 bg-yellow-500 rounded-full" />
|
||||
) : (
|
||||
<div className="h-2 w-2 bg-emerald-500 rounded-full" />
|
||||
)}
|
||||
{formatDistanceToNow(campaign.updatedAt) === "less than a minute"
|
||||
? "just now"
|
||||
: `${formatDistanceToNow(campaign.updatedAt)} ago`}
|
||||
</div>
|
||||
<Dialog open={openSendDialog} onOpenChange={setOpenSendDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default">Send Campaign</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to send this campaign? This action
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...sendForm}>
|
||||
<form
|
||||
onSubmit={sendForm.handleSubmit(onSendCampaign)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={sendForm.control}
|
||||
name="confirmation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type 'Send' to confirm</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
sendCampaignMutation.isPending ||
|
||||
confirmation?.toLocaleLowerCase() !==
|
||||
"Send".toLocaleLowerCase()
|
||||
}
|
||||
>
|
||||
{sendCampaignMutation.isPending
|
||||
? "Sending..."
|
||||
: "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 mt-8">
|
||||
<label className="block text-sm font-medium ">Subject</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => {
|
||||
setSubject(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (subject === campaign.subject || !subject) {
|
||||
return;
|
||||
}
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
subject,
|
||||
},
|
||||
{
|
||||
onError: (e) => {
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setSubject(campaign.subject);
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="mt-1 block w-full rounded-md shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium ">From</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={from}
|
||||
onChange={(e) => {
|
||||
setFrom(e.target.value);
|
||||
}}
|
||||
className="mt-1 block w-full rounded-md shadow-sm"
|
||||
placeholder="Friendly name<hello@example.com>"
|
||||
onBlur={() => {
|
||||
if (from === campaign.from) {
|
||||
return;
|
||||
}
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
from,
|
||||
},
|
||||
{
|
||||
onError: (e) => {
|
||||
toast.error(`${e.message}. Reverting changes.`);
|
||||
setFrom(campaign.from);
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-12">
|
||||
<label className="block text-sm font-medium mb-1">To</label>
|
||||
{contactBooksQuery.isLoading ? (
|
||||
<Spinner className="w-6 h-6" />
|
||||
) : (
|
||||
<Select
|
||||
value={contactBookId ?? ""}
|
||||
onValueChange={(val) => {
|
||||
// Update the campaign's contactBookId
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
contactBookId: val,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setContactBookId(campaign.contactBookId);
|
||||
},
|
||||
}
|
||||
);
|
||||
setContactBookId(val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
{contactBooksQuery.data?.find(
|
||||
(book) => book.id === contactBookId
|
||||
)?.name || "Select a contact book"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contactBooksQuery.data?.map((book) => (
|
||||
<SelectItem key={book.id} value={book.id}>
|
||||
{book.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Editor
|
||||
initialContent={json}
|
||||
onUpdate={(content) => {
|
||||
setJson(content.getJSON());
|
||||
setIsSaving(true);
|
||||
deboucedUpdateCampaign();
|
||||
}}
|
||||
variables={["email", "firstName", "lastName"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
194
apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx
Normal file
194
apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@unsend/ui/src/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import { EmailStatusIcon } from "../../emails/email-status-badge";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { Separator } from "@unsend/ui/src/separator";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
|
||||
export default function CampaignDetailsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { campaignId: string };
|
||||
}) {
|
||||
const { data: campaign, isLoading } = api.campaign.getCampaign.useQuery({
|
||||
campaignId: params.campaignId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<Spinner className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return <div>Campaign not found</div>;
|
||||
}
|
||||
|
||||
const statusCards = [
|
||||
{
|
||||
status: "delivered",
|
||||
count: campaign.delivered,
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
status: "unsubscribed",
|
||||
count: campaign.unsubscribed,
|
||||
percentage: (campaign.unsubscribed / campaign.delivered) * 100,
|
||||
},
|
||||
{
|
||||
status: "clicked",
|
||||
count: campaign.clicked,
|
||||
percentage: (campaign.clicked / campaign.delivered) * 100,
|
||||
},
|
||||
{
|
||||
status: "opened",
|
||||
count: campaign.opened,
|
||||
percentage: (campaign.opened / campaign.delivered) * 100,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/campaigns" className="text-lg">
|
||||
Campaigns
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-lg" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-lg ">
|
||||
{campaign.name}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className=" rounded-lg shadow mt-10">
|
||||
<h2 className="text-xl font-semibold mb-4"> Statistics</h2>
|
||||
<div className="flex gap-4">
|
||||
{statusCards.map((card) => (
|
||||
<div
|
||||
key={card.status}
|
||||
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg p-4 flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{card.status !== "total" ? (
|
||||
<CampaignStatusBadge status={card.status} />
|
||||
) : null}
|
||||
<div className="capitalize">{card.status.toLowerCase()}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="text-primary font-light text-2xl font-mono">
|
||||
{card.count}
|
||||
</div>
|
||||
{card.status !== "total" ? (
|
||||
<div className="text-sm pb-1">
|
||||
{card.percentage.toFixed(1)}%
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{campaign.html && (
|
||||
<div className=" rounded-lg shadow mt-16">
|
||||
<h2 className="text-xl font-semibold mb-4">Email</h2>
|
||||
|
||||
<div className="p-2 rounded-lg border flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className="w-[65px] text-muted-foreground ">From</span>
|
||||
<span>{campaign.from}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
<span className="w-[65px] text-muted-foreground ">To</span>
|
||||
{campaign.contactBookId ? (
|
||||
<Link
|
||||
href={`/contacts/${campaign.contactBookId}`}
|
||||
className="text-primary px-4 p-1 bg-muted text-sm rounded-md flex gap-1 items-center"
|
||||
target="_blank"
|
||||
>
|
||||
{campaign.contactBook?.name}
|
||||
<ExternalLinkIcon className="w-4 h-4 " />
|
||||
</Link>
|
||||
) : (
|
||||
<div>No one</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
<span className="w-[65px] text-muted-foreground ">Subject</span>
|
||||
<span>{campaign.subject}</span>
|
||||
</div>
|
||||
<div className=" dark:bg-slate-50 overflow-auto text-black rounded py-8">
|
||||
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CampaignStatusBadge: React.FC<{ status: string }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let outsideColor = "bg-gray-600";
|
||||
let insideColor = "bg-gray-600/50";
|
||||
|
||||
switch (status) {
|
||||
case "delivered":
|
||||
outsideColor = "bg-emerald-500/30";
|
||||
insideColor = "bg-emerald-500";
|
||||
break;
|
||||
case "bounced":
|
||||
case "unsubscribed":
|
||||
outsideColor = "bg-red-500/30";
|
||||
insideColor = "bg-red-500";
|
||||
break;
|
||||
case "clicked":
|
||||
outsideColor = "bg-cyan-500/30";
|
||||
insideColor = "bg-cyan-500";
|
||||
break;
|
||||
case "opened":
|
||||
outsideColor = "bg-indigo-500/30";
|
||||
insideColor = "bg-indigo-500";
|
||||
break;
|
||||
|
||||
case "complained":
|
||||
outsideColor = "bg-yellow-500/30";
|
||||
insideColor = "bg-yellow-500";
|
||||
break;
|
||||
default:
|
||||
outsideColor = "bg-gray-600/40";
|
||||
insideColor = "bg-gray-600";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
|
||||
>
|
||||
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
150
apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx
Normal file
150
apps/web/src/app/(dashboard)/campaigns/campaign-list.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@unsend/ui/src/table";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@unsend/ui/src/select";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { CampaignStatus } from "@prisma/client";
|
||||
import DeleteCampaign from "./delete-campaign";
|
||||
import { Edit2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import DuplicateCampaign from "./duplicate-campaign";
|
||||
|
||||
export default function CampaignList() {
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [status, setStatus] = useUrlState("status");
|
||||
|
||||
const pageNumber = Number(page);
|
||||
|
||||
const campaignsQuery = api.campaign.getCampaigns.useQuery({
|
||||
page: pageNumber,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
{/* <Select
|
||||
value={status ?? "All"}
|
||||
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status || "All statuses"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className=" capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
<SelectItem value="Active" className=" capitalize">
|
||||
Active
|
||||
</SelectItem>
|
||||
<SelectItem value="Inactive" className=" capitalize">
|
||||
Inactive
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select> */}
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border border-border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="">Created At</TableHead>
|
||||
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{campaignsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : campaignsQuery.data?.campaigns.length ? (
|
||||
campaignsQuery.data?.campaigns.map((campaign) => (
|
||||
<TableRow key={campaign.id} className="">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
|
||||
href={
|
||||
campaign.status === CampaignStatus.DRAFT
|
||||
? `/campaigns/${campaign.id}/edit`
|
||||
: `/campaigns/${campaign.id}`
|
||||
}
|
||||
>
|
||||
{campaign.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||
campaign.status === CampaignStatus.DRAFT
|
||||
? "bg-gray-500/10 text-gray-500 border-gray-600/10"
|
||||
: campaign.status === CampaignStatus.SENT
|
||||
? "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"
|
||||
: "bg-yellow-500/10 text-yellow-600 border-yellow-600/10"
|
||||
}`}
|
||||
>
|
||||
{campaign.status.toLowerCase()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{formatDistanceToNow(new Date(campaign.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<DuplicateCampaign campaign={campaign} />
|
||||
<DeleteCampaign campaign={campaign} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
No campaigns found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
disabled={pageNumber === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber + 1).toString())}
|
||||
disabled={pageNumber >= (campaignsQuery.data?.totalPage ?? 0)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
166
apps/web/src/app/(dashboard)/campaigns/create-campaign.tsx
Normal file
166
apps/web/src/app/(dashboard)/campaigns/create-campaign.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
|
||||
const campaignSchema = z.object({
|
||||
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
from: z.string({ required_error: "From email is required" }).min(1, {
|
||||
message: "From email is required",
|
||||
}),
|
||||
subject: z.string({ required_error: "Subject is required" }).min(1, {
|
||||
message: "Subject is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function CreateCampaign() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const createCampaignMutation = api.campaign.createCampaign.useMutation();
|
||||
|
||||
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
|
||||
resolver: zodResolver(campaignSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
from: "",
|
||||
subject: "",
|
||||
},
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
async function onCampaignCreate(values: z.infer<typeof campaignSchema>) {
|
||||
createCampaignMutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
from: values.from,
|
||||
subject: values.subject,
|
||||
},
|
||||
{
|
||||
onSuccess: async (data) => {
|
||||
utils.campaign.getCampaigns.invalidate();
|
||||
router.push(`/campaigns/${data.id}/edit`);
|
||||
toast.success("Campaign created successfully");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create new campaign</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...campaignForm}>
|
||||
<form
|
||||
onSubmit={campaignForm.handleSubmit(onCampaignCreate)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={campaignForm.control}
|
||||
name="name"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Campaign Name" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.name ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={campaignForm.control}
|
||||
name="from"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Friendly Name <from@example.com>"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{formState.errors.from ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={campaignForm.control}
|
||||
name="subject"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subject</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Campaign Subject" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.subject ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Don't worry, you can change it later.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px]"
|
||||
type="submit"
|
||||
disabled={createCampaignMutation.isPending}
|
||||
>
|
||||
{createCampaignMutation.isPending ? (
|
||||
<Spinner className="w-4 h-4" />
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
134
apps/web/src/app/(dashboard)/campaigns/delete-campaign.tsx
Normal file
134
apps/web/src/app/(dashboard)/campaigns/delete-campaign.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"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 { Campaign } from "@prisma/client";
|
||||
|
||||
const campaignSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const DeleteCampaign: React.FC<{
|
||||
campaign: Partial<Campaign> & { id: string };
|
||||
}> = ({ campaign }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteCampaignMutation = api.campaign.deleteCampaign.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
|
||||
resolver: zodResolver(campaignSchema),
|
||||
});
|
||||
|
||||
async function onCampaignDelete(values: z.infer<typeof campaignSchema>) {
|
||||
if (values.name !== campaign.name) {
|
||||
campaignForm.setError("name", {
|
||||
message: "Name does not match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
deleteCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.campaign.getCampaigns.invalidate();
|
||||
setOpen(false);
|
||||
toast.success(`Campaign deleted`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const name = campaignForm.watch("name");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{campaign.name}</span>?
|
||||
You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...campaignForm}>
|
||||
<form
|
||||
onSubmit={campaignForm.handleSubmit(onCampaignDelete)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={campaignForm.control}
|
||||
name="name"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
deleteCampaignMutation.isPending || campaign.name !== name
|
||||
}
|
||||
>
|
||||
{deleteCampaignMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCampaign;
|
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
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 { Copy } from "lucide-react";
|
||||
import { Campaign } from "@prisma/client";
|
||||
|
||||
export const DuplicateCampaign: React.FC<{
|
||||
campaign: Partial<Campaign> & { id: string };
|
||||
}> = ({ campaign }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const duplicateCampaignMutation =
|
||||
api.campaign.duplicateCampaign.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
async function onCampaignDuplicate() {
|
||||
duplicateCampaignMutation.mutate(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.campaign.getCampaigns.invalidate();
|
||||
setOpen(false);
|
||||
toast.success(`Campaign duplicated`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Copy className="h-4 w-4 text-blue-600/80" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to duplicate{" "}
|
||||
<span className="font-semibold text-primary">{campaign.name}</span>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={onCampaignDuplicate}
|
||||
variant="default"
|
||||
disabled={duplicateCampaignMutation.isPending}
|
||||
>
|
||||
{duplicateCampaignMutation.isPending
|
||||
? "Duplicating..."
|
||||
: "Duplicate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DuplicateCampaign;
|
16
apps/web/src/app/(dashboard)/campaigns/page.tsx
Normal file
16
apps/web/src/app/(dashboard)/campaigns/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import CampaignList from "./campaign-list";
|
||||
import CreateCampaign from "./create-campaign";
|
||||
|
||||
export default function ContactsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">Campaigns</h1>
|
||||
<CreateCampaign />
|
||||
</div>
|
||||
<CampaignList />
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Textarea } from "@unsend/ui/src/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
|
||||
const contactsSchema = z.object({
|
||||
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
|
||||
message: "Contacts are required",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function AddContact({
|
||||
contactBookId,
|
||||
}: {
|
||||
contactBookId: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const addContactsMutation = api.contacts.addContacts.useMutation();
|
||||
|
||||
const contactsForm = useForm<z.infer<typeof contactsSchema>>({
|
||||
resolver: zodResolver(contactsSchema),
|
||||
defaultValues: {
|
||||
contacts: "",
|
||||
},
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
|
||||
const contactsArray = values.contacts.split(",").map((email) => ({
|
||||
email: email.trim(),
|
||||
}));
|
||||
|
||||
addContactsMutation.mutate(
|
||||
{
|
||||
contactBookId,
|
||||
contacts: contactsArray,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
utils.contacts.contacts.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Contacts added successfully");
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Contacts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add new contacts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...contactsForm}>
|
||||
<form
|
||||
onSubmit={contactsForm.handleSubmit(onContactsAdd)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={contactsForm.control}
|
||||
name="contacts"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contacts</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="email1@example.com, email2@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{formState.errors.contacts ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription>
|
||||
Enter comma-separated email addresses.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
type="submit"
|
||||
disabled={addContactsMutation.isPending}
|
||||
>
|
||||
{addContactsMutation.isPending ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@unsend/ui/src/table";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@unsend/ui/src/select";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import DeleteContact from "./delete-contact";
|
||||
import EditContact from "./edit-contact";
|
||||
|
||||
export default function ContactList({
|
||||
contactBookId,
|
||||
}: {
|
||||
contactBookId: string;
|
||||
}) {
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [status, setStatus] = useUrlState("status");
|
||||
|
||||
const pageNumber = Number(page);
|
||||
|
||||
const contactsQuery = api.contacts.contacts.useQuery({
|
||||
contactBookId,
|
||||
page: pageNumber,
|
||||
subscribed:
|
||||
status === "Subscribed"
|
||||
? true
|
||||
: status === "Unsubscribed"
|
||||
? false
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Select
|
||||
value={status ?? "All"}
|
||||
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status || "All statuses"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All" className=" capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
<SelectItem value="Subscribed" className=" capitalize">
|
||||
Subscribed
|
||||
</SelectItem>
|
||||
<SelectItem value="Unsubscribed" className=" capitalize">
|
||||
Unsubscribed
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border border-broder shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="">Created At</TableHead>
|
||||
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{contactsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : contactsQuery.data?.contacts.length ? (
|
||||
contactsQuery.data?.contacts.map((contact) => (
|
||||
<TableRow key={contact.id} className="">
|
||||
<TableCell className="font-medium">{contact.email}</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
||||
contact.subscribed
|
||||
? "bg-emerald-500/10 text-emerald-500 border-emerald-600/10"
|
||||
: "bg-red-500/10 text-red-600 border-red-600/10"
|
||||
}`}
|
||||
>
|
||||
{contact.subscribed ? "Subscribed" : "Unsubscribed"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{formatDistanceToNow(new Date(contact.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<EditContact contact={contact} />
|
||||
<DeleteContact contact={contact} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center py-4">
|
||||
No contacts found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
disabled={pageNumber === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber + 1).toString())}
|
||||
disabled={pageNumber >= (contactsQuery.data?.totalPage ?? 0)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -0,0 +1,138 @@
|
||||
"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 { Contact } from "@prisma/client";
|
||||
|
||||
const contactSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const DeleteContact: React.FC<{
|
||||
contact: Partial<Contact> & { id: string; contactBookId: string };
|
||||
}> = ({ contact }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteContactMutation = api.contacts.deleteContact.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const contactForm = useForm<z.infer<typeof contactSchema>>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
});
|
||||
|
||||
async function onContactDelete(values: z.infer<typeof contactSchema>) {
|
||||
if (values.email !== contact.email) {
|
||||
contactForm.setError("email", {
|
||||
message: "Email does not match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
deleteContactMutation.mutate(
|
||||
{
|
||||
contactId: contact.id,
|
||||
contactBookId: contact.contactBookId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.contacts.contacts.invalidate();
|
||||
setOpen(false);
|
||||
toast.success(`Contact deleted`);
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(`Contact not deleted: ${e.message}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const email = contactForm.watch("email");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Contact</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{contact.email}</span>?
|
||||
You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...contactForm}>
|
||||
<form
|
||||
onSubmit={contactForm.handleSubmit(onContactDelete)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={contactForm.control}
|
||||
name="email"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.email ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
deleteContactMutation.isPending || contact.email !== email
|
||||
}
|
||||
>
|
||||
{deleteContactMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteContact;
|
@@ -0,0 +1,171 @@
|
||||
"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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Edit } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { Switch } from "@unsend/ui/src/switch";
|
||||
import { Contact } from "@prisma/client";
|
||||
|
||||
const contactSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const EditContact: React.FC<{
|
||||
contact: Partial<Contact> & { id: string; contactBookId: string };
|
||||
}> = ({ contact }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateContactMutation = api.contacts.updateContact.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
|
||||
const contactForm = useForm<z.infer<typeof contactSchema>>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
defaultValues: {
|
||||
email: contact.email || "",
|
||||
firstName: contact.firstName || "",
|
||||
lastName: contact.lastName || "",
|
||||
subscribed: contact.subscribed || false,
|
||||
},
|
||||
});
|
||||
|
||||
async function onContactUpdate(values: z.infer<typeof contactSchema>) {
|
||||
updateContactMutation.mutate(
|
||||
{
|
||||
contactId: contact.id,
|
||||
contactBookId: contact.contactBookId,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
utils.contacts.contacts.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Contact updated successfully");
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Contact</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...contactForm}>
|
||||
<form
|
||||
onSubmit={contactForm.handleSubmit(onContactUpdate)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={contactForm.control}
|
||||
name="email"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="email@example.com" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.email ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={contactForm.control}
|
||||
name="firstName"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="First Name" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={contactForm.control}
|
||||
name="lastName"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Last Name" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={contactForm.control}
|
||||
name="subscribed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="fle flex-row gap-2">
|
||||
<div>
|
||||
<FormLabel>Subscribed</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
type="submit"
|
||||
disabled={updateContactMutation.isPending}
|
||||
>
|
||||
{updateContactMutation.isPending ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditContact;
|
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@unsend/ui/src/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import AddContact from "./add-contact";
|
||||
import ContactList from "./contact-list";
|
||||
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
export default function ContactsPage({
|
||||
params,
|
||||
}: {
|
||||
params: { contactBookId: string };
|
||||
}) {
|
||||
const contactBookDetailQuery = api.contacts.getContactBookDetails.useQuery({
|
||||
contactBookId: params.contactBookId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/contacts" className="text-lg">
|
||||
Contact books
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-lg" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-lg ">
|
||||
{contactBookDetailQuery.data?.name}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<AddContact contactBookId={params.contactBookId} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className=" text-muted-foreground">Total Contacts</div>
|
||||
<div className="text-xl mt-3">
|
||||
{contactBookDetailQuery.data?.totalContacts !== undefined
|
||||
? contactBookDetailQuery.data?.totalContacts
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Unsubscribed</div>
|
||||
<div className="text-xl mt-3">
|
||||
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
|
||||
? contactBookDetailQuery.data?.unsubscribedContacts
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Created at</div>
|
||||
<div className="text-xl mt-3">
|
||||
{contactBookDetailQuery.data?.createdAt
|
||||
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Contact book id</div>
|
||||
<div className="border mt-3 px-3 rounded bg-muted/30 ">
|
||||
<TextWithCopyButton value={params.contactBookId} alwaysShowCopy />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
<ContactList contactBookId={params.contactBookId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
124
apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx
Normal file
124
apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"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 { toast } from "@unsend/ui/src/toaster";
|
||||
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";
|
||||
|
||||
const contactBookSchema = z.object({
|
||||
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function AddContactBook() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const createContactBookMutation =
|
||||
api.contacts.createContactBook.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||
resolver: zodResolver(contactBookSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave(values: z.infer<typeof contactBookSchema>) {
|
||||
createContactBookMutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.contacts.getContactBooks.invalidate();
|
||||
contactBookForm.reset();
|
||||
setOpen(false);
|
||||
toast.success("Contact book created successfully");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Contact Book
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new contact book</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...contactBookForm}>
|
||||
<form
|
||||
onSubmit={contactBookForm.handleSubmit(handleSave)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={contactBookForm.control}
|
||||
name="name"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contact book name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My contacts" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription>
|
||||
eg: product / website / newsletter name
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
type="submit"
|
||||
disabled={createContactBookMutation.isPending}
|
||||
>
|
||||
{createContactBookMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
79
apps/web/src/app/(dashboard)/contacts/contact-books-list.tsx
Normal file
79
apps/web/src/app/(dashboard)/contacts/contact-books-list.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@unsend/ui/src/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import DeleteContactBook from "./delete-contact-book";
|
||||
import Link from "next/link";
|
||||
import EditContactBook from "./edit-contact-book";
|
||||
|
||||
export default function ContactBooksList() {
|
||||
const contactBooksQuery = api.contacts.getContactBooks.useQuery();
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="border rounded-xl">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||
<TableHead>Contacts</TableHead>
|
||||
<TableHead>Created at</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{contactBooksQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : contactBooksQuery.data?.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<p>No contact books added</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
contactBooksQuery.data?.map((contactBook) => (
|
||||
<TableRow>
|
||||
<TableHead scope="row">
|
||||
<Link
|
||||
href={`/contacts/${contactBook.id}`}
|
||||
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary"
|
||||
>
|
||||
{contactBook.name}
|
||||
</Link>
|
||||
</TableHead>
|
||||
{/* <TableCell>{contactBook.name}</TableCell> */}
|
||||
<TableCell>{contactBook._count.contacts}</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(contactBook.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EditContactBook contactBook={contactBook} />
|
||||
<DeleteContactBook contactBook={contactBook} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
142
apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx
Normal file
142
apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"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 { ContactBook } from "@prisma/client";
|
||||
|
||||
const contactBookSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const DeleteContactBook: React.FC<{
|
||||
contactBook: Partial<ContactBook> & { id: string };
|
||||
}> = ({ contactBook }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteContactBookMutation =
|
||||
api.contacts.deleteContactBook.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||
resolver: zodResolver(contactBookSchema),
|
||||
});
|
||||
|
||||
async function onContactBookDelete(
|
||||
values: z.infer<typeof contactBookSchema>
|
||||
) {
|
||||
if (values.name !== contactBook.name) {
|
||||
contactBookForm.setError("name", {
|
||||
message: "Name does not match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
deleteContactBookMutation.mutate(
|
||||
{
|
||||
contactBookId: contactBook.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.contacts.getContactBooks.invalidate();
|
||||
setOpen(false);
|
||||
toast.success(`Contact book deleted`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const name = contactBookForm.watch("name");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Contact Book</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">
|
||||
{contactBook.name}
|
||||
</span>
|
||||
? You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...contactBookForm}>
|
||||
<form
|
||||
onSubmit={contactBookForm.handleSubmit(onContactBookDelete)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={contactBookForm.control}
|
||||
name="name"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
deleteContactBookMutation.isPending ||
|
||||
contactBook.name !== name
|
||||
}
|
||||
>
|
||||
{deleteContactBookMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteContactBook;
|
122
apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx
Normal file
122
apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { Edit } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
|
||||
const contactBookSchema = z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
});
|
||||
|
||||
export const EditContactBook: React.FC<{
|
||||
contactBook: { id: string; name: string };
|
||||
}> = ({ contactBook }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateContactBookMutation =
|
||||
api.contacts.updateContactBook.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
|
||||
resolver: zodResolver(contactBookSchema),
|
||||
defaultValues: {
|
||||
name: contactBook.name || "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onContactBookUpdate(
|
||||
values: z.infer<typeof contactBookSchema>
|
||||
) {
|
||||
updateContactBookMutation.mutate(
|
||||
{
|
||||
contactBookId: contactBook.id,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
utils.contacts.getContactBooks.invalidate();
|
||||
setOpen(false);
|
||||
toast.success("Contact book updated successfully");
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Contact Book</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...contactBookForm}>
|
||||
<form
|
||||
onSubmit={contactBookForm.handleSubmit(onContactBookUpdate)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={contactBookForm.control}
|
||||
name="name"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Contact Book Name" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.name ? <FormMessage /> : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
type="submit"
|
||||
disabled={updateContactBookMutation.isPending}
|
||||
>
|
||||
{updateContactBookMutation.isPending
|
||||
? "Updating..."
|
||||
: "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditContactBook;
|
16
apps/web/src/app/(dashboard)/contacts/page.tsx
Normal file
16
apps/web/src/app/(dashboard)/contacts/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import AddContactBook from "./add-contact-book";
|
||||
import ContactBooksList from "./contact-books-list";
|
||||
|
||||
export default function ContactsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">Contact books</h1>
|
||||
<AddContactBook />
|
||||
</div>
|
||||
<ContactBooksList />
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -66,14 +66,14 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
Domains
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/contacts" comingSoon>
|
||||
<NavButton href="/contacts">
|
||||
<BookUser className="h-4 w-4" />
|
||||
Contacts
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/contacts" comingSoon>
|
||||
<NavButton href="/campaigns">
|
||||
<Volume2 className="h-4 w-4" />
|
||||
Marketing
|
||||
Campaigns
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/api-keys">
|
||||
@@ -104,7 +104,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
||||
<header className=" h-14 items-center gap-4 hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
|
50
apps/web/src/app/api/to-html/route.ts
Normal file
50
apps/web/src/app/api/to-html/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const data = await req.json();
|
||||
|
||||
try {
|
||||
const renderer = new EmailRenderer(data);
|
||||
const time = Date.now();
|
||||
const html = await renderer.render({
|
||||
shouldReplaceVariableValues: true,
|
||||
linkValues: {
|
||||
"{{unsend_unsubscribe_url}}": "https://unsend.com/unsubscribe",
|
||||
},
|
||||
});
|
||||
console.log(`Time taken: ${Date.now() - time}ms`);
|
||||
return new Response(JSON.stringify({ data: html }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return new Response(
|
||||
JSON.stringify({ data: "Error in converting to html" }),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function OPTIONS() {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
});
|
||||
}
|
@@ -25,7 +25,7 @@ export default async function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`font-sans ${inter.variable}`}>
|
||||
<body className={`font-sans ${inter.variable} app`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<Toaster />
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
|
51
apps/web/src/app/unsubscribe/page.tsx
Normal file
51
apps/web/src/app/unsubscribe/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
unsubscribeContact,
|
||||
subscribeContact,
|
||||
} from "~/server/service/campaign-service";
|
||||
import ReSubscribe from "./re-subscribe";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function UnsubscribePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const id = searchParams.id as string;
|
||||
const hash = searchParams.hash as string;
|
||||
|
||||
if (!id || !hash) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="max-w-md w-full space-y-8 p-10 shadow rounded-xl">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold ">
|
||||
Unsubscribe
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Invalid unsubscribe link. Please check your URL and try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const contact = await unsubscribeContact(id, hash);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center ">
|
||||
<ReSubscribe id={id} hash={hash} contact={contact} />
|
||||
|
||||
<div className=" fixed bottom-10 p-4">
|
||||
<p>
|
||||
Powered by{" "}
|
||||
<a href="https://unsend.dev" className="font-bold" target="_blank">
|
||||
Unsend
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnsubscribePage;
|
60
apps/web/src/app/unsubscribe/re-subscribe.tsx
Normal file
60
apps/web/src/app/unsubscribe/re-subscribe.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Contact } from "@prisma/client";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export default function ReSubscribe({
|
||||
id,
|
||||
hash,
|
||||
contact,
|
||||
}: {
|
||||
id: string;
|
||||
hash: string;
|
||||
contact: Contact;
|
||||
}) {
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
|
||||
const reSubscribe = api.campaign.reSubscribeContact.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("You have been subscribed again");
|
||||
setSubscribed(true);
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-xl w-full space-y-8 p-10 border shadow rounded-xl">
|
||||
<h2 className=" text-center text-xl font-extrabold ">
|
||||
{subscribed ? "You have subscribed again" : "You have unsubscribed"}
|
||||
</h2>
|
||||
<div>
|
||||
{subscribed
|
||||
? "You have been added to our mailing list and will receive all emails at"
|
||||
: "You have been removed from our mailing list and won't receive any emails at"}{" "}
|
||||
<span className="font-bold">{contact.email}</span>.
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{!subscribed ? (
|
||||
<Button
|
||||
className="mx-auto w-[150px]"
|
||||
onClick={() => reSubscribe.mutate({ id, hash })}
|
||||
disabled={reSubscribe.isPending}
|
||||
>
|
||||
{reSubscribe.isPending ? (
|
||||
<Spinner className="w-4 h-4" />
|
||||
) : (
|
||||
"Subscribe Again"
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -22,6 +22,7 @@ const FormSchema = z.object({
|
||||
region: z.string(),
|
||||
unsendUrl: z.string().url(),
|
||||
sendRate: z.number(),
|
||||
transactionalQuota: z.number().min(0).max(100),
|
||||
});
|
||||
|
||||
type SesSettingsProps = {
|
||||
@@ -56,6 +57,7 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
|
||||
region: "",
|
||||
unsendUrl: "",
|
||||
sendRate: 1,
|
||||
transactionalQuota: 50,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -167,6 +169,26 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="transactionalQuota"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Transactional Quota</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0" className="w-full" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.transactionalQuota ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription>
|
||||
The percentage of the quota to be used for transactional
|
||||
emails (0-100%).
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={addSesSettings.isPending}
|
||||
|
25
apps/web/src/hooks/useInterval.ts
Normal file
25
apps/web/src/hooks/useInterval.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useInterval(callback: () => void, delay: number | null) {
|
||||
const savedCallback = useRef<() => void>();
|
||||
|
||||
// Remember the latest callback.
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Set up the interval.
|
||||
useEffect(() => {
|
||||
function tick() {
|
||||
if (savedCallback.current) {
|
||||
savedCallback.current();
|
||||
}
|
||||
}
|
||||
if (delay !== null) {
|
||||
const id = setInterval(tick, delay);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
export default useInterval;
|
@@ -4,6 +4,8 @@ import { apiRouter } from "./routers/api";
|
||||
import { emailRouter } from "./routers/email";
|
||||
import { teamRouter } from "./routers/team";
|
||||
import { adminRouter } from "./routers/admin";
|
||||
import { contactsRouter } from "./routers/contacts";
|
||||
import { campaignRouter } from "./routers/campaign";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -16,6 +18,8 @@ export const appRouter = createTRPCRouter({
|
||||
email: emailRouter,
|
||||
team: teamRouter,
|
||||
admin: adminRouter,
|
||||
contacts: contactsRouter,
|
||||
campaign: campaignRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
@@ -26,12 +26,32 @@ export const adminRouter = createTRPCRouter({
|
||||
z.object({
|
||||
region: z.string(),
|
||||
unsendUrl: z.string().url(),
|
||||
sendRate: z.number(),
|
||||
transactionalQuota: z.number(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return SesSettingsService.createSesSetting({
|
||||
region: input.region,
|
||||
unsendUrl: input.unsendUrl,
|
||||
sendingRateLimit: input.sendRate,
|
||||
transactionalQuota: input.transactionalQuota,
|
||||
});
|
||||
}),
|
||||
|
||||
updateSesSettings: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
settingsId: z.string(),
|
||||
sendRate: z.number(),
|
||||
transactionalQuota: z.number(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return SesSettingsService.updateSesSetting({
|
||||
id: input.settingsId,
|
||||
sendingRateLimit: input.sendRate,
|
||||
transactionalQuota: input.transactionalQuota,
|
||||
});
|
||||
}),
|
||||
|
||||
|
183
apps/web/src/server/api/routers/campaign.ts
Normal file
183
apps/web/src/server/api/routers/campaign.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
teamProcedure,
|
||||
createTRPCRouter,
|
||||
campaignProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import {
|
||||
sendCampaign,
|
||||
subscribeContact,
|
||||
} from "~/server/service/campaign-service";
|
||||
import { validateDomainFromEmail } from "~/server/service/domain-service";
|
||||
|
||||
export const campaignRouter = createTRPCRouter({
|
||||
getCampaigns: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx: { db, team }, input }) => {
|
||||
const page = input.page || 1;
|
||||
const limit = 30;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const whereConditions: Prisma.CampaignFindManyArgs["where"] = {
|
||||
teamId: team.id,
|
||||
};
|
||||
|
||||
const countP = db.campaign.count({ where: whereConditions });
|
||||
|
||||
const campaignsP = db.campaign.findMany({
|
||||
where: whereConditions,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
from: true,
|
||||
subject: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const [campaigns, count] = await Promise.all([campaignsP, countP]);
|
||||
|
||||
return { campaigns, totalPage: Math.ceil(count / limit) };
|
||||
}),
|
||||
|
||||
createCampaign: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
from: z.string(),
|
||||
subject: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx: { db, team }, input }) => {
|
||||
const domain = await validateDomainFromEmail(input.from, team.id);
|
||||
|
||||
const campaign = await db.campaign.create({
|
||||
data: {
|
||||
...input,
|
||||
teamId: team.id,
|
||||
domainId: domain.id,
|
||||
},
|
||||
});
|
||||
|
||||
return campaign;
|
||||
}),
|
||||
|
||||
updateCampaign: campaignProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
previewText: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
contactBookId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
|
||||
const { campaignId, ...data } = input;
|
||||
if (data.contactBookId) {
|
||||
const contactBook = await db.contactBook.findUnique({
|
||||
where: { id: data.contactBookId },
|
||||
});
|
||||
|
||||
if (!contactBook) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Contact book not found",
|
||||
});
|
||||
}
|
||||
}
|
||||
let domainId = campaignOld.domainId;
|
||||
if (data.from) {
|
||||
const domain = await validateDomainFromEmail(data.from, team.id);
|
||||
domainId = domain.id;
|
||||
}
|
||||
const campaign = await db.campaign.update({
|
||||
where: { id: campaignId },
|
||||
data: {
|
||||
...data,
|
||||
domainId,
|
||||
},
|
||||
});
|
||||
return campaign;
|
||||
}),
|
||||
|
||||
deleteCampaign: campaignProcedure.mutation(
|
||||
async ({ ctx: { db, team }, input }) => {
|
||||
const campaign = await db.campaign.delete({
|
||||
where: { id: input.campaignId, teamId: team.id },
|
||||
});
|
||||
return campaign;
|
||||
}
|
||||
),
|
||||
|
||||
getCampaign: campaignProcedure.query(async ({ ctx: { db, team }, input }) => {
|
||||
const campaign = await db.campaign.findUnique({
|
||||
where: { id: input.campaignId, teamId: team.id },
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Campaign not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (campaign?.contactBookId) {
|
||||
const contactBook = await db.contactBook.findUnique({
|
||||
where: { id: campaign.contactBookId },
|
||||
});
|
||||
return { ...campaign, contactBook };
|
||||
}
|
||||
return { ...campaign, contactBook: null };
|
||||
}),
|
||||
|
||||
sendCampaign: campaignProcedure.mutation(
|
||||
async ({ ctx: { db, team }, input }) => {
|
||||
await sendCampaign(input.campaignId);
|
||||
}
|
||||
),
|
||||
|
||||
reSubscribeContact: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
hash: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx: { db }, input }) => {
|
||||
await subscribeContact(input.id, input.hash);
|
||||
}),
|
||||
|
||||
duplicateCampaign: campaignProcedure.mutation(
|
||||
async ({ ctx: { db, team, campaign }, input }) => {
|
||||
const newCampaign = await db.campaign.create({
|
||||
data: {
|
||||
name: `${campaign.name} (Copy)`,
|
||||
from: campaign.from,
|
||||
subject: campaign.subject,
|
||||
content: campaign.content,
|
||||
teamId: team.id,
|
||||
domainId: campaign.domainId,
|
||||
contactBookId: campaign.contactBookId,
|
||||
},
|
||||
});
|
||||
|
||||
return newCampaign;
|
||||
}
|
||||
),
|
||||
});
|
168
apps/web/src/server/api/routers/contacts.ts
Normal file
168
apps/web/src/server/api/routers/contacts.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
contactBookProcedure,
|
||||
createTRPCRouter,
|
||||
teamProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import * as contactService from "~/server/service/contact-service";
|
||||
|
||||
export const contactsRouter = createTRPCRouter({
|
||||
getContactBooks: teamProcedure.query(async ({ ctx: { db, team } }) => {
|
||||
return db.contactBook.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { contacts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
createContactBook: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx: { db, team }, input }) => {
|
||||
const { name } = input;
|
||||
const contactBook = await db.contactBook.create({
|
||||
data: {
|
||||
name,
|
||||
teamId: team.id,
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
|
||||
return contactBook;
|
||||
}),
|
||||
|
||||
getContactBookDetails: contactBookProcedure.query(
|
||||
async ({ ctx: { contactBook, db } }) => {
|
||||
const [totalContacts, unsubscribedContacts] = await Promise.all([
|
||||
db.contact.count({
|
||||
where: { contactBookId: contactBook.id },
|
||||
}),
|
||||
db.contact.count({
|
||||
where: { contactBookId: contactBook.id, subscribed: false },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...contactBook,
|
||||
totalContacts,
|
||||
unsubscribedContacts,
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
updateContactBook: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contactBookId: z.string(),
|
||||
name: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx: { db }, input }) => {
|
||||
const { contactBookId, ...data } = input;
|
||||
return db.contactBook.update({
|
||||
where: { id: contactBookId },
|
||||
data,
|
||||
});
|
||||
}),
|
||||
|
||||
deleteContactBook: contactBookProcedure
|
||||
.input(z.object({ contactBookId: z.string() }))
|
||||
.mutation(async ({ ctx: { db }, input }) => {
|
||||
return db.contactBook.delete({ where: { id: input.contactBookId } });
|
||||
}),
|
||||
|
||||
contacts: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx: { db }, input }) => {
|
||||
const page = input.page || 1;
|
||||
const limit = 30;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const whereConditions: Prisma.ContactFindManyArgs["where"] = {
|
||||
contactBookId: input.contactBookId,
|
||||
...(input.subscribed !== undefined
|
||||
? { subscribed: input.subscribed }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const countP = db.contact.count({ where: whereConditions });
|
||||
|
||||
const contactsP = db.contact.findMany({
|
||||
where: whereConditions,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
contactBookId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const [contacts, count] = await Promise.all([contactsP, countP]);
|
||||
|
||||
return { contacts, totalPage: Math.ceil(count / limit) };
|
||||
}),
|
||||
|
||||
addContacts: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contacts: z.array(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx: { contactBook }, input }) => {
|
||||
return contactService.bulkAddContacts(contactBook.id, input.contacts);
|
||||
}),
|
||||
|
||||
updateContact: contactBookProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contactId: z.string(),
|
||||
email: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const { contactId, ...contact } = input;
|
||||
return contactService.updateContact(contactId, contact);
|
||||
}),
|
||||
|
||||
deleteContact: contactBookProcedure
|
||||
.input(z.object({ contactId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return contactService.deleteContact(input.contactId);
|
||||
}),
|
||||
});
|
@@ -175,7 +175,7 @@ export const emailRouter = createTRPCRouter({
|
||||
select: {
|
||||
emailEvents: {
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
status: "asc",
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
|
@@ -9,7 +9,7 @@
|
||||
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
import { z, ZodError } from "zod";
|
||||
import { env } from "~/env";
|
||||
|
||||
import { getServerAuthSession } from "~/server/auth";
|
||||
@@ -125,6 +125,60 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||
});
|
||||
});
|
||||
|
||||
export const contactBookProcedure = teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
contactBookId: z.string(),
|
||||
})
|
||||
)
|
||||
.use(async ({ ctx, next, input }) => {
|
||||
const contactBook = await db.contactBook.findUnique({
|
||||
where: { id: input.contactBookId },
|
||||
});
|
||||
if (!contactBook) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact book not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (contactBook.teamId !== ctx.team.id) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this contact book",
|
||||
});
|
||||
}
|
||||
|
||||
return next({ ctx: { ...ctx, contactBook } });
|
||||
});
|
||||
|
||||
export const campaignProcedure = teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
campaignId: z.string(),
|
||||
})
|
||||
)
|
||||
.use(async ({ ctx, next, input }) => {
|
||||
const campaign = await db.campaign.findUnique({
|
||||
where: { id: input.campaignId },
|
||||
});
|
||||
if (!campaign) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Campaign not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (campaign.teamId !== ctx.team.id) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this campaign",
|
||||
});
|
||||
}
|
||||
|
||||
return next({ ctx: { ...ctx, campaign } });
|
||||
});
|
||||
|
||||
/**
|
||||
* To manage application settings, for hosted version, authenticated users will be considered as admin
|
||||
*/
|
||||
|
@@ -112,6 +112,7 @@ export async function sendEmailThroughSes({
|
||||
replyTo,
|
||||
region,
|
||||
configurationSetName,
|
||||
unsubUrl,
|
||||
}: Partial<EmailContent> & {
|
||||
region: string;
|
||||
configurationSetName: string;
|
||||
@@ -149,6 +150,14 @@ export async function sendEmailThroughSes({
|
||||
Charset: "UTF-8",
|
||||
},
|
||||
},
|
||||
...(unsubUrl
|
||||
? {
|
||||
Headers: [
|
||||
{ Name: "List-Unsubscribe", Value: `<${unsubUrl}>` },
|
||||
{ Name: "List-Unsubscribe-Post", Value: "One-Click" },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
ConfigurationSetName: configurationSetName,
|
||||
|
27
apps/web/src/server/public-api/api-utils.ts
Normal file
27
apps/web/src/server/public-api/api-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Context } from "hono";
|
||||
import { db } from "../db";
|
||||
import { UnsendApiError } from "./api-error";
|
||||
|
||||
export const getContactBook = async (c: Context, teamId: number) => {
|
||||
const contactBookId = c.req.param("contactBookId");
|
||||
|
||||
if (!contactBookId) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "contactBookId is mandatory",
|
||||
});
|
||||
}
|
||||
|
||||
const contactBook = await db.contactBook.findUnique({
|
||||
where: { id: contactBookId, teamId },
|
||||
});
|
||||
|
||||
if (!contactBook) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact book not found for this team",
|
||||
});
|
||||
}
|
||||
|
||||
return contactBook;
|
||||
};
|
65
apps/web/src/server/public-api/api/contacts/add-contact.ts
Normal file
65
apps/web/src/server/public-api/api/contacts/add-contact.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { addOrUpdateContact } from "~/server/service/contact-service";
|
||||
import { getContactBook } from "../../api-utils";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/v1/contactBooks/{contactBookId}/contacts",
|
||||
request: {
|
||||
params: z.object({
|
||||
contactBookId: z
|
||||
.string()
|
||||
.min(3)
|
||||
.openapi({
|
||||
param: {
|
||||
name: "contactBookId",
|
||||
in: "path",
|
||||
},
|
||||
example: "cuiwqdj74rygf74",
|
||||
}),
|
||||
}),
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
email: z.string(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({ contactId: z.string().optional() }),
|
||||
},
|
||||
},
|
||||
description: "Retrieve the user",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function addContact(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = await getTeamFromToken(c);
|
||||
|
||||
const contactBook = await getContactBook(c, team.id);
|
||||
|
||||
const contact = await addOrUpdateContact(
|
||||
contactBook.id,
|
||||
c.req.valid("json")
|
||||
);
|
||||
|
||||
return c.json({ contactId: contact.id });
|
||||
});
|
||||
}
|
||||
|
||||
export default addContact;
|
82
apps/web/src/server/public-api/api/contacts/get-contact.ts
Normal file
82
apps/web/src/server/public-api/api/contacts/get-contact.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { db } from "~/server/db";
|
||||
import { UnsendApiError } from "../../api-error";
|
||||
import { getContactBook } from "../../api-utils";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}",
|
||||
request: {
|
||||
params: z.object({
|
||||
contactBookId: z.string().openapi({
|
||||
param: {
|
||||
name: "contactBookId",
|
||||
in: "path",
|
||||
},
|
||||
example: "cuiwqdj74rygf74",
|
||||
}),
|
||||
contactId: z.string().openapi({
|
||||
param: {
|
||||
name: "contactId",
|
||||
in: "path",
|
||||
},
|
||||
example: "cuiwqdj74rygf74",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().optional().nullable(),
|
||||
lastName: z.string().optional().nullable(),
|
||||
email: z.string(),
|
||||
subscribed: z.boolean().default(true),
|
||||
properties: z.record(z.string()),
|
||||
contactBookId: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: "Retrieve the contact",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function getContact(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = await getTeamFromToken(c);
|
||||
|
||||
await getContactBook(c, team.id);
|
||||
|
||||
const contactId = c.req.param("contactId");
|
||||
|
||||
const contact = await db.contact.findUnique({
|
||||
where: {
|
||||
id: contactId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new UnsendApiError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Contact not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure properties is a Record<string, string>
|
||||
const sanitizedContact = {
|
||||
...contact,
|
||||
properties: contact.properties as Record<string, string>,
|
||||
};
|
||||
|
||||
return c.json(sanitizedContact);
|
||||
});
|
||||
}
|
||||
|
||||
export default getContact;
|
@@ -0,0 +1,66 @@
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { updateContact } from "~/server/service/contact-service";
|
||||
import { getContactBook } from "../../api-utils";
|
||||
|
||||
const route = createRoute({
|
||||
method: "patch",
|
||||
path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}",
|
||||
request: {
|
||||
params: z.object({
|
||||
contactBookId: z.string().openapi({
|
||||
param: {
|
||||
name: "contactBookId",
|
||||
in: "path",
|
||||
},
|
||||
example: "cuiwqdj74rygf74",
|
||||
}),
|
||||
contactId: z.string().openapi({
|
||||
param: {
|
||||
name: "contactId",
|
||||
in: "path",
|
||||
},
|
||||
example: "cuiwqdj74rygf74",
|
||||
}),
|
||||
}),
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
properties: z.record(z.string()).optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({ contactId: z.string().optional() }),
|
||||
},
|
||||
},
|
||||
description: "Retrieve the user",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function updateContactInfo(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = await getTeamFromToken(c);
|
||||
|
||||
await getContactBook(c, team.id);
|
||||
const contactId = c.req.param("contactId");
|
||||
|
||||
const contact = await updateContact(contactId, c.req.valid("json"));
|
||||
|
||||
return c.json({ contactId: contact.id });
|
||||
});
|
||||
}
|
||||
|
||||
export default updateContactInfo;
|
@@ -50,7 +50,7 @@ const route = createRoute({
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: "Retrieve the user",
|
||||
description: "Retrieve the email",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@@ -2,6 +2,9 @@ import { getApp } from "./hono";
|
||||
import getDomains from "./api/domains/get-domains";
|
||||
import sendEmail from "./api/emails/send-email";
|
||||
import getEmail from "./api/emails/get-email";
|
||||
import addContact from "./api/contacts/add-contact";
|
||||
import updateContactInfo from "./api/contacts/update-contact";
|
||||
import getContact from "./api/contacts/get-contact";
|
||||
|
||||
export const app = getApp();
|
||||
|
||||
@@ -12,4 +15,9 @@ getDomains(app);
|
||||
getEmail(app);
|
||||
sendEmail(app);
|
||||
|
||||
/**Contact related APIs */
|
||||
addContact(app);
|
||||
updateContactInfo(app);
|
||||
getContact(app);
|
||||
|
||||
export default app;
|
||||
|
309
apps/web/src/server/service/campaign-service.ts
Normal file
309
apps/web/src/server/service/campaign-service.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
import { db } from "../db";
|
||||
import { createHash } from "crypto";
|
||||
import { env } from "~/env";
|
||||
import { Campaign, Contact, EmailStatus } from "@prisma/client";
|
||||
import { validateDomainFromEmail } from "./domain-service";
|
||||
import { EmailQueueService } from "./email-queue-service";
|
||||
|
||||
export async function sendCampaign(id: string) {
|
||||
let campaign = await db.campaign.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
throw new Error("Campaign not found");
|
||||
}
|
||||
|
||||
if (!campaign.content) {
|
||||
throw new Error("No content added for campaign");
|
||||
}
|
||||
|
||||
let jsonContent: Record<string, any>;
|
||||
|
||||
try {
|
||||
jsonContent = JSON.parse(campaign.content);
|
||||
const renderer = new EmailRenderer(jsonContent);
|
||||
const html = await renderer.render();
|
||||
campaign = await db.campaign.update({
|
||||
where: { id },
|
||||
data: { html },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Failed to parse campaign content");
|
||||
}
|
||||
|
||||
if (!campaign.contactBookId) {
|
||||
throw new Error("No contact book found for campaign");
|
||||
}
|
||||
|
||||
const contactBook = await db.contactBook.findUnique({
|
||||
where: { id: campaign.contactBookId },
|
||||
include: {
|
||||
contacts: {
|
||||
where: {
|
||||
subscribed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contactBook) {
|
||||
throw new Error("Contact book not found");
|
||||
}
|
||||
|
||||
if (!campaign.html) {
|
||||
throw new Error("No HTML content for campaign");
|
||||
}
|
||||
|
||||
await sendCampaignEmail(campaign, {
|
||||
campaignId: campaign.id,
|
||||
from: campaign.from,
|
||||
subject: campaign.subject,
|
||||
html: campaign.html,
|
||||
replyTo: campaign.replyTo,
|
||||
cc: campaign.cc,
|
||||
bcc: campaign.bcc,
|
||||
teamId: campaign.teamId,
|
||||
contacts: contactBook.contacts,
|
||||
});
|
||||
|
||||
await db.campaign.update({
|
||||
where: { id },
|
||||
data: { status: "SENT", total: contactBook.contacts.length },
|
||||
});
|
||||
}
|
||||
|
||||
export function createUnsubUrl(contactId: string, campaignId: string) {
|
||||
const unsubId = `${contactId}-${campaignId}`;
|
||||
|
||||
const unsubHash = createHash("sha256")
|
||||
.update(`${unsubId}-${env.NEXTAUTH_SECRET}`)
|
||||
.digest("hex");
|
||||
|
||||
return `${env.NEXTAUTH_URL}/unsubscribe?id=${unsubId}&hash=${unsubHash}`;
|
||||
}
|
||||
|
||||
export async function unsubscribeContact(id: string, hash: string) {
|
||||
const [contactId, campaignId] = id.split("-");
|
||||
|
||||
if (!contactId || !campaignId) {
|
||||
throw new Error("Invalid unsubscribe link");
|
||||
}
|
||||
|
||||
// Verify the hash
|
||||
const expectedHash = createHash("sha256")
|
||||
.update(`${id}-${env.NEXTAUTH_SECRET}`)
|
||||
.digest("hex");
|
||||
|
||||
if (hash !== expectedHash) {
|
||||
throw new Error("Invalid unsubscribe link");
|
||||
}
|
||||
|
||||
// Update the contact's subscription status
|
||||
try {
|
||||
const contact = await db.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new Error("Contact not found");
|
||||
}
|
||||
|
||||
if (contact.subscribed) {
|
||||
await db.contact.update({
|
||||
where: { id: contactId },
|
||||
data: { subscribed: false },
|
||||
});
|
||||
|
||||
await db.campaign.update({
|
||||
where: { id: campaignId },
|
||||
data: {
|
||||
unsubscribed: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return contact;
|
||||
} catch (error) {
|
||||
console.error("Error unsubscribing contact:", error);
|
||||
throw new Error("Failed to unsubscribe contact");
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeContact(id: string, hash: string) {
|
||||
const [contactId, campaignId] = id.split("-");
|
||||
|
||||
if (!contactId || !campaignId) {
|
||||
throw new Error("Invalid subscribe link");
|
||||
}
|
||||
|
||||
// Verify the hash
|
||||
const expectedHash = createHash("sha256")
|
||||
.update(`${id}-${env.NEXTAUTH_SECRET}`)
|
||||
.digest("hex");
|
||||
|
||||
if (hash !== expectedHash) {
|
||||
throw new Error("Invalid subscribe link");
|
||||
}
|
||||
|
||||
// Update the contact's subscription status
|
||||
try {
|
||||
const contact = await db.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new Error("Contact not found");
|
||||
}
|
||||
|
||||
if (!contact.subscribed) {
|
||||
await db.contact.update({
|
||||
where: { id: contactId },
|
||||
data: { subscribed: true },
|
||||
});
|
||||
|
||||
await db.campaign.update({
|
||||
where: { id: campaignId },
|
||||
data: {
|
||||
unsubscribed: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error subscribing contact:", error);
|
||||
throw new Error("Failed to subscribe contact");
|
||||
}
|
||||
}
|
||||
|
||||
type CampainEmail = {
|
||||
campaignId: string;
|
||||
from: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
replyTo?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
teamId: number;
|
||||
contacts: Array<Contact>;
|
||||
};
|
||||
|
||||
export async function sendCampaignEmail(
|
||||
campaign: Campaign,
|
||||
emailData: CampainEmail
|
||||
) {
|
||||
const { campaignId, from, subject, replyTo, cc, bcc, teamId, contacts } =
|
||||
emailData;
|
||||
|
||||
const jsonContent = JSON.parse(campaign.content || "{}");
|
||||
const renderer = new EmailRenderer(jsonContent);
|
||||
|
||||
const domain = await validateDomainFromEmail(from, teamId);
|
||||
|
||||
const contactWithHtml = await Promise.all(
|
||||
contacts.map(async (contact) => {
|
||||
const unsubscribeUrl = createUnsubUrl(contact.id, campaignId);
|
||||
|
||||
return {
|
||||
...contact,
|
||||
html: await renderer.render({
|
||||
shouldReplaceVariableValues: true,
|
||||
variableValues: {
|
||||
email: contact.email,
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
},
|
||||
linkValues: {
|
||||
"{{unsend_unsubscribe_url}}": unsubscribeUrl,
|
||||
},
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Create emails in bulk
|
||||
await db.email.createMany({
|
||||
data: contactWithHtml.map((contact) => ({
|
||||
to: [contact.email],
|
||||
replyTo: replyTo
|
||||
? Array.isArray(replyTo)
|
||||
? replyTo
|
||||
: [replyTo]
|
||||
: undefined,
|
||||
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
|
||||
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
|
||||
from,
|
||||
subject,
|
||||
html: contact.html,
|
||||
teamId,
|
||||
campaignId,
|
||||
contactId: contact.id,
|
||||
domainId: domain.id,
|
||||
})),
|
||||
});
|
||||
|
||||
// Fetch created emails
|
||||
const emails = await db.email.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
campaignId,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue emails
|
||||
await Promise.all(
|
||||
emails.map((email) =>
|
||||
EmailQueueService.queueEmail(email.id, domain.region, false)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateCampaignAnalytics(
|
||||
campaignId: string,
|
||||
emailStatus: EmailStatus
|
||||
) {
|
||||
const campaign = await db.campaign.findUnique({
|
||||
where: { id: campaignId },
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
throw new Error("Campaign not found");
|
||||
}
|
||||
|
||||
const updateData: Record<string, any> = {};
|
||||
|
||||
switch (emailStatus) {
|
||||
case EmailStatus.SENT:
|
||||
updateData.sent = { increment: 1 };
|
||||
break;
|
||||
case EmailStatus.DELIVERED:
|
||||
updateData.delivered = { increment: 1 };
|
||||
break;
|
||||
case EmailStatus.OPENED:
|
||||
updateData.opened = { increment: 1 };
|
||||
break;
|
||||
case EmailStatus.CLICKED:
|
||||
updateData.clicked = { increment: 1 };
|
||||
break;
|
||||
case EmailStatus.BOUNCED:
|
||||
updateData.bounced = { increment: 1 };
|
||||
break;
|
||||
case EmailStatus.COMPLAINED:
|
||||
updateData.complained = { increment: 1 };
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
await db.campaign.update({
|
||||
where: { id: campaignId },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
92
apps/web/src/server/service/contact-service.ts
Normal file
92
apps/web/src/server/service/contact-service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { db } from "../db";
|
||||
|
||||
export type ContactInput = {
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
properties?: Record<string, string>;
|
||||
subscribed?: boolean;
|
||||
};
|
||||
|
||||
export async function addOrUpdateContact(
|
||||
contactBookId: string,
|
||||
contact: ContactInput
|
||||
) {
|
||||
const createdContact = await db.contact.upsert({
|
||||
where: {
|
||||
contactBookId_email: {
|
||||
contactBookId,
|
||||
email: contact.email,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
contactBookId,
|
||||
email: contact.email,
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
properties: contact.properties ?? {},
|
||||
subscribed: contact.subscribed,
|
||||
},
|
||||
update: {
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
properties: contact.properties ?? {},
|
||||
subscribed: contact.subscribed,
|
||||
},
|
||||
});
|
||||
|
||||
return createdContact;
|
||||
}
|
||||
|
||||
export async function updateContact(
|
||||
contactId: string,
|
||||
contact: Partial<ContactInput>
|
||||
) {
|
||||
return db.contact.update({
|
||||
where: {
|
||||
id: contactId,
|
||||
},
|
||||
data: contact,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteContact(contactId: string) {
|
||||
return db.contact.delete({
|
||||
where: {
|
||||
id: contactId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkAddContacts(
|
||||
contactBookId: string,
|
||||
contacts: Array<ContactInput>
|
||||
) {
|
||||
const createdContacts = await Promise.all(
|
||||
contacts.map((contact) => addOrUpdateContact(contactBookId, contact))
|
||||
);
|
||||
|
||||
return createdContacts;
|
||||
}
|
||||
|
||||
export async function unsubscribeContact(contactId: string) {
|
||||
await db.contact.update({
|
||||
where: {
|
||||
id: contactId,
|
||||
},
|
||||
data: {
|
||||
subscribed: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function subscribeContact(contactId: string) {
|
||||
await db.contact.update({
|
||||
where: {
|
||||
id: contactId,
|
||||
},
|
||||
data: {
|
||||
subscribed: true,
|
||||
},
|
||||
});
|
||||
}
|
@@ -4,9 +4,45 @@ import * as tldts from "tldts";
|
||||
import * as ses from "~/server/aws/ses";
|
||||
import { db } from "~/server/db";
|
||||
import { SesSettingsService } from "./ses-settings-service";
|
||||
import { UnsendApiError } from "../public-api/api-error";
|
||||
|
||||
const dnsResolveTxt = util.promisify(dns.resolveTxt);
|
||||
|
||||
export async function validateDomainFromEmail(email: string, teamId: number) {
|
||||
let fromDomain = email.split("@")[1];
|
||||
if (fromDomain?.endsWith(">")) {
|
||||
fromDomain = fromDomain.slice(0, -1);
|
||||
}
|
||||
|
||||
if (!fromDomain) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "From email is invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const domain = await db.domain.findUnique({
|
||||
where: { name: fromDomain, teamId },
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Domain of from email is wrong. Use the domain verified by unsend",
|
||||
});
|
||||
}
|
||||
|
||||
if (domain.status !== "SUCCESS") {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Domain is not verified",
|
||||
});
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
export async function createDomain(
|
||||
teamId: number,
|
||||
name: string,
|
||||
|
@@ -5,54 +5,120 @@ import { getConfigurationSetName } from "~/utils/ses-utils";
|
||||
import { db } from "../db";
|
||||
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
|
||||
import { getRedis } from "../redis";
|
||||
import { createUnsubUrl } from "./campaign-service";
|
||||
|
||||
function createQueueAndWorker(region: string, quota: number, suffix: string) {
|
||||
const connection = getRedis();
|
||||
|
||||
const queueName = `${region}-${suffix}`;
|
||||
|
||||
const queue = new Queue(queueName, { connection });
|
||||
|
||||
const worker = new Worker(queueName, executeEmail, {
|
||||
concurrency: quota,
|
||||
connection,
|
||||
});
|
||||
|
||||
return { queue, worker };
|
||||
}
|
||||
|
||||
export class EmailQueueService {
|
||||
private static initialized = false;
|
||||
private static regionQueue = new Map<string, Queue>();
|
||||
private static regionWorker = new Map<string, Worker>();
|
||||
public static transactionalQueue = new Map<string, Queue>();
|
||||
private static transactionalWorker = new Map<string, Worker>();
|
||||
public static marketingQueue = new Map<string, Queue>();
|
||||
private static marketingWorker = new Map<string, Worker>();
|
||||
|
||||
public static initializeQueue(region: string, quota: number) {
|
||||
const connection = getRedis();
|
||||
public static initializeQueue(
|
||||
region: string,
|
||||
quota: number,
|
||||
transactionalQuotaPercentage: number
|
||||
) {
|
||||
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
|
||||
|
||||
const queueName = `${region}-transaction`;
|
||||
const transactionalQuota = Math.floor(
|
||||
(quota * transactionalQuotaPercentage) / 100
|
||||
);
|
||||
const marketingQuota = quota - transactionalQuota;
|
||||
|
||||
const queue = new Queue(queueName, { connection });
|
||||
console.log(
|
||||
"is transactional queue",
|
||||
this.transactionalQueue.has(region),
|
||||
"is marketing queue",
|
||||
this.marketingQueue.has(region)
|
||||
);
|
||||
|
||||
const worker = new Worker(queueName, executeEmail, {
|
||||
limiter: {
|
||||
max: quota,
|
||||
duration: 1000,
|
||||
},
|
||||
concurrency: quota,
|
||||
connection,
|
||||
});
|
||||
if (this.transactionalQueue.has(region)) {
|
||||
console.log(
|
||||
`[EmailQueueService]: Updating transactional quota for region ${region} to ${transactionalQuota}`
|
||||
);
|
||||
const transactionalWorker = this.transactionalWorker.get(region);
|
||||
if (transactionalWorker) {
|
||||
transactionalWorker.concurrency = transactionalQuota;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[EmailQueueService]: Creating transactional queue for region ${region} with quota ${transactionalQuota}`
|
||||
);
|
||||
const { queue: transactionalQueue, worker: transactionalWorker } =
|
||||
createQueueAndWorker(region, transactionalQuota ?? 1, "transaction");
|
||||
this.transactionalQueue.set(region, transactionalQueue);
|
||||
this.transactionalWorker.set(region, transactionalWorker);
|
||||
}
|
||||
|
||||
this.regionQueue.set(region, queue);
|
||||
this.regionWorker.set(region, worker);
|
||||
if (this.marketingQueue.has(region)) {
|
||||
console.log(
|
||||
`[EmailQueueService]: Updating marketing quota for region ${region} to ${marketingQuota}`
|
||||
);
|
||||
const marketingWorker = this.marketingWorker.get(region);
|
||||
if (marketingWorker) {
|
||||
marketingWorker.concurrency = marketingQuota;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[EmailQueueService]: Creating marketing queue for region ${region} with quota ${marketingQuota}`
|
||||
);
|
||||
const { queue: marketingQueue, worker: marketingWorker } =
|
||||
createQueueAndWorker(region, marketingQuota ?? 1, "marketing");
|
||||
this.marketingQueue.set(region, marketingQueue);
|
||||
this.marketingWorker.set(region, marketingWorker);
|
||||
}
|
||||
}
|
||||
|
||||
public static async queueEmail(emailId: string, region: string) {
|
||||
public static async queueEmail(
|
||||
emailId: string,
|
||||
region: string,
|
||||
transactional: boolean,
|
||||
unsubUrl?: string
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
await this.init();
|
||||
}
|
||||
const queue = this.regionQueue.get(region);
|
||||
const queue = transactional
|
||||
? this.transactionalQueue.get(region)
|
||||
: this.marketingQueue.get(region);
|
||||
if (!queue) {
|
||||
throw new Error(`Queue for region ${region} not found`);
|
||||
}
|
||||
queue.add("send-email", { emailId, timestamp: Date.now() });
|
||||
queue.add("send-email", { emailId, timestamp: Date.now(), unsubUrl });
|
||||
}
|
||||
|
||||
public static async init() {
|
||||
const sesSettings = await db.sesSetting.findMany();
|
||||
for (const sesSetting of sesSettings) {
|
||||
this.initializeQueue(sesSetting.region, sesSetting.sesEmailRateLimit);
|
||||
this.initializeQueue(
|
||||
sesSetting.region,
|
||||
sesSetting.sesEmailRateLimit,
|
||||
sesSetting.transactionalQuota
|
||||
);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
|
||||
async function executeEmail(
|
||||
job: Job<{ emailId: string; timestamp: number; unsubUrl?: string }>
|
||||
) {
|
||||
console.log(
|
||||
`[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
|
||||
);
|
||||
@@ -88,13 +154,15 @@ async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
|
||||
}
|
||||
|
||||
console.log(`[EmailQueueService]: Sending email ${email.id}`);
|
||||
const unsubUrl = job.data.unsubUrl;
|
||||
|
||||
try {
|
||||
const messageId = attachments.length
|
||||
? await sendEmailWithAttachments({
|
||||
to: email.to,
|
||||
from: email.from,
|
||||
subject: email.subject,
|
||||
text: email.text ?? undefined,
|
||||
text: email.text ?? "",
|
||||
html: email.html ?? undefined,
|
||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||
configurationSetName,
|
||||
@@ -105,11 +173,12 @@ async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
|
||||
from: email.from,
|
||||
subject: email.subject,
|
||||
replyTo: email.replyTo ?? undefined,
|
||||
text: email.text ?? undefined,
|
||||
text: email.text ?? "",
|
||||
html: email.html ?? undefined,
|
||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||
configurationSetName,
|
||||
attachments,
|
||||
unsubUrl,
|
||||
});
|
||||
|
||||
// Delete attachments after sending the email
|
||||
|
@@ -2,7 +2,14 @@ import { EmailContent } from "~/types";
|
||||
import { db } from "../db";
|
||||
import { UnsendApiError } from "~/server/public-api/api-error";
|
||||
import { EmailQueueService } from "./email-queue-service";
|
||||
import { validateDomainFromEmail } from "./domain-service";
|
||||
import { Campaign, Contact } from "@prisma/client";
|
||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
import { createUnsubUrl } from "./campaign-service";
|
||||
|
||||
/**
|
||||
Send transactional email
|
||||
*/
|
||||
export async function sendEmail(
|
||||
emailContent: EmailContent & { teamId: number }
|
||||
) {
|
||||
@@ -19,29 +26,7 @@ export async function sendEmail(
|
||||
bcc,
|
||||
} = emailContent;
|
||||
|
||||
let fromDomain = from.split("@")[1];
|
||||
if (fromDomain?.endsWith(">")) {
|
||||
fromDomain = fromDomain.slice(0, -1);
|
||||
}
|
||||
|
||||
const domain = await db.domain.findFirst({
|
||||
where: { teamId, name: fromDomain },
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Domain of from email is wrong. Use the email verified by unsend",
|
||||
});
|
||||
}
|
||||
|
||||
if (domain.status !== "SUCCESS") {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Domain is not verified",
|
||||
});
|
||||
}
|
||||
const domain = await validateDomainFromEmail(from, teamId);
|
||||
|
||||
const email = await db.email.create({
|
||||
data: {
|
||||
@@ -64,7 +49,7 @@ export async function sendEmail(
|
||||
});
|
||||
|
||||
try {
|
||||
await EmailQueueService.queueEmail(email.id, domain.region);
|
||||
await EmailQueueService.queueEmail(email.id, domain.region, true);
|
||||
} catch (error: any) {
|
||||
await db.emailEvent.create({
|
||||
data: {
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { SesEvent, SesEventDataKey } from "~/types/aws-types";
|
||||
import { EmailStatus, Prisma } from "@prisma/client";
|
||||
import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
|
||||
import { db } from "../db";
|
||||
|
||||
const STATUS_LIST = Object.values(EmailStatus);
|
||||
import { updateCampaignAnalytics } from "./campaign-service";
|
||||
import { env } from "~/env";
|
||||
|
||||
export async function parseSesHook(data: SesEvent) {
|
||||
const mailStatus = getEmailStatus(data);
|
||||
@@ -34,14 +34,34 @@ export async function parseSesHook(data: SesEvent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await db.email.update({
|
||||
where: {
|
||||
id: email.id,
|
||||
},
|
||||
data: {
|
||||
latestStatus: getLatestStatus(email.latestStatus, mailStatus),
|
||||
},
|
||||
});
|
||||
// Update the latest status and to avoid race conditions
|
||||
await db.$executeRaw`
|
||||
UPDATE "Email"
|
||||
SET "latestStatus" = CASE
|
||||
WHEN ${mailStatus}::text::\"EmailStatus\" > "latestStatus" OR "latestStatus" IS NULL
|
||||
THEN ${mailStatus}::text::\"EmailStatus\"
|
||||
ELSE "latestStatus"
|
||||
END
|
||||
WHERE id = ${email.id}
|
||||
`;
|
||||
|
||||
if (email.campaignId) {
|
||||
if (
|
||||
mailStatus !== "CLICKED" ||
|
||||
!(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`)
|
||||
) {
|
||||
const mailEvent = await db.emailEvent.findFirst({
|
||||
where: {
|
||||
emailId: email.id,
|
||||
status: mailStatus,
|
||||
},
|
||||
});
|
||||
|
||||
if (!mailEvent) {
|
||||
await updateCampaignAnalytics(email.campaignId, mailStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.emailEvent.create({
|
||||
data: {
|
||||
@@ -89,12 +109,3 @@ function getEmailData(data: SesEvent) {
|
||||
return data[eventType.toLowerCase() as SesEventDataKey];
|
||||
}
|
||||
}
|
||||
|
||||
function getLatestStatus(
|
||||
currentEmailStatus: EmailStatus,
|
||||
incomingStatus: EmailStatus
|
||||
) {
|
||||
const index = STATUS_LIST.indexOf(currentEmailStatus);
|
||||
const incomingIndex = STATUS_LIST.indexOf(incomingStatus);
|
||||
return STATUS_LIST[Math.max(index, incomingIndex)] ?? incomingStatus;
|
||||
}
|
||||
|
@@ -52,9 +52,13 @@ export class SesSettingsService {
|
||||
public static async createSesSetting({
|
||||
region,
|
||||
unsendUrl,
|
||||
sendingRateLimit,
|
||||
transactionalQuota,
|
||||
}: {
|
||||
region: string;
|
||||
unsendUrl: string;
|
||||
sendingRateLimit: number;
|
||||
transactionalQuota: number;
|
||||
}) {
|
||||
await this.checkInitialized();
|
||||
if (this.cache[region]) {
|
||||
@@ -80,12 +84,62 @@ export class SesSettingsService {
|
||||
region,
|
||||
callbackUrl: `${parsedUrl}/api/ses_callback`,
|
||||
topic: `${idPrefix}-${region}-unsend`,
|
||||
sesEmailRateLimit: sendingRateLimit,
|
||||
transactionalQuota,
|
||||
idPrefix,
|
||||
},
|
||||
});
|
||||
|
||||
await createSettingInAws(setting);
|
||||
EmailQueueService.initializeQueue(region, setting.sesEmailRateLimit);
|
||||
|
||||
EmailQueueService.initializeQueue(
|
||||
region,
|
||||
setting.sesEmailRateLimit,
|
||||
setting.transactionalQuota
|
||||
);
|
||||
console.log(
|
||||
EmailQueueService.transactionalQueue,
|
||||
EmailQueueService.marketingQueue
|
||||
);
|
||||
|
||||
await this.invalidateCache();
|
||||
}
|
||||
|
||||
public static async updateSesSetting({
|
||||
id,
|
||||
sendingRateLimit,
|
||||
transactionalQuota,
|
||||
}: {
|
||||
id: string;
|
||||
sendingRateLimit: number;
|
||||
transactionalQuota: number;
|
||||
}) {
|
||||
await this.checkInitialized();
|
||||
|
||||
const setting = await db.sesSetting.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
transactionalQuota,
|
||||
sesEmailRateLimit: sendingRateLimit,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
EmailQueueService.transactionalQueue,
|
||||
EmailQueueService.marketingQueue
|
||||
);
|
||||
|
||||
EmailQueueService.initializeQueue(
|
||||
setting.region,
|
||||
setting.sesEmailRateLimit,
|
||||
setting.transactionalQuota
|
||||
);
|
||||
|
||||
console.log(
|
||||
EmailQueueService.transactionalQueue,
|
||||
EmailQueueService.marketingQueue
|
||||
);
|
||||
|
||||
await this.invalidateCache();
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ export type EmailContent = {
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
attachments?: Array<EmailAttachment>;
|
||||
unsubUrl?: string;
|
||||
};
|
||||
|
||||
export type EmailAttachment = {
|
||||
|
Reference in New Issue
Block a user