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:
@@ -38,6 +38,8 @@
|
||||
"bullmq": "^5.8.2",
|
||||
"chrono-node": "^2.7.6",
|
||||
"date-fns": "^3.6.0",
|
||||
"emoji-picker-react": "^4.12.0",
|
||||
"framer-motion": "^11.0.24",
|
||||
"hono": "^4.2.2",
|
||||
"html-to-text": "^9.0.5",
|
||||
"install": "^0.13.0",
|
||||
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ContactBook" ADD COLUMN "emoji" TEXT NOT NULL DEFAULT '📙';
|
@@ -221,6 +221,7 @@ model ContactBook {
|
||||
properties Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
emoji String @default("📙")
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contacts Contact[]
|
||||
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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}
|
||||
{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>
|
||||
|
@@ -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>
|
||||
// );
|
||||
// }
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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}
|
||||
>
|
||||
|
@@ -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"}
|
||||
|
@@ -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">
|
||||
|
@@ -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}
|
||||
>
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
|
@@ -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}
|
||||
>
|
||||
|
@@ -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 />
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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}
|
||||
>
|
||||
|
@@ -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 (
|
||||
|
@@ -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">
|
||||
|
@@ -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>;
|
||||
};
|
||||
|
@@ -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">
|
||||
|
@@ -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(
|
||||
|
@@ -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 (
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
16
apps/web/src/app/signup/page.tsx
Normal file
16
apps/web/src/app/signup/page.tsx
Normal 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 />;
|
||||
}
|
45
apps/web/src/components/theme/ThemeSwitcher.tsx
Normal file
45
apps/web/src/components/theme/ThemeSwitcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
@@ -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,
|
||||
},
|
||||
});
|
||||
|
@@ -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 }) => {
|
||||
|
@@ -177,7 +177,7 @@ export const emailRouter = createTRPCRouter({
|
||||
select: {
|
||||
emailEvents: {
|
||||
orderBy: {
|
||||
status: "asc",
|
||||
status: "desc",
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
|
@@ -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,
|
||||
|
Reference in New Issue
Block a user