Initial Commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface AlertProps {
|
||||
type: 'info' | 'danger' | 'warning' | 'success';
|
||||
title: string;
|
||||
children?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-300',
|
||||
danger: 'bg-red-50 text-red-800 border-red-300',
|
||||
warning: 'bg-yellow-50 text-yellow-800 border-yellow-300',
|
||||
success: 'bg-green-50 text-green-800 border-green-300',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.type
|
||||
* @param root0.title
|
||||
* @param root0.children
|
||||
*/
|
||||
export default function Alert({type = 'info', title, children}: AlertProps) {
|
||||
const classNames = ['w-full px-7 py-5 border rounded-lg'];
|
||||
classNames.push(styles[type]);
|
||||
|
||||
return (
|
||||
<div className={classNames.join(' ')}>
|
||||
<p className={'font-medium'}>{title}</p>
|
||||
<p className={'text-sm'}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Alert} from './Alert';
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface BadgeProps {
|
||||
type: 'info' | 'danger' | 'warning' | 'success' | 'purple';
|
||||
children: string;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.type
|
||||
* @param root0.children
|
||||
*/
|
||||
export default function Badge({type = 'info', children}: BadgeProps) {
|
||||
const classNames = ['inline-flex items-center px-2 py-0.5 rounded text-xs font-medium'];
|
||||
classNames.push(styles[type]);
|
||||
|
||||
return <span className={classNames.join(' ')}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Badge} from './Badge';
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
options?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.children
|
||||
* @param root0.className
|
||||
* @param root0.actions
|
||||
* @param root0.options
|
||||
*/
|
||||
export default function Card({title, description, children, className, actions, options}: CardProps) {
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && optionsOpen) {
|
||||
setOptionsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div className={`rounded border border-neutral-200 bg-white px-8 py-4 ${className}`}>
|
||||
<div className={'flex items-center'}>
|
||||
<div className={'flex w-full flex-col gap-3 md:flex-row md:items-center'}>
|
||||
<div>
|
||||
<h2 className={'text-xl font-semibold leading-tight text-neutral-800'}>{title}</h2>
|
||||
<p className={'text-sm text-neutral-500'}>{description}</p>
|
||||
</div>
|
||||
<div className={'flex flex-1 gap-x-2.5 md:justify-end'}>{actions}</div>
|
||||
</div>
|
||||
|
||||
{options && (
|
||||
<div className="relative ml-3 inline-block text-left" ref={ref}>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptionsOpen(!optionsOpen)}
|
||||
className="flex items-center rounded-full text-neutral-500 transition hover:text-neutral-800"
|
||||
id="menu-button"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span className="sr-only">Open options</span>
|
||||
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{optionsOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.9}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.9}}
|
||||
transition={{duration: 0.1}}
|
||||
className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="menu-button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{options}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'py-4'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Card} from './Card';
|
||||
@@ -0,0 +1,118 @@
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import React from 'react';
|
||||
|
||||
export interface CodeBlockProps {
|
||||
language: string;
|
||||
code: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.code
|
||||
* @param root0.language
|
||||
* @param root0.style
|
||||
*/
|
||||
export default function ({code, language, style}: CodeBlockProps) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
customStyle={style}
|
||||
showLineNumbers
|
||||
language={language}
|
||||
style={{
|
||||
'hljs': {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
padding: '0.5em',
|
||||
background: '#1e293b',
|
||||
color: '#f8f8f2',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#8be9fd',
|
||||
},
|
||||
'hljs-function .hljs-keyword': {
|
||||
color: '#ff79c6',
|
||||
},
|
||||
'hljs-subst': {
|
||||
color: '#f8f8f2',
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#d8b4fe',
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#c3e88d',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#c3e88d',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#f1fa8c',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-template-tag': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-comment': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-doctag': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as CodeBlock} from './CodeBlock';
|
||||
@@ -0,0 +1,191 @@
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
|
||||
export interface Dropdownprops {
|
||||
withSearch?: boolean;
|
||||
inModal?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
values: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
selectedValue: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.onChange
|
||||
* @param root0.values
|
||||
* @param root0.selectedValue
|
||||
* @param root0.className
|
||||
* @param root0.withSearch
|
||||
* @param root0.inModal
|
||||
* @param root0.disabled
|
||||
*/
|
||||
export default function Dropdown({
|
||||
onChange,
|
||||
values,
|
||||
selectedValue,
|
||||
className,
|
||||
withSearch = false,
|
||||
inModal = false,
|
||||
disabled = false,
|
||||
}: Dropdownprops) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className ?? ''}>
|
||||
<div className="relative mt-1 w-full">
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? 'cursor-default bg-neutral-100' : 'cursor-pointer bg-white'
|
||||
} relative w-full rounded border border-neutral-300 py-2 pl-3 pr-10 text-left focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500 sm:text-sm`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block flex items-center truncate">
|
||||
{values.find(v => v.value === selectedValue)
|
||||
? `${values
|
||||
.find(v => v.value === selectedValue)
|
||||
?.name.charAt(0)
|
||||
.toUpperCase()}${values
|
||||
.find(v => v.value === selectedValue)
|
||||
?.name.slice(1)
|
||||
.toLowerCase()}`
|
||||
: 'No value selected'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{rotate: '90deg'}}
|
||||
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
|
||||
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"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{opacity: 0, height: 0}}
|
||||
animate={{opacity: 1, height: 'auto'}}
|
||||
exit={{opacity: 0, height: 0}}
|
||||
transition={{duration: 0.2, ease: 'easeInOut'}}
|
||||
className={`${
|
||||
inModal ? 'fixed w-64' : 'absolute w-full'
|
||||
} z-50 mt-1 max-h-72 rounded-md border border-black border-opacity-10 bg-white text-base shadow-lg focus:outline-none sm:text-sm`}
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="sticky top-0 z-50 bg-white">
|
||||
{withSearch ? (
|
||||
<>
|
||||
<li className="relative cursor-default select-none px-3 py-2 text-neutral-800">
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
autoComplete={'off'}
|
||||
className="block w-full rounded border-neutral-300 border-opacity-5 focus:border-neutral-800 sm:text-sm"
|
||||
placeholder={'Search'}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</li>
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 max-h-52 overflow-y-scroll p-1'
|
||||
}
|
||||
>
|
||||
{values.filter(value => value.name.toLowerCase().startsWith(query.toLowerCase())).length === 0 ? (
|
||||
<li className="relative cursor-default select-none py-2.5 pl-3 pr-9 text-neutral-800">
|
||||
No results found
|
||||
</li>
|
||||
) : (
|
||||
values
|
||||
.filter(value => value.name.toLowerCase().startsWith(query.toLowerCase()))
|
||||
.map((value, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`x-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
onChange(value.value);
|
||||
setQuery('');
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{value.name.charAt(0).toUpperCase() + value.name.slice(1).toLowerCase()}
|
||||
</span>
|
||||
{value.value === selectedValue ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-800">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Dropdown} from './Dropdown';
|
||||
@@ -0,0 +1,57 @@
|
||||
import {FieldError, UseFormRegisterReturn} from 'react-hook-form';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
export interface InputProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'number';
|
||||
register: UseFormRegisterReturn;
|
||||
error?: FieldError;
|
||||
className?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @param props.label
|
||||
* @param props.type
|
||||
* @param props.register
|
||||
* @param props.error
|
||||
* @param props.placeholder
|
||||
* @param props.className
|
||||
*/
|
||||
export default function Input(props: InputProps) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<label className="block text-sm font-medium text-neutral-700">{props.label}</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
autoComplete={'off'}
|
||||
type={props.type}
|
||||
min={props.type === 'number' ? props.min : undefined}
|
||||
max={props.type === 'number' ? props.max : undefined}
|
||||
className={
|
||||
'block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm'
|
||||
}
|
||||
placeholder={props.placeholder}
|
||||
{...props.register}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{props.error && (
|
||||
<motion.p
|
||||
initial={{height: 0}}
|
||||
animate={{height: 'auto'}}
|
||||
exit={{height: 0}}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{props.error.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Input} from './Input';
|
||||
@@ -0,0 +1,873 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { API_URI } from "../../../lib/constants";
|
||||
import { Modal } from "../../Overlay";
|
||||
import "tippy.js/animations/scale.css";
|
||||
import HTMLEditor from "@monaco-editor/react";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { Dropcursor } from "@tiptap/extension-dropcursor";
|
||||
import FontFamily from "@tiptap/extension-font-family";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
ImageIcon,
|
||||
Inspect,
|
||||
LinkIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Dropdown } from "../Dropdown";
|
||||
import { Button } from "./extensions/Button";
|
||||
import { EditorBubbleMenu } from "./extensions/EditorBubbleMenu";
|
||||
import { Mention } from "./extensions/MetadataSuggestion/MetadataSuggestion";
|
||||
import suggestion from "./extensions/MetadataSuggestion/Suggestions";
|
||||
import { Progress, type colors } from "./extensions/Progress";
|
||||
import Slash from "./extensions/Slash";
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
value: string;
|
||||
mode: "PLUNK" | "HTML";
|
||||
onChange: (value: string, type: "PLUNK" | "HTML") => void;
|
||||
modeSwitcher?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.value
|
||||
* @param root0.onChange
|
||||
*/
|
||||
export default function Editor({
|
||||
value,
|
||||
onChange,
|
||||
mode,
|
||||
modeSwitcher,
|
||||
}: MarkdownEditorProps) {
|
||||
const [imageModal, setImageModal] = useState(false);
|
||||
const [urlModal, setUrlModal] = useState(false);
|
||||
const [barModal, setBarModal] = useState(false);
|
||||
const [buttonModal, setButtonModal] = useState(false);
|
||||
const [confirmModal, setConfirmModal] = useState(false);
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Slash,
|
||||
StarterKit,
|
||||
Typography,
|
||||
TextStyle,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
// @ts-ignore
|
||||
suggestion,
|
||||
}),
|
||||
Dropcursor.configure({
|
||||
width: 3,
|
||||
color: "#e5e5e5",
|
||||
}),
|
||||
TextAlign.configure({
|
||||
alignments: ["left", "center", "right"],
|
||||
types: ["heading", "paragraph"],
|
||||
defaultAlignment: "left",
|
||||
}),
|
||||
FontFamily.configure({
|
||||
types: ["textStyle"],
|
||||
}),
|
||||
Image.configure({ allowBase64: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: "Start typing or press / to use a slash command",
|
||||
includeChildren: true,
|
||||
}),
|
||||
Progress,
|
||||
Button,
|
||||
Link.configure({
|
||||
autolink: true,
|
||||
protocols: ["http", "https", "mailto"],
|
||||
}).extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Space: ({ editor }) => {
|
||||
if (editor.isActive("link")) {
|
||||
// Toggle the link and add a space
|
||||
editor.commands.toggleMark("link");
|
||||
// Add a space
|
||||
return editor.chain().focus().insertContent(" ").run();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
Color,
|
||||
],
|
||||
content: value,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose font-sans my-5 focus:outline-none",
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
return event.key === "Enter" && !event.shiftKey;
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML(), "PLUNK");
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerUrl,
|
||||
handleSubmit: handleSubmitUrl,
|
||||
reset: resetUrl,
|
||||
setFocus: setFocusUrl,
|
||||
formState: { errors: errorsUrl },
|
||||
} = useForm<{
|
||||
url: string;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
url: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?(?:\?\S*)?$/,
|
||||
)
|
||||
.transform((u) => {
|
||||
if (u.startsWith("{{") && u.endsWith("}}")) {
|
||||
return u;
|
||||
}
|
||||
|
||||
return u.startsWith("http") ? u : `https://${u}`;
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerBar,
|
||||
handleSubmit: handleSubmitBar,
|
||||
reset: resetBar,
|
||||
setValue: setValueBar,
|
||||
watch: watchBar,
|
||||
formState: { errors: errorsBar },
|
||||
} = useForm<{
|
||||
percent: number;
|
||||
color: colors;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
percent: z.preprocess(
|
||||
(a) => Number.parseInt(z.string().parse(a), 10),
|
||||
z.number().positive().max(100),
|
||||
),
|
||||
color: z
|
||||
.enum([
|
||||
"red",
|
||||
"yellow",
|
||||
"green",
|
||||
"blue",
|
||||
"indigo",
|
||||
"purple",
|
||||
"pink",
|
||||
"orange",
|
||||
"black",
|
||||
])
|
||||
.default("blue"),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
color: "blue",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerButton,
|
||||
handleSubmit: handleSubmitButton,
|
||||
reset: resetButton,
|
||||
setValue: setValueButton,
|
||||
watch: watchButton,
|
||||
formState: { errors: errorsButton },
|
||||
} = useForm<{
|
||||
link: string;
|
||||
color: colors;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
link: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?$/,
|
||||
)
|
||||
.transform((u) => {
|
||||
if (u.startsWith("{{") && u.endsWith("}}")) {
|
||||
return u;
|
||||
}
|
||||
|
||||
return u.startsWith("http") ? u : `https://${u}`;
|
||||
}),
|
||||
color: z
|
||||
.enum([
|
||||
"red",
|
||||
"yellow",
|
||||
"green",
|
||||
"blue",
|
||||
"indigo",
|
||||
"purple",
|
||||
"pink",
|
||||
"orange",
|
||||
"black",
|
||||
])
|
||||
.default("blue"),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
color: "blue",
|
||||
},
|
||||
});
|
||||
|
||||
const addImage = useCallback(
|
||||
(data: { url: string }) => {
|
||||
editor?.chain().focus().setImage({ src: data.url }).run();
|
||||
setImageModal(false);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const addBar = useCallback(
|
||||
(data: { percent: number; color: colors }) => {
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.setProgress({ percent: data.percent, color: data.color })
|
||||
.run();
|
||||
setBarModal(false);
|
||||
resetBar();
|
||||
},
|
||||
[editor, resetBar],
|
||||
);
|
||||
|
||||
const addButton = useCallback(
|
||||
(data: { link: string; color: colors }) => {
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.setButton({ href: data.link, color: data.color })
|
||||
.run();
|
||||
setButtonModal(false);
|
||||
resetButton();
|
||||
},
|
||||
[editor, resetButton],
|
||||
);
|
||||
|
||||
const addUrl = useCallback(
|
||||
(data: { url: string }) => {
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.setLink({ href: data.url, target: "_blank" })
|
||||
.run();
|
||||
setUrlModal(false);
|
||||
resetUrl();
|
||||
},
|
||||
[editor, resetUrl],
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={"Watch out!"}
|
||||
isOpen={confirmModal}
|
||||
onToggle={() => setConfirmModal(!confirmModal)}
|
||||
onAction={() => {
|
||||
if (mode === "PLUNK") {
|
||||
void onChange("", "HTML");
|
||||
} else {
|
||||
void onChange("", "PLUNK");
|
||||
}
|
||||
|
||||
editor.chain().clearContent().run();
|
||||
setConfirmModal(false);
|
||||
}}
|
||||
type={"danger"}
|
||||
>
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
<p className={"text-sm text-neutral-700"}>
|
||||
Are you sure you want to switch to{" "}
|
||||
{mode === "PLUNK" ? "HTML" : "the Plunk Editor"}? <br /> This will
|
||||
clear your current content.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
{modeSwitcher && (
|
||||
<div className={"my-3 flex w-full gap-3 rounded-lg bg-neutral-100 p-2"}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setConfirmModal(true);
|
||||
}}
|
||||
className={`w-full flex-1 rounded p-2 text-sm font-medium ${
|
||||
mode === "PLUNK" ? "bg-white" : "hover:bg-neutral-50"
|
||||
} transition ease-in-out`}
|
||||
>
|
||||
Plunk Editor
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setConfirmModal(true);
|
||||
}}
|
||||
className={`w-full flex-1 rounded p-2 text-sm font-medium ${
|
||||
mode === "HTML" ? "bg-white" : "hover:bg-neutral-50"
|
||||
} transition ease-in-out`}
|
||||
>
|
||||
HTML
|
||||
</button>{" "}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={"Add image"}
|
||||
description={"Enter the URL of the image you want to add."}
|
||||
isOpen={imageModal}
|
||||
onToggle={() => setImageModal(!imageModal)}
|
||||
onAction={handleSubmitUrl(addImage)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M4.75 16L7.49619 12.5067C8.2749 11.5161 9.76453 11.4837 10.5856 12.4395L13 15.25M10.915 12.823C11.9522 11.5037 13.3973 9.63455 13.4914 9.51294C13.4947 9.50859 13.4979 9.50448 13.5013 9.50017C14.2815 8.51598 15.7663 8.48581 16.5856 9.43947L19 12.25M6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V6.75C19.25 5.64543 18.3546 4.75 17.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25Z" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitUrl(addImage)}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"url"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Image URL
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<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="https://www.example.com/image.png"
|
||||
{...registerUrl("url")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsUrl.url?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsUrl.url.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add URL"}
|
||||
description={"Copy and paste the URL"}
|
||||
isOpen={urlModal}
|
||||
onToggle={() => setUrlModal(!urlModal)}
|
||||
onAction={handleSubmitUrl(addUrl)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M16.75 13.25L18 12C19.6569 10.3431 19.6569 7.65685 18 6V6C16.3431 4.34315 13.6569 4.34315 12 6L10.75 7.25" />
|
||||
<path d="M7.25 10.75L6 12C4.34315 13.6569 4.34315 16.3431 6 18V18C7.65685 19.6569 10.3431 19.6569 12 18L13.25 16.75" />
|
||||
<path d="M14.25 9.75L9.75 14.25" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitUrl(addUrl)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"url"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
URL
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<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"
|
||||
autoComplete={"off"}
|
||||
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"
|
||||
{...registerUrl("url")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsUrl.url?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsUrl.url.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add a progress bar"}
|
||||
description={"Show the percentage of progress with ease"}
|
||||
isOpen={barModal}
|
||||
onToggle={() => setBarModal(!barModal)}
|
||||
onAction={handleSubmitBar(addBar)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M3 12m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M9 8m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M15 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M4 20l14 0" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitBar(addBar)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"percentage"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Percentage
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
autoComplete={"off"}
|
||||
type={"number"}
|
||||
min={0}
|
||||
max={100}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder={"20"}
|
||||
{...registerBar("percent")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsBar.percent?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsBar.percent.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"style"}
|
||||
className="flex items-center text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<Dropdown
|
||||
inModal={true}
|
||||
onChange={(t) => setValueBar("color", t as colors)}
|
||||
values={[
|
||||
{ value: "blue", name: "Blue" },
|
||||
{ value: "red", name: "Red" },
|
||||
{ value: "green", name: "Green" },
|
||||
{ value: "yellow", name: "Yellow" },
|
||||
{ value: "orange", name: "Orange" },
|
||||
{ value: "purple", name: "Purple" },
|
||||
{ value: "pink", name: "Pink" },
|
||||
{ value: "indigo", name: "Indigo" },
|
||||
]}
|
||||
selectedValue={watchBar("color")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errorsBar.color?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsBar.color.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add a button"}
|
||||
description={"Create a button with a link"}
|
||||
isOpen={buttonModal}
|
||||
onToggle={() => setButtonModal(!buttonModal)}
|
||||
onAction={handleSubmitButton(addButton)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M8 13v-8.5a1.5 1.5 0 0 1 3 0v7.5" />
|
||||
<path d="M11 11.5v-2a1.5 1.5 0 0 1 3 0v2.5" />
|
||||
<path d="M14 10.5a1.5 1.5 0 0 1 3 0v1.5" />
|
||||
<path d="M17 11.5a1.5 1.5 0 0 1 3 0v4.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7l-.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47" />
|
||||
<path d="M5 3l-1 -1" />
|
||||
<path d="M4 7h-1" />
|
||||
<path d="M14 3l1 -1" />
|
||||
<path d="M15 6h1" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitButton(addButton)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"percentage"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Link
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<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"
|
||||
autoComplete={"off"}
|
||||
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"
|
||||
{...registerButton("link")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsButton.link?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsButton.link.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"style"}
|
||||
className="flex items-center text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<Dropdown
|
||||
inModal={true}
|
||||
onChange={(t) => setValueButton("color", t as colors)}
|
||||
values={[
|
||||
{ value: "blue", name: "Blue" },
|
||||
{ value: "red", name: "Red" },
|
||||
{ value: "green", name: "Green" },
|
||||
{ value: "yellow", name: "Yellow" },
|
||||
{ value: "orange", name: "Orange" },
|
||||
{ value: "purple", name: "Purple" },
|
||||
{ value: "pink", name: "Pink" },
|
||||
{ value: "indigo", name: "Indigo" },
|
||||
{ value: "black", name: "Black" },
|
||||
]}
|
||||
selectedValue={watchButton("color")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errorsButton.color?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsButton.color.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<div>
|
||||
<>
|
||||
{mode === "PLUNK" ? (
|
||||
<>
|
||||
<div
|
||||
onClick={() => {
|
||||
editor.chain().focus().run();
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Email Body
|
||||
</label>
|
||||
<div className="mt-1 h-full">
|
||||
<div
|
||||
className={
|
||||
"flex h-full max-h-[600px] flex-col items-center overflow-y-auto overflow-x-hidden rounded border border-neutral-300 px-3 py-1"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"sticky top-3 z-10 mt-3 flex flex-col justify-center gap-3 rounded-lg border border-neutral-300 bg-white p-4 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-3"}>
|
||||
<div className={"flex"}>
|
||||
<button
|
||||
title={"Align Left"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-l-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().setTextAlign("left").run();
|
||||
}}
|
||||
>
|
||||
<AlignLeft
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "left",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
title={"Align Center"}
|
||||
className={
|
||||
"flex items-center justify-center border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextAlign("center")
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
<AlignCenter
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "center",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
title={"Align Right"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-r-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextAlign("right")
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
<AlignRight
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "right",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={"flex"}>
|
||||
<button
|
||||
title={"Image"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-l-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setImageModal(true);
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={24} strokeWidth={"1.5"} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={"Button"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-r-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setButtonModal(true);
|
||||
}}
|
||||
>
|
||||
<Inspect size={24} strokeWidth={"1.5"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"prose prose-sm prose-neutral space-y-4 break-words p-4"
|
||||
}
|
||||
>
|
||||
<div className={"w-full"} style={{ width: "600px" }}>
|
||||
<EditorContent editor={editor} />
|
||||
<EditorBubbleMenu
|
||||
editor={editor}
|
||||
items={[
|
||||
{
|
||||
name: "Link",
|
||||
icon: LinkIcon,
|
||||
command: () => {
|
||||
setUrlModal(true);
|
||||
setTimeout(() => {
|
||||
setFocusUrl("url", { shouldSelect: true });
|
||||
}, 100);
|
||||
},
|
||||
isActive: () => false,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"mb-3 grid gap-3 md:grid-cols-1"}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Email Body
|
||||
</label>
|
||||
<div className="mt-1 h-full">
|
||||
<HTMLEditor
|
||||
height={400}
|
||||
className={"rounded border border-neutral-300"}
|
||||
language="html"
|
||||
theme="vs-light"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e as string, "HTML")}
|
||||
options={{
|
||||
inlineSuggest: true,
|
||||
fontSize: "12px",
|
||||
formatOnType: true,
|
||||
autoClosingBrackets: true,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Preview
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={
|
||||
"mt-1 h-full rounded border border-neutral-300 p-3"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={"revert-tailwind"}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: value,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {mergeAttributes, Node, wrappingInputRule} from '@tiptap/core';
|
||||
|
||||
export type colors = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' | 'black';
|
||||
|
||||
// Map each color to a tailwind color hex code for 500
|
||||
const colorMap = {
|
||||
red: '#ef4444',
|
||||
orange: '#f97316',
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
blue: '#2563eb',
|
||||
indigo: '#6366f1',
|
||||
purple: '#8b5cf6',
|
||||
pink: '#ec4899',
|
||||
black: '#171717',
|
||||
} as const;
|
||||
|
||||
export interface ButtonOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
button: {
|
||||
/**
|
||||
* Set a blockquote node
|
||||
*/
|
||||
setButton: (attributes: {href: string; color: colors}) => ReturnType;
|
||||
/**
|
||||
* Toggle a blockquote node
|
||||
*/
|
||||
toggleButton: (attributes: {href: string; color: colors}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const inputRegex = /^\s*>\s$/;
|
||||
|
||||
export const Button = Node.create<ButtonOptions>({
|
||||
name: 'button',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'btn',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
href: {
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
default: 'blue' as colors,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a.btn',
|
||||
priority: 51,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `color: white; background-color: ${
|
||||
colorMap[node.attrs.color as colors]
|
||||
}; text-align: center; text-decoration: none; padding: 12px; border-radius: 8px; display: block; font-size: 15px; line-height: 20px; font-weight: 600; margin: 9px 0 9px 0;`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setButton:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.setNode(this.name, attributes);
|
||||
},
|
||||
toggleButton:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.toggleNode(this.name, 'paragraph', attributes);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
wrappingInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {Editor} from '@tiptap/core';
|
||||
import cx from 'classnames';
|
||||
import {Check, ChevronDown} from 'lucide-react';
|
||||
import {FC} from 'react';
|
||||
|
||||
export interface BubbleColorMenuItem {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ColorSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const ColorSelector: FC<ColorSelectorProps> = ({editor, isOpen, setIsOpen}) => {
|
||||
const items: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: 'Default',
|
||||
color: '#000000',
|
||||
},
|
||||
{
|
||||
name: 'Purple',
|
||||
color: '#9333EA',
|
||||
},
|
||||
{
|
||||
name: 'Red',
|
||||
color: '#E00000',
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
color: '#2563EB',
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
color: '#008A00',
|
||||
},
|
||||
{
|
||||
name: 'Orange',
|
||||
color: '#FFA500',
|
||||
},
|
||||
{
|
||||
name: 'Pink',
|
||||
color: '#BA4081',
|
||||
},
|
||||
{
|
||||
name: 'Gray',
|
||||
color: '#A8A29E',
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.find(({color}) => editor.isActive('textStyle', {color}));
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
className="flex h-full items-center gap-1 p-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<span style={{color: activeItem?.color ?? '#000000'}}>A</span>
|
||||
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="animate-in fade-in slide-in-from-top-1 fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-neutral-200 bg-white p-1 shadow-xl">
|
||||
{items.map(({name, color}, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cx(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1 text-sm text-neutral-600 hover:bg-neutral-100',
|
||||
{
|
||||
'text-blue-600': editor.isActive('textStyle', {color}),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border border-neutral-200 px-1 py-px font-medium" style={{color}}>
|
||||
A
|
||||
</div>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
{editor.isActive('textStyle', {color}) && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import {BubbleMenu, BubbleMenuProps} from '@tiptap/react';
|
||||
import cx from 'classnames';
|
||||
import {FC, useState} from 'react';
|
||||
import {BoldIcon, ItalicIcon, StrikethroughIcon} from 'lucide-react';
|
||||
|
||||
import {NodeSelector} from './NodeSelector';
|
||||
import {ColorSelector} from './ColorSelector';
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof BoldIcon;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'> & {
|
||||
items: BubbleMenuItem[];
|
||||
};
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = props => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'bold',
|
||||
isActive: () => props.editor?.isActive('bold') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleBold().run(),
|
||||
icon: BoldIcon,
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
isActive: () => props.editor?.isActive('italic') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
||||
icon: ItalicIcon,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'strike',
|
||||
isActive: () => props.editor?.isActive('strike') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleStrike().run(),
|
||||
icon: StrikethroughIcon,
|
||||
},
|
||||
...props.items,
|
||||
];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({editor}) => {
|
||||
// don't show if image is selected
|
||||
if (editor.isActive('image')) {
|
||||
return false;
|
||||
}
|
||||
return editor.view.state.selection.content().size > 0;
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: 'transform 0.15s ease-out',
|
||||
onHidden: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
className="flex overflow-hidden rounded border border-neutral-200 bg-white shadow-xl"
|
||||
>
|
||||
{props.editor && (
|
||||
<NodeSelector editor={props.editor} isOpen={isNodeSelectorOpen} setIsOpen={setIsNodeSelectorOpen} />
|
||||
)}
|
||||
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
item.command();
|
||||
}}
|
||||
className="p-2 text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
>
|
||||
<item.icon
|
||||
className={cx('h-4 w-4', {
|
||||
'text-blue-500': item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{props.editor && (
|
||||
<ColorSelector editor={props.editor} isOpen={isColorSelectorOpen} setIsOpen={setIsColorSelectorOpen} />
|
||||
)}
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import {mergeAttributes, Node} from '@tiptap/core';
|
||||
import {Node as ProseMirrorNode} from '@tiptap/pm/model';
|
||||
import {PluginKey} from '@tiptap/pm/state';
|
||||
import Suggestion, {SuggestionOptions} from '@tiptap/suggestion';
|
||||
|
||||
export interface MentionOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
renderLabel: (props: {options: MentionOptions; node: ProseMirrorNode}) => string;
|
||||
suggestion: Omit<SuggestionOptions, 'editor'>;
|
||||
}
|
||||
|
||||
export const MentionPluginKey = new PluginKey('mention');
|
||||
|
||||
export const Mention = Node.create<MentionOptions>({
|
||||
name: 'mention',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderLabel({options, node}) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
suggestion: {
|
||||
char: '{{',
|
||||
pluginKey: MentionPluginKey,
|
||||
command: ({editor, range, props}) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(' ');
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1;
|
||||
}
|
||||
|
||||
editor.chain().focus().insertContent(`${props.id}}}`).run();
|
||||
|
||||
window.getSelection()?.collapseToEnd();
|
||||
},
|
||||
allow: ({state, range}) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const type = state.schema.nodes[this.name];
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type);
|
||||
|
||||
return allow;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
group: 'inline',
|
||||
|
||||
inline: true,
|
||||
|
||||
selectable: true,
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.id) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
'data-id': attributes.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-label'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.label) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
'data-label': attributes.label,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes({'data-type': this.name}, this.options.HTMLAttributes, HTMLAttributes),
|
||||
this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
renderText({node}) {
|
||||
return this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
});
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () =>
|
||||
this.editor.commands.command(({tr, state}) => {
|
||||
let isMention = false;
|
||||
const {selection} = state;
|
||||
const {empty, anchor} = selection;
|
||||
|
||||
if (!empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||
if (node.type.name === this.name) {
|
||||
isMention = true;
|
||||
tr.insertText(this.options.suggestion.char ?? '', pos, pos + node.nodeSize);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return isMention;
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export default forwardRef((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = props.items[index];
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item });
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex(
|
||||
(selectedIndex + props.items.length - 1) % props.items.length,
|
||||
);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="z-50 mt-2 w-56 origin-top-right rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{props.items.length ? (
|
||||
props.items.map((item, index) => (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 ${
|
||||
index === selectedIndex ? "bg-neutral-50" : ""
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100">
|
||||
No result
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { network } from "dashboard/src/lib/network";
|
||||
import type { RefAttributes } from "react";
|
||||
import tippy from "tippy.js";
|
||||
import MentionList from "./SuggestionList";
|
||||
|
||||
export default {
|
||||
items: async ({ query }: { query: string }) => {
|
||||
const activeProject =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem("project")
|
||||
: null;
|
||||
|
||||
if (!activeProject) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys = await network.fetch<string[]>(
|
||||
"GET",
|
||||
`/projects/id/${activeProject}/contacts/metadata`,
|
||||
);
|
||||
|
||||
return keys.filter((key) =>
|
||||
key.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<unknown, RefAttributes<unknown>>;
|
||||
let popup: { destroy: () => void }[];
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: any; clientRect: any }) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props);
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (popup[0]) {
|
||||
popup[0].destroy();
|
||||
}
|
||||
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import {Editor} from '@tiptap/core';
|
||||
import cx from 'classnames';
|
||||
import {Check, ChevronDown, Heading1, Heading2, Heading3, ListOrdered, TextIcon} from 'lucide-react';
|
||||
import {FC} from 'react';
|
||||
|
||||
import {BubbleMenuItem} from './EditorBubbleMenu';
|
||||
|
||||
interface NodeSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeSelector: FC<NodeSelectorProps> = ({editor, isOpen, setIsOpen}) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'Text',
|
||||
icon: TextIcon,
|
||||
command: () => editor.chain().focus().toggleNode('paragraph', 'paragraph').run(),
|
||||
isActive: () => editor.isActive('paragraph') && !editor.isActive('bulletList') && !editor.isActive('orderedList'),
|
||||
},
|
||||
{
|
||||
name: 'Heading 1',
|
||||
icon: Heading1,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 1}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 1}),
|
||||
},
|
||||
{
|
||||
name: 'Heading 2',
|
||||
icon: Heading2,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 2}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 2}),
|
||||
},
|
||||
{
|
||||
name: 'Heading 3',
|
||||
icon: Heading3,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 3}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 3}),
|
||||
},
|
||||
{
|
||||
name: 'Bullet List',
|
||||
icon: ListOrdered,
|
||||
command: () => editor.chain().focus().toggleBulletList().run(),
|
||||
isActive: () => editor.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
name: 'Numbered List',
|
||||
icon: ListOrdered,
|
||||
command: () => editor.chain().focus().toggleOrderedList().run(),
|
||||
isActive: () => editor.isActive('orderedList'),
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.find(item => item.isActive());
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
className="flex h-full items-center gap-1 p-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<span>{activeItem?.name}</span>
|
||||
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="animate-in fade-in slide-in-from-top-1 fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-neutral-200 bg-white p-1 shadow-xl">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cx(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1 text-sm text-neutral-600 hover:bg-neutral-100',
|
||||
{
|
||||
'text-blue-600': item.isActive(),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border border-neutral-200 p-1">
|
||||
<item.icon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{item.isActive() && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import {Node} from '@tiptap/core';
|
||||
|
||||
export type colors = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' | 'black';
|
||||
|
||||
// Map each color to a tailwind color hex code for 500
|
||||
const colorMap = {
|
||||
red: '#ef4444',
|
||||
orange: '#f97316',
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
blue: '#2563eb',
|
||||
indigo: '#6366f1',
|
||||
purple: '#8b5cf6',
|
||||
pink: '#ec4899',
|
||||
black: '#171717',
|
||||
} as const;
|
||||
|
||||
export interface ProgressOptions {
|
||||
percent: number;
|
||||
color: colors;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
progress: {
|
||||
/**
|
||||
* Set a heading node
|
||||
*/
|
||||
setProgress: (attributes: {percent: number; color: colors}) => ReturnType;
|
||||
/**
|
||||
* Toggle a heading node
|
||||
*/
|
||||
toggleProgress: (attributes: {percent: number; color: colors}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Progress = Node.create<ProgressOptions>({
|
||||
name: 'progress',
|
||||
|
||||
content: 'inline*',
|
||||
|
||||
group: 'block',
|
||||
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
percent: {
|
||||
default: 100,
|
||||
rendered: false,
|
||||
},
|
||||
color: {
|
||||
default: 'blue' as colors,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'table',
|
||||
getAttrs: element => {
|
||||
// @ts-ignore
|
||||
const percent = element.querySelector('td')?.style.width;
|
||||
// @ts-ignore
|
||||
const color = element.querySelector('td')?.style.backgroundColor;
|
||||
|
||||
const rgb = color?.slice(4, color.length - 1).split(', ');
|
||||
const hex = rgb?.map((value: any) => {
|
||||
const hex = Number(value).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
});
|
||||
|
||||
return {
|
||||
percent: Number(percent?.slice(0, percent.length - 1)),
|
||||
color: Object.keys(colorMap).find(key => colorMap[key as colors] === `#${hex?.join('')}`) as colors,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node}) {
|
||||
// Render a progress bar using table elements
|
||||
return [
|
||||
'table',
|
||||
{
|
||||
class: 'progress',
|
||||
style: `width: 100%; border-radius: 10px;height: 28px;`,
|
||||
},
|
||||
[
|
||||
'tr',
|
||||
{
|
||||
style: `width: 100%; border-radius: 8px;`,
|
||||
},
|
||||
// Render two cells, one for the progress bar and one for the percentage
|
||||
[
|
||||
'td',
|
||||
{
|
||||
style: `width: ${node.attrs.percent}%; background-color: ${
|
||||
colorMap[node.attrs.color as colors]
|
||||
}; border-top-left-radius: 8px; border-bottom-left-radius: 8px;`,
|
||||
},
|
||||
],
|
||||
[
|
||||
'td',
|
||||
{
|
||||
style: `width: ${
|
||||
100 - node.attrs.percent
|
||||
}%; background-color: #f5f5f5; border-top-right-radius: 8px; border-bottom-right-radius: 8px;`,
|
||||
},
|
||||
],
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setProgress:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.setNode(this.name, attributes);
|
||||
},
|
||||
toggleProgress:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.toggleNode(this.name, 'paragraph', attributes);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { type Editor, Extension, type Range } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Strikethrough,
|
||||
} from "lucide-react";
|
||||
import React, {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
interface CommandItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface Command {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const Command = Extension.create({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const getSuggestionItems = ({ query }: { query: string }) => {
|
||||
return [
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
icon: <Heading1 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 1 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
icon: <Heading2 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 2 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
icon: <Heading3 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 3 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bold",
|
||||
description: "Make text bold.",
|
||||
icon: <Bold size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("bold").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Italic",
|
||||
description: "Make text italic.",
|
||||
icon: <Italic size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("italic").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Strikethrough",
|
||||
description: "Make text strikethrough.",
|
||||
icon: <Strikethrough size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("strike").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a bullet list.",
|
||||
icon: <List size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a numbered list.",
|
||||
icon: <ListOrdered size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
description: "Create a code block.",
|
||||
icon: <Code size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Create a quote.",
|
||||
icon: <Quote size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Left",
|
||||
description: "Align text to the left.",
|
||||
icon: <AlignLeft size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("left").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Center",
|
||||
description: "Align text to the center.",
|
||||
icon: <AlignCenter size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("center").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Right",
|
||||
description: "Align text to the right.",
|
||||
icon: <AlignRight size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("right").run();
|
||||
},
|
||||
},
|
||||
].filter((item) => {
|
||||
if (query.length > 0) {
|
||||
return item.title.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// .slice(0, 10);
|
||||
};
|
||||
|
||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item.offsetHeight;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
command,
|
||||
editor,
|
||||
}: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
|
||||
command(item);
|
||||
},
|
||||
[command, editor, items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (container) {
|
||||
updateScrollView(container, item);
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
return items.length > 0 ? (
|
||||
<div
|
||||
ref={commandListContainer}
|
||||
className="z-50 h-auto max-h-[330px] w-72 overflow-y-auto scroll-smooth rounded-md border border-neutral-200 bg-white px-1 py-2 shadow-md transition-all"
|
||||
>
|
||||
{items.map((item: CommandItemProps, index: number) => {
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-neutral-800 hover:bg-neutral-100 ${
|
||||
index === selectedIndex ? "bg-neutral-100 text-neutral-800" : ""
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-neutral-200 bg-white">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-xs text-neutral-500">{item.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const Slash = Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems,
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
export default Slash;
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Editor} from './Editor';
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface MultiselectDropdownProps {
|
||||
onChange: (value: string[]) => void;
|
||||
values: readonly {
|
||||
name: string;
|
||||
value: string;
|
||||
tag?: string;
|
||||
}[];
|
||||
selectedValues?: readonly string[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.onChange
|
||||
* @param root0.values
|
||||
* @param root0.selectedValues
|
||||
* @param root0.className
|
||||
* @param root0.disabled
|
||||
*/
|
||||
export default function MultiselectDropdown({
|
||||
onChange,
|
||||
values,
|
||||
selectedValues: PropsselectedValues,
|
||||
className,
|
||||
disabled = false,
|
||||
}: MultiselectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedValues, setSelectedValues] = useState<readonly string[]>([]);
|
||||
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (PropsselectedValues) {
|
||||
setSelectedValues(PropsselectedValues);
|
||||
}
|
||||
}, [PropsselectedValues]);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className={className ?? ''}>
|
||||
<div className="relative mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? 'cursor-default bg-neutral-100' : 'cursor-pointer bg-white'
|
||||
} relative w-full rounded border border-neutral-300 py-2 pl-3 pr-10 text-left sm:text-sm`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">{selectedValues.length} selected</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{rotate: '90deg'}}
|
||||
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
|
||||
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"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{opacity: 0, height: 0}}
|
||||
animate={{opacity: 1, height: 'auto'}}
|
||||
exit={{opacity: 0, height: 0}}
|
||||
transition={{duration: 0.2, ease: 'easeInOut'}}
|
||||
className="scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 absolute z-40 mt-1 max-h-72 w-full overflow-y-scroll rounded-md border border-black border-opacity-5 bg-white p-1 pr-1 text-base shadow focus:outline-none sm:text-sm"
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
>
|
||||
<li className="relative cursor-default select-none px-3 py-2 text-neutral-800">
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
autoComplete={'off'}
|
||||
className="block w-full rounded border-neutral-300 focus:border-black focus:ring-black sm:text-sm"
|
||||
placeholder={'Search'}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</li>
|
||||
|
||||
{values.filter(value => value.name.toLowerCase().includes(query.toLowerCase())).length === 0 ? (
|
||||
<li className="relative cursor-default select-none py-2 pl-3 pr-9 text-neutral-800">
|
||||
No results found
|
||||
</li>
|
||||
) : (
|
||||
values
|
||||
.filter(value => value.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((value, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`multiselect-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
const isAlreadySelected = selectedValues.find(selection => value.value === selection);
|
||||
|
||||
const updatedArray = isAlreadySelected
|
||||
? selectedValues.filter(selection => selection !== value.value)
|
||||
: [...selectedValues, value.value];
|
||||
|
||||
onChange(updatedArray);
|
||||
setSelectedValues(updatedArray);
|
||||
}}
|
||||
>
|
||||
{value.tag && (
|
||||
<span
|
||||
className={'mr-3 whitespace-nowrap rounded bg-blue-100 px-3 py-0.5 text-xs text-blue-900'}
|
||||
>
|
||||
{value.tag}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate font-normal">
|
||||
{value.name.charAt(0).toUpperCase() + value.name.slice(1).toLowerCase()}
|
||||
</span>
|
||||
{value.value === selectedValues.find(selection => value.value === selection) ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-black">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as MultiselectDropdown} from './MultiselectDropdown';
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface ToggleProps {
|
||||
title: string;
|
||||
description: string;
|
||||
toggled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.toggled
|
||||
* @param root0.onToggle
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.className
|
||||
* @param root0.disabled
|
||||
*/
|
||||
export default function Toggle({title, description, toggled, onToggle, disabled, className}: ToggleProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center justify-between ${className}`}>
|
||||
<span className="flex flex-grow flex-col">
|
||||
<span
|
||||
className={`${
|
||||
disabled ? 'text-neutral-400' : 'text-neutral-800'
|
||||
} text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className={`${disabled ? 'text-neutral-300' : 'text-neutral-500'} w-10/12 text-sm`}>{description}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? 'bg-neutral-100' : toggled ? 'bg-neutral-800' : 'bg-neutral-200'
|
||||
} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2`}
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
aria-labelledby="availability-label"
|
||||
aria-describedby="availability-description"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
disabled ? 'translate-x-0' : toggled ? 'translate-x-5' : 'translate-x-0'
|
||||
} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Toggle} from './Toggle';
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './Toggle';
|
||||
export * from './Dropdown';
|
||||
export * from './MultiselectDropdown';
|
||||
export * from './MarkdownEditor';
|
||||
export * from './Input';
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.onMethodChange
|
||||
*/
|
||||
export default function AnalyticsTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [
|
||||
{to: '/analytics', text: 'Overview', active: router.route === '/analytics'},
|
||||
{to: '/analytics/clicks', text: 'Clicks', active: router.route === '/analytics/clicks'},
|
||||
];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as AnalyticsTabs} from './AnalyticsTabs';
|
||||
@@ -0,0 +1,14 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function DeveloperTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [{to: '/developers/webhooks', text: 'Webhooks', active: router.route === '/developers/webhooks'}];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as DeveloperTabs} from './DeveloperTabs';
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, {MutableRefObject, useEffect} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useActiveProject, useProjects} from '../../../lib/hooks/projects';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useAtom} from 'jotai';
|
||||
import {atomActiveProject} from '../../../lib/atoms/project';
|
||||
|
||||
export interface ProjectSelectorProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const ProjectSelector = React.forwardRef<HTMLDivElement, ProjectSelectorProps>(
|
||||
({open, onToggle}: ProjectSelectorProps, ref) => {
|
||||
const router = useRouter();
|
||||
const {data: projects} = useProjects();
|
||||
const activeProject = useActiveProject();
|
||||
const [, setActiveProjectId] = useAtom(atomActiveProject);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
const onChange = (project: string) => {
|
||||
localStorage.setItem('project', project);
|
||||
setActiveProjectId(project);
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={'projects'} className="block select-none text-sm font-semibold text-neutral-600">
|
||||
Projects
|
||||
</label>
|
||||
<div ref={ref}>
|
||||
<div className="relative mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'relative w-full cursor-pointer rounded border border-neutral-300 bg-white py-2 pl-3 pr-10 text-left focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500 sm:text-sm'
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="block flex items-center gap-x-1.5 truncate font-medium">
|
||||
{activeProject?.name ?? 'No active project'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{rotate: '90deg'}}
|
||||
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
|
||||
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"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{opacity: 0, scale: 0.6}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.6}}
|
||||
transition={{duration: 0.1}}
|
||||
className={`absolute z-50 mt-1 w-full rounded bg-white text-base shadow-md ring-1 ring-neutral-800 ring-opacity-5 focus:outline-none sm:text-sm`}
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
aria-labelledby="listbox-label"
|
||||
aria-activedescendant="listbox-option-3"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 max-h-72 overflow-y-scroll p-1'
|
||||
}
|
||||
>
|
||||
{projects?.map((project, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`projects-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
onChange(project.id);
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
project.id === activeProject?.id ? 'font-medium' : 'font-normal'
|
||||
} flex items-center truncate`}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
{project.id === activeProject?.id ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-4 text-neutral-800">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<hr className={'my-0.5'} />
|
||||
<li
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={async () => {
|
||||
await router.push('/new');
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center truncate font-normal">Create new project</span>
|
||||
</li>
|
||||
</div>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ProjectSelector;
|
||||
@@ -0,0 +1 @@
|
||||
export {default as ProjectSelector} from './ProjectSelector';
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function SettingTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [
|
||||
{to: '/settings/project', text: 'Project Settings', active: router.route === '/settings/project'},
|
||||
{to: '/settings/api', text: 'API Keys', active: router.route === '/settings/api'},
|
||||
{to: '/settings/identity', text: 'Verified Domain', active: router.route === '/settings/identity'},
|
||||
{to: '/settings/members', text: 'Members', active: router.route === '/settings/members'},
|
||||
];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as SettingTabs} from './SettingTabs';
|
||||
@@ -0,0 +1,366 @@
|
||||
import React, {ReactElement, useState} from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import {ProjectSelector} from '../../index';
|
||||
import Image from 'next/image';
|
||||
import logo from '../../../../public/assets/logo.png';
|
||||
import {Home, LayoutTemplate, LineChart, LogOut, Send, Settings, TerminalSquare, Users2, Workflow} from 'lucide-react';
|
||||
|
||||
interface SidebarLinkType {
|
||||
to: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
highlight?: boolean;
|
||||
position: 'top' | 'bottom';
|
||||
icon: ReactElement;
|
||||
}
|
||||
|
||||
interface SidebarLinkProps {
|
||||
active?: boolean;
|
||||
to: string;
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
highlight?: boolean;
|
||||
svgPath: React.ReactElement;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
mobileOpen: boolean;
|
||||
onSidebarVisibilityChange: () => void;
|
||||
}
|
||||
|
||||
const links: SidebarLinkType[] = [
|
||||
{
|
||||
to: '/',
|
||||
text: 'Dashboard',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Home />,
|
||||
},
|
||||
{
|
||||
to: '/contacts',
|
||||
text: 'Contacts',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Users2 />,
|
||||
},
|
||||
{
|
||||
to: '/analytics',
|
||||
text: 'Analytics',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <LineChart />,
|
||||
},
|
||||
// {
|
||||
// to: '/developers',
|
||||
// text: 'Developers',
|
||||
// disabled: false,
|
||||
// position: 'top',
|
||||
// icon: <TerminalSquare />,
|
||||
// },
|
||||
{
|
||||
to: '/settings/project',
|
||||
text: 'Project Settings',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Settings />,
|
||||
},
|
||||
{
|
||||
to: '/events',
|
||||
text: 'Events',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <TerminalSquare />,
|
||||
},
|
||||
{
|
||||
to: '/templates',
|
||||
text: 'Templates',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <LayoutTemplate />,
|
||||
},
|
||||
{
|
||||
to: '/actions',
|
||||
text: 'Actions',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Workflow />,
|
||||
},
|
||||
{
|
||||
to: '/campaigns',
|
||||
text: 'Campaigns',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Send />,
|
||||
},
|
||||
|
||||
// {
|
||||
// to: '/settings/account',
|
||||
// text: 'Account Settings',
|
||||
// disabled: false,
|
||||
// position: 'bottom',
|
||||
// icon: (
|
||||
// <>
|
||||
// <Settings />
|
||||
// </>
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.active
|
||||
* @param root0.to
|
||||
* @param root0.text
|
||||
* @param root0.disabled
|
||||
* @param root0.svgPath
|
||||
* @param root0.highlight
|
||||
*/
|
||||
function SidebarLink({active, to, text, disabled, highlight, svgPath}: SidebarLinkProps) {
|
||||
if (to.startsWith('http')) {
|
||||
return (
|
||||
<a
|
||||
onClick={() => window.open(to, '_blank')?.focus()}
|
||||
className={`${
|
||||
active
|
||||
? 'cursor-default bg-neutral-100 text-neutral-700'
|
||||
: disabled
|
||||
? 'text-neutral-200'
|
||||
: 'cursor-pointer text-neutral-400 hover:bg-neutral-50 hover:text-neutral-700'
|
||||
} flex items-center gap-x-3 rounded p-2 text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">{svgPath}</div>
|
||||
{text}
|
||||
{highlight && <div className="ml-auto rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-800">New</div>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={to}
|
||||
className={`${
|
||||
active
|
||||
? 'cursor-default bg-neutral-100 text-neutral-700'
|
||||
: disabled
|
||||
? 'text-neutral-200'
|
||||
: 'cursor-pointer text-neutral-400 hover:bg-neutral-50 hover:text-neutral-700'
|
||||
} flex items-center gap-x-3 rounded p-2 text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">{svgPath}</div>
|
||||
{text}
|
||||
{highlight && <div className="ml-auto rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-800">New</div>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.mobileOpen
|
||||
* @param root0.onSidebarVisibilityChange
|
||||
*/
|
||||
export default function Sidebar({mobileOpen, onSidebarVisibilityChange}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const projectSelectorRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
const [projectSelectorOpen, setProjectSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, x: -100}}
|
||||
animate={{opacity: 1, x: 0}}
|
||||
exit={{opacity: 0, x: -100}}
|
||||
transition={{ease: 'easeOut', duration: 0.15}}
|
||||
className="fixed inset-0 z-40 flex w-full md:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<motion.div
|
||||
animate={{opacity: [0, 1]}}
|
||||
transition={{ease: 'easeOut', duration: 0.15}}
|
||||
className="fixed inset-0 bg-neutral-600 bg-opacity-75"
|
||||
aria-hidden={!mobileOpen}
|
||||
/>
|
||||
|
||||
<div className="relative flex h-full w-full max-w-xs flex-1 flex-col bg-white">
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => onSidebarVisibilityChange()}
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-0 flex-1 overflow-y-auto pb-4 pt-5">
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<Link href={'/'} passHref>
|
||||
<Image className={'cursor-pointer'} width={40} height={40} quality={40} src={logo} alt="Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={'mt-5 px-2'}>
|
||||
<ProjectSelector
|
||||
open={projectSelectorOpen}
|
||||
onToggle={() => setProjectSelectorOpen(!projectSelectorOpen)}
|
||||
ref={projectSelectorRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="mt-5 space-y-1 px-2">
|
||||
{links
|
||||
.filter(l => l.position === 'top')
|
||||
.map((link, index) => {
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`mobile-top-${index}`}
|
||||
active={
|
||||
link.to === '/'
|
||||
? router.pathname === link.to
|
||||
: router.pathname.split('/')[1].includes(link.to.split('/')[1])
|
||||
}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex-0 mb-4 space-y-1 bg-white px-2">
|
||||
<nav>
|
||||
{links
|
||||
.filter(l => l.position === 'bottom')
|
||||
.map((link, index) => {
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`mobile-bottom-${index}`}
|
||||
active={router.pathname === link.to}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-14 flex-shrink-0" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex w-72 flex-col">
|
||||
<div className="flex h-0 flex-1 flex-col border-r border-neutral-100 bg-white px-6">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pb-4 pt-5">
|
||||
<div className="flex flex-shrink-0 items-center justify-center px-4">
|
||||
<Link href={'/'} passHref>
|
||||
<Image className={'cursor-pointer'} width={35} height={35} quality={80} src={logo} alt="Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={'px-2'}>
|
||||
<ProjectSelector
|
||||
open={projectSelectorOpen}
|
||||
onToggle={() => setProjectSelectorOpen(!projectSelectorOpen)}
|
||||
ref={projectSelectorRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="mt-5 flex-1 space-y-1 px-2">
|
||||
{links
|
||||
.filter(l => l.position === 'top')
|
||||
.map((link, index) => {
|
||||
if (link.to === '/events') {
|
||||
return (
|
||||
<div className={'pt-3'}>
|
||||
<p className={'pb-1 text-sm font-semibold text-neutral-500'}>Automations</p>
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={router.pathname.split('/')[1].includes(link.to.split('/')[1])}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (link.to === '/campaigns') {
|
||||
return (
|
||||
<div className={'py-3'}>
|
||||
<p className={'pb-1 text-sm font-semibold text-neutral-500'}>Campaigns</p>
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={router.pathname.split('/')[1].includes(link.to.split('/')[1])}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={
|
||||
link.to === '/'
|
||||
? router.pathname === link.to
|
||||
: router.pathname.split('/')[1].includes(link.to.split('/')[1])
|
||||
}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex-0 mb-4 w-full space-y-1 bg-white px-2">
|
||||
<Link
|
||||
href={'/auth/logout'}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-x-3 rounded p-2 text-sm font-medium text-neutral-400 transition ease-in-out hover:bg-neutral-50 hover:text-neutral-700'
|
||||
}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<LogOut />
|
||||
</div>
|
||||
Sign out
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Sidebar} from './Sidebar';
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
export interface TabProps {
|
||||
links: {
|
||||
to: string;
|
||||
text: string;
|
||||
active: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.links
|
||||
*/
|
||||
export default function Tabs({links}: TabProps) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="tabs" className="sr-only">
|
||||
Select a tab
|
||||
</label>
|
||||
<select
|
||||
id="tabs"
|
||||
name="tabs"
|
||||
className="focus:ring-mirage-500 focus:border-mirage-500 block w-full rounded border-neutral-300 py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
|
||||
onChange={e => router.push(e.target.value)}
|
||||
>
|
||||
{links.map(link => {
|
||||
return (
|
||||
<option value={link.to} selected={link.active}>
|
||||
{link.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div className="border-b border-neutral-200">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
{links.map(link => {
|
||||
return (
|
||||
<Link
|
||||
href={link.to}
|
||||
className={`${
|
||||
link.active
|
||||
? 'border-mirage-500 text-mirage-600'
|
||||
: 'text-neutral-500 hover:border-neutral-300 hover:text-neutral-700'
|
||||
} whitespace-nowrap border-b-2 border-transparent px-1 py-4 text-sm font-medium transition`}
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Tabs} from './Tabs';
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './Sidebar';
|
||||
export * from './ProjectSelector';
|
||||
export * from './Tabs';
|
||||
export * from './SettingTabs';
|
||||
export * from './AnalyticsTabs';
|
||||
export * from './DeveloperTabs';
|
||||
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onAction: () => void;
|
||||
children?: React.ReactNode;
|
||||
action?: string;
|
||||
type: 'info' | 'danger';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.isOpen
|
||||
* @param root0.onToggle
|
||||
* @param root0.onAction
|
||||
* @param root0.children
|
||||
* @param root0.action
|
||||
* @param root0.type
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.icon
|
||||
*/
|
||||
export default function Modal({
|
||||
title,
|
||||
description,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onAction,
|
||||
children,
|
||||
action,
|
||||
type,
|
||||
icon,
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{ease: 'easeInOut', duration: 0.15}}
|
||||
className="fixed inset-0 z-20 bg-neutral-500 bg-opacity-75 transition ease-in-out"
|
||||
aria-hidden="true"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.7}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.7}}
|
||||
transition={{ease: 'easeInOut', duration: 0.15}}
|
||||
className="relative z-40 inline-block transform overflow-hidden rounded-lg border border-black border-opacity-5 bg-white px-8 py-10 text-left align-bottom shadow-2xl sm:my-8 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
>
|
||||
<div className="absolute right-0 top-0 hidden p-8 sm:block">
|
||||
<button
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-md bg-white text-neutral-400 transition hover:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="sm:flex sm:items-start">
|
||||
{type === 'info' ? (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-neutral-100 p-3 text-neutral-800 sm:mx-0 sm:h-12 sm:w-12">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-red-50 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-900"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex-1 sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<div className={'mb-3'}>
|
||||
<p className={'text-lg font-semibold text-neutral-800'}>{title}</p>
|
||||
<p className={'text-sm text-neutral-500'}>{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${children ? 'mt-5' : ''} sm:flex sm:flex-row-reverse`}>
|
||||
<motion.button
|
||||
whileHover={{scale: 1.05}}
|
||||
whileTap={{scale: 0.95}}
|
||||
type="button"
|
||||
className={`${
|
||||
type === 'info'
|
||||
? 'bg-neutral-800 focus:ring-neutral-800'
|
||||
: 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
} inline-flex w-full justify-center rounded border border-transparent px-6 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm`}
|
||||
onClick={onAction}
|
||||
>
|
||||
{action ? action : 'Confirm'}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{scale: 1.05}}
|
||||
whileTap={{scale: 0.95}}
|
||||
type="button"
|
||||
className="mt-3 inline-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"
|
||||
onClick={onToggle}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Modal} from './Modal';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Modal/index';
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface SkeletonProps {
|
||||
type: 'table' | 'card';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.type
|
||||
*/
|
||||
export default function Skeleton({type}: SkeletonProps) {
|
||||
if (type === 'table') {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className="w-full animate-pulse space-y-4 divide-y divide-neutral-200 rounded border border-neutral-200 p-4 shadow md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Skeleton} from './Skeleton';
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TableProps {
|
||||
values: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||
[key: string]: string | number | boolean | Date | React.ReactNode | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.values
|
||||
*/
|
||||
export default function Table({values}: TableProps) {
|
||||
if (values.length === 0) {
|
||||
return <h1>No values provided</h1>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div className="overflow-hidden rounded border border-neutral-200">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-neutral-50">
|
||||
<tr>
|
||||
{Object.keys(values[0]).map(header => {
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={`${
|
||||
typeof values[0][header] === 'boolean' ? 'text-center' : 'text-left'
|
||||
} px-6 py-3 text-xs font-medium text-neutral-800`}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{values.map(row => {
|
||||
return (
|
||||
<tr className={`border-t border-neutral-100 bg-white transition ease-in-out hover:bg-neutral-50`}>
|
||||
{Object.entries(row).map(value => {
|
||||
if (value[1] === null || value[1] === undefined) {
|
||||
return (
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-neutral-500">Not specified</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value[1] === 'boolean') {
|
||||
return (
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-neutral-500">
|
||||
{value[1] ? (
|
||||
<svg
|
||||
className={'mx-auto h-7 w-7 rounded-full bg-green-50 p-1 text-green-500'}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M5.75 12.8665L8.33995 16.4138C9.15171 17.5256 10.8179 17.504 11.6006 16.3715L18.25 6.75"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className={'mx-auto h-7 w-7 rounded-full bg-red-50 p-1 text-red-500'}
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M17.25 6.75L6.75 17.25"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M6.75 6.75L17.25 17.25"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return <td className="whitespace-nowrap px-6 py-4 text-sm text-neutral-500">{value[1]}</td>;
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Table} from './Table';
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import {Ghost} from 'lucide-react';
|
||||
|
||||
export interface EmptyProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.icon
|
||||
*/
|
||||
export default function Empty({title, description, icon}: EmptyProps) {
|
||||
return (
|
||||
<div className="relative block w-full p-12 text-center">
|
||||
<svg
|
||||
className="mx-auto mb-6 h-12 w-12 rounded bg-neutral-100 p-3 font-bold text-neutral-800"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? (
|
||||
<>
|
||||
<Ghost />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className="mt-2 block text-sm font-medium text-neutral-800">{title}</span>
|
||||
<span className="mt-1 block text-sm font-normal text-neutral-600">{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Empty} from './Empty';
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
*
|
||||
*/
|
||||
import { LineWobble } from "@uiball/loaders";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function FullscreenLoader() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="mt-8 flex flex-col items-center justify-center sm:mx-auto sm:w-full sm:max-w-lg">
|
||||
<h1 className="mt-3 text-center font-medium" suppressHydrationWarning>
|
||||
Loading...
|
||||
</h1>
|
||||
<p className={"text-center text-sm text-neutral-600"}>
|
||||
Does this take longer than expected? Try clearing your browser's cache
|
||||
or check if you have an ad blocker enabled!
|
||||
</p>
|
||||
<div className={"mt-6"}>
|
||||
<LineWobble size={200} color={"#262626"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as FullscreenLoader} from './FullscreenLoader';
|
||||
@@ -0,0 +1,24 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.percentage
|
||||
*/
|
||||
export default function ProgressBar({percentage}: ProgressBarProps) {
|
||||
const formattedPercentage = isNaN(percentage) ? 0 : percentage;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-full bg-neutral-200 transition ease-in-out">
|
||||
<motion.div
|
||||
transition={{duration: 0.5}}
|
||||
animate={{width: ['0%', `${formattedPercentage}%`]}}
|
||||
className="h-2 rounded-full bg-neutral-700"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as ProgressBar} from './ProgressBar';
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
export interface RedirectProps {
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.to
|
||||
*/
|
||||
export default function Redirect({to}: RedirectProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
void router.push(to);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Redirect} from './Redirect';
|
||||
@@ -0,0 +1,37 @@
|
||||
import Tippy from '@tippyjs/react';
|
||||
import React, {ReactNode} from 'react';
|
||||
|
||||
export interface TooltipProps {
|
||||
content: ReactNode | string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.content
|
||||
* @param root0.icon
|
||||
*/
|
||||
export default function Tooltip({content, icon}: TooltipProps) {
|
||||
return (
|
||||
<>
|
||||
<Tippy
|
||||
maxWidth={450}
|
||||
className={'rounded-md border border-neutral-200 bg-white px-6 py-6 text-sm text-neutral-800 shadow-md'}
|
||||
content={<div>{content}</div>}
|
||||
>
|
||||
<svg
|
||||
className={'ml-1 h-4 w-4 cursor-pointer'}
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{icon}
|
||||
</svg>
|
||||
</Tippy>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Tooltip} from './Tooltip';
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './Redirect';
|
||||
export * from './FullscreenLoader';
|
||||
export * from './Empty';
|
||||
export * from './ProgressBar';
|
||||
export * from './Tooltip';
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './Input';
|
||||
export * from './Utility';
|
||||
export * from './Alert';
|
||||
export * from './Badge';
|
||||
export * from './Table';
|
||||
export * from './Overlay';
|
||||
export * from './Navigation';
|
||||
export * from './Card';
|
||||
export * from './Skeleton';
|
||||
export * from './CodeBlock';
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, {useState} from 'react';
|
||||
import {FullscreenLoader, Redirect, Sidebar} from '../components';
|
||||
import {useActiveProject, useProjects} from '../lib/hooks/projects';
|
||||
import {useUser} from '../lib/hooks/users';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
export const Dashboard = (props: {children: React.ReactNode}) => {
|
||||
const router = useRouter();
|
||||
const activeProject = useActiveProject();
|
||||
const {data: projects} = useProjects();
|
||||
const {data: user} = useUser();
|
||||
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
if (!projects || !user || !activeProject) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return <Redirect to={'/new'} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-50">
|
||||
<Sidebar
|
||||
mobileOpen={mobileSidebarOpen}
|
||||
onSidebarVisibilityChange={() => setMobileSidebarOpen(!mobileSidebarOpen)}
|
||||
/>
|
||||
<div className="flex w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="pl-1 pt-1 sm:pl-3 sm:pt-3 md:hidden">
|
||||
<button
|
||||
className="focus:ring-azure-500 -ml-0.5 -mt-0.5 inline-flex h-12 w-12 items-center justify-center rounded text-neutral-500 hover:text-neutral-800 focus:outline-none focus:ring-2 focus:ring-inset"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<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 strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<main className="relative z-0 flex-1 overflow-y-scroll focus:outline-none">
|
||||
<div className="min-h-screen">
|
||||
<div className="relative mx-auto min-h-screen">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={router.pathname}
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2, ease: 'easeInOut'}}
|
||||
className="mx-auto h-full max-w-7xl space-y-6 px-4 py-5 sm:px-6 md:px-8"
|
||||
>
|
||||
{props.children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Dashboard';
|
||||
@@ -0,0 +1,5 @@
|
||||
import {atom} from 'jotai';
|
||||
|
||||
export const atomActiveProject = atom<string | null>(
|
||||
typeof window !== 'undefined' ? window.localStorage.getItem('project') : null,
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export const API_URI = process.env.NEXT_PUBLIC_API_URI ?? 'http://localhost:8080';
|
||||
export const AWS_REGION = process.env.NEXT_PUBLIC_AWS_REGION;
|
||||
|
||||
export const NO_AUTH_ROUTES = ['/auth/signup', '/auth/login', '/auth/reset', '/unsubscribe/[id]', '/subscribe/[id]'];
|
||||
@@ -0,0 +1,43 @@
|
||||
import useSWR from 'swr';
|
||||
import {Action, Email, Event, Task, Template, Trigger} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function useAction(id: string) {
|
||||
return useSWR(`/v1/actions/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function useRelatedActions(id: string) {
|
||||
return useSWR<
|
||||
(Action & {
|
||||
events: Event[];
|
||||
notevents: Event[];
|
||||
triggers: Trigger[];
|
||||
emails: Email[];
|
||||
template: Template;
|
||||
})[]
|
||||
>(`/v1/actions/${id}/related`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActions() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(Action & {
|
||||
triggers: Trigger[];
|
||||
template: Template;
|
||||
emails: Email[];
|
||||
tasks: Task[];
|
||||
})[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/actions` : null);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useActiveProject} from './projects';
|
||||
import useSWR from 'swr';
|
||||
|
||||
/**
|
||||
|
||||
* @param method
|
||||
*/
|
||||
export function useAnalytics(method?: 'week' | 'month' | 'year') {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<{
|
||||
contacts: {
|
||||
timeseries: {
|
||||
day: Date;
|
||||
count: number;
|
||||
}[];
|
||||
subscribed: number;
|
||||
unsubscribed: number;
|
||||
};
|
||||
emails: {
|
||||
total: number;
|
||||
bounced: number;
|
||||
opened: number;
|
||||
complaint: number;
|
||||
totalPrev: number;
|
||||
bouncedPrev: number;
|
||||
openedPrev: number;
|
||||
complaintPrev: number;
|
||||
};
|
||||
clicks: {
|
||||
actions: {link: string; name: string; count: number}[];
|
||||
};
|
||||
}>(activeProject ? `/projects/id/${activeProject.id}/analytics?method=${method ?? 'week'}` : null);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import useSWR from 'swr';
|
||||
import {Campaign} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function useCampaign(id: string) {
|
||||
return useSWR(`/v1/campaigns/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useCampaigns() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(Campaign & {
|
||||
emails: {
|
||||
id: string;
|
||||
status: string;
|
||||
}[];
|
||||
tasks: {
|
||||
id: string;
|
||||
}[];
|
||||
recipients: {
|
||||
id: string;
|
||||
}[];
|
||||
})[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/campaigns` : null);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import useSWR from 'swr';
|
||||
import {Action, Contact, Email, Event, Project, Trigger} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
export interface WithProject {
|
||||
id: string;
|
||||
withProject: true;
|
||||
}
|
||||
|
||||
export interface WithoutProject {
|
||||
id: string;
|
||||
withProject?: false;
|
||||
}
|
||||
|
||||
export type WithOrWithoutProject<T extends WithProject | WithoutProject> = T extends WithProject
|
||||
?
|
||||
| (Contact & {
|
||||
emails: Email[];
|
||||
triggers: (Trigger & {
|
||||
event: Event | null;
|
||||
action: Action | null;
|
||||
})[];
|
||||
project: Project;
|
||||
})
|
||||
| null
|
||||
:
|
||||
| (Contact & {
|
||||
emails: Email[];
|
||||
triggers: (Trigger & {
|
||||
event: Event | null;
|
||||
action: Action | null;
|
||||
})[];
|
||||
})
|
||||
| null;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id.id
|
||||
* @param id
|
||||
* @param id.withProject
|
||||
*/
|
||||
export function useContact<T extends WithProject | WithoutProject>({id, withProject = false}: T) {
|
||||
return useSWR<WithOrWithoutProject<T>>(withProject ? `/v1/contacts/${id}?withProject=true` : `/v1/contacts/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param page
|
||||
*/
|
||||
export function useContacts(page: number) {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<{
|
||||
contacts: (Contact & {
|
||||
triggers: Trigger[];
|
||||
})[];
|
||||
count: number;
|
||||
}>(activeProject ? `/projects/id/${activeProject.id}/contacts?page=${page}` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useContactsCount() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<number>(activeProject ? `/projects/id/${activeProject.id}/contacts/count` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useContactMetadata() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<string[]>(activeProject ? `/projects/id/${activeProject.id}/contacts/metadata` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param query
|
||||
*/
|
||||
export function searchContacts(query: string | undefined) {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
if (!query) {
|
||||
return useSWR<{
|
||||
contacts: (Contact & {
|
||||
triggers: Trigger[];
|
||||
emails: Email[];
|
||||
})[];
|
||||
count: number;
|
||||
}>(activeProject ? `/projects/id/${activeProject.id}/contacts` : null);
|
||||
}
|
||||
|
||||
return useSWR<{
|
||||
contacts: (Contact & {
|
||||
triggers: Trigger[];
|
||||
emails: Email[];
|
||||
})[];
|
||||
count: number;
|
||||
}>(activeProject ? `/projects/id/${activeProject.id}/contacts/search?query=${query}` : null, {
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useActiveProject} from './projects';
|
||||
import useSWR from 'swr';
|
||||
import {Email} from '@prisma/client';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useEmails() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<Email[]>(activeProject ? `/projects/id/${activeProject.id}/emails` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useEmailsCount() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<number>(activeProject ? `/projects/id/${activeProject.id}/emails/count` : null);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import useSWR from 'swr';
|
||||
import {Event} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useEvents() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(Event & {
|
||||
triggers: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
contactId: string;
|
||||
}[];
|
||||
})[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/events` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useEventsWithoutTriggers() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<Event[]>(activeProject ? `/projects/id/${activeProject.id}/events?triggers=false` : null);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {Action, Contact, Email, Event, Project, Role} from '@prisma/client';
|
||||
import {useAtom} from 'jotai';
|
||||
import useSWR from 'swr';
|
||||
import {atomActiveProject} from '../atoms/project';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useProjects() {
|
||||
return useSWR<Project[]>('/users/@me/projects');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActiveProject(): Project | null {
|
||||
const [activeProject, setActiveProject] = useAtom(atomActiveProject);
|
||||
const {data: projects} = useProjects();
|
||||
|
||||
if (!projects) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeProject && !projects.find(project => project.id === activeProject)) {
|
||||
setActiveProject(null);
|
||||
window.localStorage.removeItem('project');
|
||||
}
|
||||
|
||||
if (!activeProject && projects.length > 0) {
|
||||
setActiveProject(projects[0].id);
|
||||
window.localStorage.setItem('project', projects[0].id);
|
||||
}
|
||||
|
||||
return projects.find(project => project.id === activeProject) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActiveProjectMemberships() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
{
|
||||
userId: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/memberships` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActiveProjectFeed(page: number) {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(
|
||||
| {
|
||||
createdAt: Date;
|
||||
contact: Contact;
|
||||
event: Event | null;
|
||||
action: Action | null;
|
||||
}
|
||||
| ({
|
||||
contact: Contact;
|
||||
} & Email)
|
||||
)[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/feed?page=${page}` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActiveProjectVerifiedIdentity() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<{
|
||||
tokens: string[];
|
||||
}>(activeProject ? `/identities/id/${activeProject.id}` : null);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import useSWR from 'swr';
|
||||
import {Action, Template} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function useTemplate(id: string) {
|
||||
return useSWR(`/v1/templates/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useTemplates() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(Template & {
|
||||
actions: Action[];
|
||||
})[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/templates` : null);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
/**
|
||||
* Fetch the current user. undefined means loading, null means logged out
|
||||
*
|
||||
*/
|
||||
export function useUser() {
|
||||
return useSWR('/users/@me', {shouldRetryOnError: false});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {API_URI} from './constants';
|
||||
import {infer as ZodInfer, ZodSchema} from 'zod';
|
||||
|
||||
interface Json {
|
||||
[x: string]: string | number | boolean | Date | Json | JsonArray;
|
||||
}
|
||||
|
||||
type JsonArray = (string | number | boolean | Date | Json | JsonArray)[];
|
||||
|
||||
interface TypedSchema extends ZodSchema {
|
||||
_type: any;
|
||||
}
|
||||
|
||||
export class network {
|
||||
/**
|
||||
* Fetcher function that includes toast support
|
||||
* @param method Request method
|
||||
* @param path Request endpoint or path
|
||||
* @param body Request body
|
||||
*/
|
||||
public static async fetch<T, Schema extends TypedSchema | void = void>(
|
||||
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
|
||||
path: string,
|
||||
body?: Schema extends TypedSchema ? ZodInfer<Schema> : never,
|
||||
): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : API_URI + path;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body: body && JSON.stringify(body),
|
||||
headers: body && {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (response.status >= 400) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
throw new Error(res?.message ?? 'Something went wrong!');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public static async mock<T, Schema extends TypedSchema | void = void>(
|
||||
key: string,
|
||||
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
|
||||
path: string,
|
||||
body?: Schema extends TypedSchema ? ZodInfer<Schema> : never,
|
||||
): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : API_URI + path;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body: body && JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${key}`},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (response.status >= 400) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
throw new Error(res?.message ?? 'Something went wrong!');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user