Initial Commit

This commit is contained in:
Dries Augustyns
2024-07-23 13:49:48 +02:00
commit 91a8840c3c
191 changed files with 36022 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
NEXT_PUBLIC_API_URI=http://localhost:8080
NEXT_PUBLIC_AWS_REGION=eu-west-3
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
swcMinify: true,
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
});
config.module.rules.push({
test: [/src\/(components|layouts)\/index.ts/i],
sideEffects: false,
});
return config;
},
};
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@plunk/dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"clean": "rimraf .next node_modules .turbo"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@monaco-editor/react": "^4.6.0",
"@plunk/shared": "1.0.0",
"@svgr/webpack": "^8.1.0",
"@tippyjs/react": "^4.2.6",
"@tiptap/core": "^2.5.4",
"@tiptap/extension-color": "^2.5.4",
"@tiptap/extension-dropcursor": "^2.5.4",
"@tiptap/extension-font-family": "^2.5.4",
"@tiptap/extension-image": "^2.5.4",
"@tiptap/extension-link": "^2.5.4",
"@tiptap/extension-placeholder": "^2.5.4",
"@tiptap/extension-text-align": "^2.5.4",
"@tiptap/extension-text-style": "^2.5.4",
"@tiptap/extension-typography": "^2.5.4",
"@tiptap/pm": "^2.5.4",
"@tiptap/react": "^2.5.4",
"@tiptap/starter-kit": "^2.5.4",
"@tiptap/suggestion": "^2.5.4",
"@uiball/loaders": "^1.3.1",
"classnames": "^2.5.1",
"dayjs": "^1.11.12",
"framer-motion": "^11.3.7",
"jotai": "2.9.0",
"lucide-react": "^0.408.0",
"next": "14.2.5",
"next-seo": "^6.5.0",
"nprogress": "^0.2.0",
"prosemirror-commands": "^1.5.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-schema-list": "^1.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.52.1",
"react-syntax-highlighter": "^15.5.0",
"recharts": "^2.12.7",
"sharp": "^0.33.4",
"sonner": "^1.5.0",
"swr": "2.2.5",
"tailwind-scrollbar": "^3.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"@types/node": "20.14.11",
"@types/nprogress": "^0.2.3",
"@types/react": "18.3.3",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "5.5.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/favicon/mstile-150x150.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,73 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1080.000000pt" height="1080.000000pt" viewBox="0 0 1080.000000 1080.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1080.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M6218 10755 c-1 -1 -52 -6 -113 -9 -109 -6 -147 -10 -235 -21 -25 -3
-65 -8 -90 -11 -38 -5 -115 -17 -255 -40 -16 -3 -68 -14 -115 -24 -47 -10 -96
-21 -110 -23 -23 -5 -66 -16 -239 -64 -136 -37 -411 -134 -546 -193 -252 -109
-535 -257 -735 -384 -59 -38 -184 -121 -195 -129 -5 -4 -59 -44 -120 -88 -266
-197 -581 -493 -785 -739 -19 -23 -43 -52 -54 -64 -10 -11 -39 -48 -64 -81
-25 -33 -49 -62 -52 -65 -18 -14 -260 -357 -260 -367 0 -3 -10 -20 -23 -37
-69 -93 -387 -656 -392 -696 -1 -3 -11 -24 -23 -47 -108 -206 -293 -687 -382
-993 -12 -41 -26 -86 -30 -100 -5 -14 -19 -63 -30 -110 -12 -47 -28 -105 -35
-130 -8 -25 -16 -58 -19 -75 -3 -16 -14 -68 -26 -115 -11 -47 -25 -107 -30
-135 -6 -27 -12 -61 -15 -75 -3 -14 -7 -36 -10 -50 -2 -14 -9 -50 -15 -80 -6
-30 -13 -66 -16 -80 -2 -14 -7 -41 -10 -60 -7 -36 -9 -47 -18 -115 -3 -22 -8
-49 -10 -61 -2 -12 -7 -45 -11 -75 -3 -30 -8 -61 -10 -69 -2 -8 -6 -37 -10
-65 -3 -27 -7 -63 -9 -80 -2 -16 -7 -59 -10 -95 -4 -36 -9 -76 -11 -90 -2 -14
-7 -61 -11 -105 -4 -44 -8 -93 -10 -110 -3 -38 -14 -184 -19 -255 -27 -407
-27 -1218 0 -1455 2 -16 6 -64 10 -105 4 -41 9 -84 11 -95 2 -11 6 -46 9 -77
3 -32 10 -83 16 -115 5 -32 12 -76 15 -98 10 -68 73 -355 100 -450 237 -845
667 -1476 1235 -1810 46 -26 238 -121 299 -147 69 -30 263 -91 323 -103 15 -3
39 -7 55 -10 15 -3 32 -7 39 -10 6 -2 38 -7 70 -11 32 -3 62 -8 66 -11 15 -9
391 -12 452 -3 144 20 160 22 166 26 3 2 19 5 36 8 148 27 377 140 510 253
203 172 308 322 457 656 18 39 94 267 102 302 2 11 13 58 25 105 11 47 22 96
24 110 2 14 17 97 34 185 17 88 33 180 36 204 3 23 7 46 9 50 2 3 7 28 10 55
4 27 9 52 11 56 2 3 6 24 9 45 2 22 23 141 46 265 23 124 43 236 45 250 4 27
4 28 40 230 25 134 33 182 41 230 3 14 7 36 9 50 3 14 8 37 10 53 3 15 7 39
10 55 3 15 12 68 20 117 9 50 20 110 25 135 5 25 11 59 14 75 3 26 14 88 30
170 2 11 25 142 51 290 26 149 50 273 54 276 3 4 50 10 104 13 93 6 137 11
217 22 19 3 58 7 85 10 28 2 64 6 80 9 17 3 48 7 70 10 142 21 400 73 575 116
560 139 1036 346 1478 640 111 74 294 208 312 228 3 3 25 22 50 42 82 67 183
163 304 290 399 418 702 991 821 1549 11 55 36 196 44 255 40 283 33 767 -14
995 -2 14 -12 60 -20 102 -115 560 -455 1118 -898 1470 -328 261 -755 473
-1157 573 -81 20 -249 56 -305 66 -72 12 -78 12 -155 23 -25 4 -56 8 -70 11
-14 2 -61 7 -105 11 -44 3 -93 8 -110 11 -36 5 -672 13 -677 8z m142 -1239
c84 -6 217 -21 275 -31 11 -1 36 -6 55 -9 72 -12 87 -16 165 -35 367 -91 649
-262 871 -529 77 -92 74 -88 126 -172 84 -135 144 -280 187 -455 19 -77 24
-108 38 -225 14 -112 6 -513 -11 -608 -2 -9 -7 -42 -11 -72 -8 -63 -7 -54 -34
-185 -12 -55 -35 -145 -53 -200 -17 -55 -32 -102 -32 -105 -3 -17 -59 -142
-109 -240 -51 -100 -148 -252 -204 -321 -300 -366 -742 -625 -1309 -767 -142
-36 -267 -62 -359 -75 -16 -3 -46 -7 -65 -11 -106 -18 -102 -19 -96 22 14 88
18 112 32 192 9 47 17 97 19 112 4 27 21 122 30 168 3 14 8 43 11 65 2 22 18
119 35 215 16 96 32 189 35 205 5 33 15 89 20 120 2 11 15 88 29 170 14 83 34
200 45 260 11 61 22 126 24 145 3 19 9 60 15 90 11 59 17 92 27 155 3 22 12
74 20 115 8 41 17 91 19 110 3 19 9 64 14 100 10 69 7 336 -3 383 -54 239
-237 378 -531 404 -232 20 -416 -5 -580 -81 -223 -103 -343 -243 -414 -482
-16 -54 -85 -415 -135 -699 -15 -88 -58 -325 -76 -425 -12 -63 -23 -126 -25
-140 -2 -14 -13 -77 -25 -140 -11 -63 -23 -130 -26 -149 -3 -18 -7 -43 -10
-55 -4 -19 -56 -320 -68 -391 -3 -16 -12 -70 -21 -120 -9 -49 -18 -101 -20
-115 -2 -14 -22 -135 -44 -270 -22 -135 -52 -315 -65 -400 -14 -85 -28 -166
-30 -180 -16 -87 -69 -389 -71 -407 -2 -12 -6 -34 -9 -50 -3 -15 -8 -44 -11
-63 -4 -19 -10 -54 -16 -78 -5 -23 -12 -59 -15 -80 -5 -32 -37 -223 -50 -292
-2 -14 -7 -40 -10 -59 -6 -38 -11 -64 -19 -101 -3 -14 -7 -41 -9 -60 -3 -19
-19 -114 -36 -210 -17 -96 -33 -188 -36 -205 -3 -16 -7 -37 -9 -45 -2 -8 -6
-33 -10 -55 -3 -22 -12 -76 -20 -120 -8 -44 -17 -97 -21 -119 -3 -21 -8 -48
-10 -60 -2 -11 -29 -163 -59 -336 -68 -390 -62 -356 -90 -475 -147 -622 -384
-760 -719 -420 -129 131 -256 357 -354 632 -42 117 -104 320 -117 380 -2 10
-8 38 -14 63 -5 25 -12 59 -15 75 -3 17 -8 40 -10 52 -5 24 -34 226 -41 283
-2 19 -6 53 -9 75 -5 40 -10 107 -22 280 -18 274 -2 931 32 1290 13 146 34
338 39 375 2 14 9 61 15 105 6 44 13 95 16 114 2 19 6 43 9 55 2 12 7 38 10
59 5 35 50 280 59 322 2 11 12 56 21 100 69 335 236 874 359 1160 116 268 243
523 340 684 31 51 56 95 56 97 0 8 169 256 228 334 207 274 434 510 672 700
221 176 482 331 735 435 154 64 412 142 560 170 147 27 261 41 405 51 46 3 85
6 87 7 4 5 335 -1 423 -7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
@@ -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>
);
};
@@ -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>
);
};
@@ -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,
}),
];
},
});
@@ -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>
);
});
@@ -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">
&#8203;
</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>
</>
);
};
+1
View File
@@ -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,
);
+4
View File
@@ -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});
}
+67
View File
@@ -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;
}
}
+105
View File
@@ -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>
</>
);
}
+263
View File
@@ -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>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More