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:
KM Koushik
2024-08-10 10:09:10 +10:00
committed by GitHub
parent 0c072579b9
commit 5ddc0a7bb9
92 changed files with 11766 additions and 338 deletions

View 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>
);
};

View File

@@ -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>
))
)}

View File

@@ -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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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;

View File

@@ -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;

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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>
);
}

View File

@@ -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

View 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",
},
});
}

View File

@@ -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>

View 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;

View 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>
);
}