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

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

View File

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

View File

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

View File

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

View File

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