Initial Commit
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
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?:
|
||||
| undefined
|
||||
| {
|
||||
[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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user