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

@@ -38,6 +38,8 @@
"bullmq": "^5.8.2", "bullmq": "^5.8.2",
"chrono-node": "^2.7.6", "chrono-node": "^2.7.6",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"emoji-picker-react": "^4.12.0",
"framer-motion": "^11.0.24",
"hono": "^4.2.2", "hono": "^4.2.2",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"install": "^0.13.0", "install": "^0.13.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ContactBook" ADD COLUMN "emoji" TEXT NOT NULL DEFAULT '📙';

View File

@@ -221,6 +221,7 @@ model ContactBook {
properties Json properties Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
emoji String @default("📙")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contacts Contact[] contacts Contact[]

View File

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

View File

@@ -35,6 +35,12 @@ import {
import { toast } from "@unsend/ui/src/toaster"; import { toast } from "@unsend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@unsend/ui/src/accordion";
const sendSchema = z.object({ const sendSchema = z.object({
confirmation: z.string(), confirmation: z.string(),
@@ -97,6 +103,12 @@ function CampaignEditor({
const [subject, setSubject] = useState(campaign.subject); const [subject, setSubject] = useState(campaign.subject);
const [from, setFrom] = useState(campaign.from); const [from, setFrom] = useState(campaign.from);
const [contactBookId, setContactBookId] = useState(campaign.contactBookId); 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 [openSendDialog, setOpenSendDialog] = useState(false);
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({ const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
@@ -179,10 +191,14 @@ function CampaignEditor({
const confirmation = sendForm.watch("confirmation"); const confirmation = sendForm.watch("confirmation");
const contactBook = contactBooksQuery.data?.find(
(book) => book.id === contactBookId
);
return ( return (
<div className="p-4 container mx-auto"> <div className="p-4 container mx-auto ">
<div className="w-[664px] mx-auto"> <div className="mx-auto">
<div className="mb-4 flex justify-between items-center"> <div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
<Input <Input
type="text" type="text"
value={name} value={name}
@@ -269,114 +285,204 @@ function CampaignEditor({
</Dialog> </Dialog>
</div> </div>
</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 <Accordion type="single" collapsible>
initialContent={json} <AccordionItem value="item-1">
onUpdate={(content) => { <div className="flex flex-col border shadow rounded-lg mt-12 mb-12 p-4 w-[700px] mx-auto z-50">
setJson(content.getJSON()); <div className="flex items-center gap-4">
setIsSaving(true); <label className="block text-sm w-[80px] text-muted-foreground">
deboucedUpdateCampaign(); Subject
}} </label>
variables={["email", "firstName", "lastName"]} <input
uploadImage={ type="text"
campaign.imageUploadSupported ? handleFileChange : undefined 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>
</div> </div>
); );

View File

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

View File

@@ -11,19 +11,18 @@ import {
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState"; import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@unsend/ui/src/button"; 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 Spinner from "@unsend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { CampaignStatus } from "@prisma/client"; import { CampaignStatus } from "@prisma/client";
import DeleteCampaign from "./delete-campaign"; import DeleteCampaign from "./delete-campaign";
import { Edit2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import DuplicateCampaign from "./duplicate-campaign"; import DuplicateCampaign from "./duplicate-campaign";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
} from "@unsend/ui/src/select";
export default function CampaignList() { export default function CampaignList() {
const [page, setPage] = useUrlState("page", "1"); const [page, setPage] = useUrlState("page", "1");
@@ -33,30 +32,37 @@ export default function CampaignList() {
const campaignsQuery = api.campaign.getCampaigns.useQuery({ const campaignsQuery = api.campaign.getCampaigns.useQuery({
page: pageNumber, page: pageNumber,
status: status as CampaignStatus | null,
}); });
return ( return (
<div className="mt-10 flex flex-col gap-4"> <div className="mt-10 flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex justify-end">
{/* <Select <Select
value={status ?? "All"} value={status ?? "all"}
onValueChange={(val) => setStatus(val === "All" ? null : val)} onValueChange={(val) => setStatus(val === "all" ? null : val)}
> >
<SelectTrigger className="w-[180px] capitalize"> <SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"} {status ? status.toLowerCase() : "All statuses"}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="All" className=" capitalize"> <SelectItem value="all" className=" capitalize">
All statuses All statuses
</SelectItem> </SelectItem>
<SelectItem value="Active" className=" capitalize"> <SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
Active Draft
</SelectItem> </SelectItem>
<SelectItem value="Inactive" className=" capitalize"> <SelectItem
Inactive value={CampaignStatus.SCHEDULED}
className=" capitalize"
>
Scheduled
</SelectItem>
<SelectItem value={CampaignStatus.SENT} className=" capitalize">
Sent
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> */} </Select>
</div> </div>
<div className="flex flex-col rounded-xl border border-border shadow"> <div className="flex flex-col rounded-xl border border-border shadow">
<Table className=""> <Table className="">
@@ -97,10 +103,10 @@ export default function CampaignList() {
<div <div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${ className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
campaign.status === CampaignStatus.DRAFT 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 : campaign.status === CampaignStatus.SENT
? "bg-emerald-500/10 text-emerald-500 border-emerald-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/10 text-yellow-600 border-yellow-600/10" : "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()} {campaign.status.toLowerCase()}
@@ -148,3 +154,170 @@ export default function CampaignList() {
</div> </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)} onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="h-4 w-4 text-red-600/80" /> <Trash2 className="h-[18px] w-[18px] text-red-600/80" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>

View File

@@ -45,8 +45,8 @@ export const DuplicateCampaign: React.FC<{
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Copy className="h-4 w-4 text-blue-600/80" /> <Copy className="h-[18px] w-[18px] text-blue-600/80" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>

View File

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

View File

@@ -94,8 +94,8 @@ export default function ContactList({
<div <div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${ className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
contact.subscribed contact.subscribed
? "bg-emerald-500/10 text-emerald-500 border-emerald-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 border-red-600/10" : "bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10"
}`} }`}
> >
{contact.subscribed ? "Subscribed" : "Unsubscribed"} {contact.subscribed ? "Subscribed" : "Unsubscribed"}

View File

@@ -10,22 +10,55 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@unsend/ui/src/breadcrumb"; } from "@unsend/ui/src/breadcrumb";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@unsend/ui/src/button";
import { Plus } from "lucide-react";
import AddContact from "./add-contact"; import AddContact from "./add-contact";
import ContactList from "./contact-list"; import ContactList from "./contact-list";
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy"; import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
import { formatDistanceToNow } from "date-fns"; 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({ export default function ContactsPage({
params, params,
}: { }: {
params: { contactBookId: string }; params: { contactBookId: string };
}) { }) {
const { theme } = useTheme();
const contactBookDetailQuery = api.contacts.getContactBookDetails.useQuery({ const contactBookDetailQuery = api.contacts.getContactBookDetails.useQuery({
contactBookId: params.contactBookId, 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 ( return (
<div> <div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@@ -34,15 +67,51 @@ export default function ContactsPage({
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink asChild> <BreadcrumbLink asChild>
<Link href="/contacts" className="text-lg"> <Link href="/contacts" className="text-xl">
Contact books Contact books
</Link> </Link>
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" /> <BreadcrumbSeparator className="text-xl" />
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbPage className="text-lg "> <BreadcrumbPage className="text-xl">
{contactBookDetailQuery.data?.name} <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> </BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
@@ -53,38 +122,77 @@ export default function ContactsPage({
</div> </div>
</div> </div>
<div className="mt-16"> <div className="mt-16">
<div className="flex justify-between"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
<div> <div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<div className=" text-muted-foreground">Total Contacts</div> <p className="font-semibold mb-1">Metrics</p>
<div className="text-xl mt-3"> <div className="flex items-center gap-2">
{contactBookDetailQuery.data?.totalContacts !== undefined <div className="text-muted-foreground w-[130px] text-sm">
? contactBookDetailQuery.data?.totalContacts 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>
<div> <div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<div className="text-muted-foreground">Unsubscribed</div> <p className="font-semibold">Details</p>
<div className="text-xl mt-3"> <div className="flex items-center gap-2">
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined <div className="text-muted-foreground w-[130px] text-sm">
? contactBookDetailQuery.data?.unsubscribedContacts 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>
<div> <div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<div className="text-muted-foreground">Created at</div> <p className="font-semibold">Recent campaigns</p>
<div className="text-xl mt-3"> {!contactBookDetailQuery.isLoading &&
{contactBookDetailQuery.data?.createdAt contactBookDetailQuery.data?.campaigns.length === 0 ? (
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, { <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, addSuffix: true,
}) })}
: "--"} </div>
</div> </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"> <div className="mt-16">

View File

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

View File

@@ -1,78 +1,61 @@
"use client"; "use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unsend/ui/src/table";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import Spinner from "@unsend/ui/src/spinner";
import DeleteContactBook from "./delete-contact-book"; import DeleteContactBook from "./delete-contact-book";
import Link from "next/link"; import Link from "next/link";
import EditContactBook from "./edit-contact-book"; import EditContactBook from "./edit-contact-book";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
export default function ContactBooksList() { export default function ContactBooksList() {
const contactBooksQuery = api.contacts.getContactBooks.useQuery(); const contactBooksQuery = api.contacts.getContactBooks.useQuery();
const router = useRouter();
return ( return (
<div className="mt-10"> <div className="mt-10">
<div className="border rounded-xl"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
<Table className=""> {contactBooksQuery.data?.map((contactBook) => (
<TableHeader className=""> <motion.div
<TableRow className=" bg-muted/30"> whileHover={{ scale: 1.01 }}
<TableHead className="rounded-tl-xl">Name</TableHead> transition={{ type: "spring", stiffness: 600, damping: 10 }}
<TableHead>Contacts</TableHead> whileTap={{ scale: 0.99 }}
<TableHead>Created at</TableHead> className="border rounded-xl shadow hover:shadow-lg"
<TableHead>Actions</TableHead> >
</TableRow> <div className="flex flex-col">
</TableHeader> <Link href={`/contacts/${contactBook.id}`} key={contactBook.id}>
<TableBody> <div className="flex justify-between items-center p-4 mb-4">
{contactBooksQuery.isLoading ? ( <div className="flex items-center gap-2">
<TableRow className="h-32"> <div>{contactBook.emoji}</div>
<TableCell colSpan={6} className="text-center py-4"> <div className="font-semibold">{contactBook.name}</div>
<Spinner </div>
className="w-6 h-6 mx-auto" <div className="text-sm">
innerSvgClass="stroke-primary" <span className="font-mono">
/> {contactBook._count.contacts}
</TableCell> </span>{" "}
</TableRow> contacts
) : contactBooksQuery.data?.length === 0 ? ( </div>
<TableRow className="h-32"> </div>
<TableCell colSpan={6} className="text-center py-4"> </Link>
<p>No contact books added</p>
</TableCell> <div className="flex justify-between items-center border-t bg-muted/50">
</TableRow> <div
) : ( className="text-muted-foreground text-xs cursor-pointer w-full py-3 pl-4"
contactBooksQuery.data?.map((contactBook) => ( onClick={() => router.push(`/contacts/${contactBook.id}`)}
<TableRow> >
<TableHead scope="row"> {formatDistanceToNow(contactBook.createdAt, {
<Link addSuffix: true,
href={`/contacts/${contactBook.id}`} })}
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-primary" </div>
> <div className="flex gap-3 pr-4">
{contactBook.name} <EditContactBook contactBook={contactBook} />
</Link> <DeleteContactBook contactBook={contactBook} />
</TableHead> </div>
{/* <TableCell>{contactBook.name}</TableCell> */} </div>
<TableCell>{contactBook._count.contacts}</TableCell> </div>
<TableCell> </motion.div>
{formatDistanceToNow(contactBook.createdAt, { ))}
addSuffix: true,
})}
</TableCell>
<TableCell>
<EditContactBook contactBook={contactBook} />
<DeleteContactBook contactBook={contactBook} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ export default function ContactsPage() {
return ( return (
<div> <div>
<div className="flex justify-between items-center"> <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 /> <AddContactBook />
</div> </div>
<ContactBooksList /> <ContactBooksList />

View File

@@ -32,6 +32,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@unsend/ui/src/dropdown-menu"; } from "@unsend/ui/src/dropdown-menu";
import { ThemeSwitcher } from "~/components/theme/ThemeSwitcher";
export function DashboardLayout({ children }: { children: React.ReactNode }) { export function DashboardLayout({ children }: { children: React.ReactNode }) {
const { data: session } = useSession(); const { data: session } = useSession();
@@ -50,7 +51,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
</div> </div>
<div className="flex-1 h-full"> <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"> <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"> <NavButton href="/dashboard">
<LayoutDashboard className="h-4 w-4" /> <LayoutDashboard className="h-4 w-4" />
Dashboard Dashboard
@@ -61,11 +62,6 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Emails Emails
</NavButton> </NavButton>
<NavButton href="/domains">
<Globe className="h-4 w-4" />
Domains
</NavButton>
<NavButton href="/contacts"> <NavButton href="/contacts">
<BookUser className="h-4 w-4" /> <BookUser className="h-4 w-4" />
Contacts Contacts
@@ -76,6 +72,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
Campaigns Campaigns
</NavButton> </NavButton>
<NavButton href="/domains">
<Globe className="h-4 w-4" />
Domains
</NavButton>
<NavButton href="/dev-settings"> <NavButton href="/dev-settings">
<Code className="h-4 w-4" /> <Code className="h-4 w-4" />
Developer settings Developer settings
@@ -87,7 +88,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
</NavButton> </NavButton>
) : null} ) : null}
</div> </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 <Link
href="https://docs.unsend.dev" href="https://docs.unsend.dev"
target="_blank" target="_blank"
@@ -97,6 +98,9 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
<span className="">Docs</span> <span className="">Docs</span>
</Link> </Link>
<LogoutButton /> <LogoutButton />
<div>
<ThemeSwitcher />
</div>
</div> </div>
</nav> </nav>
</div> </div>

View File

@@ -21,7 +21,7 @@ export default function DashboardChart() {
return ( return (
<div> <div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h1 className="font-bold text-lg">Dashboard</h1> <h1 className="font-semibold text-xl">Dashboard</h1>
<Tabs <Tabs
value={days || "7"} value={days || "7"}
onValueChange={(value) => setDays(value)} onValueChange={(value) => setDays(value)}
@@ -100,7 +100,7 @@ export default function DashboardChart() {
)} )}
</div> </div>
{!statusQuery.isLoading && statusQuery.data ? ( {!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%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
width={900} width={900}
@@ -205,7 +205,7 @@ const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
percentage, percentage,
}) => { }) => {
return ( 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"> <div className="flex items-center gap-3">
{status !== "total" ? <EmailStatusIcon status={status} /> : null} {status !== "total" ? <EmailStatusIcon status={status} /> : null}
<div className=" capitalize">{status.toLowerCase()}</div> <div className=" capitalize">{status.toLowerCase()}</div>

View File

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

View File

@@ -111,7 +111,7 @@ export default function DomainItemPage({
</div> </div>
</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> <p className="font-semibold text-xl">DNS records</p>
<Table className="mt-2"> <Table className="mt-2">
<TableHeader className=""> <TableHeader className="">
@@ -266,7 +266,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
); );
} }
return ( 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> <p className="font-semibold text-xl">Settings</p>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="font-semibold">Click tracking</div> <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 }) => { const DnsVerificationStatus: React.FC<{ status: string }> = ({ status }) => {
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) { switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case DomainStatus.SUCCESS: 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; break;
case DomainStatus.FAILED: 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; break;
case DomainStatus.TEMPORARY_FAILURE: case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING: 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; break;
default: 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 ( return (
<div <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()}
{status.split("_").join(" ").toLowerCase()}
</span>
</div> </div>
); );
}; };

View File

@@ -169,7 +169,7 @@ export default function AddDomain() {
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100" className=" w-[100px]"
type="submit" type="submit"
disabled={addDomainMutation.isPending} 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 let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) { switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case DomainStatus.SUCCESS: 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; break;
case DomainStatus.FAILED: 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; break;
case DomainStatus.TEMPORARY_FAILURE: case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING: 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; break;
default: 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 ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useState } from "react"; import { useState } from "react";
import { ClientSafeProvider, signIn } from "next-auth/react"; import { ClientSafeProvider, LiteralUnion, signIn } from "next-auth/react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -23,7 +23,9 @@ import {
} from "@unsend/ui/src/input-otp"; } from "@unsend/ui/src/input-otp";
import { Input } from "@unsend/ui/src/input"; import { Input } from "@unsend/ui/src/input";
import { env } from "~/env"; 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({ const emailSchema = z.object({
email: z email: z
@@ -42,7 +44,7 @@ const providerSvgs = {
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512" 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" /> <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> </svg>
@@ -51,7 +53,7 @@ const providerSvgs = {
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488 512" 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" /> <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> </svg>
@@ -60,8 +62,10 @@ const providerSvgs = {
export default function LoginPage({ export default function LoginPage({
providers, providers,
isSignup = false,
}: { }: {
providers?: ClientSafeProvider[]; providers?: ClientSafeProvider[];
isSignup?: boolean;
}) { }) {
const [emailStatus, setEmailStatus] = useState< const [emailStatus, setEmailStatus] = useState<
"idle" | "sending" | "success" "idle" | "sending" | "success"
@@ -85,7 +89,7 @@ export default function LoginPage({
} }
async function onOTPSubmit(values: z.infer<typeof otpSchema>) { async function onOTPSubmit(values: z.infer<typeof otpSchema>) {
const { href: callbackUrl } = window.location; const { origin: callbackUrl } = window.location;
const email = emailForm.getValues().email; const email = emailForm.getValues().email;
console.log("email", email); console.log("email", email);
@@ -98,9 +102,17 @@ export default function LoginPage({
(provider) => provider.type === "email" (provider) => provider.type === "email"
); );
const [submittedProvider, setSubmittedProvider] =
useState<LiteralUnion<BuiltInProviderType> | null>(null);
const handleSubmit = (provider: LiteralUnion<BuiltInProviderType>) => {
setSubmittedProvider(provider);
signIn(provider);
};
return ( return (
<main className="h-screen flex justify-center items-center"> <main className="h-screen flex justify-center items-center">
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-6">
<Image <Image
src="/logo-dark.png" src="/logo-dark.png"
alt="Unsend" alt="Unsend"
@@ -108,8 +120,22 @@ export default function LoginPage({
height={60} height={60}
className="mx-auto border rounded-lg p-2 bg-black" className="mx-auto border rounded-lg p-2 bg-black"
/> />
<p className="text-2xl text-center">Log in to unsend</p> <div>
<div className="flex flex-col gap-8 mt-8"> <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 && {providers &&
Object.values(providers).map((provider) => { Object.values(providers).map((provider) => {
if (provider.type === "email") return null; if (provider.type === "email") return null;
@@ -118,10 +144,17 @@ export default function LoginPage({
key={provider.id} key={provider.id}
className="w-[350px]" className="w-[350px]"
size="lg" size="lg"
onClick={() => signIn(provider.id)} onClick={() => handleSubmit(provider.id)}
> >
{providerSvgs[provider.id as keyof typeof providerSvgs]} {submittedProvider === provider.id ? (
Continue with {provider.name} <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> </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"> <p className=" z-10 ml-[175px] -translate-x-1/2 bg-background px-4 text-sm">
or or
</p> </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> </div>
{emailStatus === "success" ? ( {emailStatus === "success" ? (
<> <>
@@ -141,7 +174,7 @@ export default function LoginPage({
<Form {...otpForm}> <Form {...otpForm}>
<form <form
onSubmit={otpForm.handleSubmit(onOTPSubmit)} onSubmit={otpForm.handleSubmit(onOTPSubmit)}
className="space-y-4" className=""
> >
<FormField <FormField
control={otpForm.control} 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 Submit
</Button> </Button>
</form> </form>
@@ -197,7 +230,7 @@ export default function LoginPage({
<Form {...emailForm}> <Form {...emailForm}>
<form <form
onSubmit={emailForm.handleSubmit(onEmailSubmit)} onSubmit={emailForm.handleSubmit(onEmailSubmit)}
className="space-y-4" className="space-y-6"
> >
<FormField <FormField
control={emailForm.control} control={emailForm.control}
@@ -218,13 +251,13 @@ export default function LoginPage({
)} )}
/> />
<Button <Button
className=" w-[350px] bg-white hover:bg-gray-100 focus:bg-gray-100" className=" w-[350px] "
type="submit" size="lg"
disabled={emailStatus === "sending"} disabled={emailStatus === "sending"}
> >
{emailStatus === "sending" {emailStatus === "sending"
? "Sending..." ? "Sending..."
: "Send magic link"} : "Continue with email"}
</Button> </Button>
</form> </form>
</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 { TRPCError } from "@trpc/server";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env"; import { env } from "~/env";
import { import {
@@ -20,11 +21,14 @@ import {
isStorageConfigured, isStorageConfigured,
} from "~/server/service/storage-service"; } from "~/server/service/storage-service";
const statuses = Object.values(CampaignStatus) as [CampaignStatus];
export const campaignRouter = createTRPCRouter({ export const campaignRouter = createTRPCRouter({
getCampaigns: teamProcedure getCampaigns: teamProcedure
.input( .input(
z.object({ z.object({
page: z.number().optional(), page: z.number().optional(),
status: z.enum(statuses).optional().nullable(),
}) })
) )
.query(async ({ ctx: { db, team }, input }) => { .query(async ({ ctx: { db, team }, input }) => {
@@ -36,6 +40,10 @@ export const campaignRouter = createTRPCRouter({
teamId: team.id, teamId: team.id,
}; };
if (input.status) {
whereConditions.status = input.status;
}
const countP = db.campaign.count({ where: whereConditions }); const countP = db.campaign.count({ where: whereConditions });
const campaignsP = db.campaign.findMany({ const campaignsP = db.campaign.findMany({
@@ -48,6 +56,7 @@ export const campaignRouter = createTRPCRouter({
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
status: true, status: true,
html: true,
}, },
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
@@ -92,6 +101,7 @@ export const campaignRouter = createTRPCRouter({
previewText: z.string().optional(), previewText: z.string().optional(),
content: z.string().optional(), content: z.string().optional(),
contactBookId: z.string().optional(), contactBookId: z.string().optional(),
replyTo: z.string().array().optional(),
}) })
) )
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => { .mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
@@ -113,10 +123,21 @@ export const campaignRouter = createTRPCRouter({
const domain = await validateDomainFromEmail(data.from, team.id); const domain = await validateDomainFromEmail(data.from, team.id);
domainId = domain.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({ const campaign = await db.campaign.update({
where: { id: campaignId }, where: { id: campaignId },
data: { data: {
...data, ...data,
html,
domainId, domainId,
}, },
}); });

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,390 @@
import { Extension } from "@tiptap/core";
import {
NodeSelection,
Plugin,
PluginKey,
TextSelection,
} from "@tiptap/pm/state";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
// @ts-ignore
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
export interface GlobalDragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
/**
* The treshold for scrolling
*/
scrollTreshold: number;
/*
* The css selector to query for the drag handle. (eg: '.custom-handle').
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
*/
dragHandleSelector?: string;
/**
* Tags to be excluded for drag handle
*/
excludedTags: string[];
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const modal = node.closest('[role="dialog"]');
if (modal && window.getComputedStyle(modal).transform !== "none") {
const modalRect = modal.getBoundingClientRect();
return {
top: data.top - modalRect.top,
left: data.left - modalRect.left,
width: data.width,
};
}
return {
top: data.top,
left: data.left,
width: data.width,
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }) {
return document
.elementsFromPoint(coords.x, coords.y)
.find(
(elem: Element) =>
elem.parentElement?.matches?.(".ProseMirror") ||
elem.matches(
[
"li",
"p:not(:first-child)",
"pre",
"blockquote",
"h1, h2, h3, h4, h5, h6",
].join(", ")
)
);
}
function nodePosAtDOM(
node: Element,
view: EditorView,
options: GlobalDragHandleOptions
) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
}
export function DragHandlePlugin(
options: GlobalDragHandleOptions & { pluginKey: string }
) {
let listType = "";
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(
view.state.doc,
nodePos.before()
);
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos &&
draggedNodePos <= nodeSelection.$to.pos
);
}
let selection = view.state.selection;
if (
!differentNodeSelected &&
diff !== 0 &&
!(view.state.selection instanceof NodeSelection)
) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
selection = TextSelection.create(
view.state.doc,
draggedNodePos,
endSelection.$to.pos
);
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
if (
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === "tableRow"
) {
let $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
}
}
view.dispatch(view.state.tr.setSelection(selection));
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem"
) {
listType = node.parentElement!.tagName;
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hide");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hide");
}
}
function hideHandleOnEditorOut(event: MouseEvent) {
if (event.target instanceof Element) {
const isInsideEditor = !!event.target.closest(".tiptap.ProseMirror");
const isHandle =
!!event.target.attributes.getNamedItem("data-drag-handle");
if (isInsideEditor || isHandle) return;
}
hideDragHandle();
}
return new Plugin({
key: new PluginKey(options.pluginKey),
view: (view) => {
const handleBySelector = options.dragHandleSelector
? document.querySelector<HTMLElement>(options.dragHandleSelector)
: null;
dragHandleElement = handleBySelector ?? document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view);
}
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
function onDragHandleDrag(e: DragEvent) {
hideDragHandle();
let scrollY = window.scrollY;
if (e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
}
}
dragHandleElement.addEventListener("drag", onDragHandleDrag);
hideDragHandle();
if (!handleBySelector) {
view?.dom?.parentElement?.appendChild(dragHandleElement);
}
view?.dom?.parentElement?.parentElement?.addEventListener(
"mouseleave",
hideHandleOnEditorOut
);
return {
destroy: () => {
if (!handleBySelector) {
dragHandleElement?.remove?.();
}
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
dragHandleElement?.removeEventListener(
"dragstart",
onDragHandleDragStart
);
dragHandleElement = null;
view?.dom?.parentElement?.parentElement?.removeEventListener(
"mouseleave",
hideHandleOnEditorOut
);
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
const notDragging = node?.closest(".not-draggable");
const excludedTagList = options.excludedTags
.concat(["ol", "ul"])
.join(", ");
if (
!(node instanceof Element) ||
node.matches(excludedTagList) ||
notDragging
) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
? parseInt(compStyle.fontSize) * 1.2
: parsedLineHeight;
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add("dragging");
},
drop: (view, event) => {
view.dom.classList.remove("dragging");
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
const isDroppedInsideList =
resolvedPos.parent.type.name === "listItem";
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const newList = view.state.schema.nodes.orderedList?.createAndFill(
null,
droppedNode
);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}
const GlobalDragHandle = Extension.create({
name: "globalDragHandle",
addOptions() {
return {
dragHandleWidth: 20,
scrollTreshold: 100,
excludedTags: [],
};
},
addProseMirrorPlugins() {
return [
DragHandlePlugin({
pluginKey: "globalDragHandle",
dragHandleWidth: this.options.dragHandleWidth,
scrollTreshold: this.options.scrollTreshold,
dragHandleSelector: this.options.dragHandleSelector,
excludedTags: this.options.excludedTags,
}),
];
},
});
export default GlobalDragHandle;

View File

@@ -10,7 +10,7 @@ import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item"; import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list"; import TaskList from "@tiptap/extension-task-list";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import GlobalDragHandle from "tiptap-extension-global-drag-handle"; import GlobalDragHandle from "./dragHandle";
import { ButtonExtension } from "./ButtonExtension"; import { ButtonExtension } from "./ButtonExtension";
import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand"; import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand";
import { VariableExtension } from "./VariableExtension"; import { VariableExtension } from "./VariableExtension";

View File

@@ -64,8 +64,8 @@ const config = {
}, },
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.4s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.4s ease-out",
}, },
// fontFamily: { // fontFamily: {
// sans: ["var(--font-geist-sans)"], // sans: ["var(--font-geist-sans)"],

View File

@@ -1,4 +1,4 @@
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
export { cn }; export { cn };
export { ThemeProvider } from "next-themes"; export { ThemeProvider, useTheme } from "next-themes";

View File

@@ -30,6 +30,7 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
@@ -44,12 +45,14 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"framer-motion": "^11.0.24",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"lucide-react": "^0.359.0", "lucide-react": "^0.359.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pnpm": "^8.15.5", "pnpm": "^8.15.5",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.51.3",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"recharts": "^2.12.5",
"sonner": "^1.4.41", "sonner": "^1.4.41",
"tailwind-merge": "^2.2.2", "tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View File

@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { motion } from "framer-motion";
import { cn } from "../lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {

365
packages/ui/src/charts.tsx Normal file
View File

@@ -0,0 +1,365 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "../lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}

View File

@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className
)} )}
{...props} {...props}

View File

@@ -25,7 +25,7 @@ export const TextWithCopyButton: React.FC<{
<div className={className}>{value}</div> <div className={className}>{value}</div>
<Button <Button
variant="ghost" variant="ghost"
className={`hover:bg-transparent p-0 cursor-pointer text-muted-foreground ${ className={`hover:bg-transparent p-0 h-6 cursor-pointer text-muted-foreground ${
alwaysShowCopy ? "opacity-100" : "opacity-0 group-hover:opacity-100" alwaysShowCopy ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`} }`}
onClick={copyToClipboard} onClick={copyToClipboard}

View File

@@ -26,14 +26,20 @@
--accent: 210 40% 96.1%; --accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 214 1% 71%; --border: 220 14% 96%;
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
--radius: 0.5rem; --radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
} }
.dark { .dark {
@@ -64,6 +70,12 @@
--border: 217.2 32.6% 17.5%; --border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%;
--ring: 217.2 32.6% 17.5%; --ring: 217.2 32.6% 17.5%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
} }
} }

193
pnpm-lock.yaml generated
View File

@@ -194,6 +194,12 @@ importers:
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
emoji-picker-react:
specifier: ^4.12.0
version: 4.12.0(react@18.2.0)
framer-motion:
specifier: ^11.0.24
version: 11.0.24(react-dom@18.2.0)(react@18.2.0)
hono: hono:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
@@ -524,6 +530,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^3.3.4 specifier: ^3.3.4
version: 3.3.4(react-hook-form@7.51.3) version: 3.3.4(react-hook-form@7.51.3)
'@radix-ui/react-accordion':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.0.5 specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
@@ -566,6 +575,9 @@ importers:
cmdk: cmdk:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) version: 1.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
framer-motion:
specifier: ^11.0.24
version: 11.0.24(react-dom@18.2.0)(react@18.2.0)
input-otp: input-otp:
specifier: ^1.2.4 specifier: ^1.2.4
version: 1.2.4(react-dom@18.2.0)(react@18.2.0) version: 1.2.4(react-dom@18.2.0)(react@18.2.0)
@@ -584,6 +596,9 @@ importers:
react-syntax-highlighter: react-syntax-highlighter:
specifier: ^15.5.0 specifier: ^15.5.0
version: 15.5.0(react@18.2.0) version: 15.5.0(react@18.2.0)
recharts:
specifier: ^2.12.5
version: 2.12.5(react-dom@18.2.0)(react@18.2.0)
sonner: sonner:
specifier: ^1.4.41 specifier: ^1.4.41
version: 1.4.41(react-dom@18.2.0)(react@18.2.0) version: 1.4.41(react-dom@18.2.0)(react@18.2.0)
@@ -4399,6 +4414,34 @@ packages:
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
dev: false dev: false
/@radix-ui/react-accordion@1.2.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-HJOzSX8dQqtsp/3jVxCU3CXEONF7/2jlGAB28oX8TTw1Dz8JYbEI1UcL8355PuLBE41/IRRMvCw7VkiK/jcUOQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/primitive': 1.1.0
'@radix-ui/react-collapsible': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-collection': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-context': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-direction': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@types/react': 18.2.66
'@types/react-dom': 18.2.22
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): /@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==} resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==}
peerDependencies: peerDependencies:
@@ -4487,6 +4530,33 @@ packages:
react-dom: 18.2.0(react@18.3.1) react-dom: 18.2.0(react@18.3.1)
dev: false dev: false
/@radix-ui/react-collapsible@1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/primitive': 1.1.0
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-context': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-presence': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@types/react': 18.2.66
'@types/react-dom': 18.2.22
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==}
peerDependencies: peerDependencies:
@@ -4534,6 +4604,29 @@ packages:
react-dom: 18.2.0(react@18.3.1) react-dom: 18.2.0(react@18.3.1)
dev: false dev: false
/@radix-ui/react-collection@1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-context': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.1.0(@types/react@18.2.66)(react@18.2.0)
'@types/react': 18.2.66
'@types/react-dom': 18.2.22
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.66)(react@18.2.0): /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.66)(react@18.2.0):
resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
peerDependencies: peerDependencies:
@@ -4678,6 +4771,19 @@ packages:
react: 18.3.1 react: 18.3.1
dev: false dev: false
/@radix-ui/react-direction@1.1.0(@types/react@18.2.66)(react@18.2.0):
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.2.66
react: 18.2.0
dev: false
/@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): /@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==}
peerDependencies: peerDependencies:
@@ -9831,6 +9937,16 @@ packages:
minimalistic-crypto-utils: 1.0.1 minimalistic-crypto-utils: 1.0.1
dev: false dev: false
/emoji-picker-react@4.12.0(react@18.2.0):
resolution: {integrity: sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
dependencies:
flairup: 1.0.0
react: 18.2.0
dev: false
/emoji-regex@8.0.0: /emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -10140,7 +10256,7 @@ packages:
eslint: 8.57.0 eslint: 8.57.0
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.34.0(eslint@8.57.0) eslint-plugin-react: 7.34.0(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@@ -10198,7 +10314,7 @@ packages:
enhanced-resolve: 5.16.0 enhanced-resolve: 5.16.0
eslint: 8.57.0 eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
fast-glob: 3.3.2 fast-glob: 3.3.2
get-tsconfig: 4.7.3 get-tsconfig: 4.7.3
is-core-module: 2.13.1 is-core-module: 2.13.1
@@ -10332,6 +10448,41 @@ packages:
ignore: 5.3.1 ignore: 5.3.1
dev: true dev: true
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.2)
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.7
object.groupby: 1.0.2
object.values: 1.1.7
semver: 6.3.1
tsconfig-paths: 3.15.0
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
dev: true
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0): /eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -10367,40 +10518,6 @@ packages:
- supports-color - supports-color
dev: true dev: true
/eslint-plugin-import@2.29.1(eslint@8.57.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.7
object.groupby: 1.0.2
object.values: 1.1.7
semver: 6.3.1
tsconfig-paths: 3.15.0
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
dev: true
/eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint@8.57.0)(typescript@5.4.2): /eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint@8.57.0)(typescript@5.4.2):
resolution: {integrity: sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==} resolution: {integrity: sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -11000,6 +11117,10 @@ packages:
locate-path: 6.0.0 locate-path: 6.0.0
path-exists: 4.0.0 path-exists: 4.0.0
/flairup@1.0.0:
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
dev: false
/flat-cache@3.2.0: /flat-cache@3.2.0:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}