475 lines
13 KiB
TypeScript
475 lines
13 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { ContactSchemas } from "@plunk/shared";
|
|
import type { Template } from "@prisma/client";
|
|
import dayjs from "dayjs";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Edit2, Plus } from "lucide-react";
|
|
import Link from "next/link";
|
|
import React, { useState } from "react";
|
|
import { type FieldError, useFieldArray, useForm } from "react-hook-form";
|
|
import { toast } from "sonner";
|
|
import { z } from "zod";
|
|
import { Card, Empty, FullscreenLoader, Modal, Skeleton, Table, Toggle } from "../../components";
|
|
import { Dashboard } from "../../layouts";
|
|
import { searchContacts, useContacts } from "../../lib/hooks/contacts";
|
|
import { useActiveProject } from "../../lib/hooks/projects";
|
|
import { useUser } from "../../lib/hooks/users";
|
|
import { network } from "../../lib/network";
|
|
|
|
interface ContactValues {
|
|
email: string;
|
|
data?:
|
|
| {
|
|
[x: string]: string | string[];
|
|
}
|
|
| null
|
|
| undefined;
|
|
subscribed: boolean;
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
export default function Index() {
|
|
const [page, setPage] = useState(1);
|
|
const [query, setQuery] = useState<string>();
|
|
|
|
const project = useActiveProject();
|
|
const { data: user } = useUser();
|
|
const { data: contacts, mutate } = useContacts(page);
|
|
const { data: search } = searchContacts(query);
|
|
|
|
const [contactModal, setContactModal] = useState(false);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
formState: { errors },
|
|
reset,
|
|
watch,
|
|
setValue,
|
|
} = useForm<ContactValues>({
|
|
resolver: zodResolver(ContactSchemas.create),
|
|
defaultValues: {
|
|
subscribed: true,
|
|
},
|
|
});
|
|
|
|
const {
|
|
register: dataRegister,
|
|
control,
|
|
getValues: getDataValues,
|
|
reset: dataReset,
|
|
} = useForm({
|
|
resolver: zodResolver(
|
|
z.object({
|
|
data: z
|
|
.array(
|
|
z.object({
|
|
value: z.object({ key: z.string(), value: z.string() }),
|
|
}),
|
|
)
|
|
.min(0),
|
|
}),
|
|
),
|
|
defaultValues: {
|
|
data: [{ value: { key: "", value: "" } }],
|
|
},
|
|
});
|
|
|
|
const { fields, append: fieldAppend, remove: fieldRemove } = useFieldArray({ control, name: "data" });
|
|
|
|
if (!project || !user) {
|
|
return <FullscreenLoader />;
|
|
}
|
|
|
|
const create = (data: ContactValues) => {
|
|
const entries = getDataValues().data.map(({ value }) => [value.key, value.value]);
|
|
let dataObject = {};
|
|
|
|
entries.forEach(([key, value]) => {
|
|
Object.assign(dataObject, { [key]: value });
|
|
});
|
|
|
|
dataObject = Object.fromEntries(Object.entries(dataObject).filter(([, value]) => value !== ""));
|
|
|
|
toast.promise(
|
|
network.mock<Template, typeof ContactSchemas.create>(project.secret, "POST", "/v1/contacts", {
|
|
...data,
|
|
subscribed: true,
|
|
data: dataObject,
|
|
}),
|
|
{
|
|
loading: "Creating new contact",
|
|
success: () => {
|
|
void mutate();
|
|
reset();
|
|
return "Created new contacts";
|
|
},
|
|
error: "Could not create new contact!",
|
|
},
|
|
);
|
|
|
|
reset();
|
|
dataReset();
|
|
|
|
setContactModal(false);
|
|
};
|
|
|
|
const renderContacts = () => {
|
|
if (!contacts && !search) {
|
|
return <Skeleton type={"table"} />;
|
|
}
|
|
|
|
if (query && !search) {
|
|
return <Skeleton type={"table"} />;
|
|
}
|
|
|
|
if (search && query !== undefined) {
|
|
if (search.contacts.length > 0) {
|
|
return (
|
|
<>
|
|
<Table
|
|
values={search.contacts
|
|
.sort((a, b) => {
|
|
const aTrigger = a.triggers.length > 0 ? a.triggers.sort()[0].createdAt : a.createdAt;
|
|
|
|
const bTrigger = b.triggers.length > 0 ? b.triggers.sort()[0].createdAt : b.createdAt;
|
|
|
|
return bTrigger > aTrigger ? 1 : -1;
|
|
})
|
|
.map((u) => {
|
|
return {
|
|
Email: u.email,
|
|
"Last Activity": dayjs()
|
|
.to(
|
|
[...u.triggers, ...u.emails].length > 0
|
|
? [...u.triggers, ...u.emails].sort((a, b) => {
|
|
return a.createdAt > b.createdAt ? -1 : 1;
|
|
})[0].createdAt
|
|
: u.createdAt,
|
|
)
|
|
.toString(),
|
|
Subscribed: u.subscribed,
|
|
Edit: (
|
|
<Link href={`/contacts/${u.id}`} className={"transition hover:text-neutral-800"}>
|
|
<Edit2 size={18} />
|
|
</Link>
|
|
),
|
|
};
|
|
})}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
<Empty
|
|
icon={
|
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
|
|
<path
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.5"
|
|
d="M19.25 19.25L15.5 15.5M4.75 11C4.75 7.54822 7.54822 4.75 11 4.75C14.4518 4.75 17.25 7.54822 17.25 11C17.25 14.4518 14.4518 17.25 11 17.25C7.54822 17.25 4.75 14.4518 4.75 11Z"
|
|
/>
|
|
</svg>
|
|
}
|
|
title={"No contacts found"}
|
|
description={`Your query ${query} did not return any contacts`}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (contacts) {
|
|
if (contacts.contacts.length > 0) {
|
|
return (
|
|
<>
|
|
<Table
|
|
values={contacts.contacts
|
|
.sort((a, b) => {
|
|
const aTrigger = a.triggers.length > 0 ? a.triggers.sort()[0].createdAt : a.createdAt;
|
|
|
|
const bTrigger = b.triggers.length > 0 ? b.triggers.sort()[0].createdAt : b.createdAt;
|
|
|
|
return bTrigger > aTrigger ? 1 : -1;
|
|
})
|
|
.map((u) => {
|
|
return {
|
|
Email: u.email,
|
|
"Last Activity": dayjs()
|
|
.to(
|
|
u.triggers.length > 0
|
|
? u.triggers.sort((a, b) => {
|
|
return a.createdAt > b.createdAt ? -1 : 1;
|
|
})[0].createdAt
|
|
: u.createdAt,
|
|
)
|
|
.toString(),
|
|
Subscribed: u.subscribed,
|
|
Edit: (
|
|
<Link href={`/contacts/${u.id}`} className={"transition hover:text-neutral-800"}>
|
|
<Edit2 size={18} />
|
|
</Link>
|
|
),
|
|
};
|
|
})}
|
|
/>
|
|
<nav className="flex items-center justify-between py-3" aria-label="Pagination">
|
|
<div className="hidden sm:block">
|
|
<p className="text-sm text-neutral-700">
|
|
Showing <span className="font-medium">{(page - 1) * 20}</span> to{" "}
|
|
<span className="font-medium">{page * 20}</span> of <span className="font-medium">{contacts.count}</span>{" "}
|
|
contacts
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-1 justify-between gap-1 sm:justify-end">
|
|
{page > 1 && (
|
|
<button
|
|
onClick={() => setPage(page - 1)}
|
|
className={
|
|
"flex w-28 items-center justify-center gap-x-0.5 rounded bg-neutral-800 py-2 text-center text-sm font-medium text-white"
|
|
}
|
|
>
|
|
Previous
|
|
</button>
|
|
)}
|
|
{page < Math.ceil(contacts.count / 20) && (
|
|
<button
|
|
onClick={() => setPage(page + 1)}
|
|
className={
|
|
"flex w-28 items-center justify-center gap-x-0.5 rounded bg-neutral-800 py-2 text-center text-sm font-medium text-white"
|
|
}
|
|
>
|
|
Next
|
|
</button>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
<Empty title={"No contacts"} description={"New contacts will automatically be added when they trigger an event"} />
|
|
</>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
isOpen={contactModal}
|
|
onToggle={() => setContactModal(!contactModal)}
|
|
onAction={handleSubmit(create)}
|
|
type={"info"}
|
|
action={"Create"}
|
|
title={"Create new contact"}
|
|
>
|
|
<div>
|
|
<label htmlFor={"email"} className="block text-sm font-medium text-neutral-700">
|
|
Email
|
|
</label>
|
|
<div className="mt-1">
|
|
<input
|
|
type={"text"}
|
|
autoComplete={"off"}
|
|
className={
|
|
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
|
}
|
|
placeholder={"[email protected]"}
|
|
{...register("email")}
|
|
/>
|
|
</div>
|
|
<AnimatePresence>
|
|
{errors.email?.message && (
|
|
<motion.p
|
|
initial={{ height: 0 }}
|
|
animate={{ height: "auto" }}
|
|
exit={{ height: 0 }}
|
|
className="mt-1 text-xs text-red-500"
|
|
>
|
|
{errors.email.message}
|
|
</motion.p>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div className={"my-6"}>
|
|
<div className={"grid sm:col-span-2"}>
|
|
<div className={"grid items-center gap-3 sm:grid-cols-9"}>
|
|
<label htmlFor={"data"} className="block text-sm font-medium text-neutral-700 sm:col-span-8">
|
|
Metadata
|
|
</label>
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
fieldAppend({ value: { key: "", value: "" } });
|
|
}}
|
|
className={
|
|
"ml-auto flex w-full items-center justify-center gap-x-0.5 rounded border border-neutral-200 bg-white py-1 text-center text-sm text-neutral-700 transition ease-in-out hover:bg-neutral-50 sm:col-span-1"
|
|
}
|
|
>
|
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
|
|
<path
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.5"
|
|
d="M12 5.75V18.25"
|
|
/>
|
|
<path
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.5"
|
|
d="M18.25 12L5.75 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{fields.length > 0 ? (
|
|
fields.map((field, index) => {
|
|
// @ts-ignore
|
|
return (
|
|
<>
|
|
<div>
|
|
<div className="grid w-full grid-cols-9 items-end gap-3">
|
|
<div className={"col-span-4"}>
|
|
<label htmlFor={"data"} className="text-xs font-light">
|
|
Key
|
|
</label>
|
|
<input
|
|
type={"text"}
|
|
placeholder={"Key"}
|
|
className={
|
|
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
|
}
|
|
key={field.id}
|
|
{...dataRegister(`data.${index}.value.key`)}
|
|
/>
|
|
</div>
|
|
<div className={"col-span-4"}>
|
|
<label htmlFor={"data"} className="text-xs font-light">
|
|
Value
|
|
</label>
|
|
<input
|
|
type={"text"}
|
|
placeholder={"Value"}
|
|
className={
|
|
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
|
}
|
|
key={field.id}
|
|
{...dataRegister(`data.${index}.value.value`)}
|
|
/>
|
|
</div>
|
|
<button
|
|
className={
|
|
"col-span-1 flex h-10 items-center justify-center rounded bg-red-100 text-sm text-red-800 transition hover:bg-red-200"
|
|
}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
fieldRemove(index);
|
|
}}
|
|
>
|
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
|
|
<path
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.5"
|
|
d="M6.75 7.75L7.59115 17.4233C7.68102 18.4568 8.54622 19.25 9.58363 19.25H14.4164C15.4538 19.25 16.319 18.4568 16.4088 17.4233L17.25 7.75"
|
|
/>
|
|
<path
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.5"
|
|
d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5"
|
|
/>
|
|
<path
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.5"
|
|
d="M5 7.75H19"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
})
|
|
) : (
|
|
<p className={"text-sm text-neutral-500"}>No fields added</p>
|
|
)}
|
|
</div>
|
|
<AnimatePresence>
|
|
{(errors.data as FieldError | undefined)?.message && (
|
|
<motion.p
|
|
initial={{ height: 0 }}
|
|
animate={{ height: "auto" }}
|
|
exit={{ height: 0 }}
|
|
className="mt-1 text-xs text-red-500"
|
|
>
|
|
{(errors.data as FieldError | undefined)?.message}
|
|
</motion.p>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div className={"mt-3"}>
|
|
<Toggle
|
|
title={"Subscribed"}
|
|
description={
|
|
watch("subscribed")
|
|
? "This contact has opted-in to receive marketing emails"
|
|
: "This contact prefers not to receive marketing emails"
|
|
}
|
|
toggled={watch("subscribed")}
|
|
onToggle={() => setValue("subscribed", !watch("subscribed"))}
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
<Dashboard>
|
|
<Card
|
|
title={"Contacts"}
|
|
description={"View and manage your contacts"}
|
|
actions={
|
|
<div className={"grid w-full gap-3 md:w-fit md:grid-cols-2"}>
|
|
<input
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
autoComplete={"off"}
|
|
type="search"
|
|
placeholder={"Search email or metadata"}
|
|
className={
|
|
"rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
|
}
|
|
/>
|
|
|
|
<motion.button
|
|
onClick={() => setContactModal(true)}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
className={
|
|
"flex items-center justify-center gap-x-1 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
|
}
|
|
>
|
|
<Plus strokeWidth={1.5} size={18} />
|
|
New
|
|
</motion.button>
|
|
</div>
|
|
}
|
|
>
|
|
{renderContacts()}
|
|
</Card>
|
|
</Dashboard>
|
|
</>
|
|
);
|
|
}
|