Initial Commit

This commit is contained in:
Dries Augustyns
2024-07-23 13:49:48 +02:00
commit 91a8840c3c
191 changed files with 36022 additions and 0 deletions
@@ -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>
</>
);
}