Initial Commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import "../../styles/index.css";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import Router, { useRouter } from "next/router";
|
||||
import NProgress from "nprogress";
|
||||
import React from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { SWRConfig } from "swr";
|
||||
import { network } from "../lib/network";
|
||||
import "nprogress/nprogress.css";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import { FullscreenLoader, Redirect } from "../components";
|
||||
import { NO_AUTH_ROUTES } from "../lib/constants";
|
||||
import { useUser } from "../lib/hooks/users";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(duration);
|
||||
|
||||
Router.events.on("routeChangeStart", () => NProgress.start());
|
||||
Router.events.on("routeChangeComplete", () => NProgress.done());
|
||||
Router.events.on("routeChangeError", () => NProgress.done());
|
||||
|
||||
/**
|
||||
* Main app component
|
||||
* @param props Props
|
||||
* @param props.Component App component
|
||||
* @param props.pageProps
|
||||
*/
|
||||
function App({ Component, pageProps }: AppProps) {
|
||||
const router = useRouter();
|
||||
const { data: user, error } = useUser();
|
||||
|
||||
if (error && !NO_AUTH_ROUTES.includes(router.route)) {
|
||||
return <Redirect to={"/auth/login"} />;
|
||||
}
|
||||
|
||||
if (!user && !NO_AUTH_ROUTES.includes(router.route)) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Plunk Dashboard | The Email Platform for SaaS</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
key={"viewport"}
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Toaster position={"bottom-right"} />
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main app root component that houses all components
|
||||
* @param props Default nextjs props
|
||||
*/
|
||||
export default function WithProviders(props: AppProps) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: (url: string) => network.fetch("GET", url),
|
||||
revalidateOnFocus: true,
|
||||
}}
|
||||
>
|
||||
<JotaiProvider>
|
||||
<DefaultSeo
|
||||
defaultTitle={"Plunk Dashboard | The Email Platform for SaaS"}
|
||||
title={"Plunk Dashboard | The Email Platform for SaaS"}
|
||||
description={
|
||||
"Plunk is the affordable, developer-friendly email platform that brings together marketing, transactional and broadcast emails into one single, complete solution"
|
||||
}
|
||||
twitter={{
|
||||
cardType: "summary_large_image",
|
||||
handle: "@useplunk",
|
||||
site: "@useplunk",
|
||||
}}
|
||||
openGraph={{
|
||||
title: "Plunk Dashboard | The Email Platform for SaaS",
|
||||
description:
|
||||
"Plunk is the affordable, developer-friendly email platform that brings together marketing, transactional and broadcast emails into one single, complete solution",
|
||||
images: [
|
||||
{ url: "https://app.useplunk.com/assets/card.png", alt: "Plunk" },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<App {...props} />
|
||||
</JotaiProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Document, {Head, Html, Main, NextScript} from 'next/document';
|
||||
import React from 'react';
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
public render() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
{/* Start fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* End fonts */}
|
||||
|
||||
{/* Start favicon */}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#171717" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
|
||||
<meta name="theme-color" content="#171717" />
|
||||
{/* End favicon */}
|
||||
</Head>
|
||||
<body className={'cursor-default antialiased'}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ActionSchemas, type UtilitySchemas } from "@plunk/shared";
|
||||
import type { Action } from "@prisma/client";
|
||||
import { useEvents } from "dashboard/src/lib/hooks/events";
|
||||
import { useTemplates } from "dashboard/src/lib/hooks/templates";
|
||||
import dayjs from "dayjs";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Save } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type FieldError, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Dropdown,
|
||||
Empty,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
MultiselectDropdown,
|
||||
Toggle,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import {
|
||||
useAction,
|
||||
useActions,
|
||||
useRelatedActions,
|
||||
} from "../../lib/hooks/actions";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface ActionValues {
|
||||
name: string;
|
||||
runOnce: boolean;
|
||||
delay: number;
|
||||
template: string;
|
||||
events: string[];
|
||||
notevents: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.isReady) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const project = useActiveProject();
|
||||
const { mutate } = useActions();
|
||||
const { data: templates } = useTemplates();
|
||||
const { data: events } = useEvents();
|
||||
const { data: action } = useAction(router.query.id as string);
|
||||
const { data: related } = useRelatedActions(router.query.id as string);
|
||||
|
||||
const [delay, setDelay] = useState<{
|
||||
delay: number;
|
||||
unit: "MINUTES" | "HOURS" | "DAYS";
|
||||
}>({
|
||||
delay: 0,
|
||||
unit: "MINUTES",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
switch (delay.unit) {
|
||||
case "MINUTES":
|
||||
setValue("delay", delay.delay);
|
||||
break;
|
||||
case "HOURS":
|
||||
setValue("delay", delay.delay * 60);
|
||||
break;
|
||||
case "DAYS":
|
||||
setValue("delay", delay.delay * 24 * 60);
|
||||
break;
|
||||
}
|
||||
}, [delay]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
reset,
|
||||
setValue,
|
||||
} = useForm<ActionValues>({
|
||||
defaultValues: { events: [], notevents: [] },
|
||||
resolver: zodResolver(ActionSchemas.update),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.delay !== 0) {
|
||||
if (action.delay % 1440 === 0) {
|
||||
setDelay({ unit: "DAYS", delay: action.delay / 1440 });
|
||||
} else if (action.delay % 60 === 0) {
|
||||
setDelay({ unit: "HOURS", delay: action.delay / 60 });
|
||||
} else {
|
||||
setDelay({ unit: "MINUTES", delay: action.delay });
|
||||
}
|
||||
}
|
||||
|
||||
reset({
|
||||
...action,
|
||||
template: action.templateId,
|
||||
delay: 0,
|
||||
events: action.events.map((e: { id: string }) => e.id),
|
||||
notevents: action.notevents.map((e: { id: string }) => e.id),
|
||||
});
|
||||
}, [reset, action]);
|
||||
|
||||
if (!project || !action || !templates || !events || !related) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const updateAction = (data: ActionValues) => {
|
||||
toast.promise(
|
||||
network.mock<Action, typeof ActionSchemas.update>(
|
||||
project.secret,
|
||||
"PUT",
|
||||
"/v1/actions",
|
||||
{
|
||||
id: action.id,
|
||||
...data,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Saving your action",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Saved your action";
|
||||
},
|
||||
error: "Could not save your action!",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const remove = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
toast.promise(
|
||||
network.mock<Action, typeof UtilitySchemas.id>(
|
||||
project.secret,
|
||||
"DELETE",
|
||||
"/v1/actions",
|
||||
{
|
||||
id: action.id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Deleting your action",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Deleted your action";
|
||||
},
|
||||
error: "Could not delete your action!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/actions");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card
|
||||
title={"Edit your action"}
|
||||
options={
|
||||
<>
|
||||
<button
|
||||
onClick={remove}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-800 transition hover:bg-neutral-100"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(updateAction)}
|
||||
className="mx-auto my-3 max-w-xl space-y-6"
|
||||
>
|
||||
<Input
|
||||
label={"Name"}
|
||||
placeholder={"Onboarding Flow"}
|
||||
register={register("name")}
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"events"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Run on triggers
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => setValue("events", e)}
|
||||
values={events
|
||||
.filter(
|
||||
(e) => !e.campaignId && !watch("notevents").includes(e.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.templateId && !b.templateId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!a.templateId && b.templateId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name === "unsubscribe" || a.name === "subscribe") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name === "unsubscribe" || b.name === "subscribe") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag: e.templateId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: e.name === "unsubscribe" || e.name === "subscribe"
|
||||
? "Automated"
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
selectedValues={watch("events")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.events as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.events as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"events"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Exclude contacts with triggers
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => setValue("notevents", e)}
|
||||
values={events
|
||||
.filter(
|
||||
(e) => !e.campaignId && !watch("events").includes(e.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.templateId && !b.templateId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!a.templateId && b.templateId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name === "unsubscribe" || a.name === "subscribe") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name === "unsubscribe" || b.name === "subscribe") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag: e.templateId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: e.name === "unsubscribe" || e.name === "subscribe"
|
||||
? "Automated"
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
selectedValues={watch("notevents")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.notevents as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.notevents as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"template"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Template
|
||||
</label>
|
||||
<div className={"grid gap-6 sm:grid-cols-6"}>
|
||||
<div className={"sm:col-span-4"}>
|
||||
<Dropdown
|
||||
onChange={(t) => setValue("template", t)}
|
||||
values={templates.map((t) => {
|
||||
return { name: t.subject, value: t.id };
|
||||
})}
|
||||
selectedValue={watch("template")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.template?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.template.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<Link
|
||||
href={`/templates/${action.templateId}`}
|
||||
passHref
|
||||
className={"sm:col-span-2"}
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex h-full w-full items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white text-center text-sm font-medium text-neutral-800"
|
||||
}
|
||||
>
|
||||
<svg className={"h-5 w-5"} fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M4.75 19.25L9 18.25L18.2929 8.95711C18.6834 8.56658 18.6834 7.93342 18.2929 7.54289L16.4571 5.70711C16.0666 5.31658 15.4334 5.31658 15.0429 5.70711L5.75 15L4.75 19.25Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M19.25 19.25H13.75"
|
||||
/>
|
||||
</svg>
|
||||
Edit
|
||||
</motion.button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"template"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Delay before sending
|
||||
</label>
|
||||
<div className={"grid grid-cols-6 gap-4"}>
|
||||
<div className={"col-span-2 mt-1"}>
|
||||
<input
|
||||
type={"number"}
|
||||
autoComplete={"off"}
|
||||
min={0}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder={"0"}
|
||||
value={delay.delay}
|
||||
onChange={(e) =>
|
||||
setDelay({
|
||||
...delay,
|
||||
delay: Number.parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={"col-span-4"}>
|
||||
<Dropdown
|
||||
onChange={(t) =>
|
||||
setDelay({
|
||||
...delay,
|
||||
unit: t as "MINUTES" | "HOURS" | "DAYS",
|
||||
})
|
||||
}
|
||||
values={[
|
||||
{ name: "Minutes", value: "MINUTES" },
|
||||
{ name: "Hours", value: "HOURS" },
|
||||
{ name: "Days", value: "DAYS" },
|
||||
]}
|
||||
selectedValue={delay.unit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Toggle
|
||||
title={"Run once"}
|
||||
description={
|
||||
"Toggle this on if you want to run this action only once per contact."
|
||||
}
|
||||
toggled={watch("runOnce")}
|
||||
onToggle={() => setValue("runOnce", !watch("runOnce"))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex justify-end gap-3"}>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
return router.push("/actions");
|
||||
}}
|
||||
className={
|
||||
"flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-center gap-x-2 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<Save strokeWidth={1.5} size={18} />
|
||||
Save
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card title={"Related Actions"}>
|
||||
<div className={"grid gap-3 sm:grid-cols-2"}>
|
||||
{related.length > 0 ? (
|
||||
related
|
||||
.sort((a, b) => {
|
||||
if (a.delay < b.delay) {
|
||||
return -1;
|
||||
}
|
||||
if (a.delay > b.delay) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.map((r) => {
|
||||
return (
|
||||
<Link href={`/actions/${r.id}`} key={r.id}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-6 rounded border border-solid border-neutral-200 bg-white px-8 py-4"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className="inline-flex rounded bg-neutral-100 p-4 text-neutral-800 ring-4 ring-white">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeWidth={"1.5"}
|
||||
d="M16 21h3c.81 0 1.48 -.67 1.48 -1.48l.02 -.02c0 -.82 -.69 -1.5 -1.5 -1.5h-3v3z"
|
||||
/>
|
||||
<path
|
||||
strokeWidth={"1.5"}
|
||||
d="M16 15h2.5c.84 -.01 1.5 .66 1.5 1.5s-.66 1.5 -1.5 1.5h-2.5v-3z"
|
||||
/>
|
||||
<path
|
||||
strokeWidth={"1.5"}
|
||||
d="M4 9v-4c0 -1.036 .895 -2 2 -2s2 .964 2 2v4"
|
||||
/>
|
||||
<path
|
||||
strokeWidth={"1.5"}
|
||||
d="M2.99 11.98a9 9 0 0 0 9 9m9 -9a9 9 0 0 0 -9 -9"
|
||||
/>
|
||||
<path strokeWidth={"1.5"} d="M8 7h-4" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className={"text-sm"}>
|
||||
<p
|
||||
className={
|
||||
"text-base font-semibold leading-tight text-neutral-800"
|
||||
}
|
||||
>
|
||||
{r.name}
|
||||
</p>
|
||||
<p className={"text-neutral-500"}>
|
||||
Runs after{" "}
|
||||
{r.events
|
||||
.filter(
|
||||
(e) =>
|
||||
action.events.filter(
|
||||
(a: { id: string }) => a.id === e.id,
|
||||
).length > 0,
|
||||
)
|
||||
.map((e) => e.name)}{" "}
|
||||
and{" "}
|
||||
{
|
||||
r.events.filter((e) => {
|
||||
return (
|
||||
action.events.filter(
|
||||
(a: { id: string }) => a.id === e.id,
|
||||
).length === 0
|
||||
);
|
||||
}).length
|
||||
}{" "}
|
||||
other events
|
||||
</p>
|
||||
<div className={"mt-1"}>
|
||||
{r.delay === action.delay ? (
|
||||
<Badge type={"info"}>Same delay</Badge>
|
||||
) : r.delay > action.delay ? (
|
||||
<Badge type={"info"}>
|
||||
{`${dayjs.duration(r.delay - action.delay, "minutes").humanize()} after this action`}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge type={"info"}>
|
||||
{`${dayjs.duration(action.delay - r.delay, "minutes").humanize()} before this action`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={"sm:col-span-3"}>
|
||||
<Empty
|
||||
title={"No related actions"}
|
||||
description={"Easy access to all actions that share events"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import dayjs from "dayjs";
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, Workflow } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Empty,
|
||||
FullscreenLoader,
|
||||
Skeleton,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActions } from "../../lib/hooks/actions";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const { data: actions } = useActions();
|
||||
|
||||
if (!project) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
{actions?.length === 0 && (
|
||||
<Alert type={"info"} title={"Need a hand?"}>
|
||||
<div className={"mt-3 grid items-center sm:grid-cols-4"}>
|
||||
<p className={"sm:col-span-3"}>
|
||||
Want us to help you get started? We can help you build your
|
||||
first action in less than 5 minutes.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={"/onboarding/actions"}
|
||||
className={
|
||||
"inline-block rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white sm:col-span-1"
|
||||
}
|
||||
>
|
||||
Build an action
|
||||
</Link>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={"Actions"}
|
||||
description={
|
||||
"Repeatable automations that can be triggered by your applications"
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Link href={"actions/new"} passHref>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-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>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{actions ? (
|
||||
actions.length > 0 ? (
|
||||
<>
|
||||
<div className={"grid grid-cols-1 gap-6 sm:grid-cols-2"}>
|
||||
{actions
|
||||
.sort((a, b) => {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((a) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="col-span-1 divide-y divide-neutral-200 rounded border border-neutral-200 bg-white"
|
||||
key={a.id}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between space-x-6 p-6">
|
||||
<span className="inline-flex rounded bg-neutral-100 p-3 text-neutral-800 ring-4 ring-white">
|
||||
<Workflow size={20} />
|
||||
</span>
|
||||
<div className="flex-1 truncate">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="truncate text-lg font-bold text-neutral-800">
|
||||
{a.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={"mb-6"}>
|
||||
<h2
|
||||
className={
|
||||
"text col-span-2 truncate font-semibold text-neutral-700"
|
||||
}
|
||||
>
|
||||
Quick stats
|
||||
</h2>
|
||||
<div className={"grid grid-cols-2 gap-3"}>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Total triggers
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{a.triggers.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Last activity
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{a.triggers.length > 0
|
||||
? "Last triggered"
|
||||
: "Created"}{" "}
|
||||
{dayjs()
|
||||
.to(
|
||||
a.triggers.length > 0
|
||||
? a.triggers.sort((a, b) => {
|
||||
return a.createdAt >
|
||||
b.createdAt
|
||||
? -1
|
||||
: 1;
|
||||
})[0].createdAt
|
||||
: a.createdAt,
|
||||
)
|
||||
.toString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Open rate
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{a.emails.length > 0
|
||||
? Math.round(
|
||||
(a.emails.filter(
|
||||
(e) => e.status === "OPENED",
|
||||
).length /
|
||||
a.emails.length) *
|
||||
100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</p>
|
||||
</div>
|
||||
{a.delay > 0 && (
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Emails in queue
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{a.tasks.length}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"my-4"}>
|
||||
<h2
|
||||
className={
|
||||
"col-span-2 truncate font-semibold text-neutral-700"
|
||||
}
|
||||
>
|
||||
Properties
|
||||
</h2>
|
||||
<div className={"grid grid-cols-2 gap-3"}>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Repeats
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
<Badge
|
||||
type={a.runOnce ? "success" : "info"}
|
||||
>
|
||||
{a.runOnce
|
||||
? "Runs once per user"
|
||||
: "Recurring"}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Delay
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
<Badge
|
||||
type={
|
||||
a.delay === 0 ? "info" : "success"
|
||||
}
|
||||
>
|
||||
{a.delay === 0
|
||||
? "Instant"
|
||||
: a.delay % 1440 === 0
|
||||
? `${a.delay / 1440} day delay`
|
||||
: a.delay % 60 === 0
|
||||
? `${a.delay / 60} hour delay`
|
||||
: `${a.delay} minute delay`}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="-mt-px flex divide-x divide-neutral-200">
|
||||
<div className="flex w-0 flex-1">
|
||||
<Link
|
||||
href={`/actions/${a.id}`}
|
||||
passHref
|
||||
className="relative inline-flex w-0 flex-1 items-center justify-center rounded-bl rounded-br py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M4.75 19.25L9 18.25L18.2929 8.95711C18.6834 8.56658 18.6834 7.93342 18.2929 7.54289L16.4571 5.70711C16.0666 5.31658 15.4334 5.31658 15.0429 5.70711L5.75 15L4.75 19.25Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M19.25 19.25H13.75"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="ml-3">Edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Empty
|
||||
title={"No actions here"}
|
||||
description={"Set up a new automation in a few clicks"}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<Skeleton type={"table"} />
|
||||
)}
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ActionSchemas } from "@plunk/shared";
|
||||
import type { Template } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type FieldError, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
Dropdown,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
MultiselectDropdown,
|
||||
Toggle,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActions } from "../../lib/hooks/actions";
|
||||
import { useEvents } from "../../lib/hooks/events";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { useTemplates } from "../../lib/hooks/templates";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface ActionValues {
|
||||
name: string;
|
||||
runOnce: boolean;
|
||||
delay: number;
|
||||
template: string;
|
||||
events: string[];
|
||||
notevents: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const { mutate } = useActions();
|
||||
const { data: templates } = useTemplates();
|
||||
const { data: events } = useEvents();
|
||||
const router = useRouter();
|
||||
|
||||
const [delay, setDelay] = useState<{
|
||||
delay: number;
|
||||
unit: "MINUTES" | "HOURS" | "DAYS";
|
||||
}>({
|
||||
delay: 0,
|
||||
unit: "MINUTES",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
switch (delay.unit) {
|
||||
case "MINUTES":
|
||||
setValue("delay", delay.delay);
|
||||
break;
|
||||
case "HOURS":
|
||||
setValue("delay", delay.delay * 60);
|
||||
break;
|
||||
case "DAYS":
|
||||
setValue("delay", delay.delay * 24 * 60);
|
||||
break;
|
||||
}
|
||||
}, [delay]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<ActionValues>({
|
||||
resolver: zodResolver(ActionSchemas.create),
|
||||
defaultValues: {
|
||||
template: "No template selected",
|
||||
events: [],
|
||||
notevents: [],
|
||||
runOnce: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project || !templates || !events) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const create = async (data: ActionValues) => {
|
||||
toast.promise(
|
||||
network.mock<Template, typeof ActionSchemas.create>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1/actions",
|
||||
{
|
||||
...data,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Creating new action",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Created new action";
|
||||
},
|
||||
error: "Could not create new action!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/actions");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card title={"Create a new action"}>
|
||||
<form
|
||||
onSubmit={handleSubmit(create)}
|
||||
className="mx-auto my-3 max-w-xl space-y-6"
|
||||
>
|
||||
<Input
|
||||
label={"Name"}
|
||||
placeholder={"Onboarding Flow"}
|
||||
register={register("name")}
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"events"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Run on triggers
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => setValue("events", e)}
|
||||
values={events
|
||||
.filter(
|
||||
(e) => !e.campaignId && !watch("notevents").includes(e.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.templateId && !b.templateId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!a.templateId && b.templateId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name === "unsubscribe" || a.name === "subscribe") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name === "unsubscribe" || b.name === "subscribe") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag: e.templateId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: e.name === "unsubscribe" || e.name === "subscribe"
|
||||
? "Automated"
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
selectedValues={watch("events")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.events as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.events as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"events"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Exclude contacts with triggers
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => setValue("notevents", e)}
|
||||
values={events
|
||||
.filter(
|
||||
(e) => !e.campaignId && !watch("events").includes(e.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.templateId && !b.templateId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!a.templateId && b.templateId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name === "unsubscribe" || a.name === "subscribe") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name === "unsubscribe" || b.name === "subscribe") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag: e.templateId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: e.name === "unsubscribe" || e.name === "subscribe"
|
||||
? "Automated"
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
selectedValues={watch("notevents")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.notevents as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.notevents as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"template"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Template
|
||||
</label>
|
||||
<Dropdown
|
||||
onChange={(t) => setValue("template", t)}
|
||||
values={templates.map((t) => {
|
||||
return { name: t.subject, value: t.id };
|
||||
})}
|
||||
selectedValue={watch("template")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.template?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.template.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"template"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Delay before sending
|
||||
</label>
|
||||
<div className={"grid grid-cols-6 gap-4"}>
|
||||
<div className={"col-span-2 mt-1"}>
|
||||
<input
|
||||
type={"number"}
|
||||
autoComplete={"off"}
|
||||
min={0}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder={"0"}
|
||||
value={delay.delay}
|
||||
onChange={(e) =>
|
||||
setDelay({
|
||||
...delay,
|
||||
delay: Number.parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={"col-span-4"}>
|
||||
<Dropdown
|
||||
onChange={(t) =>
|
||||
setDelay({
|
||||
...delay,
|
||||
unit: t as "MINUTES" | "HOURS" | "DAYS",
|
||||
})
|
||||
}
|
||||
values={[
|
||||
{ name: "Minutes", value: "MINUTES" },
|
||||
{ name: "Hours", value: "HOURS" },
|
||||
{ name: "Days", value: "DAYS" },
|
||||
]}
|
||||
selectedValue={delay.unit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Toggle
|
||||
title={"Run once"}
|
||||
description={
|
||||
"Toggle this on if you want to run this action only once per contact."
|
||||
}
|
||||
toggled={watch("runOnce")}
|
||||
onToggle={() => setValue("runOnce", !watch("runOnce"))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex justify-end gap-3"}>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
return router.push("/actions");
|
||||
}}
|
||||
className={
|
||||
"flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<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>
|
||||
Create
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {useActiveProject} from '../../lib/hooks/projects';
|
||||
import {useAnalytics} from '../../lib/hooks/analytics';
|
||||
import {AnalyticsTabs, Card, FullscreenLoader} from '../../components';
|
||||
import React from 'react';
|
||||
import {Dashboard} from '../../layouts';
|
||||
import {Ring} from '@uiball/loaders';
|
||||
import {Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import {valueFormatter} from './index';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const {data: analytics} = useAnalytics();
|
||||
|
||||
if (!project) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<AnalyticsTabs />
|
||||
|
||||
<div className={'grid grid-cols-2 gap-6'}>
|
||||
<Card title={'Clicks'} description={'Last 7 days'} className={'sm:col-span-2'}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
height={300}
|
||||
data={analytics.clicks.actions}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="4 5" />
|
||||
|
||||
<YAxis axisLine={false} fill={'#fff'} tick={<></>} tickSize={0} width={5} />
|
||||
|
||||
<XAxis
|
||||
tickSize={0}
|
||||
stroke={'#fff'}
|
||||
interval={0}
|
||||
dataKey="link"
|
||||
tick={({x, y, payload}) => {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text x={0} y={0} dy={16} fill={'#666'} textAnchor={'middle'} className="text-xs">
|
||||
{payload.value.length > 5 ? `${payload.value.substring(0, 20)}...` : payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
// Ease in out animation
|
||||
cursor={{fill: '#f5f5f5', opacity: '0.5'}}
|
||||
content={({active, payload, label}) => {
|
||||
if (active && payload?.length) {
|
||||
const dataPoint = payload[0];
|
||||
return (
|
||||
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
|
||||
<p className="font-medium text-neutral-800">{`${label}`}</p>
|
||||
<p className="text-neutral-600">{valueFormatter(dataPoint.value as number)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Bar dataKey="count" stackId="a" fill={'#3b82f6'} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={'flex h-[300px] items-center justify-center'}>
|
||||
<Ring size={32} color={'#a3a3a3'} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import { AnalyticsTabs, Card, FullscreenLoader } from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
|
||||
import { Ring } from "@uiball/loaders";
|
||||
import dayjs from "dayjs";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import React from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useAnalytics } from "../../lib/hooks/analytics";
|
||||
|
||||
export const valueFormatter = (number: number) => {
|
||||
if (number > 999999999) {
|
||||
return `${Intl.NumberFormat("us")
|
||||
.format(number / 1000000000)
|
||||
.toString()}B`;
|
||||
}
|
||||
|
||||
if (number > 999999) {
|
||||
return `${Intl.NumberFormat("us")
|
||||
.format(number / 1000000)
|
||||
.toString()}M`;
|
||||
}
|
||||
|
||||
if (number > 999) {
|
||||
return `${Intl.NumberFormat("us")
|
||||
.format(number / 1000)
|
||||
.toString()}K`;
|
||||
}
|
||||
|
||||
return Intl.NumberFormat("us").format(number).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const { data: analytics } = useAnalytics();
|
||||
|
||||
if (!project) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<AnalyticsTabs />
|
||||
|
||||
<div className={"grid grid-cols-2 gap-6"}>
|
||||
<Card>
|
||||
{analytics ? (
|
||||
<div className={"flex items-center"}>
|
||||
<div>
|
||||
<p className={"font-medium text-neutral-600"}>Bounce Rate</p>
|
||||
<p className={"text-2xl font-semibold text-neutral-800"}>
|
||||
<>
|
||||
{(
|
||||
(analytics.emails.bounced / analytics.emails.total) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
<div className={"flex flex-1 justify-end"}>
|
||||
{analytics.emails.bounced / analytics.emails.total >
|
||||
analytics.emails.bouncedPrev / analytics.emails.totalPrev ? (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-1 text-sm font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
{Number.isNaN(
|
||||
(analytics.emails.bounced / analytics.emails.total -
|
||||
analytics.emails.bouncedPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100,
|
||||
)
|
||||
? 0
|
||||
: (
|
||||
(analytics.emails.bounced /
|
||||
analytics.emails.total -
|
||||
analytics.emails.bouncedPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
<ArrowUp className={"text-red-400"} size={24} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-1 text-sm font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
{Number.isNaN(
|
||||
(analytics.emails.bounced / analytics.emails.total -
|
||||
analytics.emails.bouncedPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100,
|
||||
)
|
||||
? 0
|
||||
: (
|
||||
(analytics.emails.bounced /
|
||||
analytics.emails.total -
|
||||
analytics.emails.bouncedPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
<ArrowDown className={"text-green-400"} size={24} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[55px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card>
|
||||
{analytics ? (
|
||||
<div className={"flex items-center"}>
|
||||
<div>
|
||||
<p className={"font-medium text-neutral-600"}>Spam Rate</p>
|
||||
<p className={"text-2xl font-semibold text-neutral-800"}>
|
||||
<>
|
||||
{(
|
||||
(analytics.emails.complaint / analytics.emails.total) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
<div className={"flex flex-1 justify-end"}>
|
||||
{analytics.emails.complaint / analytics.emails.total >
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev ? (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-1 text-sm font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
{Number.isNaN(
|
||||
(analytics.emails.complaint / analytics.emails.total -
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100,
|
||||
)
|
||||
? 0
|
||||
: (
|
||||
(analytics.emails.complaint /
|
||||
analytics.emails.total -
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
<ArrowUp className={"text-red-400"} size={24} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-1 text-sm font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
{Number.isNaN(
|
||||
(analytics.emails.complaint / analytics.emails.total -
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100,
|
||||
)
|
||||
? 0
|
||||
: (
|
||||
(analytics.emails.complaint /
|
||||
analytics.emails.total -
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
<ArrowDown className={"text-green-400"} size={24} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[55px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card title={"Contacts"} className={"col-span-2"}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<ResponsiveContainer width={"100%"} height={300}>
|
||||
<AreaChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={analytics.contacts.timeseries
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(a.day).getTime() - new Date(b.day).getTime()
|
||||
);
|
||||
})
|
||||
.map((i) => {
|
||||
return {
|
||||
day: dayjs(i.day).format("MMM DD"),
|
||||
count: i.count,
|
||||
};
|
||||
})}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 20,
|
||||
left: 20,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="4 5" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="gradientFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#2563eb"
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#93c5fd"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
domain={[
|
||||
0,
|
||||
analytics.contacts.timeseries.length === 0
|
||||
? 10
|
||||
: analytics.contacts.timeseries[
|
||||
analytics.contacts.timeseries.length - 1
|
||||
].count * 1.1,
|
||||
]}
|
||||
fill={"#fff"}
|
||||
tickSize={0}
|
||||
width={5}
|
||||
interval={0}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
tickSize={0}
|
||||
stroke={"#fff"}
|
||||
dataKey="day"
|
||||
interval={3}
|
||||
tick={({ x, y, payload }) => {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={16}
|
||||
fill={"#666"}
|
||||
textAnchor={"middle"}
|
||||
className="text-sm" // Add your custom class name here
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload?.length) {
|
||||
// Customize the tooltip content here
|
||||
const dataPoint = payload[0];
|
||||
return (
|
||||
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
|
||||
<p className="font-medium text-neutral-800">{`${label}`}</p>
|
||||
<p className="text-neutral-600">
|
||||
{valueFormatter(dataPoint.value as number)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Area
|
||||
type="basis"
|
||||
dataKey="count"
|
||||
stroke="#2563eb"
|
||||
fill="url(#gradientFill)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[300px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card title={"Retention Rate"}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart width={300} height={300}>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
// Customize the tooltip content here
|
||||
const dataPoint = payload[0];
|
||||
return (
|
||||
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
|
||||
<p className="font-medium text-neutral-800">{`${dataPoint.name}`}</p>
|
||||
<p className="text-neutral-600">
|
||||
{valueFormatter(dataPoint.value as number)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
name: "Subscribed",
|
||||
value: analytics.contacts.subscribed,
|
||||
},
|
||||
{
|
||||
name: "Unsubscribed",
|
||||
value: analytics.contacts.unsubscribed,
|
||||
},
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}) => {
|
||||
const radius =
|
||||
innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x =
|
||||
cx + radius * Math.cos((-midAngle * Math.PI) / 180);
|
||||
const y =
|
||||
cy + radius * Math.sin((-midAngle * Math.PI) / 180);
|
||||
|
||||
if (percent < 0.1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={percent > 0.5 ? "white" : "#666"}
|
||||
className={"text-sm font-semibold"}
|
||||
textAnchor={x > cx ? "start" : "middle"}
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
outerRadius={90}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{[
|
||||
{
|
||||
name: "Subscribed",
|
||||
value: analytics.contacts.subscribed,
|
||||
},
|
||||
{
|
||||
name: "Unsubscribed",
|
||||
value: analytics.contacts.unsubscribed,
|
||||
},
|
||||
].map((entry, index) => (
|
||||
<Cell
|
||||
style={{ outline: "none" }}
|
||||
key={`cell-${entry.name}`}
|
||||
fill={["#3b82f6", "#e5e5e5"][index % 2]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[200px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card title={"Open rate"}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart width={300} height={300}>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
// Customize the tooltip content here
|
||||
const dataPoint = payload[0];
|
||||
return (
|
||||
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
|
||||
<p className="font-medium text-neutral-800">{`${dataPoint.name}`}</p>
|
||||
<p className="text-neutral-600">
|
||||
{valueFormatter(dataPoint.value as number)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Pie
|
||||
data={[
|
||||
{ name: "Opened", value: analytics.emails.opened },
|
||||
|
||||
{
|
||||
name: "Unopened",
|
||||
value:
|
||||
analytics.emails.total -
|
||||
analytics.emails.opened -
|
||||
analytics.emails.bounced -
|
||||
analytics.emails.complaint,
|
||||
},
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}) => {
|
||||
const radius =
|
||||
innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x =
|
||||
cx + radius * Math.cos((-midAngle * Math.PI) / 180);
|
||||
const y =
|
||||
cy + radius * Math.sin((-midAngle * Math.PI) / 180);
|
||||
|
||||
if (percent < 0.1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={percent > 0.5 ? "white" : "#666"}
|
||||
className={"text-sm font-semibold"}
|
||||
textAnchor={x > cx ? "start" : "middle"}
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
outerRadius={90}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{[
|
||||
{ name: "Opened", value: analytics.emails.opened },
|
||||
|
||||
{
|
||||
name: "Unopened",
|
||||
value:
|
||||
analytics.emails.total -
|
||||
analytics.emails.opened -
|
||||
analytics.emails.bounced -
|
||||
analytics.emails.complaint,
|
||||
},
|
||||
].map((entry, index) => (
|
||||
<Cell
|
||||
style={{ outline: "none" }}
|
||||
key={`cell-${entry.name}`}
|
||||
fill={["#3b82f6", "#e5e5e5"][index % 2]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[200px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UserSchemas } from "@plunk/shared";
|
||||
import type { User } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import logo from "../../../public/assets/logo.png";
|
||||
import { FullscreenLoader, Redirect } from "../../components";
|
||||
import { useUser } from "../../lib/hooks/users";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
interface AuthValues {
|
||||
password: string;
|
||||
email: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: user, error, mutate } = useUser();
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [hidePassword, setHidePassword] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
} = useForm<AuthValues>({
|
||||
resolver: zodResolver(UserSchemas.credentials),
|
||||
});
|
||||
|
||||
if (user && !error) {
|
||||
return <Redirect to={"/"} />;
|
||||
}
|
||||
|
||||
if (!user && !error) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const login = async (data: AuthValues) => {
|
||||
setSubmitted(true);
|
||||
const result = await network.fetch<
|
||||
| {
|
||||
success: true;
|
||||
data: User;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
data: string;
|
||||
}
|
||||
| {
|
||||
success: "redirect";
|
||||
redirect: string;
|
||||
},
|
||||
typeof UserSchemas.credentials
|
||||
>("POST", "/auth/login", {
|
||||
...data,
|
||||
});
|
||||
|
||||
if (result.success === "redirect") {
|
||||
return router.push(result.redirect);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
await mutate(result.data);
|
||||
|
||||
return router.push("/");
|
||||
}
|
||||
setError("auth", { message: result.data });
|
||||
|
||||
setSubmitted(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-off-white flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col items-center sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<Image
|
||||
src={logo}
|
||||
placeholder={"blur"}
|
||||
width={35}
|
||||
height={35}
|
||||
alt={"Plunk Logo"}
|
||||
/>
|
||||
<h2 className="mt-4 text-center text-3xl font-bold text-neutral-800">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="rounded border border-neutral-200 bg-white px-4 py-8 sm:px-10">
|
||||
<form onSubmit={handleSubmit(login)} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"email"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type={"email"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
autoComplete={"email"}
|
||||
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>
|
||||
<label
|
||||
htmlFor={"password"}
|
||||
className="block text-sm font-semibold text-neutral-600"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
type={hidePassword ? "password" : "text"}
|
||||
placeholder={hidePassword ? "•••••••••••••" : "Password"}
|
||||
autoComplete={"current-password"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
{...register("password")}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3">
|
||||
<svg
|
||||
onClick={() => setHidePassword(!hidePassword)}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{hidePassword ? (
|
||||
<>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errors.password?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
Password must be at least 6 characters long
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="submit"
|
||||
className={
|
||||
"flex w-full items-center justify-center rounded-md bg-neutral-800 py-2.5 text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
{submitted ? (
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</motion.button>
|
||||
<AnimatePresence>
|
||||
{errors.auth?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.auth.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className={"w-full text-center"}>
|
||||
<Link
|
||||
href={"/auth/signup"}
|
||||
passHref
|
||||
className={
|
||||
"text-sm text-neutral-500 underline transition ease-in-out hover:text-neutral-500"
|
||||
}
|
||||
>
|
||||
Want to create an account instead?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { FullscreenLoader } from "../../components/";
|
||||
import { useUser } from "../../lib/hooks/users";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
const { error, mutate } = useUser();
|
||||
|
||||
if (error) {
|
||||
void router.push("/");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void network.fetch<boolean>("GET", "/auth/logout").then(async (success) => {
|
||||
if (success) {
|
||||
await mutate(null);
|
||||
await router.push("/");
|
||||
}
|
||||
});
|
||||
}, [mutate, router.push]);
|
||||
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UserSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Redirect } from "../../components";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface ResetValues {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.query.id) {
|
||||
return <Redirect to={"/"} />;
|
||||
}
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [hidePassword, setHidePassword] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ResetValues>({
|
||||
resolver: zodResolver(UserSchemas.credentials.pick({ password: true })),
|
||||
});
|
||||
|
||||
const resetPassword = async (data: ResetValues) => {
|
||||
const schema = UtilitySchemas.id.merge(
|
||||
UserSchemas.credentials.pick({ password: true }),
|
||||
);
|
||||
|
||||
setSubmitted(true);
|
||||
await network.fetch<
|
||||
{
|
||||
success: true;
|
||||
},
|
||||
typeof schema
|
||||
>("POST", "/auth/reset", {
|
||||
id: router.query.id as string,
|
||||
...data,
|
||||
});
|
||||
|
||||
return router.push("/auth/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={"flex h-screen w-screen items-center justify-center"}>
|
||||
<div className={"space-y-6"}>
|
||||
<div>
|
||||
<svg
|
||||
className={
|
||||
"mx-auto h-14 w-14 rounded-full bg-blue-100 p-2 text-blue-900"
|
||||
}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M15 13.25C17.3472 13.25 19.25 11.3472 19.25 9C19.25 6.65279 17.3472 4.75 15 4.75C12.6528 4.75 10.75 6.65279 10.75 9C10.75 9.31012 10.7832 9.61248 10.8463 9.90372L4.75 16V19.25H8L8.75 18.5V16.75H10.5L11.75 15.5V13.75H13.5L14.0963 13.1537C14.3875 13.2168 14.6899 13.25 15 13.25Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
d="M16.5 8C16.5 8.27614 16.2761 8.5 16 8.5C15.7239 8.5 15.5 8.27614 15.5 8C15.5 7.72386 15.7239 7.5 16 7.5C16.2761 7.5 16.5 7.72386 16.5 8Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className={"space-y-3 text-center"}>
|
||||
<h1 className={"text-4xl font-bold text-neutral-800"}>
|
||||
Reset password
|
||||
</h1>
|
||||
<p className={"text-neutral-700"}>
|
||||
Please enter your new password and confirm it.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(resetPassword)} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"password"}
|
||||
className="block text-sm font-semibold text-neutral-600"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
type={hidePassword ? "password" : "text"}
|
||||
placeholder={hidePassword ? "•••••••••••••" : "Password"}
|
||||
autoComplete={"current-password"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
{...register("password")}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3">
|
||||
<svg
|
||||
onClick={() => setHidePassword(!hidePassword)}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{hidePassword ? (
|
||||
<>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errors.password?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
Password must be atleast 6 characters long
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="submit"
|
||||
className={
|
||||
"flex w-full items-center justify-center rounded-md bg-neutral-800 py-2.5 text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
{submitted ? (
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
"Change password"
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UserSchemas } from "@plunk/shared";
|
||||
import type { User } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import logo from "../../../public/assets/logo.png";
|
||||
import { FullscreenLoader, Redirect } from "../../components";
|
||||
import { useUser } from "../../lib/hooks/users";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface AuthValues {
|
||||
password: string;
|
||||
email: string;
|
||||
terms: boolean;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.isReady) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const { data: user, error, mutate } = useUser();
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [hidePassword, setHidePassword] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
} = useForm<AuthValues>({
|
||||
defaultValues: { email: (router.query.email as string | undefined) ?? "" },
|
||||
resolver: zodResolver(UserSchemas.credentials),
|
||||
});
|
||||
|
||||
if (user && !error) {
|
||||
return <Redirect to={"/"} />;
|
||||
}
|
||||
|
||||
if (!user && !error) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const signup = async (data: AuthValues) => {
|
||||
setSubmitted(true);
|
||||
|
||||
const result = await network.fetch<
|
||||
| {
|
||||
success: true;
|
||||
data: User;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
data: string;
|
||||
},
|
||||
typeof UserSchemas.credentials
|
||||
>("POST", "/auth/signup", {
|
||||
...data,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await mutate(result.data);
|
||||
|
||||
return router.push("/new");
|
||||
}
|
||||
|
||||
setError("auth", { message: result.data });
|
||||
|
||||
setSubmitted(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-screen">
|
||||
<div className="flex flex-1 flex-col justify-center px-4 py-12 sm:border-r-2 sm:border-neutral-100 sm:px-6 lg:flex-none lg:px-20 xl:px-32">
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<div>
|
||||
<Image
|
||||
width={35}
|
||||
height={35}
|
||||
src={logo}
|
||||
alt={"Plunk logo"}
|
||||
placeholder={"blur"}
|
||||
/>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-neutral-800">
|
||||
Create a Plunk account
|
||||
</h2>
|
||||
<div>
|
||||
<Link
|
||||
href={"/auth/login"}
|
||||
className={
|
||||
"text-sm text-neutral-500 underline transition ease-in-out hover:text-neutral-600"
|
||||
}
|
||||
>
|
||||
Already have an account?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="mt-6">
|
||||
<form onSubmit={handleSubmit(signup)} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"email"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Your Email
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type={"email"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
autoComplete={"email"}
|
||||
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>
|
||||
<label
|
||||
htmlFor={"password"}
|
||||
className="block text-sm font-semibold text-neutral-600"
|
||||
>
|
||||
A Strong Password
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
type={hidePassword ? "password" : "text"}
|
||||
placeholder={
|
||||
hidePassword ? "•••••••••••••" : "Password"
|
||||
}
|
||||
autoComplete={"new-password"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
{...register("password")}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3">
|
||||
<svg
|
||||
onClick={() => setHidePassword(!hidePassword)}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{hidePassword ? (
|
||||
<>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errors.password?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
Password must be atleast 6 characters long
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="submit"
|
||||
className={
|
||||
"flex w-full items-center justify-center rounded-md bg-neutral-800 py-2.5 text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
{submitted ? (
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</motion.button>
|
||||
<AnimatePresence>
|
||||
{errors.auth?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.auth.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden w-0 flex-1 items-center justify-center bg-gradient-to-br from-blue-50 to-white lg:flex" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, Send } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { Badge, Card, Empty, Skeleton } from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useCampaigns } from "../../lib/hooks/campaigns";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const { data: campaigns } = useCampaigns();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card
|
||||
title={"Campaigns"}
|
||||
description={"Send your contacts emails in bulk with a few clicks"}
|
||||
actions={
|
||||
<>
|
||||
<Link href={"/campaigns/new"} passHref>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-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>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{campaigns ? (
|
||||
campaigns.length > 0 ? (
|
||||
<>
|
||||
<div className={"grid grid-cols-1 gap-6 sm:grid-cols-2"}>
|
||||
{campaigns.map((c) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="col-span-1 divide-y divide-neutral-200 rounded border border-neutral-200 bg-white"
|
||||
key={c.id}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between space-x-6 p-6">
|
||||
<span className="inline-flex rounded bg-neutral-100 p-3 text-neutral-800 ring-4 ring-white">
|
||||
<Send size={20} />
|
||||
</span>
|
||||
<div className="flex-1 truncate">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="truncate text-lg font-bold text-neutral-800">
|
||||
{c.subject}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={"mb-6"}>
|
||||
<h2
|
||||
className={
|
||||
"text col-span-2 truncate font-semibold text-neutral-700"
|
||||
}
|
||||
>
|
||||
Quick Stats
|
||||
</h2>
|
||||
<div className={"grid grid-cols-2 gap-3"}>
|
||||
{c.status === "DELIVERED" ? (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Open rate
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{c.emails.length > 0
|
||||
? Math.round(
|
||||
(c.emails.filter(
|
||||
(e) => e.status === "OPENED",
|
||||
).length /
|
||||
c.emails.length) *
|
||||
100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{c.tasks.length > 0 && (
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Emails in queue
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{c.tasks.length}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Open rate
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
Awaiting delivery
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"my-4"}>
|
||||
<h2
|
||||
className={
|
||||
"col-span-2 truncate font-semibold text-neutral-700"
|
||||
}
|
||||
>
|
||||
Properties
|
||||
</h2>
|
||||
<div className={"grid grid-cols-2 gap-3"}>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Recipients
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{c.recipients.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{c.status === "DRAFT" ? (
|
||||
<Badge type={"info"}>Draft</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
type={
|
||||
c.tasks.length > 0
|
||||
? "info"
|
||||
: "success"
|
||||
}
|
||||
>
|
||||
{c.tasks.length > 0
|
||||
? "Sending"
|
||||
: "Delivered"}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="-mt-px flex divide-x divide-neutral-200">
|
||||
<div className="flex w-0 flex-1">
|
||||
<Link
|
||||
href={`/campaigns/${c.id}`}
|
||||
className="relative inline-flex w-0 flex-1 items-center justify-center rounded-bl rounded-br py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{c.status === "DELIVERED" ? (
|
||||
<>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M19.25 12C19.25 13 17.5 18.25 12 18.25C6.5 18.25 4.75 13 4.75 12C4.75 11 6.5 5.75 12 5.75C17.5 5.75 19.25 11 19.25 12Z"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="2.25"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M4.75 19.25L9 18.25L18.2929 8.95711C18.6834 8.56658 18.6834 7.93342 18.2929 7.54289L16.4571 5.70711C16.0666 5.31658 15.4334 5.31658 15.0429 5.70711L5.75 15L4.75 19.25Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M19.25 19.25H13.75"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<span className="ml-3">
|
||||
{c.status === "DELIVERED" ? "View" : "Edit"}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
title={"No campaigns found"}
|
||||
description={
|
||||
"Send your contacts emails in bulk with a few clicks"
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Skeleton type={"table"} />
|
||||
)}
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CampaignSchemas } from "@plunk/shared";
|
||||
import type { Campaign } from "@prisma/client";
|
||||
import { Ring } from "@uiball/loaders";
|
||||
import dayjs from "dayjs";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Search, Users2, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { type FieldError, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Dropdown,
|
||||
Editor,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
MultiselectDropdown,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useCampaigns } from "../../lib/hooks/campaigns";
|
||||
import { useContacts } from "../../lib/hooks/contacts";
|
||||
import { useEventsWithoutTriggers } from "../../lib/hooks/events";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface CampaignValues {
|
||||
subject: string;
|
||||
body: string;
|
||||
recipients: string[];
|
||||
style: "PLUNK" | "HTML";
|
||||
}
|
||||
|
||||
const templates = {
|
||||
blank: {
|
||||
subject: "",
|
||||
body: "",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
const project = useActiveProject();
|
||||
const { mutate } = useCampaigns();
|
||||
const { data: contacts } = useContacts(0);
|
||||
const { data: events } = useEventsWithoutTriggers();
|
||||
|
||||
const [query, setQuery] = useState<{
|
||||
events?: string[];
|
||||
last?: "day" | "week" | "month";
|
||||
data?: string;
|
||||
value?: string;
|
||||
notevents?: string[];
|
||||
notlast?: "day" | "week" | "month";
|
||||
}>({});
|
||||
const [paymentModal, setPaymentModal] = useState(false);
|
||||
const [advancedSelector, setSelector] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<CampaignValues>({
|
||||
resolver: zodResolver(CampaignSchemas.create),
|
||||
defaultValues: {
|
||||
recipients: [],
|
||||
...templates.blank,
|
||||
style: "PLUNK",
|
||||
},
|
||||
});
|
||||
|
||||
if (!project || !events) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const selectQuery = () => {
|
||||
if (!contacts) {
|
||||
return;
|
||||
}
|
||||
|
||||
let filteredContacts = contacts.contacts;
|
||||
|
||||
if (query.events && query.events.length > 0) {
|
||||
query.events.map((e) => {
|
||||
filteredContacts = filteredContacts.filter((c) =>
|
||||
c.triggers.some((t) => t.eventId === e),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (query.last) {
|
||||
filteredContacts = filteredContacts.filter((c) => {
|
||||
if (c.triggers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastTrigger = c.triggers.sort((a, b) =>
|
||||
a.createdAt > b.createdAt ? -1 : 1,
|
||||
);
|
||||
|
||||
if (lastTrigger.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return dayjs(lastTrigger[0].createdAt).isAfter(
|
||||
dayjs().subtract(1, query.last),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (query.notevents && query.notevents.length > 0 && query.notlast) {
|
||||
query.notevents.map((e) => {
|
||||
filteredContacts = filteredContacts.filter((c) => {
|
||||
if (c.triggers.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastTrigger = c.triggers
|
||||
.filter((t) => t.eventId === e)
|
||||
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
|
||||
|
||||
if (lastTrigger.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return dayjs(lastTrigger[0].createdAt).isAfter(
|
||||
dayjs().subtract(1, query.last),
|
||||
);
|
||||
});
|
||||
});
|
||||
} else if (query.notevents && query.notevents.length > 0) {
|
||||
query.notevents.map((e) => {
|
||||
filteredContacts = filteredContacts.filter((c) =>
|
||||
c.triggers.every((t) => t.eventId !== e),
|
||||
);
|
||||
});
|
||||
} else if (query.notlast) {
|
||||
filteredContacts = filteredContacts.filter((c) => {
|
||||
if (c.triggers.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastTrigger = c.triggers.sort((a, b) =>
|
||||
a.createdAt > b.createdAt ? -1 : 1,
|
||||
);
|
||||
|
||||
if (lastTrigger.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !dayjs(lastTrigger[0].createdAt).isAfter(
|
||||
dayjs().subtract(1, query.notlast),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (query.data) {
|
||||
filteredContacts = filteredContacts.filter((c) => {
|
||||
if (!c.data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return JSON.parse(c.data)[query.data as string];
|
||||
});
|
||||
}
|
||||
|
||||
if (query.data && query.value) {
|
||||
filteredContacts = filteredContacts.filter((c) => {
|
||||
if (!c.data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.isArray(JSON.parse(c.data)[query.data as string])
|
||||
? JSON.parse(c.data)[query.data as string].includes(query.value)
|
||||
: JSON.parse(c.data)[query.data as string] === query.value;
|
||||
});
|
||||
}
|
||||
|
||||
setValue(
|
||||
"recipients",
|
||||
filteredContacts.map((c) => c.id),
|
||||
);
|
||||
};
|
||||
|
||||
const create = async (data: CampaignValues) => {
|
||||
toast.promise(
|
||||
network.mock<Campaign, typeof CampaignSchemas.create>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1/campaigns",
|
||||
data.recipients.length ===
|
||||
contacts?.contacts.filter((c) => c.subscribed).length
|
||||
? { ...data, recipients: ["all"] }
|
||||
: {
|
||||
...data,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Creating new campaign",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Created new campaign";
|
||||
},
|
||||
error: "Could not create new campaign!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/campaigns");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card title={"Create a new campaign"}>
|
||||
<form
|
||||
onSubmit={handleSubmit(create)}
|
||||
className="space-6 grid gap-6 sm:grid-cols-6"
|
||||
>
|
||||
<Input
|
||||
className={"sm:col-span-6"}
|
||||
label={"Subject"}
|
||||
placeholder={`Welcome to ${project.name}!`}
|
||||
register={register("subject")}
|
||||
error={errors.subject}
|
||||
/>
|
||||
{contacts ? (
|
||||
<>
|
||||
<div className={"sm:col-span-3"}>
|
||||
<label
|
||||
htmlFor={"recipients"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Recipients
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(c) => setValue("recipients", c)}
|
||||
values={contacts.contacts
|
||||
.filter((c) => c.subscribed)
|
||||
.map((c) => {
|
||||
return { name: c.email, value: c.id };
|
||||
})}
|
||||
selectedValues={watch("recipients")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.recipients as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.recipients as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"grid gap-6 sm:col-span-3 sm:grid-cols-2"}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (watch("recipients").length > 0) {
|
||||
return setValue("recipients", []);
|
||||
}
|
||||
|
||||
setValue(
|
||||
"recipients",
|
||||
contacts.contacts
|
||||
.filter((c) => c.subscribed)
|
||||
.map((c) => c.id),
|
||||
);
|
||||
}}
|
||||
className={
|
||||
"mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
}
|
||||
>
|
||||
{watch("recipients").length === 0 ? (
|
||||
<Users2 size={18} />
|
||||
) : (
|
||||
<XIcon size={18} />
|
||||
)}
|
||||
{watch("recipients").length === 0
|
||||
? "All contacts"
|
||||
: "Clear selection"}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelector(!advancedSelector);
|
||||
}}
|
||||
className={
|
||||
"mt-6 flex items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white px-8 py-1 text-center text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
}
|
||||
>
|
||||
{advancedSelector ? (
|
||||
<XIcon size={18} />
|
||||
) : (
|
||||
<Search size={18} />
|
||||
)}
|
||||
{advancedSelector ? "Close" : "Advanced selector"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{advancedSelector && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={
|
||||
"relative z-20 grid gap-6 rounded border border-neutral-300 px-6 py-6 sm:col-span-6 sm:grid-cols-4"
|
||||
}
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"event"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Has triggers for events
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) =>
|
||||
setQuery(
|
||||
e.length > 0
|
||||
? { ...query, events: e }
|
||||
: {
|
||||
...query,
|
||||
events: undefined,
|
||||
last: undefined,
|
||||
},
|
||||
)
|
||||
}
|
||||
values={[
|
||||
...events
|
||||
.filter((e) => !query.notevents?.includes(e.id))
|
||||
.sort((a, b) => {
|
||||
if (!a.templateId && !a.campaignId) {
|
||||
return -1;
|
||||
}
|
||||
if (!b.templateId && !b.campaignId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag:
|
||||
e.templateId ?? e.campaignId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
selectedValues={query.events ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
{query.events && query.events.length > 0 && (
|
||||
<>
|
||||
<label
|
||||
htmlFor={"event"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Has triggered {query.events.length} selected
|
||||
events
|
||||
</label>
|
||||
<Dropdown
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
...query,
|
||||
last:
|
||||
(e as "" | "day" | "week" | "month") === ""
|
||||
? undefined
|
||||
: (e as "day" | "week" | "month"),
|
||||
})
|
||||
}
|
||||
values={[
|
||||
{ name: "Anytime", value: "" },
|
||||
{ name: "In the last day", value: "day" },
|
||||
{ name: "In the last week", value: "week" },
|
||||
{ name: "In the last month", value: "month" },
|
||||
]}
|
||||
selectedValue={query.last ?? ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"event"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
No triggers for events
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => {
|
||||
setQuery(
|
||||
e.length > 0
|
||||
? { ...query, notevents: e }
|
||||
: {
|
||||
...query,
|
||||
notevents: undefined,
|
||||
notlast: undefined,
|
||||
},
|
||||
);
|
||||
}}
|
||||
values={[
|
||||
...events
|
||||
.filter((e) => !query.events?.includes(e.id))
|
||||
.sort((a, b) => {
|
||||
if (!a.templateId && !a.campaignId) {
|
||||
return -1;
|
||||
}
|
||||
if (!b.templateId && !b.campaignId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag:
|
||||
e.templateId ?? e.campaignId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
selectedValues={query.notevents ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
{query.notevents && query.notevents.length > 0 && (
|
||||
<>
|
||||
<label
|
||||
htmlFor={"event"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Not triggered {query.notevents.length} selected
|
||||
events
|
||||
</label>
|
||||
<Dropdown
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
...query,
|
||||
notlast:
|
||||
(e as "" | "day" | "week" | "month") === ""
|
||||
? undefined
|
||||
: (e as "day" | "week" | "month"),
|
||||
})
|
||||
}
|
||||
values={[
|
||||
{ name: "Anytime", value: "" },
|
||||
{ name: "In the last day", value: "day" },
|
||||
{ name: "In the last week", value: "week" },
|
||||
{ name: "In the last month", value: "month" },
|
||||
]}
|
||||
selectedValue={query.notlast ?? ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"event"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
All contacts with parameter
|
||||
</label>
|
||||
<Dropdown
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
...query,
|
||||
data: e === "" ? undefined : e,
|
||||
})
|
||||
}
|
||||
values={[
|
||||
{ name: "Any parameter", value: "" },
|
||||
...new Set(
|
||||
contacts.contacts
|
||||
.filter((c) => c.data)
|
||||
.map((c) => {
|
||||
return Object.keys(
|
||||
JSON.parse(c.data ?? "{}"),
|
||||
);
|
||||
})
|
||||
.reduce((acc, val) => acc.concat(val), []),
|
||||
),
|
||||
].map((k) =>
|
||||
typeof k === "string" ? { name: k, value: k } : k,
|
||||
)}
|
||||
selectedValue={query.data ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
{query.data && (
|
||||
<>
|
||||
<label
|
||||
htmlFor={"event"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
All contacts where parameter {query.data} is
|
||||
</label>
|
||||
|
||||
<Dropdown
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
...query,
|
||||
value: e === "" ? undefined : e,
|
||||
})
|
||||
}
|
||||
values={[
|
||||
{ name: "Any value", value: "" },
|
||||
...new Set(
|
||||
contacts.contacts
|
||||
.filter(
|
||||
(c) =>
|
||||
c.data &&
|
||||
JSON.parse(c.data)[query.data ?? ""],
|
||||
)
|
||||
.map((c) => {
|
||||
return JSON.parse(c.data ?? "{}")[
|
||||
query.data ?? ""
|
||||
];
|
||||
})
|
||||
.reduce((acc, val) => acc.concat(val), []),
|
||||
),
|
||||
].map((k) =>
|
||||
typeof k === "string"
|
||||
? {
|
||||
name: k,
|
||||
value: k,
|
||||
}
|
||||
: (k as {
|
||||
name: string;
|
||||
value: string;
|
||||
}),
|
||||
)}
|
||||
selectedValue={query.value ?? ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-4"}>
|
||||
<motion.button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
selectQuery();
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"ml-auto flex items-center justify-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<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>
|
||||
Select contacts
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-6 rounded border border-neutral-300 px-8 py-3 sm:col-span-6"
|
||||
}
|
||||
>
|
||||
<Ring size={20} />
|
||||
<div>
|
||||
<h1 className={"text-lg font-semibold text-neutral-800"}>
|
||||
Hang on!
|
||||
</h1>
|
||||
<p className={"text-sm text-neutral-600"}>
|
||||
We're still loading your contacts. This might take up to a
|
||||
minute. You can already start writing your campaign in the
|
||||
editor below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{watch("recipients").length >= 10 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className={"relative z-10 sm:col-span-6"}
|
||||
>
|
||||
<Alert type={"info"} title={"Automatic batching"}>
|
||||
Your campaign will be sent out in batches of 80 recipients
|
||||
each. It will be delivered to all contacts{" "}
|
||||
{dayjs().to(
|
||||
dayjs().add(
|
||||
Math.ceil(watch("recipients").length / 80),
|
||||
"minutes",
|
||||
),
|
||||
)}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className={"sm:col-span-6"}>
|
||||
<Editor
|
||||
value={watch("body")}
|
||||
mode={watch("style")}
|
||||
onChange={(value, type) => {
|
||||
setValue("body", value);
|
||||
setValue("style", type);
|
||||
}}
|
||||
modeSwitcher
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.body?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.body.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={"ml-auto mt-6 flex justify-end gap-3 sm:col-span-6"}
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
return router.push("/campaigns");
|
||||
}}
|
||||
className={
|
||||
"flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<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>
|
||||
Create
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
// @ts-nocheck
|
||||
// React Hook Form messes up our types, ignore the entire file
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
ContactSchemas,
|
||||
EventSchemas,
|
||||
type UtilitySchemas,
|
||||
} from "@plunk/shared";
|
||||
import type { Contact, Email, Template } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { motion } from "framer-motion";
|
||||
import { Save } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Card,
|
||||
Empty,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
Modal,
|
||||
Toggle,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useContact } from "../../lib/hooks/contacts";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface ContactValues {
|
||||
email: string;
|
||||
data: string | null;
|
||||
subscribed: boolean;
|
||||
}
|
||||
|
||||
interface EventValues {
|
||||
event: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.isReady) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const [eventModal, setEventModal] = useState(false);
|
||||
|
||||
const project = useActiveProject();
|
||||
const { data: contact, mutate } = useContact({
|
||||
id: router.query.id as string,
|
||||
});
|
||||
|
||||
const { handleSubmit, watch, setValue, reset } = useForm<ContactValues>({
|
||||
resolver: zodResolver(ContactSchemas.update),
|
||||
});
|
||||
|
||||
const {
|
||||
register: dataRegister,
|
||||
control,
|
||||
getValues: getDataValues,
|
||||
reset: dataReset,
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
data: Object.entries(JSON.parse(contact?.data ? contact.data : "{}")).map(
|
||||
([key]) => ({
|
||||
value: { key },
|
||||
}),
|
||||
),
|
||||
},
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.object({ key: z.string(), value: z.string() }),
|
||||
}),
|
||||
)
|
||||
.min(0),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const {
|
||||
fields,
|
||||
append: fieldAppend,
|
||||
remove: fieldRemove,
|
||||
} = useFieldArray({ control, name: "data" });
|
||||
|
||||
const {
|
||||
register: eventRegister,
|
||||
handleSubmit: eventHandleSubmit,
|
||||
formState: { errors: eventErrors },
|
||||
reset: eventReset,
|
||||
} = useForm<EventValues>({
|
||||
resolver: zodResolver(EventSchemas.post.pick({ event: true })),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!contact) {
|
||||
return;
|
||||
}
|
||||
|
||||
reset(contact);
|
||||
dataReset({
|
||||
data: Object.entries(JSON.parse(contact.data ? contact.data : "{}")).map(
|
||||
([key, value]) => ({
|
||||
value: { key, value },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}, [dataReset, reset, contact]);
|
||||
|
||||
if (!contact) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const create = (data: EventValues) => {
|
||||
toast.promise(
|
||||
network.mock<Template, typeof EventSchemas.post>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1",
|
||||
{
|
||||
...data,
|
||||
email: contact.email,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Creating new event",
|
||||
success: () => {
|
||||
void mutate();
|
||||
eventReset();
|
||||
return "Created new event";
|
||||
},
|
||||
error: "Could not create new event!",
|
||||
},
|
||||
);
|
||||
|
||||
setEventModal(false);
|
||||
};
|
||||
|
||||
const update = (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<Contact, typeof ContactSchemas.update>(
|
||||
project.secret,
|
||||
"PUT",
|
||||
"/v1/contacts",
|
||||
{
|
||||
id: contact.id,
|
||||
...data,
|
||||
data: dataObject,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Saving your changes",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Saved your changes";
|
||||
},
|
||||
error: "Could not save your changes!",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const remove = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
toast.promise(
|
||||
network.mock<Contact, typeof UtilitySchemas.id>(
|
||||
project.secret,
|
||||
"DELETE",
|
||||
"/v1/contacts",
|
||||
{
|
||||
id: contact.id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Deleting contact",
|
||||
success: "Deleted contact",
|
||||
error: "Could not delete contact!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/contacts");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={eventModal}
|
||||
onToggle={() => setEventModal(!eventModal)}
|
||||
onAction={eventHandleSubmit(create)}
|
||||
type={"info"}
|
||||
action={"Trigger"}
|
||||
title={"Trigger event"}
|
||||
description={`Trigger an event for ${contact.email}`}
|
||||
icon={
|
||||
<>
|
||||
<rect
|
||||
strokeWidth={2}
|
||||
width="14.5"
|
||||
height="14.5"
|
||||
x="4.75"
|
||||
y="4.75"
|
||||
rx="2"
|
||||
/>
|
||||
<path strokeWidth={2} d="M8.75 10.75L11.25 13L8.75 15.25" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
register={eventRegister("event")}
|
||||
label={"Event"}
|
||||
placeholder={"signup"}
|
||||
error={eventErrors.event}
|
||||
/>
|
||||
</Modal>
|
||||
<Dashboard>
|
||||
<Card
|
||||
title={""}
|
||||
options={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEventModal(true)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<rect
|
||||
width="14.5"
|
||||
height="14.5"
|
||||
x="4.75"
|
||||
y="4.75"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
rx="2"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M8.75 10.75L11.25 13L8.75 15.25"
|
||||
/>
|
||||
</svg>
|
||||
Trigger event
|
||||
</button>
|
||||
<button
|
||||
onClick={remove}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(update)}
|
||||
className="grid gap-x-5 space-y-9 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"col-span-2 flex items-center gap-6"}>
|
||||
<span className="inline-flex h-20 w-20 items-center justify-center rounded-full bg-neutral-100">
|
||||
<span className="text-xl font-semibold leading-none text-neutral-800">
|
||||
{contact.email[0].toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<h1 className={"text-2xl font-semibold text-neutral-800"}>
|
||||
{contact.email[0].toUpperCase()}
|
||||
{contact.email.slice(1)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 ? (
|
||||
fields.map((field, index) => {
|
||||
return (
|
||||
<>
|
||||
<div key={field.id}>
|
||||
<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>
|
||||
|
||||
<div className={"col-span-2"}>
|
||||
<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>
|
||||
|
||||
<div className={"col-span-2 ml-auto flex justify-end gap-x-5"}>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"ml-auto mt-6 flex items-center gap-x-2 rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<Save strokeWidth={1.5} size={18} />
|
||||
Save
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card title={"Journey"}>
|
||||
{contact.triggers.length > 0 || contact.emails.length > 0 ? (
|
||||
<div className="scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-neutral-100 scrollbar-thumb-rounded-full scrollbar-track-rounded-full flow-root h-96 max-h-96 overflow-y-auto pr-6">
|
||||
<ul className="-mb-8">
|
||||
{[...contact.triggers, ...contact.emails]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime(),
|
||||
)
|
||||
.map((t, index) => {
|
||||
if (t.messageId) {
|
||||
const email = t as Email;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="relative pb-8">
|
||||
{contact.triggers.length +
|
||||
contact.emails.length -
|
||||
1 !==
|
||||
index && (
|
||||
<span
|
||||
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white">
|
||||
<svg
|
||||
className={"h-5 w-5"}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<>
|
||||
<path
|
||||
stroke="none"
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
/>
|
||||
<polyline points="3 7 12 13 21 7" />
|
||||
</>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Transactional email {email.subject}{" "}
|
||||
delivered
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-right text-sm text-neutral-500">
|
||||
<time
|
||||
dateTime={dayjs(t.createdAt).format(
|
||||
"YYYY-MM-DD",
|
||||
)}
|
||||
>
|
||||
{dayjs().to(t.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
if (t.action) {
|
||||
return (
|
||||
<li>
|
||||
<div className="relative pb-8">
|
||||
{contact.triggers.length +
|
||||
contact.emails.length -
|
||||
1 !==
|
||||
index && (
|
||||
<span
|
||||
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white">
|
||||
<svg
|
||||
className={"h-5 w-5"}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M16 21h3c.81 0 1.48 -.67 1.48 -1.48l.02 -.02c0 -.82 -.69 -1.5 -1.5 -1.5h-3v3z" />
|
||||
<path d="M16 15h2.5c.84 -.01 1.5 .66 1.5 1.5s-.66 1.5 -1.5 1.5h-2.5v-3z" />
|
||||
<path d="M4 9v-4c0 -1.036 .895 -2 2 -2s2 .964 2 2v4" />
|
||||
<path d="M2.99 11.98a9 9 0 0 0 9 9m9 -9a9 9 0 0 0 -9 -9" />
|
||||
<path d="M8 7h-4" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t.action.name} triggered
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-right text-sm text-neutral-500">
|
||||
<time
|
||||
dateTime={dayjs(t.createdAt).format(
|
||||
"YYYY-MM-DD",
|
||||
)}
|
||||
>
|
||||
{dayjs().to(t.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
if (t.event) {
|
||||
return (
|
||||
<li>
|
||||
<div className="relative pb-8">
|
||||
{contact.triggers.length +
|
||||
contact.emails.length -
|
||||
1 !==
|
||||
index && (
|
||||
<span
|
||||
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-neutral-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-800 ring-8 ring-white">
|
||||
{t.event.templateId ? (
|
||||
<svg
|
||||
className={"h-5 w-5"}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{t.event.name.includes("delivered") ? (
|
||||
<>
|
||||
<path
|
||||
stroke="none"
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
/>
|
||||
<polyline points="3 7 12 13 21 7" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
stroke="none"
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<polyline points="3 9 12 15 21 9 12 3 3 9" />
|
||||
<path d="M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10" />
|
||||
<line x1="3" y1="19" x2="9" y2="13" />
|
||||
<line
|
||||
x1="15"
|
||||
y1="13"
|
||||
x2="21"
|
||||
y2="19"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
) : t.event.campaignId ? (
|
||||
<svg
|
||||
className={"h-5 w-5"}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M13 5h8" />
|
||||
<path d="M13 9h5" />
|
||||
<path d="M13 15h8" />
|
||||
<path d="M13 19h5" />
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="6"
|
||||
height="6"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="14"
|
||||
width="6"
|
||||
height="6"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className={"h-5 w-5"}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M8 9l3 3l-3 3" />
|
||||
<line x1="13" y1="15" x2="16" y2="15" />
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="16"
|
||||
rx="2"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t.event.templateId || t.event.campaignId
|
||||
? `${t.event.name.charAt(0).toUpperCase()}${t.event.name
|
||||
.replaceAll("-", " ")
|
||||
.slice(1)
|
||||
.replace(/(delivered|opened)$/, "")}`
|
||||
: t.event.name}{" "}
|
||||
{t.event.templateId
|
||||
? t.event.name.endsWith("delivered")
|
||||
? "delivered"
|
||||
: "opened"
|
||||
: t.event.campaignId
|
||||
? t.event.name.endsWith("delivered")
|
||||
? "delivered"
|
||||
: "opened"
|
||||
: "triggered"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-right text-sm text-neutral-500">
|
||||
<time
|
||||
dateTime={dayjs(t.createdAt).format(
|
||||
"YYYY-MM-DD",
|
||||
)}
|
||||
>
|
||||
{dayjs().to(t.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
title={"No triggers"}
|
||||
description={
|
||||
"This contact has not yet triggered any events or actions"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventSchemas, type UtilitySchemas } from "@plunk/shared";
|
||||
import type { Template } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, TerminalSquare, Trash } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Empty,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
Modal,
|
||||
Skeleton,
|
||||
Table,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useContactsCount } from "../../lib/hooks/contacts";
|
||||
import { useEvents } from "../../lib/hooks/events";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { useUser } from "../../lib/hooks/users";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface EventValues {
|
||||
event: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const { data: user } = useUser();
|
||||
const { data: contacts } = useContactsCount();
|
||||
const { data: events, mutate } = useEvents();
|
||||
|
||||
const [eventModal, setEventModal] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<EventValues>({
|
||||
resolver: zodResolver(EventSchemas.post.pick({ event: true })),
|
||||
});
|
||||
|
||||
if (!project || !user) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const create = (data: EventValues) => {
|
||||
toast.promise(
|
||||
network.mock<Template, typeof EventSchemas.post>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1",
|
||||
{
|
||||
...data,
|
||||
email: user.email,
|
||||
subscribed: true,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Creating new event",
|
||||
success: () => {
|
||||
void mutate();
|
||||
reset();
|
||||
return "Created new event";
|
||||
},
|
||||
error: "Could not create new event!",
|
||||
},
|
||||
);
|
||||
|
||||
setEventModal(false);
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
toast.promise(
|
||||
network.mock<Event, typeof UtilitySchemas.id>(
|
||||
project.secret,
|
||||
"DELETE",
|
||||
"/v1/events",
|
||||
{
|
||||
id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Deleting your event",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Deleted your event";
|
||||
},
|
||||
error: "Could not delete your event!",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={eventModal}
|
||||
onToggle={() => setEventModal(!eventModal)}
|
||||
onAction={handleSubmit(create)}
|
||||
type={"info"}
|
||||
action={"Trigger"}
|
||||
title={"Create a new event"}
|
||||
description={"Trigger a new event to send out emails to your contacts"}
|
||||
icon={
|
||||
<>
|
||||
<rect
|
||||
strokeWidth={2}
|
||||
width="14.5"
|
||||
height="14.5"
|
||||
x="4.75"
|
||||
y="4.75"
|
||||
rx="2"
|
||||
/>
|
||||
<path strokeWidth={2} d="M8.75 10.75L11.25 13L8.75 15.25" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
register={register("event")}
|
||||
label={"Event"}
|
||||
placeholder={"user-signup"}
|
||||
error={errors.event}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Dashboard>
|
||||
{events?.length === 0 && (
|
||||
<Alert type={"info"} title={"Need a hand?"}>
|
||||
<div className={"mt-3 grid items-center sm:grid-cols-4"}>
|
||||
<p className={"sm:col-span-3"}>
|
||||
Want us to help you get started? We can help you build your
|
||||
first action in less than 5 minutes.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={"/onboarding/actions"}
|
||||
className={
|
||||
"inline-block rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white sm:col-span-1"
|
||||
}
|
||||
>
|
||||
Build an action
|
||||
</Link>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={"Events"}
|
||||
description={"View the events your application has sent to Plunk"}
|
||||
actions={
|
||||
<>
|
||||
<motion.button
|
||||
onClick={() => setEventModal(true)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{events && contacts ? (
|
||||
events.filter((event) => !event.templateId && !event.campaignId)
|
||||
.length > 0 ? (
|
||||
<Table
|
||||
values={events
|
||||
.filter((event) => !event.templateId && !event.campaignId)
|
||||
.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((e) => {
|
||||
return {
|
||||
Event: e.name,
|
||||
"Triggered by users": (
|
||||
<Badge type={"info"}>{`${
|
||||
e.triggers.length > 0
|
||||
? Math.round(
|
||||
([
|
||||
...new Map(
|
||||
e.triggers.map((t) => [t.contactId, t]),
|
||||
).values(),
|
||||
].length /
|
||||
contacts) *
|
||||
100,
|
||||
)
|
||||
: 0
|
||||
}%`}</Badge>
|
||||
),
|
||||
"Total triggers": e.triggers.length,
|
||||
Timeline: (
|
||||
<>
|
||||
<ResponsiveContainer width={100} height={40}>
|
||||
<AreaChart
|
||||
width={100}
|
||||
height={40}
|
||||
data={Object.entries(
|
||||
e.triggers.reduce(
|
||||
(acc, cur) => {
|
||||
const date = dayjs(cur.createdAt).format(
|
||||
"MM/YYYY",
|
||||
);
|
||||
|
||||
if (acc[date]) {
|
||||
acc[date] += 1;
|
||||
} else {
|
||||
acc[date] = 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// day is the month with year e.g 01/2021
|
||||
const aDay = a[0];
|
||||
const bDay = b[0];
|
||||
|
||||
return aDay > bDay ? 1 : -1;
|
||||
})
|
||||
.map(([day, count]) => {
|
||||
return {
|
||||
day,
|
||||
count,
|
||||
};
|
||||
})}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="gradientFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#2563eb"
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#93c5fd"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
fill={"#fff"}
|
||||
tickSize={0}
|
||||
width={5}
|
||||
interval={0}
|
||||
/>
|
||||
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#2563eb"
|
||||
fill="url(#gradientFill)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
),
|
||||
"Last Activity": dayjs()
|
||||
.to(
|
||||
e.triggers.length > 0
|
||||
? e.triggers.sort((a, b) => {
|
||||
return b.createdAt > a.createdAt ? 1 : -1;
|
||||
})[0].createdAt
|
||||
: e.createdAt,
|
||||
)
|
||||
.toString(),
|
||||
Trigger: (
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.promise(
|
||||
network.mock<true, typeof EventSchemas.post>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1",
|
||||
{
|
||||
email: user.email,
|
||||
event: e.name,
|
||||
subscribed: true,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Creating new trigger",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Trigger created";
|
||||
},
|
||||
error: "Could not create new trigger!",
|
||||
},
|
||||
);
|
||||
}}
|
||||
className={
|
||||
"flex items-center text-center text-sm font-medium transition hover:text-neutral-800"
|
||||
}
|
||||
>
|
||||
<TerminalSquare size={18} />
|
||||
</button>
|
||||
),
|
||||
|
||||
Remove: !e.templateId ? (
|
||||
<button
|
||||
onClick={() => remove(e.id)}
|
||||
className={
|
||||
"flex items-center text-center text-sm font-medium transition hover:text-neutral-800"
|
||||
}
|
||||
>
|
||||
<Trash size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<span className={"text-xs"}>Cannot be deleted</span>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
title={"No events"}
|
||||
description={"You have not yet posted an event to Plunk"}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Skeleton type={"table"} />
|
||||
)}
|
||||
</Card>
|
||||
<Card
|
||||
title={"Template events"}
|
||||
description={"Events linked to your templates"}
|
||||
>
|
||||
{events && contacts ? (
|
||||
events.filter((event) => event.templateId).length > 0 ? (
|
||||
<Table
|
||||
values={events
|
||||
.filter((event) => event.templateId)
|
||||
.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((e) => {
|
||||
return {
|
||||
Event: e.name,
|
||||
"Triggered by users": (
|
||||
<Badge type={"info"}>{`${
|
||||
e.triggers.length > 0
|
||||
? Math.round(
|
||||
([
|
||||
...new Map(
|
||||
e.triggers.map((t) => [t.contactId, t]),
|
||||
).values(),
|
||||
].length /
|
||||
contacts) *
|
||||
100,
|
||||
)
|
||||
: 0
|
||||
}%`}</Badge>
|
||||
),
|
||||
"Total times triggered": e.triggers.length,
|
||||
"Last Activity": dayjs()
|
||||
.to(
|
||||
e.triggers.length > 0
|
||||
? e.triggers.sort((a, b) => {
|
||||
return b.createdAt > a.createdAt ? 1 : -1;
|
||||
})[0].createdAt
|
||||
: e.createdAt,
|
||||
)
|
||||
.toString(),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
title={"No template events"}
|
||||
description={
|
||||
"All delivery tracking for templates can be found here"
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Skeleton type={"table"} />
|
||||
)}
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Book, Eye, Frown, LineChart, Send } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Empty,
|
||||
FullscreenLoader,
|
||||
Redirect,
|
||||
Skeleton,
|
||||
Table,
|
||||
} from "../components";
|
||||
import { Dashboard } from "../layouts";
|
||||
import {
|
||||
useActiveProject,
|
||||
useActiveProjectFeed,
|
||||
useProjects,
|
||||
} from "../lib/hooks/projects";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const [feedPage, setFeedPage] = useState(1);
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { data: projects } = useProjects();
|
||||
const { data: feed } = useActiveProjectFeed(feedPage);
|
||||
|
||||
if (projects?.length === 0) {
|
||||
return <Redirect to={"/new"} />;
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<>
|
||||
<div className="divide-y divide-neutral-200 overflow-hidden rounded border border-neutral-200 bg-neutral-200 sm:grid sm:grid-cols-3 sm:gap-px sm:divide-y-0">
|
||||
<div className="group relative rounded-tl rounded-tr bg-white p-6 transition focus-within:ring-2 focus-within:ring-inset focus-within:ring-neutral-800 sm:rounded-tr-none">
|
||||
{activeProject.verified ? (
|
||||
<>
|
||||
<div>
|
||||
<span className="inline-flex rounded bg-neutral-100 p-3 text-neutral-800 ring-4 ring-white">
|
||||
<Send size={20} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium">
|
||||
<Link
|
||||
href={"/campaigns/new"}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Send a campaign
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Send a broadcast to your contacts
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<span className="inline-flex rounded bg-neutral-100 p-3 text-neutral-800 ring-4 ring-white">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="7.25"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M15.25 12C15.25 16.5 13.2426 19.25 12 19.25C10.7574 19.25 8.75 16.5 8.75 12C8.75 7.5 10.7574 4.75 12 4.75C13.2426 4.75 15.25 7.5 15.25 12Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M5 12H12H19"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Badge type={"danger"}>Important</Badge>
|
||||
<h3 className="mt-3 text-lg font-medium">
|
||||
<Link
|
||||
href={"/settings/identity"}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Verify your domain
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Verify your domain before you send emails
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span
|
||||
className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="group relative bg-white p-6 transition focus-within:ring-2 focus-within:ring-inset focus-within:ring-neutral-800 sm:rounded-tr">
|
||||
<div>
|
||||
<span className="inline-flex rounded bg-neutral-100 p-3 text-neutral-800 ring-4 ring-white">
|
||||
<LineChart size={20} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex h-4/6 flex-col justify-end">
|
||||
<h3 className="text-lg font-medium">
|
||||
<Link
|
||||
href={"/analytics"}
|
||||
passHref
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Analytics
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Discover insights about your emails
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="group relative bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-neutral-800 sm:rounded-bl">
|
||||
<div>
|
||||
<span className="inline-flex rounded bg-neutral-100 p-3 text-neutral-800 ring-4 ring-white">
|
||||
<Book size={20} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex h-4/6 flex-col justify-end">
|
||||
<h3 className="text-lg font-medium">
|
||||
<a
|
||||
href={"https://docs.useplunk.com"}
|
||||
target={"_blank"}
|
||||
className="focus:outline-none"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
Documentation
|
||||
</a>
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Discover how to use Plunk
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="pointer-events-none absolute right-6 top-6 text-neutral-300 transition group-hover:text-neutral-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title={"Activity feed"}>
|
||||
{feed ? (
|
||||
feed.length === 0 ? (
|
||||
<>
|
||||
<Empty
|
||||
icon={<Frown size={24} />}
|
||||
title={"No feed yet"}
|
||||
description={
|
||||
"Send an email or track an event to see it here"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
values={feed.map((f) => {
|
||||
if ("messageId" in f) {
|
||||
return {
|
||||
Email: f.contact.email,
|
||||
Activity: (
|
||||
<Badge type={"info"}>
|
||||
{f.createdAt === f.updatedAt
|
||||
? "Email delivered"
|
||||
: `Email ${f.status.toLowerCase()}`}
|
||||
</Badge>
|
||||
),
|
||||
Type: <Badge type={"success"}>Email</Badge>,
|
||||
Time: dayjs().to(dayjs(f.createdAt)),
|
||||
View: (
|
||||
<Link href={`/contacts/${f.contact.id}`}>
|
||||
<Eye size={20} />
|
||||
</Link>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (f.action) {
|
||||
return {
|
||||
Email: f.contact.email,
|
||||
Activity: (
|
||||
<Badge type={"info"}>{f.action.name}</Badge>
|
||||
),
|
||||
Type: <Badge type={"info"}>Action</Badge>,
|
||||
Time: dayjs().to(dayjs(f.createdAt)),
|
||||
View: (
|
||||
<Link href={`/contacts/${f.contact.id}`}>
|
||||
<Eye size={20} />
|
||||
</Link>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (f.event) {
|
||||
return {
|
||||
Email: f.contact.email,
|
||||
Activity: <Badge type={"info"}>{f.event.name}</Badge>,
|
||||
Type: <Badge type={"purple"}>Event</Badge>,
|
||||
Time: dayjs().to(dayjs(f.createdAt)),
|
||||
View: (
|
||||
<Link href={`/contacts/${f.contact.id}`}>
|
||||
<Eye size={20} />
|
||||
</Link>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
})}
|
||||
/>
|
||||
|
||||
<button
|
||||
className={
|
||||
"mx-auto mt-5 block rounded border border-neutral-200 px-5 py-2.5 text-sm font-medium text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={() => {
|
||||
setFeedPage(feedPage + 1);
|
||||
}}
|
||||
>
|
||||
Load older
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<Skeleton type={"table"} />
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { UtilitySchemas } from "@plunk/shared";
|
||||
import type { User } from "@prisma/client";
|
||||
import { motion } from "framer-motion";
|
||||
import { NextSeo } from "next-seo";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FullscreenLoader, Redirect } from "../../components";
|
||||
import { useContact } from "../../lib/hooks/contacts";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.isReady) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const {
|
||||
data: contact,
|
||||
error,
|
||||
mutate,
|
||||
} = useContact({ id: router.query.id as string, withProject: true });
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
if (error) {
|
||||
return <Redirect to={"/"} />;
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
setSubmitted(true);
|
||||
|
||||
toast.promise(
|
||||
network.mock<User, typeof UtilitySchemas.id>(
|
||||
contact.project.public,
|
||||
"POST",
|
||||
`/v1/contacts/${contact.subscribed ? "unsubscribe" : "subscribe"}`,
|
||||
{
|
||||
id: contact.id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Updating your preferences",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Updated your preferences";
|
||||
},
|
||||
error: "Could not update your preferences!",
|
||||
},
|
||||
);
|
||||
|
||||
setSubmitted(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={`Manage your preferences for ${contact.project.name}`}
|
||||
openGraph={{
|
||||
title: `Manage your preferences for ${contact.project.name}`,
|
||||
}}
|
||||
additionalMetaTags={[
|
||||
{
|
||||
property: "title",
|
||||
content: `Manage your preferences for ${contact.project.name}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"flex h-screen w-full flex-col items-center justify-center bg-neutral-50"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"
|
||||
}
|
||||
>
|
||||
<h1
|
||||
className={
|
||||
"text-center text-2xl font-bold leading-tight text-neutral-800"
|
||||
}
|
||||
>
|
||||
{contact.subscribed ? "Unsubscribe from" : "Subscribe to"}{" "}
|
||||
{contact.project.name}
|
||||
</h1>
|
||||
<p className={"mt-4 text-center text-sm text-neutral-500"}>
|
||||
{contact.subscribed
|
||||
? `You will no longer receive emails from ${contact.project.name} on ${contact.email} when you confirm that you want to unsubscribe.`
|
||||
: `By confirming your subscription to ${contact.project.name} for ${contact.email} you agree to receive emails from us.`}
|
||||
</p>
|
||||
<div className="relative mt-2 w-full">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={update}
|
||||
className={
|
||||
"mt-5 flex w-full items-center justify-center rounded bg-neutral-800 py-2.5 text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
{submitted ? (
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-6 w-6 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
`${contact.subscribed ? "Unsubscribe" : "Subscribe"}`
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ProjectSchemas } from "@plunk/shared";
|
||||
import type { Project } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useAtom } from "jotai";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { useForm, useFormState } from "react-hook-form";
|
||||
import Shared from "../../public/assets/shared.svg";
|
||||
import { FullscreenLoader, Redirect } from "../components";
|
||||
import { atomActiveProject } from "../lib/atoms/project";
|
||||
import { useProjects } from "../lib/hooks/projects";
|
||||
import { useUser } from "../lib/hooks/users";
|
||||
import { network } from "../lib/network";
|
||||
|
||||
interface ProjectValues {
|
||||
name: string;
|
||||
url: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
const [, setActiveProjectId] = useAtom(atomActiveProject);
|
||||
|
||||
const { data: user, error } = useUser();
|
||||
const { data: projects, mutate } = useProjects();
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
control,
|
||||
} = useForm<ProjectValues>({
|
||||
resolver: zodResolver(ProjectSchemas.create),
|
||||
});
|
||||
|
||||
const { isValid } = useFormState({
|
||||
control,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <Redirect to={"auth/login"} />;
|
||||
}
|
||||
|
||||
if (!user || !projects) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const create = async (data: ProjectValues) => {
|
||||
setSubmitted(true);
|
||||
|
||||
localStorage.removeItem("skip_onboarding");
|
||||
|
||||
const result = await network.fetch<
|
||||
| {
|
||||
data: Project;
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
data: string;
|
||||
},
|
||||
typeof ProjectSchemas.create
|
||||
>("POST", "/projects/create", {
|
||||
...data,
|
||||
url: data.url.startsWith("http") ? data.url : `https://${data.url}`,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await mutate([...projects, result.data]);
|
||||
localStorage.setItem("project", result.data.id);
|
||||
setActiveProjectId(result.data.id);
|
||||
return router.push("/");
|
||||
}
|
||||
setSubmitted(false);
|
||||
setError("error", { message: result.data });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-screen">
|
||||
<div className="flex flex-1 flex-col justify-center px-4 py-12 sm:border-r-2 sm:border-neutral-100 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
|
||||
<div className="mx-auto w-full max-w-sm lg:w-96">
|
||||
<div>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-neutral-800">
|
||||
Create a new project
|
||||
</h2>
|
||||
<p className={"text-sm text-neutral-500"}>
|
||||
Get ready to take your emails to the next level.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="mt-6">
|
||||
<form
|
||||
onSubmit={handleSubmit(create)}
|
||||
className="relative mt-2 w-full"
|
||||
>
|
||||
<div className="mt-4 flex flex-col">
|
||||
<label htmlFor="name" className="text-xs font-light">
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
autoComplete={"off"}
|
||||
type="text"
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder="My project"
|
||||
{...register("name")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.name?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.name.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col">
|
||||
<label htmlFor="url" className="text-xs font-light">
|
||||
Project URL
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md">
|
||||
<span className="inline-flex items-center rounded-l border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
|
||||
https://
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className={
|
||||
"block w-full rounded-r border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder="www.example.com"
|
||||
{...register("url")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{errors.url?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.url.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{errors.error?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.error.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.button
|
||||
whileHover={isValid ? { scale: 1.05 } : {}}
|
||||
whileTap={isValid ? { scale: 0.9 } : {}}
|
||||
type="submit"
|
||||
disabled={!isValid || submitted}
|
||||
className={` ${
|
||||
isValid
|
||||
? "bg-neutral-800 text-white"
|
||||
: "bg-neutral-200 text-white"
|
||||
} mt-5 flex w-full items-center justify-center rounded py-2.5 text-sm font-medium transition`}
|
||||
>
|
||||
{submitted ? (
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-6 w-6 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span
|
||||
className={"flex items-center justify-center gap-x-2"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3 -5a9 9 0 0 0 6 -8a3 3 0 0 0 -3 -3a9 9 0 0 0 -8 6a6 6 0 0 0 -5 3" />
|
||||
<path d="M7 14a6 6 0 0 0 -3 6a6 6 0 0 0 6 -3" />
|
||||
<circle cx="15" cy="9" r="1" />
|
||||
</svg>
|
||||
Launch
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
|
||||
{projects.length > 0 ? (
|
||||
<div className={"w-full"}>
|
||||
<Link
|
||||
href={"/"}
|
||||
className={
|
||||
"mt-2 block text-center text-sm text-neutral-400 underline transition ease-in-out hover:text-neutral-600"
|
||||
}
|
||||
>
|
||||
Back to the dashboard
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden w-0 flex-1 items-center justify-center bg-gradient-to-br from-blue-50 to-white lg:flex">
|
||||
<div
|
||||
className={
|
||||
"w-full max-w-lg rounded-2xl border border-neutral-200 bg-white p-9"
|
||||
}
|
||||
>
|
||||
<Shared />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import {motion} from 'framer-motion';
|
||||
import {useRouter} from 'next/router';
|
||||
import {TerminalSquare, Workflow} from 'lucide-react';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex min-h-screen w-screen flex-col items-center justify-center gap-6'}>
|
||||
<div className={'text-center'}>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-neutral-800 sm:text-4xl">Pick your fighter</h1>
|
||||
|
||||
<p className="mx-auto mt-2 text-lg text-neutral-500">
|
||||
Don't worry! You can use both, but we recommend starting with one.
|
||||
</p>
|
||||
</div>
|
||||
<div className={'grid gap-6 p-3 sm:grid-cols-2'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center gap-6 rounded-md border border-neutral-200 bg-white px-12 py-6 text-center'
|
||||
}
|
||||
>
|
||||
<div className={'rounded-md bg-neutral-100 p-3 text-neutral-800'}>
|
||||
<Workflow />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={'text-xl font-medium text-neutral-800'}>Actions</p>
|
||||
<p className={'text-neutral-600'}>Repeatable workflows that are triggered by your app</p>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={() => router.push('/onboarding/actions')}
|
||||
whileHover={{scale: 1.05}}
|
||||
whileTap={{scale: 0.9}}
|
||||
className={
|
||||
'flex items-center gap-x-0.5 rounded-md bg-neutral-800 px-10 py-2.5 text-center text-sm font-medium text-white sm:col-span-2'
|
||||
}
|
||||
>
|
||||
<span>Start with actions</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center gap-6 rounded-md border border-neutral-200 bg-white px-12 py-6 text-center'
|
||||
}
|
||||
>
|
||||
<div className={'rounded-md bg-neutral-100 p-3 text-neutral-800'}>
|
||||
<TerminalSquare />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={'text-xl font-medium text-neutral-800'}>Transactional</p>
|
||||
<p className={'text-neutral-600'}>Emails sent with a single API call</p>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={() => router.push('/onboarding/transactional')}
|
||||
whileHover={{scale: 1.05}}
|
||||
whileTap={{scale: 0.9}}
|
||||
className={
|
||||
'mx-auto flex items-center gap-x-0.5 rounded-md bg-neutral-800 px-10 py-2.5 text-center text-sm font-medium text-white sm:col-span-2'
|
||||
}
|
||||
>
|
||||
<span>Start with transactional</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { EventSchemas } from "@plunk/shared";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CodeBlock, Dropdown, FullscreenLoader } from "../../components";
|
||||
import { API_URI } from "../../lib/constants";
|
||||
import { useEmailsCount } from "../../lib/hooks/emails";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { useUser } from "../../lib/hooks/users";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const project = useActiveProject();
|
||||
const { data: user } = useUser();
|
||||
const { data: emails, mutate } = useEmailsCount();
|
||||
const [language, setLanguage] = useState<
|
||||
"javascript" | "python" | "curl" | "PHP" | "ruby"
|
||||
>("curl");
|
||||
|
||||
if (!project || !user || emails === undefined) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"flex min-h-screen w-screen flex-col items-center justify-center gap-6"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{emails > 0 ? (
|
||||
<>
|
||||
<motion.div
|
||||
key={"email-success"}
|
||||
initial={{ opacity: 0, x: 100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -100 }}
|
||||
className={
|
||||
"flex h-96 flex-col items-center justify-center text-center"
|
||||
}
|
||||
>
|
||||
<motion.span
|
||||
animate={{
|
||||
x: [0, -20, 20, 0],
|
||||
}}
|
||||
transition={{ repeat: 1, duration: 1 }}
|
||||
className={"text-6xl"}
|
||||
>
|
||||
🏎
|
||||
</motion.span>
|
||||
<h2 className={"my-4 text-2xl font-bold"}>Wasn't that easy?</h2>
|
||||
<p className={"font-medium text-neutral-500"}>
|
||||
Just like that you've sent your first email with Plunk!
|
||||
</p>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"mt-9 rounded-md bg-neutral-800 px-24 py-3 text-sm font-medium text-white"
|
||||
}
|
||||
onClick={async () => {
|
||||
await router.push("/");
|
||||
}}
|
||||
>
|
||||
Explore the rest of Plunk
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
key={"welcome"}
|
||||
initial={{ opacity: 0, x: 100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -100 }}
|
||||
className={"flex flex-col items-center justify-center gap-6"}
|
||||
>
|
||||
<div className={"text-center"}>
|
||||
<motion.span
|
||||
animate={{
|
||||
rotate: [0, 35, 0],
|
||||
}}
|
||||
transition={{ repeat: 5, duration: 1 }}
|
||||
className={"text-6xl"}
|
||||
>
|
||||
👋
|
||||
</motion.span>
|
||||
<h2 className={"my-4 text-4xl font-bold"}>Send it!</h2>
|
||||
<div className={"max-w-2xl font-medium text-neutral-500"}>
|
||||
<p>
|
||||
Are you ready to send a transactional email with Plunk?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Sending a transactional email is as easy as making a
|
||||
single API call.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"w-full max-w-2xl space-y-3"}>
|
||||
<Dropdown
|
||||
onChange={(e) =>
|
||||
setLanguage(e as "javascript" | "python" | "curl")
|
||||
}
|
||||
values={[
|
||||
{ value: "curl", name: "cURL" },
|
||||
{ name: "JavaScript", value: "javascript" },
|
||||
{ value: "python", name: "Python" },
|
||||
{ value: "PHP", name: "PHP" },
|
||||
{ value: "ruby", name: "Ruby" },
|
||||
]}
|
||||
selectedValue={language}
|
||||
/>
|
||||
|
||||
<CodeBlock
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "1rem",
|
||||
}}
|
||||
language={language}
|
||||
code={
|
||||
{
|
||||
javascript: `await fetch('${API_URI}/v1/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
to: "${user.email}",
|
||||
subject: "Your first email",
|
||||
body: "Hello from Plunk!"
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${project.secret}',
|
||||
},
|
||||
});`,
|
||||
python: `import requests
|
||||
|
||||
requests.post(
|
||||
"${API_URI}/v1/send",
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer ${project.secret}"},
|
||||
json={
|
||||
"subject": "Your first email",
|
||||
"body": "Hello from Plunk!",
|
||||
"to": "${user.email}",
|
||||
},
|
||||
)`,
|
||||
curl: `curl --location --request POST '${API_URI}/v1/send' \\
|
||||
--header 'Authorization: Bearer ${project.secret}' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '{"subject": "Your first email", "body": "Hello from Plunk!", "to": "${user.email}"}'`,
|
||||
|
||||
PHP: `<?php
|
||||
$client = new Client();
|
||||
$request = new Request('POST', '${API_URI}/v1/send', ['Authorization' => 'Bearer ${project.secret}', 'Content-Type' => 'application/json'], '{
|
||||
"subject": "Your first email",
|
||||
"body": "Hello from Plunk!",
|
||||
"to": "${user.email}",
|
||||
}');
|
||||
$res = $client->sendAsync($request)->wait();`,
|
||||
|
||||
ruby: `require "uri"
|
||||
require "json"
|
||||
require "net/http"
|
||||
|
||||
url = URI("${API_URI}/v1/send")
|
||||
|
||||
https = Net::HTTP.new(url.host, url.port)
|
||||
https.use_ssl = true
|
||||
|
||||
request = Net::HTTP::Post.new(url)
|
||||
request["Authorization"] = "Bearer ${project.secret}"
|
||||
request["Content-Type"] = "application/json"
|
||||
request.body = JSON.dump({
|
||||
"subject": "Your first email",
|
||||
"body": "Hello from Plunk!",
|
||||
"to": "${user.email}",
|
||||
})
|
||||
|
||||
response = https.request(request)`,
|
||||
}[language]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"rounded-md bg-neutral-800 px-24 py-3 text-sm font-medium text-white"
|
||||
}
|
||||
onClick={() => {
|
||||
toast.promise(
|
||||
network.mock<boolean, typeof EventSchemas.send>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1/send",
|
||||
{
|
||||
subject: "Your first email",
|
||||
body: "Hello from Plunk!",
|
||||
to: user.email,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Sending the email",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return `Sent! Check your inbox at ${user.email}`;
|
||||
},
|
||||
error: "Could not send the email",
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Run this code
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={"fixed bottom-3 w-full bg-white text-center"}>
|
||||
<span
|
||||
className={
|
||||
"cursor-pointer text-sm text-neutral-500 transition ease-in-out hover:text-neutral-700"
|
||||
}
|
||||
onClick={async () => {
|
||||
await router.push("/onboarding");
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {Dashboard} from '../../layouts';
|
||||
import {Card, FullscreenLoader} from '../../components';
|
||||
import {useUser} from '../../lib/hooks/users';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const {data: user} = useUser();
|
||||
|
||||
if (!user) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card title={'Account details'} description={'Manage your account and contact details'}>
|
||||
<div className={'grid gap-5 sm:grid-cols-2'}>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="email" className="text-xs font-light">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
autoComplete={'off'}
|
||||
type="email"
|
||||
className={
|
||||
'block w-full rounded border-neutral-300 transition ease-in-out focus:border-purple-500 focus:ring-purple-500 disabled:bg-neutral-100 sm:text-sm'
|
||||
}
|
||||
placeholder="Your email"
|
||||
disabled={true}
|
||||
value={user.email}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Project } from "@prisma/client";
|
||||
import React, { useState } from "react";
|
||||
import { Card, FullscreenLoader, Modal, SettingTabs } from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActiveProject, useProjects } from "../../lib/hooks/projects";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
||||
const [project, setProject] = useState<Project>();
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { data: projects, mutate: projectMutate } = useProjects();
|
||||
|
||||
if (activeProject && !project) {
|
||||
setProject(activeProject);
|
||||
}
|
||||
|
||||
if (!project || !projects) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const regenerate = () => {
|
||||
setShowRegenerateModal(!showRegenerateModal);
|
||||
|
||||
toast.promise(
|
||||
network
|
||||
.fetch<{
|
||||
success: true;
|
||||
project: Project;
|
||||
}>("POST", `/projects/id/${project.id}/regenerate`)
|
||||
.then(async (res) => {
|
||||
await projectMutate(
|
||||
[
|
||||
...projects.filter((project) => {
|
||||
return project.id !== res.project.id;
|
||||
}),
|
||||
res.project,
|
||||
],
|
||||
false,
|
||||
);
|
||||
}),
|
||||
{
|
||||
loading: "Regenerating API keys...",
|
||||
success: "Successfully regenerated API keys!",
|
||||
error: "Failed to create new API keys",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={showRegenerateModal}
|
||||
onToggle={() => setShowRegenerateModal(!showRegenerateModal)}
|
||||
onAction={regenerate}
|
||||
type={"danger"}
|
||||
title={"Are you sure?"}
|
||||
description={
|
||||
"Any applications that use your previously generated keys will stop working!"
|
||||
}
|
||||
/>
|
||||
<Dashboard>
|
||||
<SettingTabs />
|
||||
<Card
|
||||
title={"API access"}
|
||||
description={`Manage your API keys for ${activeProject.name}`}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowRegenerateModal(!showRegenerateModal)}
|
||||
className={
|
||||
"flex items-center gap-x-1 rounded bg-red-600 px-8 py-2 text-center text-sm font-medium text-white transition ease-in-out hover:bg-red-700"
|
||||
}
|
||||
>
|
||||
<RefreshCw strokeWidth={1.5} size={18} />
|
||||
Regenerate
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(activeProject.public);
|
||||
toast.success("Copied your public API key");
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Public API Key
|
||||
</label>
|
||||
<p
|
||||
className={
|
||||
"cursor-pointer rounded border border-neutral-300 bg-neutral-100 px-3 py-2 text-sm"
|
||||
}
|
||||
>
|
||||
{activeProject.public}
|
||||
</p>
|
||||
|
||||
<p className={"text-sm text-neutral-500"}>
|
||||
Use this key for any front-end services. This key can only be used
|
||||
to publish events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={"mt-4"}>
|
||||
<div
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(activeProject.secret);
|
||||
toast.success("Copied your secret API key");
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Secret API Key
|
||||
</label>
|
||||
<p
|
||||
className={
|
||||
"cursor-pointer rounded border border-neutral-300 bg-neutral-100 px-3 py-2 text-sm"
|
||||
}
|
||||
>
|
||||
{activeProject.secret}
|
||||
</p>
|
||||
|
||||
<p className={"text-sm text-neutral-500"}>
|
||||
Use this key for any secure back-end services. This key gives
|
||||
complete access to your Plunk setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { IdentitySchemas, type UtilitySchemas } from "@plunk/shared";
|
||||
import { motion } from "framer-motion";
|
||||
import { Copy, Unlink } from "lucide-react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
SettingTabs,
|
||||
Table,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { AWS_REGION } from "../../lib/constants";
|
||||
import {
|
||||
useActiveProject,
|
||||
useActiveProjectVerifiedIdentity,
|
||||
useProjects,
|
||||
} from "../../lib/hooks/projects";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface EmailValues {
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface FromValues {
|
||||
from: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const activeProject = useActiveProject();
|
||||
const { mutate: projectsMutate } = useProjects();
|
||||
const { data: identity, mutate: identityMutate } =
|
||||
useActiveProjectVerifiedIdentity();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<EmailValues>({
|
||||
resolver: zodResolver(IdentitySchemas.create.omit({ id: true })),
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerUpdate,
|
||||
handleSubmit: handleSubmitUpdate,
|
||||
formState: { errors: errorsUpdate },
|
||||
reset,
|
||||
} = useForm<FromValues>({
|
||||
resolver: zodResolver(IdentitySchemas.update.omit({ id: true })),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
reset({ from: activeProject.from ?? undefined });
|
||||
}, [reset, activeProject]);
|
||||
|
||||
if (!activeProject || !identity) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const create = async (data: EmailValues) => {
|
||||
toast.promise(
|
||||
network.fetch<
|
||||
{
|
||||
success: true;
|
||||
tokens: string[];
|
||||
},
|
||||
typeof IdentitySchemas.create
|
||||
>("POST", "/identities/create", {
|
||||
id: activeProject.id,
|
||||
...data,
|
||||
}),
|
||||
{
|
||||
loading: "Adding your domain",
|
||||
success: (res) => {
|
||||
void identityMutate({ tokens: res.tokens }, { revalidate: false });
|
||||
void projectsMutate();
|
||||
|
||||
return "Added your domain";
|
||||
},
|
||||
error: "Could not add domain",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const update = async (data: FromValues) => {
|
||||
toast.promise(
|
||||
network.fetch<
|
||||
{
|
||||
success: true;
|
||||
},
|
||||
typeof IdentitySchemas.update
|
||||
>("PUT", "/projects/update/identity", {
|
||||
id: activeProject.id,
|
||||
...data,
|
||||
}),
|
||||
{
|
||||
loading: "Updating your sender name",
|
||||
success: "Updated your sender name",
|
||||
error: "Could not update sender name",
|
||||
},
|
||||
);
|
||||
|
||||
await identityMutate();
|
||||
await projectsMutate();
|
||||
};
|
||||
|
||||
const unlink = async () => {
|
||||
toast.promise(
|
||||
network.fetch<
|
||||
{
|
||||
success: true;
|
||||
},
|
||||
typeof UtilitySchemas.id
|
||||
>("POST", "/identities/reset", {
|
||||
id: activeProject.id,
|
||||
}),
|
||||
{
|
||||
loading: "Unlinking your domain",
|
||||
success: "Unlinked your domain",
|
||||
error: "Could not unlink domain",
|
||||
},
|
||||
);
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<SettingTabs />
|
||||
|
||||
<Card
|
||||
title={"Domain"}
|
||||
description={
|
||||
"By sending emails from your own domain you build up domain authority and trust."
|
||||
}
|
||||
actions={
|
||||
activeProject.email && (
|
||||
<>
|
||||
<button
|
||||
onClick={unlink}
|
||||
className={
|
||||
"flex items-center gap-x-2 rounded bg-red-600 px-8 py-2 text-center text-sm font-medium text-white transition ease-in-out hover:bg-red-700"
|
||||
}
|
||||
>
|
||||
<Unlink strokeWidth={1.5} size={18} />
|
||||
Unlink domain
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{activeProject.email && !activeProject.verified ? (
|
||||
<>
|
||||
<Alert type={"warning"} title={"Waiting for DNS verification"}>
|
||||
Please add the following records to{" "}
|
||||
{activeProject.email.split("@")[1]} to verify{" "}
|
||||
{activeProject.email}, this may take up to 15 minutes to
|
||||
register. <br />
|
||||
In the meantime you can already start sending emails, we will
|
||||
automatically switch to your domain once it is verified.
|
||||
</Alert>
|
||||
|
||||
<div className="mt-6">
|
||||
<Table
|
||||
values={[
|
||||
{
|
||||
Type: <Badge type={"info"}>TXT</Badge>,
|
||||
Key: (
|
||||
<div
|
||||
className={"flex cursor-pointer items-center gap-3"}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText("plunk");
|
||||
toast.success("Copied key to clipboard");
|
||||
}}
|
||||
>
|
||||
<p className={"font-mono text-sm"}>plunk</p>
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
),
|
||||
Value: (
|
||||
<div
|
||||
className={"flex cursor-pointer items-center gap-3"}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(
|
||||
"v=spf1 include:amazonses.com ~all",
|
||||
);
|
||||
toast.success("Copied value to clipboard");
|
||||
}}
|
||||
>
|
||||
<p className={"font-mono text-sm"}>
|
||||
v=spf1 include:amazonses.com ~all
|
||||
</p>{" "}
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: <Badge type={"info"}>MX</Badge>,
|
||||
Key: (
|
||||
<div
|
||||
className={"flex cursor-pointer items-center gap-3"}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText("plunk");
|
||||
toast.success("Copied key to clipboard");
|
||||
}}
|
||||
>
|
||||
<p className={"font-mono text-sm"}>plunk</p>
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
),
|
||||
Value: (
|
||||
<div
|
||||
className={"flex cursor-pointer items-center gap-3"}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(
|
||||
`10 feedback-smtp.${AWS_REGION}.amazonses.com`,
|
||||
);
|
||||
toast.success("Copied value to clipboard");
|
||||
}}
|
||||
>
|
||||
<p className={"font-mono text-sm"}>
|
||||
10 feedback-smtp.{AWS_REGION}.amazonses.com
|
||||
</p>
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...identity.tokens.map((token) => {
|
||||
return {
|
||||
Type: <Badge type={"info"}>CNAME</Badge>,
|
||||
Key: (
|
||||
<div
|
||||
className={"flex cursor-pointer items-center gap-3"}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(
|
||||
`${token}._domainkey`,
|
||||
);
|
||||
toast.success("Copied key to clipboard");
|
||||
}}
|
||||
>
|
||||
<p className={"font-mono text-sm"}>
|
||||
{token}._domainkey
|
||||
</p>
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
),
|
||||
Value: (
|
||||
<div
|
||||
className={"flex cursor-pointer items-center gap-3"}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(
|
||||
`${token}.dkim.amazonses.com`,
|
||||
);
|
||||
toast.success("Copied value to clipboard");
|
||||
}}
|
||||
>
|
||||
<p className={"font-mono text-sm"}>
|
||||
{token}.dkim.amazonses.com
|
||||
</p>
|
||||
<Copy size={14} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : activeProject.email && activeProject.verified ? (
|
||||
<>
|
||||
<Alert type={"success"} title={"Domain verified"}>
|
||||
You have confirmed {activeProject.email} as your domain. Any
|
||||
emails sent by Plunk will now use this address.
|
||||
</Alert>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(create)} className="space-y-6">
|
||||
<Input
|
||||
register={register("email")}
|
||||
error={errors.email}
|
||||
placeholder={"[email protected]"}
|
||||
label={"Email"}
|
||||
/>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"ml-auto flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<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>
|
||||
Verify domain
|
||||
</motion.button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={"Sender name"}
|
||||
description={
|
||||
"The name that will be used when sending emails from Plunk. Your project name will be used by default"
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmitUpdate(update)} className="space-y-6">
|
||||
<Input
|
||||
register={registerUpdate("from")}
|
||||
placeholder={activeProject.name}
|
||||
label={"Name"}
|
||||
error={errorsUpdate.from}
|
||||
/>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"ml-auto flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
Save
|
||||
</motion.button>
|
||||
</form>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Redirect } from "../../components";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
return <Redirect to={"/settings/project"} />;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { MembershipSchemas, type UtilitySchemas } from "@plunk/shared";
|
||||
import type { Project, Role } from "@prisma/client";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
Modal,
|
||||
SettingTabs,
|
||||
Table,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import {
|
||||
useActiveProject,
|
||||
useActiveProjectMemberships,
|
||||
useProjects,
|
||||
} from "../../lib/hooks/projects";
|
||||
import { useUser } from "../../lib/hooks/users";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface EmailValues {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [showLeaveModal, setShowLeaveModal] = useState(false);
|
||||
|
||||
const [project, setProject] = useState<Project>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const activeProject = useActiveProject();
|
||||
const { data: user } = useUser();
|
||||
const { data: projects, mutate: projectMutate } = useProjects();
|
||||
const { data: memberships, mutate: membershipMutate } =
|
||||
useActiveProjectMemberships();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<EmailValues>({
|
||||
resolver: zodResolver(
|
||||
MembershipSchemas.invite.omit({ id: true, role: true }),
|
||||
),
|
||||
});
|
||||
|
||||
if (activeProject && !project) {
|
||||
setProject(activeProject);
|
||||
}
|
||||
|
||||
if (!project || !projects || !memberships || !user) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const inviteAccount = (data: EmailValues) => {
|
||||
toast.promise(
|
||||
network.mock<
|
||||
{
|
||||
success: true;
|
||||
members: {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}[];
|
||||
},
|
||||
typeof MembershipSchemas.invite
|
||||
>(project.secret, "POST", "/memberships/invite", {
|
||||
id: project.id,
|
||||
email: data.email,
|
||||
role: "ADMIN",
|
||||
}),
|
||||
{
|
||||
loading: "Adding new member",
|
||||
success: async (result) => {
|
||||
await membershipMutate(result.members);
|
||||
setShowInviteModal(false);
|
||||
|
||||
return "Added new member";
|
||||
},
|
||||
error: "Could not add new member!",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const kickAccount = (email: string) => {
|
||||
void network
|
||||
.fetch<
|
||||
{
|
||||
success: true;
|
||||
members: {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}[];
|
||||
},
|
||||
typeof MembershipSchemas.kick
|
||||
>("POST", "/memberships/kick", {
|
||||
id: project.id,
|
||||
email,
|
||||
})
|
||||
.then(async (res) => {
|
||||
await membershipMutate(res.members);
|
||||
});
|
||||
};
|
||||
|
||||
const leaveProject = () => {
|
||||
void network
|
||||
.fetch<
|
||||
{
|
||||
success: true;
|
||||
memberships: Project[];
|
||||
},
|
||||
typeof UtilitySchemas.id
|
||||
>("POST", "/memberships/leave", {
|
||||
id: project.id,
|
||||
})
|
||||
.then(async (res) => {
|
||||
await projectMutate(res.memberships);
|
||||
localStorage.removeItem("project");
|
||||
await router.push("/");
|
||||
window.location.reload();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={showLeaveModal}
|
||||
onToggle={() => setShowLeaveModal(!showLeaveModal)}
|
||||
onAction={leaveProject}
|
||||
type={"danger"}
|
||||
title={"Are you sure?"}
|
||||
description={
|
||||
memberships.length === 1
|
||||
? "You are the last person in this project, if you leave it we will automatically delete it!"
|
||||
: "Leaving a project is permanent, you will lose access to the data and will need to be reinvited again."
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onToggle={() => setShowInviteModal(!showInviteModal)}
|
||||
onAction={handleSubmit(inviteAccount)}
|
||||
type={"info"}
|
||||
title={"Invite a new member"}
|
||||
description={
|
||||
"Enter the email of the account you want to invite to this project. The person you want to invite needs to have an account on Plunk."
|
||||
}
|
||||
>
|
||||
<Input
|
||||
register={register("email")}
|
||||
error={errors.email}
|
||||
label={"Email"}
|
||||
placeholder={"[email protected]"}
|
||||
/>
|
||||
</Modal>
|
||||
<Dashboard>
|
||||
<SettingTabs />
|
||||
<Card title={"Project members"}>
|
||||
<Table
|
||||
values={memberships.map((membership) => {
|
||||
return {
|
||||
Account: membership.email,
|
||||
Role:
|
||||
membership.role.charAt(0).toUpperCase() +
|
||||
membership.role.slice(1).toLowerCase(),
|
||||
|
||||
Manage:
|
||||
membership.userId === user.id ? (
|
||||
<button
|
||||
className={
|
||||
"mb-2 text-sm text-neutral-400 underline transition ease-in-out hover:text-neutral-700"
|
||||
}
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
) : memberships.find(
|
||||
(membership) => membership.userId === user.id,
|
||||
)?.role === "OWNER" ? (
|
||||
<button
|
||||
className={
|
||||
"mb-2 text-sm text-neutral-400 underline transition ease-in-out hover:text-neutral-700"
|
||||
}
|
||||
onClick={() => kickAccount(membership.email)}
|
||||
>
|
||||
Kick
|
||||
</button>
|
||||
) : (
|
||||
""
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<div className={"mt-9 flex items-center"}>
|
||||
<div className={"w-2/3"}>
|
||||
<p className={"text-sm font-semibold text-neutral-800"}>
|
||||
Invite team
|
||||
</p>
|
||||
<p className={"text-sm text-neutral-400"}>
|
||||
By adding someone to your project you give them access to all
|
||||
data present in your project including emails and your API key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"ml-auto mt-4 self-end rounded bg-neutral-800 px-8 py-2.5 text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
Invite user
|
||||
</motion.button>
|
||||
</div>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ProjectSchemas, type UtilitySchemas } from "@plunk/shared";
|
||||
import { network } from "dashboard/src/lib/network";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
Modal,
|
||||
SettingTabs,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import {
|
||||
useActiveProject,
|
||||
useActiveProjectMemberships,
|
||||
useProjects,
|
||||
} from "../../lib/hooks/projects";
|
||||
import { useUser } from "../../lib/hooks/users";
|
||||
|
||||
interface ProjectValues {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const activeProject = useActiveProject();
|
||||
const { data: user } = useUser();
|
||||
const { data: memberships } = useActiveProjectMemberships();
|
||||
const { mutate: projectsMutate } = useProjects();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<ProjectValues>({
|
||||
resolver: zodResolver(ProjectSchemas.update.omit({ id: true })),
|
||||
});
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
reset(activeProject);
|
||||
}, [reset, activeProject]);
|
||||
|
||||
if (!activeProject || !memberships || !user) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const update = async (data: ProjectValues) => {
|
||||
toast.promise(
|
||||
network.fetch<
|
||||
{
|
||||
success: true;
|
||||
},
|
||||
typeof ProjectSchemas.update
|
||||
>("PUT", "/projects/update/", {
|
||||
id: activeProject.id,
|
||||
...data,
|
||||
}),
|
||||
{
|
||||
loading: "Updating your project",
|
||||
success: "Updated your project",
|
||||
error: "Could not update your project",
|
||||
},
|
||||
);
|
||||
|
||||
await projectsMutate();
|
||||
};
|
||||
|
||||
const deleteProject = async () => {
|
||||
setShowDeleteModal(!showDeleteModal);
|
||||
|
||||
await fetch("/api/plunk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
event: "project-deleted",
|
||||
email: user.email,
|
||||
data: {
|
||||
project: activeProject.name,
|
||||
},
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
toast.promise(
|
||||
network
|
||||
.fetch<
|
||||
{
|
||||
success: true;
|
||||
},
|
||||
typeof UtilitySchemas.id
|
||||
>("DELETE", "/projects/delete", {
|
||||
id: activeProject.id,
|
||||
})
|
||||
.then(async () => {
|
||||
localStorage.removeItem("project");
|
||||
await router.push("/");
|
||||
window.location.reload();
|
||||
}),
|
||||
{
|
||||
loading: "Deleting your project",
|
||||
success: "Deleted your project",
|
||||
error: "Could not delete your project",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={showDeleteModal}
|
||||
onToggle={() => setShowDeleteModal(!showDeleteModal)}
|
||||
onAction={deleteProject}
|
||||
type={"danger"}
|
||||
title={"Are you sure?"}
|
||||
description={
|
||||
"All data associated with this project will also be permanently deleted. This action cannot be reversed!"
|
||||
}
|
||||
/>
|
||||
<Dashboard>
|
||||
<SettingTabs />
|
||||
<Card
|
||||
title={"Project details"}
|
||||
description={"Manage your project details"}
|
||||
>
|
||||
<form onSubmit={handleSubmit(update)} className="space-y-6">
|
||||
<div className={"grid gap-5 sm:grid-cols-2"}>
|
||||
<Input
|
||||
register={register("name")}
|
||||
label={"Name"}
|
||||
placeholder={"ACME Inc."}
|
||||
error={errors.name}
|
||||
/>
|
||||
<Input
|
||||
register={register("url")}
|
||||
label={"URL"}
|
||||
placeholder={"https://useplunk.com"}
|
||||
error={errors.url}
|
||||
/>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"ml-auto flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
Save
|
||||
</motion.button>
|
||||
</form>
|
||||
</Card>
|
||||
{memberships.find((membership) => membership.userId === user.id)
|
||||
?.role === "OWNER" ? (
|
||||
<Card
|
||||
title={"Danger zone"}
|
||||
description={"Better watch out here"}
|
||||
className={"mt-4"}
|
||||
>
|
||||
<div className={"flex"}>
|
||||
<div className={"w-2/3"}>
|
||||
<p className={"text-sm font-bold text-neutral-500"}>
|
||||
Delete your project
|
||||
</p>
|
||||
<p className={"text-sm text-neutral-400"}>
|
||||
Deleting your project may have unwanted consequences. All data
|
||||
associated with this project will get deleted and can not be
|
||||
recovered!{" "}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={
|
||||
"ml-auto h-1/2 self-center rounded bg-red-500 px-6 py-2 text-sm font-medium text-white transition ease-in-out hover:bg-red-600"
|
||||
}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
Delete project
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { UtilitySchemas } from "@plunk/shared";
|
||||
import type { User } from "@prisma/client";
|
||||
import { motion } from "framer-motion";
|
||||
import { NextSeo } from "next-seo";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FullscreenLoader, Redirect } from "../../components";
|
||||
import { useContact } from "../../lib/hooks/contacts";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.isReady) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const { data: contact, error } = useContact({
|
||||
id: router.query.id as string,
|
||||
withProject: true,
|
||||
});
|
||||
const [submitted, setSubmitted] = useState<
|
||||
"initial" | "loading" | "submitted"
|
||||
>("initial");
|
||||
|
||||
if (error) {
|
||||
return <Redirect to={"/"} />;
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const subscribe = async () => {
|
||||
setSubmitted("loading");
|
||||
|
||||
toast.promise(
|
||||
network.mock<User, typeof UtilitySchemas.id>(
|
||||
contact.project.public,
|
||||
"POST",
|
||||
"/v1/contacts/subscribe",
|
||||
{
|
||||
id: contact.id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Subscribing...",
|
||||
success: "Thank you for subscribing!",
|
||||
error: "Could not subscribe you!",
|
||||
},
|
||||
);
|
||||
|
||||
setSubmitted("submitted");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={`Subscribe to ${contact.project.name}`}
|
||||
openGraph={{
|
||||
title: `Subscribe to ${contact.project.name}`,
|
||||
}}
|
||||
additionalMetaTags={[
|
||||
{
|
||||
property: "title",
|
||||
content: `Subscribe to ${contact.project.name}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"flex h-screen w-full flex-col items-center justify-center bg-neutral-50"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"
|
||||
}
|
||||
>
|
||||
{submitted === "submitted" ? (
|
||||
<>
|
||||
<motion.div className={"mb-3 flex items-center justify-center"}>
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.2, ease: "easeInOut" }}
|
||||
className={
|
||||
"h-24 w-24 rounded-full bg-emerald-100 p-6 text-emerald-900"
|
||||
}
|
||||
>
|
||||
<motion.path
|
||||
d="M20 6 9 17l-5-5"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
</motion.svg>
|
||||
</motion.div>
|
||||
|
||||
<h1
|
||||
className={
|
||||
"text-center text-2xl font-bold leading-tight text-neutral-800"
|
||||
}
|
||||
>
|
||||
You have been subscribed!
|
||||
</h1>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1
|
||||
className={
|
||||
"text-center text-2xl font-bold leading-tight text-neutral-800"
|
||||
}
|
||||
>
|
||||
Confirm your subscription?
|
||||
</h1>
|
||||
<p className={"mt-4 text-center text-sm text-neutral-500"}>
|
||||
By confirming your subscription to {contact.project.name} for{" "}
|
||||
{contact.email} you agree to receive emails from us.
|
||||
</p>
|
||||
<div className="relative mt-2 w-full">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={subscribe}
|
||||
className={
|
||||
"mt-5 flex w-full items-center justify-center rounded bg-neutral-800 py-2.5 text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
{submitted === "loading" ? (
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-6 w-6 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
"Subscribe"
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TemplateSchemas, type UtilitySchemas } from "@plunk/shared";
|
||||
import type { Template } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Save } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
Dropdown,
|
||||
Editor,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
Tooltip,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { useTemplate, useTemplates } from "../../lib/hooks/templates";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface TemplateValues {
|
||||
subject: string;
|
||||
body: string;
|
||||
type: "MARKETING" | "TRANSACTIONAL";
|
||||
style: "PLUNK" | "HTML";
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.isReady) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const project = useActiveProject();
|
||||
const { mutate } = useTemplates();
|
||||
const { data: template } = useTemplate(router.query.id as string);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
} = useForm<TemplateValues>({
|
||||
resolver: zodResolver(TemplateSchemas.update),
|
||||
defaultValues: {
|
||||
body: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
reset(template);
|
||||
}, [reset, template]);
|
||||
|
||||
if (
|
||||
!project ||
|
||||
!template ||
|
||||
(watch("body") as string | undefined) === undefined
|
||||
) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const update = (data: TemplateValues) => {
|
||||
toast.promise(
|
||||
network.mock<Template, typeof TemplateSchemas.update>(
|
||||
project.secret,
|
||||
"PUT",
|
||||
"/v1/templates",
|
||||
{
|
||||
id: template.id,
|
||||
...data,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Saving your template",
|
||||
success: () => {
|
||||
void mutate();
|
||||
|
||||
return "Saved your template";
|
||||
},
|
||||
error: "Could not save your template!",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const duplicate = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
toast.promise(
|
||||
network.mock<Template, typeof UtilitySchemas.id>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1/templates/duplicate",
|
||||
{
|
||||
id: template.id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Duplicating your template",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Duplicated your template";
|
||||
},
|
||||
error: "Could not duplicate your template!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/templates");
|
||||
};
|
||||
|
||||
const remove = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (template.actions.length > 0) {
|
||||
return toast.error(
|
||||
"You cannot delete a template that is linked to an action!",
|
||||
);
|
||||
}
|
||||
|
||||
toast.promise(
|
||||
network.mock<Template, typeof UtilitySchemas.id>(
|
||||
project.secret,
|
||||
"DELETE",
|
||||
"/v1/templates",
|
||||
{
|
||||
id: template.id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Deleting your template",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Deleted your template";
|
||||
},
|
||||
error: "Could not delete your template!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/templates");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card
|
||||
title={"Update your template"}
|
||||
options={
|
||||
<>
|
||||
<button
|
||||
onClick={duplicate}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M6.5 15.25V15.25C5.5335 15.25 4.75 14.4665 4.75 13.5V6.75C4.75 5.64543 5.64543 4.75 6.75 4.75H13.5C14.4665 4.75 15.25 5.5335 15.25 6.5V6.5"
|
||||
/>
|
||||
<rect
|
||||
width="10.5"
|
||||
height="10.5"
|
||||
x="8.75"
|
||||
y="8.75"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
rx="2"
|
||||
/>
|
||||
</svg>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={remove}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(update)}
|
||||
className="grid gap-6 sm:grid-cols-6"
|
||||
>
|
||||
<Input
|
||||
className={"sm:col-span-4"}
|
||||
label={"Subject"}
|
||||
placeholder={`Welcome to ${project.name}!`}
|
||||
register={register("subject")}
|
||||
error={errors.subject}
|
||||
/>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"type"}
|
||||
className="flex items-center text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Type
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
<p className={"mb-2 text-base font-semibold"}>
|
||||
What type of email is this?
|
||||
</p>
|
||||
<ul className={"list-inside"}>
|
||||
<li className={"mb-6"}>
|
||||
<span className={"font-semibold"}>Marketing</span>
|
||||
<br />
|
||||
Promotional emails with a Plunk-hosted unsubscribe
|
||||
link
|
||||
<br />
|
||||
<span className={"text-neutral-400"}>
|
||||
(e.g. welcome emails, promotions)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={"font-semibold"}>Transactional</span>
|
||||
<br />
|
||||
Mission critical emails <br />
|
||||
<span className={"text-neutral-400"}>
|
||||
{" "}
|
||||
(e.g. email verification, password reset)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
icon={
|
||||
<>
|
||||
<path d="M12 16v.01" />
|
||||
<path d="M12 13a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Dropdown
|
||||
onChange={(t) =>
|
||||
setValue("type", t as "MARKETING" | "TRANSACTIONAL")
|
||||
}
|
||||
values={[
|
||||
{ name: "Marketing", value: "MARKETING" },
|
||||
{ name: "Transactional", value: "TRANSACTIONAL" },
|
||||
]}
|
||||
selectedValue={watch("type")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.type?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.type.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-6"}>
|
||||
<Editor
|
||||
value={watch("body")}
|
||||
mode={watch("style")}
|
||||
modeSwitcher
|
||||
onChange={(value, type) => {
|
||||
setValue("style", type);
|
||||
setValue("body", value);
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.body?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.body.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"flex justify-end gap-3 sm:col-span-6"}>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
return router.push("/templates");
|
||||
}}
|
||||
className={
|
||||
"flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-center gap-x-2 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<Save strokeWidth={1.5} size={18} />
|
||||
Save
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import dayjs from "dayjs";
|
||||
import { motion } from "framer-motion";
|
||||
import { LayoutTemplate, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { Alert, Badge, Card, Empty, Skeleton } from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useTemplates } from "../../lib/hooks/templates";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const { data: templates } = useTemplates();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
{templates?.length === 0 && (
|
||||
<Alert type={"info"} title={"Need a hand?"}>
|
||||
<div className={"mt-3 grid items-center sm:grid-cols-4"}>
|
||||
<p className={"sm:col-span-3"}>
|
||||
Want us to help you get started? We can help you build your
|
||||
first action in less than 5 minutes.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={"/onboarding/actions"}
|
||||
className={
|
||||
"inline-block rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white sm:col-span-1"
|
||||
}
|
||||
>
|
||||
Build an action
|
||||
</Link>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={"Templates"}
|
||||
description={"Reusable blueprints of your emails"}
|
||||
actions={
|
||||
<>
|
||||
<Link href={"templates/new"} passHref>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-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>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{templates ? (
|
||||
templates.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
}
|
||||
>
|
||||
{templates
|
||||
.sort((a, b) => {
|
||||
if (a.actions.length > 0 && b.actions.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
if (a.actions.length === 0 && b.actions.length > 0) {
|
||||
return 1;
|
||||
}
|
||||
if (a.subject < b.subject) {
|
||||
return -1;
|
||||
}
|
||||
if (a.subject > b.subject) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.map((t) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="col-span-1 divide-y divide-neutral-200 rounded border border-neutral-200 bg-white"
|
||||
key={t.id}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between space-x-6 p-6">
|
||||
<span className="inline-flex rounded bg-neutral-100 p-3 text-neutral-800 ring-4 ring-white">
|
||||
<LayoutTemplate size={20} />
|
||||
</span>
|
||||
<div className="flex-1 truncate">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="truncate text-sm font-medium text-neutral-800">
|
||||
{t.subject}
|
||||
</h3>
|
||||
{t.actions.length > 0 && (
|
||||
<Badge type={"success"}>Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
Last edited {dayjs().to(t.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="-mt-px flex divide-x divide-neutral-200">
|
||||
<div className="flex w-0 flex-1">
|
||||
<Link
|
||||
href={`/templates/${t.id}`}
|
||||
className="relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl border border-transparent py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M4.75 19.25L9 18.25L18.2929 8.95711C18.6834 8.56658 18.6834 7.93342 18.2929 7.54289L16.4571 5.70711C16.0666 5.31658 15.4334 5.31658 15.0429 5.70711L5.75 15L4.75 19.25Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M19.25 19.25H13.75"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="ml-3">Edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Empty
|
||||
title={"No templates here"}
|
||||
description={
|
||||
"Try creating a new email blueprint for your actions"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<Skeleton type={"table"} />
|
||||
)}
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TemplateSchemas } from "@plunk/shared";
|
||||
import type { Template } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
Dropdown,
|
||||
Editor,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
Tooltip,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { useTemplates } from "../../lib/hooks/templates";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface TemplateValues {
|
||||
subject: string;
|
||||
body: string;
|
||||
type: "MARKETING" | "TRANSACTIONAL";
|
||||
style: "PLUNK" | "HTML";
|
||||
}
|
||||
|
||||
const templates = {
|
||||
blank: {
|
||||
subject: "",
|
||||
body: "",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
const project = useActiveProject();
|
||||
const { mutate } = useTemplates();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<TemplateValues>({
|
||||
resolver: zodResolver(TemplateSchemas.create),
|
||||
defaultValues: {
|
||||
...templates.blank,
|
||||
type: "MARKETING",
|
||||
style: "PLUNK",
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const create = async (data: TemplateValues) => {
|
||||
toast.promise(
|
||||
network.mock<Template, typeof TemplateSchemas.create>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1/templates",
|
||||
{
|
||||
...data,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Creating new template",
|
||||
success: () => {
|
||||
void mutate();
|
||||
|
||||
return "Created new template!";
|
||||
},
|
||||
error: "Could not create new template!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/templates");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card
|
||||
title={"Create a new template"}
|
||||
description={"Reusable blueprints of your emails"}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(create)}
|
||||
className="grid gap-6 sm:grid-cols-6"
|
||||
>
|
||||
<Input
|
||||
className={"sm:col-span-4"}
|
||||
label={"Subject"}
|
||||
placeholder={`Welcome to ${project.name}!`}
|
||||
register={register("subject")}
|
||||
error={errors.subject}
|
||||
/>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"type"}
|
||||
className="flex items-center text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Type
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
<p className={"mb-2 text-base font-semibold"}>
|
||||
What type of email is this?
|
||||
</p>
|
||||
<ul className={"list-inside"}>
|
||||
<li className={"mb-6"}>
|
||||
<span className={"font-semibold"}>Marketing</span>
|
||||
<br />
|
||||
Promotional emails with a Plunk-hosted unsubscribe
|
||||
link
|
||||
<br />
|
||||
<span className={"text-neutral-400"}>
|
||||
(e.g. welcome emails, promotions)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={"font-semibold"}>Transactional</span>
|
||||
<br />
|
||||
Mission critical emails <br />
|
||||
<span className={"text-neutral-400"}>
|
||||
{" "}
|
||||
(e.g. email verification, password reset)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
icon={
|
||||
<>
|
||||
<path d="M12 16v.01" />
|
||||
<path d="M12 13a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Dropdown
|
||||
onChange={(t) =>
|
||||
setValue("type", t as "MARKETING" | "TRANSACTIONAL")
|
||||
}
|
||||
values={[
|
||||
{ name: "Marketing", value: "MARKETING" },
|
||||
{ name: "Transactional", value: "TRANSACTIONAL" },
|
||||
]}
|
||||
selectedValue={watch("type")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.type?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.type.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-6"}>
|
||||
<Editor
|
||||
value={watch("body")}
|
||||
mode={watch("style")}
|
||||
modeSwitcher
|
||||
onChange={(value, type) => {
|
||||
setValue("body", value);
|
||||
setValue("style", type);
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.body?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.body.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"flex justify-end gap-3 sm:col-span-6"}>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
return router.push("/templates");
|
||||
}}
|
||||
className={
|
||||
"flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<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>
|
||||
Create
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { UtilitySchemas } from "@plunk/shared";
|
||||
import type { User } from "@prisma/client";
|
||||
import { motion } from "framer-motion";
|
||||
import { NextSeo } from "next-seo";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FullscreenLoader, Redirect } from "../../components";
|
||||
import { useContact } from "../../lib/hooks/contacts";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.isReady) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const { data: contact, error } = useContact({
|
||||
id: router.query.id as string,
|
||||
withProject: true,
|
||||
});
|
||||
const [submitted, setSubmitted] = useState<
|
||||
"initial" | "loading" | "submitted"
|
||||
>("initial");
|
||||
|
||||
if (error) {
|
||||
return <Redirect to={"/"} />;
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const unsubscribe = () => {
|
||||
setSubmitted("loading");
|
||||
|
||||
toast.promise(
|
||||
network.mock<User, typeof UtilitySchemas.id>(
|
||||
contact.project.public,
|
||||
"POST",
|
||||
"/v1/contacts/unsubscribe",
|
||||
{
|
||||
id: contact.id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Unsubscribing",
|
||||
success: "Unsubscribed",
|
||||
error: "Could not unsubscribe you!",
|
||||
},
|
||||
);
|
||||
|
||||
setSubmitted("submitted");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={`Unsubscribe from ${contact.project.name}`}
|
||||
openGraph={{
|
||||
title: `Unsubscribe from ${contact.project.name}`,
|
||||
}}
|
||||
additionalMetaTags={[
|
||||
{
|
||||
property: "title",
|
||||
content: `Unsubscribe from ${contact.project.name}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"flex h-screen w-full flex-col items-center justify-center bg-neutral-50"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"w-3/4 rounded border border-neutral-200 bg-white p-12 shadow-sm md:w-2/4 xl:w-2/6"
|
||||
}
|
||||
>
|
||||
{submitted === "submitted" ? (
|
||||
<>
|
||||
<motion.div className={"mb-3 flex items-center justify-center"}>
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.2, ease: "easeInOut" }}
|
||||
className={
|
||||
"h-24 w-24 rounded-full bg-emerald-100 p-6 text-emerald-900"
|
||||
}
|
||||
>
|
||||
<motion.path
|
||||
d="M20 6 9 17l-5-5"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
</motion.svg>
|
||||
</motion.div>
|
||||
|
||||
<h1
|
||||
className={
|
||||
"text-center text-2xl font-bold leading-tight text-neutral-800"
|
||||
}
|
||||
>
|
||||
You have been unsubscribed!
|
||||
</h1>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1
|
||||
className={
|
||||
"text-center text-2xl font-bold leading-tight text-neutral-800"
|
||||
}
|
||||
>
|
||||
Are you sure you want to unsubscribe?
|
||||
</h1>
|
||||
<p className={"mt-4 text-center text-sm text-neutral-500"}>
|
||||
You will no longer receive emails from {contact.project.name} on
|
||||
the email {contact.email} when you confirm that you want to
|
||||
unsubscribe.
|
||||
</p>
|
||||
<div className="relative mt-2 w-full">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={unsubscribe}
|
||||
className={
|
||||
"mt-5 flex w-full items-center justify-center rounded bg-neutral-800 py-2.5 text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
{submitted === "loading" ? (
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-6 w-6 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
"Unsubscribe"
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user