add new design (#70)

* add new design stuff

* add more ui things

* add more ui changes

* more ui changes

* add more design

* update emoji
This commit is contained in:
KM Koushik
2024-09-28 20:48:26 +10:00
committed by GitHub
parent 5ca6537a81
commit b75b125981
50 changed files with 1909 additions and 419 deletions

View File

@@ -19,7 +19,7 @@ export default function SesConfigurations() {
return (
<div className="">
<div className="border rounded-xl">
<div className="border rounded-xl shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">

View File

@@ -35,6 +35,12 @@ import {
import { toast } from "@unsend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@unsend/ui/src/accordion";
const sendSchema = z.object({
confirmation: z.string(),
@@ -97,6 +103,12 @@ function CampaignEditor({
const [subject, setSubject] = useState(campaign.subject);
const [from, setFrom] = useState(campaign.from);
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
const [replyTo, setReplyTo] = useState<string | undefined>(
campaign.replyTo[0]
);
const [previewText, setPreviewText] = useState<string | null>(
campaign.previewText
);
const [openSendDialog, setOpenSendDialog] = useState(false);
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
@@ -179,10 +191,14 @@ function CampaignEditor({
const confirmation = sendForm.watch("confirmation");
const contactBook = contactBooksQuery.data?.find(
(book) => book.id === contactBookId
);
return (
<div className="p-4 container mx-auto">
<div className="w-[664px] mx-auto">
<div className="mb-4 flex justify-between items-center">
<div className="p-4 container mx-auto ">
<div className="mx-auto">
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
<Input
type="text"
value={name}
@@ -269,114 +285,204 @@ function CampaignEditor({
</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"]}
uploadImage={
campaign.imageUploadSupported ? handleFileChange : undefined
}
/>
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<div className="flex flex-col border shadow rounded-lg mt-12 mb-12 p-4 w-[700px] mx-auto z-50">
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
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 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
/>
<AccordionTrigger className="py-0"></AccordionTrigger>
</div>
<AccordionContent className=" flex flex-col gap-4">
<div className=" flex items-center gap-4 mt-4">
<label className=" text-sm w-[80px] text-muted-foreground">
From
</label>
<input
type="text"
value={from}
onChange={(e) => {
setFrom(e.target.value);
}}
className="mt-1 py-1 w-full text-sm outline-none border-b border-transparent focus:border-border bg-transparent"
placeholder="Friendly name<hello@example.com>"
onBlur={() => {
if (from === campaign.from || !from) {
return;
}
updateCampaignMutation.mutate(
{
campaignId: campaign.id,
from,
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setFrom(campaign.from);
},
}
);
}}
/>
</div>
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
Reply To
</label>
<input
type="text"
value={replyTo}
onChange={(e) => {
setReplyTo(e.target.value);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
placeholder="hello@example.com"
onBlur={() => {
if (replyTo === campaign.replyTo[0]) {
return;
}
updateCampaignMutation.mutate(
{
campaignId: campaign.id,
replyTo: replyTo ? [replyTo] : [],
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setReplyTo(campaign.replyTo[0]);
},
}
);
}}
/>
</div>
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
Preview
</label>
<input
type="text"
value={previewText ?? undefined}
onChange={(e) => {
setPreviewText(e.target.value);
}}
onBlur={() => {
if (
previewText === campaign.previewText ||
!previewText
) {
return;
}
updateCampaignMutation.mutate(
{
campaignId: campaign.id,
previewText,
},
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setPreviewText(campaign.previewText ?? "");
},
}
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
/>
</div>
<div className=" flex items-center gap-2">
<label className="block text-sm w-[80px] text-muted-foreground">
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]">
{contactBook
? `${contactBook.emoji} ${contactBook.name}`
: "Select a contact book"}
</SelectTrigger>
<SelectContent>
{contactBooksQuery.data?.map((book) => (
<SelectItem key={book.id} value={book.id}>
{book.emoji} {book.name}{" "}
<span className="text-xs text-muted-foreground ml-4">
{" "}
{book._count.contacts} contacts
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</AccordionContent>
</div>
</AccordionItem>
</Accordion>
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<Editor
initialContent={json}
onUpdate={(content) => {
setJson(content.getJSON());
setIsSaving(true);
deboucedUpdateCampaign();
}}
variables={["email", "firstName", "lastName"]}
uploadImage={
campaign.imageUploadSupported ? handleFileChange : undefined
}
/>
</div>
</div>
</div>
</div>
);

View File

@@ -60,7 +60,7 @@ export default function CampaignDetailsPage({
];
return (
<div className="container mx-auto py-8">
<div className="container mx-auto">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
@@ -78,13 +78,13 @@ export default function CampaignDetailsPage({
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className=" rounded-lg shadow mt-10">
<div className="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"
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg shadow p-4 flex flex-col gap-3"
>
<div className="flex items-center gap-3">
{card.status !== "total" ? (
@@ -108,36 +108,33 @@ export default function CampaignDetailsPage({
</div>
{campaign.html && (
<div className=" rounded-lg shadow mt-16">
<div className=" rounded-lg 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 ? (
<div className="p-2 rounded-lg border shadow flex flex-col gap-4 w-full">
<div className="flex flex-col gap-3 px-4 py-1">
<div className=" flex text-sm">
<div className="w-[70px] text-muted-foreground">Subject</div>
<div> {campaign.subject}</div>
</div>
<div className="flex text-sm">
<div className="w-[70px] text-muted-foreground">From</div>
<div> {campaign.from}</div>
</div>
<div className="flex text-sm items-center">
<div className="w-[70px] text-muted-foreground">Contact</div>
<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 " />
<div className="bg-secondary p-0.5 px-2 rounded-md ">
{campaign.contactBook?.emoji} &nbsp;
{campaign.contactBook?.name}
</div>
</Link>
) : (
<div>No one</div>
)}
</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 className=" dark:bg-slate-50 overflow-auto text-black rounded py-8 border-t">
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }} />
</div>
</div>

View File

@@ -11,19 +11,18 @@ import {
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";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
} from "@unsend/ui/src/select";
export default function CampaignList() {
const [page, setPage] = useUrlState("page", "1");
@@ -33,30 +32,37 @@ export default function CampaignList() {
const campaignsQuery = api.campaign.getCampaigns.useQuery({
page: pageNumber,
status: status as CampaignStatus | null,
});
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)}
<Select
value={status ?? "all"}
onValueChange={(val) => setStatus(val === "all" ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"}
{status ? status.toLowerCase() : "All statuses"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className=" capitalize">
<SelectItem value="all" className=" capitalize">
All statuses
</SelectItem>
<SelectItem value="Active" className=" capitalize">
Active
<SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
Draft
</SelectItem>
<SelectItem value="Inactive" className=" capitalize">
Inactive
<SelectItem
value={CampaignStatus.SCHEDULED}
className=" capitalize"
>
Scheduled
</SelectItem>
<SelectItem value={CampaignStatus.SENT} className=" capitalize">
Sent
</SelectItem>
</SelectContent>
</Select> */}
</Select>
</div>
<div className="flex flex-col rounded-xl border border-border shadow">
<Table className="">
@@ -97,10 +103,10 @@ export default function CampaignList() {
<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"
? "bg-gray-500/15 dark:bg-gray-400/15 text-gray-700 dark:text-gray-400/90 border border-gray-500/25 dark:border-gray-700/25"
: 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"
? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
: "bg-yellow-500/15 dark:bg-yellow-600/10 text-yellow-700 dark:text-yellow-600/90 border border-yellow-500/25 dark:border-yellow-700/25"
}`}
>
{campaign.status.toLowerCase()}
@@ -148,3 +154,170 @@ export default function CampaignList() {
</div>
);
}
// "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 Spinner from "@unsend/ui/src/spinner";
// import { formatDistanceToNow } from "date-fns";
// import { CampaignStatus } from "@prisma/client";
// import DeleteCampaign from "./delete-campaign";
// import Link from "next/link";
// import DuplicateCampaign from "./duplicate-campaign";
// import { motion } from "framer-motion";
// import { useRouter } from "next/navigation";
// import {
// Select,
// SelectTrigger,
// SelectContent,
// SelectItem,
// } from "@unsend/ui/src/select";
// 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,
// status: status as CampaignStatus | null,
// });
// const router = useRouter();
// 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 ? status.toLowerCase() : "All statuses"}
// </SelectTrigger>
// <SelectContent>
// <SelectItem value="all" className=" capitalize">
// All statuses
// </SelectItem>
// <SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
// Draft
// </SelectItem>
// <SelectItem
// value={CampaignStatus.SCHEDULED}
// className=" capitalize"
// >
// Scheduled
// </SelectItem>
// <SelectItem value={CampaignStatus.SENT} className=" capitalize">
// Sent
// </SelectItem>
// </SelectContent>
// </Select>
// </div>
// {campaignsQuery.isLoading ? (
// <div className="flex justify-center items-center mt-20">
// <Spinner
// className="w-5 h-5 text-primary"
// innerSvgClass="stroke-primary"
// />
// </div>
// ) : (
// <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
// {campaignsQuery.data?.campaigns.map((campaign) => (
// <motion.div
// whileHover={{ scale: 1.01 }}
// transition={{ type: "spring", stiffness: 600, damping: 10 }}
// whileTap={{ scale: 0.99 }}
// className="border rounded-xl shadow hover:shadow-lg"
// key={campaign.id}
// >
// <div className="flex flex-col">
// <Link
// href={
// campaign.status === CampaignStatus.DRAFT
// ? `/campaigns/${campaign.id}/edit`
// : `/campaigns/${campaign.id}`
// }
// >
// <div className="h-40 overflow-hidden flex justify-center rounded-t-xl bg-muted/10">
// <div
// className="transform scale-[0.5] "
// dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }}
// />
// </div>
// </Link>
// <div className="flex justify-between items-center shadow-[0px_-5px_25px_-8px_rgba(0,0,0,0.3)] rounded-xl -mt-2 z-10 bg-background">
// <div
// className="cursor-pointer w-full py-3 pl-4 flex gap-2 items-start"
// onClick={() => router.push(`/campaigns/${campaign.id}`)}
// >
// <div className="flex flex-col gap-2">
// <div className="flex gap-4">
// <div className="font-semibold text-sm">
// {campaign.name}
// </div>
// <div
// className={`text-center px-4 rounded capitalize py-0.5 text-xs ${
// campaign.status === CampaignStatus.DRAFT
// ? "bg-gray-500/15 dark:bg-gray-600/10 text-gray-700 dark:text-gray-600/90 border border-gray-500/25 dark:border-gray-700/25"
// : campaign.status === CampaignStatus.SENT
// ? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
// : "bg-yellow-500/15 dark:bg-yellow-600/10 text-yellow-700 dark:text-yellow-600/90 border border-yellow-500/25 dark:border-yellow-700/25"
// }`}
// >
// {campaign.status.toLowerCase()}
// </div>
// </div>
// <div className="text-muted-foreground text-xs">
// {formatDistanceToNow(campaign.createdAt, {
// addSuffix: true,
// })}
// </div>
// </div>
// </div>
// <div className="flex gap-2 pr-4">
// <DuplicateCampaign campaign={campaign} />
// <DeleteCampaign campaign={campaign} />
// </div>
// </div>
// </div>
// </motion.div>
// ))}
// </div>
// )}
// {campaignsQuery.data?.totalPage && campaignsQuery.data.totalPage > 1 ? (
// <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>
// ) : null}
// </div>
// );
// }

View File

@@ -74,8 +74,8 @@ export const DeleteCampaign: React.FC<{
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 variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="h-[18px] w-[18px] text-red-600/80" />
</Button>
</DialogTrigger>
<DialogContent>

View File

@@ -45,8 +45,8 @@ export const DuplicateCampaign: React.FC<{
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 variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Copy className="h-[18px] w-[18px] text-blue-600/80" />
</Button>
</DialogTrigger>
<DialogContent>

View File

@@ -120,7 +120,7 @@ export default function AddContact({
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
className=" w-[100px]"
type="submit"
disabled={addContactsMutation.isPending}
>

View File

@@ -94,8 +94,8 @@ export default function ContactList({
<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"
? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
: "bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10"
}`}
>
{contact.subscribed ? "Subscribed" : "Unsubscribed"}

View File

@@ -10,22 +10,55 @@ import {
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";
import EmojiPicker, { Theme } from "emoji-picker-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@unsend/ui/src/popover";
import { Button } from "@unsend/ui/src/button";
import { useTheme } from "@unsend/ui";
export default function ContactsPage({
params,
}: {
params: { contactBookId: string };
}) {
const { theme } = useTheme();
const contactBookDetailQuery = api.contacts.getContactBookDetails.useQuery({
contactBookId: params.contactBookId,
});
const utils = api.useUtils();
const updateContactBookMutation = api.contacts.updateContactBook.useMutation({
onMutate: async (data) => {
await utils.contacts.getContactBookDetails.cancel();
utils.contacts.getContactBookDetails.setData(
{
contactBookId: params.contactBookId,
},
(old) => {
if (!old) return old;
return {
...old,
...data,
};
}
);
},
onSettled: () => {
utils.contacts.getContactBookDetails.invalidate({
contactBookId: params.contactBookId,
});
},
});
return (
<div>
<div className="flex justify-between items-center">
@@ -34,15 +67,51 @@ export default function ContactsPage({
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/contacts" className="text-lg">
<Link href="/contacts" className="text-xl">
Contact books
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbSeparator className="text-xl" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
{contactBookDetailQuery.data?.name}
<BreadcrumbPage className="text-xl">
<div className="flex items-center gap-2">
<span className="text-lg">
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="p-0 hover:bg-transparent text-lg"
type="button"
>
{contactBookDetailQuery.data?.emoji}
</Button>
</PopoverTrigger>
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
<EmojiPicker
onEmojiClick={(emojiObject) => {
// Handle emoji selection here
// You might want to update the contactBook's emoji
updateContactBookMutation.mutate({
contactBookId: params.contactBookId,
emoji: emojiObject.emoji,
});
}}
theme={
theme === "system"
? Theme.AUTO
: theme === "dark"
? Theme.DARK
: Theme.LIGHT
}
/>
</PopoverContent>
</Popover>
</span>
<span className="text-xl">
{contactBookDetailQuery.data?.name}
</span>
</div>
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
@@ -53,38 +122,77 @@ export default function ContactsPage({
</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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Metrics</p>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Total Contacts
</div>
<div className="font-mono text-sm">
{contactBookDetailQuery.data?.totalContacts !== undefined
? contactBookDetailQuery.data?.totalContacts
: "--"}
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Unsubscribed
</div>
<div className="font-mono text-sm">
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
? contactBookDetailQuery.data?.unsubscribedContacts
: "--"}
</div>
</div>
</div>
<div>
<div className="text-muted-foreground">Unsubscribed</div>
<div className="text-xl mt-3">
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
? contactBookDetailQuery.data?.unsubscribedContacts
: "--"}
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold">Details</p>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Contact book ID
</div>
<TextWithCopyButton
value={params.contactBookId}
alwaysShowCopy
className="text-sm w-[130px] overflow-hidden text-ellipsis font-mono"
/>
</div>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Created at
</div>
<div className="text-sm">
{contactBookDetailQuery.data?.createdAt
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
addSuffix: true,
})
: "--"}
</div>
</div>
</div>
<div>
<div className="text-muted-foreground">Created at</div>
<div className="text-xl mt-3">
{contactBookDetailQuery.data?.createdAt
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold">Recent campaigns</p>
{!contactBookDetailQuery.isLoading &&
contactBookDetailQuery.data?.campaigns.length === 0 ? (
<div className="text-muted-foreground text-sm">
No campaigns yet.
</div>
) : null}
{contactBookDetailQuery.data?.campaigns.map((campaign) => (
<div key={campaign.id} className="flex items-center gap-2">
<Link href={`/campaigns/${campaign.id}`}>
<div className="text-sm hover:underline hover:decoration-dashed text-nowrap w-[200px] overflow-hidden text-ellipsis">
{campaign.name}
</div>
</Link>
<div className="text-muted-foreground text-xs">
{formatDistanceToNow(campaign.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>
</div>
<div className="mt-16">

View File

@@ -106,7 +106,7 @@ export default function AddContactBook() {
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
className=" w-[100px]"
type="submit"
disabled={createContactBookMutation.isPending}
>

View File

@@ -1,78 +1,61 @@
"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";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
export default function ContactBooksList() {
const contactBooksQuery = api.contacts.getContactBooks.useQuery();
const router = useRouter();
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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
{contactBooksQuery.data?.map((contactBook) => (
<motion.div
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 600, damping: 10 }}
whileTap={{ scale: 0.99 }}
className="border rounded-xl shadow hover:shadow-lg"
>
<div className="flex flex-col">
<Link href={`/contacts/${contactBook.id}`} key={contactBook.id}>
<div className="flex justify-between items-center p-4 mb-4">
<div className="flex items-center gap-2">
<div>{contactBook.emoji}</div>
<div className="font-semibold">{contactBook.name}</div>
</div>
<div className="text-sm">
<span className="font-mono">
{contactBook._count.contacts}
</span>{" "}
contacts
</div>
</div>
</Link>
<div className="flex justify-between items-center border-t bg-muted/50">
<div
className="text-muted-foreground text-xs cursor-pointer w-full py-3 pl-4"
onClick={() => router.push(`/contacts/${contactBook.id}`)}
>
{formatDistanceToNow(contactBook.createdAt, {
addSuffix: true,
})}
</div>
<div className="flex gap-3 pr-4">
<EditContactBook contactBook={contactBook} />
<DeleteContactBook contactBook={contactBook} />
</div>
</div>
</div>
</motion.div>
))}
</div>
</div>
);

View File

@@ -77,8 +77,8 @@ export const DeleteContactBook: React.FC<{
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 variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
<Trash2 className="h-[18px] w-[18px] text-red-600/80 hover:text-red-600/70" />
</Button>
</DialogTrigger>
<DialogContent>
@@ -103,7 +103,7 @@ export const DeleteContactBook: React.FC<{
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>name</FormLabel>
<FormLabel>Contact book name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>

View File

@@ -17,7 +17,6 @@ import {
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Edit } from "lucide-react";
@@ -73,8 +72,13 @@ export const EditContactBook: React.FC<{
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
<Button
variant="ghost"
size="sm"
className="p-0 hover:bg-transparent"
onClick={(e) => e.stopPropagation()}
>
<Edit className="h-4 w-4 text-primary/80 hover:text-primary/70" />
</Button>
</DialogTrigger>
<DialogContent>
@@ -102,7 +106,7 @@ export const EditContactBook: React.FC<{
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
className=" w-[100px]"
type="submit"
disabled={updateContactBookMutation.isPending}
>

View File

@@ -7,7 +7,7 @@ export default function ContactsPage() {
return (
<div>
<div className="flex justify-between items-center">
<h1 className="font-bold text-lg">Contact books</h1>
<h1 className="font-semibold text-xl">Contact books</h1>
<AddContactBook />
</div>
<ContactBooksList />

View File

@@ -32,6 +32,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@unsend/ui/src/dropdown-menu";
import { ThemeSwitcher } from "~/components/theme/ThemeSwitcher";
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const { data: session } = useSession();
@@ -50,7 +51,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
</div>
<div className="flex-1 h-full">
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
<div>
<div className="h-[calc(100%-120px)]">
<NavButton href="/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
@@ -61,11 +62,6 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Emails
</NavButton>
<NavButton href="/domains">
<Globe className="h-4 w-4" />
Domains
</NavButton>
<NavButton href="/contacts">
<BookUser className="h-4 w-4" />
Contacts
@@ -76,6 +72,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Campaigns
</NavButton>
<NavButton href="/domains">
<Globe className="h-4 w-4" />
Domains
</NavButton>
<NavButton href="/dev-settings">
<Code className="h-4 w-4" />
Developer settings
@@ -87,7 +88,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
</NavButton>
) : null}
</div>
<div className=" absolute bottom-10 p-4 flex flex-col gap-2">
<div className="pl-4 flex flex-col gap-2 w-full">
<Link
href="https://docs.unsend.dev"
target="_blank"
@@ -97,6 +98,9 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
<span className="">Docs</span>
</Link>
<LogoutButton />
<div>
<ThemeSwitcher />
</div>
</div>
</nav>
</div>

View File

@@ -21,7 +21,7 @@ export default function DashboardChart() {
return (
<div>
<div className="flex justify-between items-center">
<h1 className="font-bold text-lg">Dashboard</h1>
<h1 className="font-semibold text-xl">Dashboard</h1>
<Tabs
value={days || "7"}
onValueChange={(value) => setDays(value)}
@@ -100,7 +100,7 @@ export default function DashboardChart() {
)}
</div>
{!statusQuery.isLoading && statusQuery.data ? (
<div className="w-full h-[400px] border rounded-lg p-4">
<div className="w-full h-[400px] border shadow rounded-lg p-4">
<ResponsiveContainer width="100%" height="100%">
<BarChart
width={900}
@@ -205,7 +205,7 @@ const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
percentage,
}) => {
return (
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border rounded-lg p-4 flex flex-col gap-3">
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-lg p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
<div className=" capitalize">{status.toLowerCase()}</div>

View File

@@ -18,7 +18,7 @@ export default function ApiList() {
return (
<div className="mt-10">
<div className="border rounded-xl">
<div className="border rounded-xl shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">

View File

@@ -111,7 +111,7 @@ export default function DomainItemPage({
</div>
</div>
<div className=" border rounded-lg p-4">
<div className=" border rounded-lg p-4 shadow">
<p className="font-semibold text-xl">DNS records</p>
<Table className="mt-2">
<TableHeader className="">
@@ -266,7 +266,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
);
}
return (
<div className="rounded-lg p-4 border flex flex-col gap-6">
<div className="rounded-lg shadow p-4 border flex flex-col gap-6">
<p className="font-semibold text-xl">Settings</p>
<div className="flex flex-col gap-1">
<div className="font-semibold">Click tracking</div>
@@ -309,30 +309,29 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
const DnsVerificationStatus: React.FC<{ status: string }> = ({ status }) => {
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
badgeColor =
"bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25";
break;
case DomainStatus.FAILED:
badgeColor = "bg-red-500/10 text-red-600 border-red-500/20";
badgeColor =
"bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10";
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
badgeColor =
"bg-yellow-500/20 dark:bg-yellow-500/10 text-yellow-600 border border-yellow-600/10";
break;
default:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
badgeColor =
"bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20";
}
return (
<div
className={` text-center min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
className={` text-xs text-center min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
>
<span className="text-xs">
{status.split("_").join(" ").toLowerCase()}
</span>
{status.split("_").join(" ").toLowerCase()}
</div>
);
};

View File

@@ -169,7 +169,7 @@ export default function AddDomain() {
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
className=" w-[100px]"
type="submit"
disabled={addDomainMutation.isPending}
>

View File

@@ -5,21 +5,22 @@ export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({
}) => {
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
badgeColor =
"bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25";
break;
case DomainStatus.FAILED:
badgeColor = "bg-red-500/10 text-red-600 border-red-500/20";
badgeColor =
"bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10";
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
badgeColor =
"bg-yellow-500/20 dark:bg-yellow-500/10 text-yellow-600 border border-yellow-600/10";
break;
default:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
badgeColor =
"bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20";
}
return (

View File

@@ -70,7 +70,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
return (
<div key={domain.id}>
<div className=" pr-8 border rounded-lg flex items-stretch">
<div className=" pr-8 border rounded-lg flex items-stretch shadow">
<StatusIndicator status={domain.status} />
<div className="flex justify-between w-full pl-8 py-4">
<div className="flex flex-col gap-4 w-1/5">

View File

@@ -22,5 +22,5 @@ export const StatusIndicator: React.FC<{ status: DomainStatus }> = ({
badgeColor = "bg-gray-400";
}
return <div className={` w-[1px] ${badgeColor} my-1.5 rounded-full`}></div>;
return <div className={` w-[2px] ${badgeColor} my-1.5 rounded-full`}></div>;
};

View File

@@ -28,16 +28,16 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
const emailQuery = api.email.getEmail.useQuery({ id: emailId });
return (
<div className="h-full overflow-auto">
<div className="h-full overflow-auto px-4">
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<h1 className="font-bold text-lg">{emailQuery.data?.to}</h1>
<h1 className="font-bold">{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 w-full">
<div className="flex gap-2">
<div className="flex flex-col gap-8 mt-8 items-start">
<div className="p-2 rounded-lg border flex flex-col gap-4 w-full shadow">
{/* <div className="flex gap-2">
<span className="w-[100px] text-muted-foreground text-sm">
From
</span>
@@ -54,6 +54,15 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
Subject
</span>
<span className="text-sm">{emailQuery.data?.subject}</span>
</div> */}
<div className="flex flex-col gap-1 px-4 py-1">
{/* <div className=" text-[15px] font-medium">
{emailQuery.data?.to}
</div> */}
<div className=" text-sm">Subject: {emailQuery.data?.subject}</div>
<div className="text-muted-foreground text-xs">
From: {emailQuery.data?.from}
</div>
</div>
{emailQuery.data?.latestStatus === "SCHEDULED" &&
emailQuery.data?.scheduledAt ? (
@@ -75,19 +84,20 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
</div>
</>
) : null}
<div className=" dark:bg-slate-200 h-[250px] overflow-auto text-black rounded">
<div
className="px-4 py-4 overflow-auto"
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
<div className=" dark:bg-slate-200 h-[350px] overflow-visible rounded border-t ">
<iframe
className="w-full h-full"
srcDoc={emailQuery.data?.html ?? ""}
sandbox="allow-same-origin"
/>
</div>
</div>
{emailQuery.data?.latestStatus !== "SCHEDULED" ? (
<div className=" border rounded-lg w-full ">
<div className=" border rounded-lg w-full shadow ">
<div className=" p-4 flex flex-col gap-8 w-full">
<div className="font-medium">Events History</div>
<div className="flex items-stretch px-4 w-full">
<div className="border-r border-dashed" />
<div className="border-r border-gray-300 dark:border-gray-700 border-dashed" />
<div className="flex flex-col gap-12 w-full">
{emailQuery.data?.emailEvents.map((evt) => (
<div
@@ -150,7 +160,7 @@ const EmailStatusText = ({
return (
<div className="flex flex-col gap-4 w-full">
<p>{getErrorMessage(_errorData)}</p>
<div className="rounded-xl p-4 bg-muted/20 flex flex-col gap-4">
<div className="rounded-xl p-4 bg-muted/30 flex flex-col gap-4">
<div className="flex gap-2 w-full">
<div className="w-1/2">
<p className="text-sm text-muted-foreground">Type</p>
@@ -176,7 +186,7 @@ const EmailStatusText = ({
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="w-full rounded-xl p-4 bg-muted/20 mt-4">
<div className="w-full rounded-xl p-4 bg-muted/30 mt-4">
<div className="flex w-full ">
{userAgent.os.name ? (
<div className="w-1/2">
@@ -198,7 +208,7 @@ const EmailStatusText = ({
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/20">
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/30">
<div className="flex w-full ">
{userAgent.os.name ? (
<div className="w-1/2">

View File

@@ -87,7 +87,17 @@ export default function EmailsList() {
<SelectItem value="All statuses" className=" capitalize">
All statuses
</SelectItem>
{Object.values(EmailStatus).map((status) => (
{Object.values([
"SENT",
"SCHEDULED",
"QUEUED",
"DELIVERED",
"BOUNCED",
"CLICKED",
"OPENED",
"DELIVERY_DELAYED",
"COMPLAINED",
]).map((status) => (
<SelectItem value={status} className=" capitalize">
{status.toLowerCase().replace("_", " ")}
</SelectItem>
@@ -101,7 +111,7 @@ export default function EmailsList() {
<div className="flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className=" bg-muted dark:bg-muted/70">
<TableHead className="rounded-tl-xl">To</TableHead>
<TableHead>Status</TableHead>
<TableHead>Subject</TableHead>
@@ -129,8 +139,8 @@ export default function EmailsList() {
>
<TableCell className="font-medium">
<div className="flex gap-4 items-center">
<EmailIcon status={email.latestStatus ?? "Sent"} />
<p>{email.to}</p>
{/* <EmailIcon status={email.latestStatus ?? "Sent"} /> */}
<p> {email.to}</p>
</div>
</TableCell>
<TableCell>
@@ -155,7 +165,9 @@ export default function EmailsList() {
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
)}
</TableCell>
<TableCell>{email.subject}</TableCell>
<TableCell className="">
<div className=" max-w-xs truncate">{email.subject}</div>
</TableCell>
<TableCell className="text-right">
{email.latestStatus !== "SCHEDULED"
? formatDistanceToNow(

View File

@@ -3,32 +3,36 @@ 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
let badgeColor = "bg-gray-700/10 text-gray-400 border 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";
badgeColor =
"bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25";
break;
case "BOUNCED":
case "FAILED":
badgeColor = "bg-red-500/10 text-red-600 border-red-600/10";
badgeColor =
"bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10";
break;
case "CLICKED":
badgeColor = "bg-cyan-500/10 text-cyan-500 border-cyan-600/10";
badgeColor =
"bg-sky-500/15 text-sky-700 dark:text-sky-600 border border-sky-600/20";
break;
case "OPENED":
badgeColor = "bg-indigo-500/10 text-indigo-500 border-indigo-600/10";
badgeColor =
"bg-indigo-500/15 text-indigo-600 dark:text-indigo-500 border border-indigo-600/20";
break;
case "DELIVERY_DELAYED":
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/20";
break;
case "COMPLAINED":
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
badgeColor =
"bg-yellow-500/20 dark:bg-yellow-500/10 text-yellow-600 border border-yellow-600/10";
break;
default:
badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10";
badgeColor =
"bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20";
}
return (
@@ -43,13 +47,13 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
status,
}) => {
let outsideColor = "bg-gray-600";
let insideColor = "bg-gray-600/50";
let outsideColor = "bg-gray-500";
let insideColor = "bg-gray-500/50";
switch (status) {
case "DELIVERED":
outsideColor = "bg-emerald-500/30";
insideColor = "bg-emerald-500";
outsideColor = "bg-green-500/30";
insideColor = "bg-green-500";
break;
case "BOUNCED":
case "FAILED":
@@ -57,8 +61,8 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
insideColor = "bg-red-500";
break;
case "CLICKED":
outsideColor = "bg-cyan-500/30";
insideColor = "bg-cyan-500";
outsideColor = "bg-sky-500/30";
insideColor = "bg-sky-500";
break;
case "OPENED":
outsideColor = "bg-indigo-500/30";
@@ -73,8 +77,8 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
insideColor = "bg-yellow-500";
break;
default:
outsideColor = "bg-gray-600/40";
insideColor = "bg-gray-600";
outsideColor = "bg-gray-500/20";
insideColor = "bg-gray-500";
}
return (

View File

@@ -1,13 +1,12 @@
"use client";
import dynamic from "next/dynamic";
import EmailList from "./email-list";
export default function EmailsPage() {
return (
<div>
<div className="flex justify-between items-center">
<h1 className="font-bold text-lg">Emails</h1>
<h1 className="font-semibold text-xl">Emails</h1>
</div>
<EmailList />
</div>

View File

@@ -26,7 +26,7 @@ export default async function RootLayout({
return (
<html lang="en">
<body className={`font-sans ${inter.variable} app`}>
<ThemeProvider attribute="class" defaultTheme="dark">
<ThemeProvider attribute="class" defaultTheme="light">
<Toaster />
<TRPCReactProvider>{children}</TRPCReactProvider>
</ThemeProvider>

View File

@@ -6,7 +6,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { ClientSafeProvider, signIn } from "next-auth/react";
import { ClientSafeProvider, LiteralUnion, signIn } from "next-auth/react";
import {
Form,
FormControl,
@@ -23,7 +23,9 @@ import {
} from "@unsend/ui/src/input-otp";
import { Input } from "@unsend/ui/src/input";
import { env } from "~/env";
import { Provider } from "next-auth/providers/index";
import { BuiltInProviderType, Provider } from "next-auth/providers/index";
import Spinner from "@unsend/ui/src/spinner";
import Link from "next/link";
const emailSchema = z.object({
email: z
@@ -42,7 +44,7 @@ const providerSvgs = {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
className="h-6 w-6 stroke-black fill-black mr-4"
className="h-5 w-5 fill-primary-foreground "
>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
@@ -51,7 +53,7 @@ const providerSvgs = {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488 512"
className="h-6 w-6 stroke-black fill-black mr-4"
className="h-5 w-5 fill-primary-foreground"
>
<path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z" />
</svg>
@@ -60,8 +62,10 @@ const providerSvgs = {
export default function LoginPage({
providers,
isSignup = false,
}: {
providers?: ClientSafeProvider[];
isSignup?: boolean;
}) {
const [emailStatus, setEmailStatus] = useState<
"idle" | "sending" | "success"
@@ -85,7 +89,7 @@ export default function LoginPage({
}
async function onOTPSubmit(values: z.infer<typeof otpSchema>) {
const { href: callbackUrl } = window.location;
const { origin: callbackUrl } = window.location;
const email = emailForm.getValues().email;
console.log("email", email);
@@ -98,9 +102,17 @@ export default function LoginPage({
(provider) => provider.type === "email"
);
const [submittedProvider, setSubmittedProvider] =
useState<LiteralUnion<BuiltInProviderType> | null>(null);
const handleSubmit = (provider: LiteralUnion<BuiltInProviderType>) => {
setSubmittedProvider(provider);
signIn(provider);
};
return (
<main className="h-screen flex justify-center items-center">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
<Image
src="/logo-dark.png"
alt="Unsend"
@@ -108,8 +120,22 @@ export default function LoginPage({
height={60}
className="mx-auto border rounded-lg p-2 bg-black"
/>
<p className="text-2xl text-center">Log in to unsend</p>
<div className="flex flex-col gap-8 mt-8">
<div>
<p className="text-2xl text-center font-semibold">
{isSignup ? "Create new account" : "Sign into Unsend"}
</p>
<p className="text-center mt-2 text-sm text-muted-foreground">
{isSignup ? "Already have an account?" : "New to Unsend?"}
<Link
href={isSignup ? "/login" : "/signup"}
className=" text-primary hover:underline ml-1"
>
{isSignup ? "Sign in" : "Create new account"}
</Link>
</p>
</div>
<div className="flex flex-col gap-8 mt-8 border p-8 rounded-lg shadow">
{providers &&
Object.values(providers).map((provider) => {
if (provider.type === "email") return null;
@@ -118,10 +144,17 @@ export default function LoginPage({
key={provider.id}
className="w-[350px]"
size="lg"
onClick={() => signIn(provider.id)}
onClick={() => handleSubmit(provider.id)}
>
{providerSvgs[provider.id as keyof typeof providerSvgs]}
Continue with {provider.name}
{submittedProvider === provider.id ? (
<Spinner className="w-5 h-5" />
) : (
providerSvgs[provider.id as keyof typeof providerSvgs]
)}
<span className="ml-4">
{isSignup ? "Sign up with" : "Continue with"}{" "}
{provider.name}
</span>
</Button>
);
})}
@@ -131,7 +164,7 @@ export default function LoginPage({
<p className=" z-10 ml-[175px] -translate-x-1/2 bg-background px-4 text-sm">
or
</p>
<div className="absolute h-[1px] w-[350px] bg-gradient-to-r from-zinc-800 via-zinc-300 to-zinc-800"></div>
<div className="absolute h-[1px] w-[350px] bg-gradient-to-l from-zinc-300 via-zinc-800 to-zinc-300"></div>
</div>
{emailStatus === "success" ? (
<>
@@ -141,7 +174,7 @@ export default function LoginPage({
<Form {...otpForm}>
<form
onSubmit={otpForm.handleSubmit(onOTPSubmit)}
className="space-y-4"
className=""
>
<FormField
control={otpForm.control}
@@ -186,7 +219,7 @@ export default function LoginPage({
)}
/>
<Button className="mt-6 w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100">
<Button size="lg" className=" mt-9 w-[350px]">
Submit
</Button>
</form>
@@ -197,7 +230,7 @@ export default function LoginPage({
<Form {...emailForm}>
<form
onSubmit={emailForm.handleSubmit(onEmailSubmit)}
className="space-y-4"
className="space-y-6"
>
<FormField
control={emailForm.control}
@@ -218,13 +251,13 @@ export default function LoginPage({
)}
/>
<Button
className=" w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100"
type="submit"
className=" w-[350px] "
size="lg"
disabled={emailStatus === "sending"}
>
{emailStatus === "sending"
? "Sending..."
: "Send magic link"}
: "Continue with email"}
</Button>
</form>
</Form>

View File

@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import LoginPage from "../login/login-page";
import { getProviders } from "next-auth/react";
export default async function Login() {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard");
}
const providers = await getProviders();
return <LoginPage providers={Object.values(providers ?? {})} isSignup />;
}

View File

@@ -0,0 +1,45 @@
import { cn, useTheme } from "@unsend/ui";
import { Button } from "@unsend/ui/src/button";
import { Monitor, Sun, Moon, SunMoonIcon } from "lucide-react";
export const ThemeSwitcher = () => {
const { theme, setTheme, systemTheme } = useTheme();
return (
<div className="flex gap-2 items-center justify-between w-full">
<p className="text-sm text-muted-foreground flex items-center gap-2">
<SunMoonIcon className="h-4 w-4" />
Theme
</p>
<div className="flex gap-2 border rounded-md p-0.5 ">
<Button
variant="ghost"
size="sm"
className={cn("p-0.5 h-5 w-5", theme === "system" ? "bg-muted" : "")}
onClick={() => setTheme("system")}
>
<Monitor className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"p-0.5 h-5 w-5",
theme === "light" ? " bg-gray-200" : ""
)}
onClick={() => setTheme("light")}
>
<Sun className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn("p-0.5 h-5 w-5", theme === "dark" ? "bg-muted" : "")}
onClick={() => setTheme("dark")}
>
<Moon className="h-3 w-3" />
</Button>
</div>
</div>
);
};

View File

@@ -1,5 +1,6 @@
import { Prisma } from "@prisma/client";
import { CampaignStatus, Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { z } from "zod";
import { env } from "~/env";
import {
@@ -20,11 +21,14 @@ import {
isStorageConfigured,
} from "~/server/service/storage-service";
const statuses = Object.values(CampaignStatus) as [CampaignStatus];
export const campaignRouter = createTRPCRouter({
getCampaigns: teamProcedure
.input(
z.object({
page: z.number().optional(),
status: z.enum(statuses).optional().nullable(),
})
)
.query(async ({ ctx: { db, team }, input }) => {
@@ -36,6 +40,10 @@ export const campaignRouter = createTRPCRouter({
teamId: team.id,
};
if (input.status) {
whereConditions.status = input.status;
}
const countP = db.campaign.count({ where: whereConditions });
const campaignsP = db.campaign.findMany({
@@ -48,6 +56,7 @@ export const campaignRouter = createTRPCRouter({
createdAt: true,
updatedAt: true,
status: true,
html: true,
},
orderBy: {
createdAt: "desc",
@@ -92,6 +101,7 @@ export const campaignRouter = createTRPCRouter({
previewText: z.string().optional(),
content: z.string().optional(),
contactBookId: z.string().optional(),
replyTo: z.string().array().optional(),
})
)
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
@@ -113,10 +123,21 @@ export const campaignRouter = createTRPCRouter({
const domain = await validateDomainFromEmail(data.from, team.id);
domainId = domain.id;
}
let html: string | null = null;
if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null;
const renderer = new EmailRenderer(jsonContent);
html = await renderer.render();
}
const campaign = await db.campaign.update({
where: { id: campaignId },
data: {
...data,
html,
domainId,
},
});

View File

@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { CampaignStatus, Prisma } from "@prisma/client";
import { z } from "zod";
import {
@@ -43,19 +43,31 @@ export const contactsRouter = createTRPCRouter({
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 },
}),
]);
const [totalContacts, unsubscribedContacts, campaigns] =
await Promise.all([
db.contact.count({
where: { contactBookId: contactBook.id },
}),
db.contact.count({
where: { contactBookId: contactBook.id, subscribed: false },
}),
db.campaign.findMany({
where: {
contactBookId: contactBook.id,
status: CampaignStatus.SENT,
},
orderBy: {
createdAt: "desc",
},
take: 2,
}),
]);
return {
...contactBook,
totalContacts,
unsubscribedContacts,
campaigns,
};
}
),
@@ -66,6 +78,7 @@ export const contactsRouter = createTRPCRouter({
contactBookId: z.string(),
name: z.string().optional(),
properties: z.record(z.string()).optional(),
emoji: z.string().optional(),
})
)
.mutation(async ({ ctx: { db }, input }) => {

View File

@@ -177,7 +177,7 @@ export const emailRouter = createTRPCRouter({
select: {
emailEvents: {
orderBy: {
status: "asc",
status: "desc",
},
},
id: true,

View File

@@ -188,6 +188,7 @@ type CampainEmail = {
from: string;
subject: string;
html: string;
previewText?: string;
replyTo?: string[];
cc?: string[];
bcc?: string[];
@@ -199,8 +200,17 @@ export async function sendCampaignEmail(
campaign: Campaign,
emailData: CampainEmail
) {
const { campaignId, from, subject, replyTo, cc, bcc, teamId, contacts } =
emailData;
const {
campaignId,
from,
subject,
replyTo,
cc,
bcc,
teamId,
contacts,
previewText,
} = emailData;
const jsonContent = JSON.parse(campaign.content || "{}");
const renderer = new EmailRenderer(jsonContent);
@@ -242,6 +252,7 @@ export async function sendCampaignEmail(
from,
subject,
html: contact.html,
text: previewText,
teamId,
campaignId,
contactId: contact.id,