Add attachment support

This commit is contained in:
KMKoushik
2024-04-19 20:58:30 +10:00
parent 9465960f0a
commit 80878679cd
22 changed files with 1029 additions and 190 deletions

View File

@@ -0,0 +1,103 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { Separator } from "@unsend/ui/src/separator";
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
import { formatDate } from "date-fns";
import { EmailStatus } from "@prisma/client";
import { JsonValue } from "@prisma/client/runtime/library";
import { SesDeliveryDelay } from "~/types/aws-types";
import { DELIVERY_DELAY_ERRORS } from "~/lib/constants/ses-errors";
export default function EmailDetails({ emailId }: { emailId: string }) {
const emailQuery = api.email.getEmail.useQuery({ id: emailId });
return (
<div>
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<h1 className="font-bold text-lg">{emailQuery.data?.to}</h1>
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? "SENT"} />
</div>
</div>
<div className="flex flex-col gap-8 mt-10 items-start">
<div className="p-2 rounded-lg border flex flex-col gap-4 ">
<div className="flex gap-2">
<span className="w-[65px] text-muted-foreground ">From</span>
<span>{emailQuery.data?.from}</span>
</div>
<Separator />
<div className="flex gap-2">
<span className="w-[65px] text-muted-foreground ">To</span>
<span>{emailQuery.data?.to}</span>
</div>
<Separator />
<div className="flex gap-2">
<span className="w-[65px] text-muted-foreground ">Subject</span>
<span>{emailQuery.data?.subject}</span>
</div>
<div className=" dark:bg-slate-200 h-[350px] overflow-auto text-black rounded">
<div
className="px-4 py-4 overflow-auto"
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
/>
</div>
</div>
<div className=" border rounded-lg w-full ">
<div className=" p-4 flex flex-col gap-8">
<div className="font-medium">Events History</div>
<div className="flex items-stretch px-4">
<div className="border-r border-dashed" />
<div className="flex flex-col gap-12">
{emailQuery.data?.emailEvents.map((evt) => (
<div key={evt.status} className="flex gap-5 items-start">
<div className=" -ml-2.5">
<EmailStatusIcon status={evt.status} />
</div>
<div className="-mt-1">
<div className=" capitalize font-medium">
<EmailStatusBadge status={evt.status} />
</div>
<div className="text-xs text-muted-foreground mt-2">
{formatDate(evt.createdAt, "MMM dd, hh:mm a")}
</div>
<div className="mt-1 text-primary/70">
<EmailStatusText status={evt.status} data={evt.data} />
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
const EmailStatusText = ({
status,
data,
}: {
status: EmailStatus;
data: JsonValue;
}) => {
if (status === "SENT") {
return (
<div>
We received your request and sent the email to recipient's server.
</div>
);
} else if (status === "DELIVERED") {
return <div>Mail is successfully delivered to the recipient.</div>;
} else if (status === "DELIVERY_DELAYED") {
const _errorData = data as unknown as SesDeliveryDelay;
const errorMessage = DELIVERY_DELAY_ERRORS[_errorData.delayType];
return <div>{errorMessage}</div>;
}
return <div>{status}</div>;
};

View File

@@ -3,7 +3,6 @@
import Link from "next/link";
import {
Table,
TableCaption,
TableHeader,
TableRow,
TableHead,
@@ -20,15 +19,85 @@ import {
MailWarning,
MailX,
} from "lucide-react";
import { formatDistance, formatDistanceToNow } from "date-fns";
import { formatDistanceToNow } from "date-fns";
import { EmailStatus } from "@prisma/client";
import { EmailStatusBadge } from "./email-status-badge";
import { useState } from "react";
import EmailDetails from "./email-details";
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation"; // Adjust the import based on your project setup
import dynamic from "next/dynamic";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@unsend/ui/src/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unsend/ui/src/select";
/* Stupid hydrating error. And I so stupid to understand the stupid NextJS docs. Because they stupid change it everyday */
const DynamicSheetWithNoSSR = dynamic(
() => import("@unsend/ui/src/sheet").then((mod) => mod.Sheet),
{ ssr: false }
);
const DynamicSheetContentWithNoSSR = dynamic(
() => import("@unsend/ui/src/sheet").then((mod) => mod.SheetContent),
{ ssr: false }
);
export default function EmailsList() {
const emailsQuery = api.email.emails.useQuery();
const [selectedEmail, setSelectedEmail] = useUrlState("emailId");
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const pageNumber = Number(page);
const emailsQuery = api.email.emails.useQuery({
page: pageNumber,
status: status?.toUpperCase() as EmailStatus,
});
const handleSelectEmail = (emailId: string) => {
setSelectedEmail(emailId);
};
const handleSheetChange = (isOpen: boolean) => {
if (!isOpen) {
setSelectedEmail(null);
}
};
return (
<div className="mt-10">
<div className="flex rounded-xl border shadow">
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-end">
<Select
value={status ?? "All statuses"}
onValueChange={(val) =>
setStatus(val === "All statuses" ? null : val)
}
>
<SelectTrigger className="w-[180px] capitalize">
{status ? status.toLowerCase().replace("_", " ") : "All statuses"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All statuses" className=" capitalize">
All statuses
</SelectItem>
{Object.values(EmailStatus).map((status) => (
<SelectItem value={status} className=" capitalize">
{status.toLowerCase().replace("_", " ")}
</SelectItem>
))}
{/* <SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem> */}
</SelectContent>
</Select>
</div>
<div className="flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
@@ -41,26 +110,62 @@ export default function EmailsList() {
</TableRow>
</TableHeader>
<TableBody>
{emailsQuery.data?.map((email) => (
<TableRow key={email.id}>
<TableCell className="font-medium flex gap-4 items-center">
<EmailIcon status={email.latestStatus ?? "Send"} />
<p>{email.to}</p>
</TableCell>
<TableCell>
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
{/* <Badge className="w-[100px] flex py-1 justify-center text-emerald-400 hover:bg-emerald-500/10 bg-emerald-500/10 rounded">
{email.latestStatus ?? "Sent"}
</Badge> */}
</TableCell>
<TableCell>{email.subject}</TableCell>
<TableCell className="text-right">
{formatDistanceToNow(email.createdAt, { addSuffix: true })}
{emailsQuery.data?.emails.length ? (
emailsQuery.data?.emails.map((email) => (
<TableRow
key={email.id}
onClick={() => handleSelectEmail(email.id)}
className=" cursor-pointer"
>
<TableCell className="font-medium">
<div className="flex gap-4 items-center">
<EmailIcon status={email.latestStatus ?? "Send"} />
<p>{email.to}</p>
</div>
</TableCell>
<TableCell>
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
</TableCell>
<TableCell>{email.subject}</TableCell>
<TableCell className="text-right">
{formatDistanceToNow(email.createdAt, { addSuffix: true })}
</TableCell>
</TableRow>
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center">
No emails found
</TableCell>
</TableRow>
))}
)}
</TableBody>
</Table>
<DynamicSheetWithNoSSR
open={!!selectedEmail}
onOpenChange={handleSheetChange}
>
<DynamicSheetContentWithNoSSR className=" sm:max-w-3xl">
{selectedEmail ? <EmailDetails emailId={selectedEmail} /> : null}
</DynamicSheetContentWithNoSSR>
</DynamicSheetWithNoSSR>
</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 >= (emailsQuery.data?.totalPage ?? 0)}
>
Next
</Button>
</div>
</div>
);
@@ -113,39 +218,3 @@ const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
);
}
};
const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({ status }) => {
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) {
case "SENT":
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case "DELIVERED":
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break;
case "BOUNCED":
badgeColor = "bg-red-500/10 text-red-800 border-red-600/10";
break;
case "CLICKED":
badgeColor = "bg-cyan-500/10 text-cyan-600 border-cyan-600/10";
break;
case "OPENED":
badgeColor = "bg-indigo-500/10 text-indigo-600 border-indigo-600/10";
break;
case "DELIVERY_DELAYED":
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
case "COMPLAINED":
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
break;
default:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
}
return (
<div
className={` text-center w-[130px] rounded capitalize py-0.5 text-xs ${badgeColor}`}
>
{status.toLowerCase().split("_").join(" ")}
</div>
);
};

View File

@@ -0,0 +1,83 @@
import { EmailStatus } from "@prisma/client";
export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10"; // Default color
switch (status) {
case "SENT":
badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10";
break;
case "DELIVERED":
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break;
case "BOUNCED":
badgeColor = "bg-red-500/10 text-red-800 border-red-600/10";
break;
case "CLICKED":
badgeColor = "bg-cyan-500/10 text-cyan-600 border-cyan-600/10";
break;
case "OPENED":
badgeColor = "bg-indigo-500/10 text-indigo-600 border-indigo-600/10";
break;
case "DELIVERY_DELAYED":
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
case "COMPLAINED":
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
break;
default:
badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10";
}
return (
<div
className={` text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
>
{status.toLowerCase().split("_").join(" ")}
</div>
);
};
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
status,
}) => {
let outsideColor = "bg-gray-600";
let insideColor = "bg-gray-600/50";
switch (status) {
case "DELIVERED":
outsideColor = "bg-emerald-500/40";
insideColor = "bg-emerald-500";
break;
case "BOUNCED":
outsideColor = "bg-red-500/40";
insideColor = "bg-red-500";
break;
case "CLICKED":
outsideColor = "bg-cyan-500/40";
insideColor = "bg-cyan-500";
break;
case "OPENED":
outsideColor = "bg-indigo-500/40";
insideColor = "bg-indigo-500";
break;
case "DELIVERY_DELAYED":
outsideColor = "bg-yellow-500/40";
insideColor = "bg-yellow-500";
case "COMPLAINED":
outsideColor = "bg-yellow-500/40";
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

@@ -1,13 +1,20 @@
import type { Metadata } from "next";
import EmailList from "./email-list";
import { Suspense } from "react";
import dynamic from "next/dynamic";
const EmailList = dynamic(
() => import("./email-list").then((mod) => mod.default),
{ ssr: false }
);
export default async function EmailsPage() {
return (
<div>
<div className="flex justify-between items-center">
<h1 className="font-bold text-lg">Emails</h1>
</div>
{/* <Suspense fallback={<div>Loading...</div>}> */}
<EmailList />
{/* </Suspense> */}
</div>
);
}