Initial push of Plunk Next
@@ -0,0 +1,5 @@
|
||||
# URLS (Frontend - Next.js Public Variables)
|
||||
NEXT_PUBLIC_API_URI=http://localhost:8080
|
||||
NEXT_PUBLIC_DASHBOARD_URI=http://localhost:3000
|
||||
NEXT_PUBLIC_LANDING_URI=http://localhost:4000
|
||||
NEXT_PUBLIC_WIKI_URI=http://localhost:1000
|
||||
@@ -0,0 +1,3 @@
|
||||
import config from '@plunk/eslint-config/next';
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.swyp.be',
|
||||
generateRobotsTxt: true,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
transpilePackages: ['@plunk/ui'],
|
||||
output: 'standalone', // Optimized for Docker
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"sitemap": "next-sitemap",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"clean": "rimraf node_modules .next .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plunk/db": "*",
|
||||
"@plunk/shared": "*",
|
||||
"@plunk/ui": "*",
|
||||
"@tiptap/core": "^3.11.0",
|
||||
"@tiptap/extension-color": "^3.11.0",
|
||||
"@tiptap/extension-image": "^3.11.0",
|
||||
"@tiptap/extension-link": "^3.11.0",
|
||||
"@tiptap/extension-mention": "^3.11.0",
|
||||
"@tiptap/extension-table": "^3.11.0",
|
||||
"@tiptap/extension-table-cell": "^3.11.0",
|
||||
"@tiptap/extension-table-header": "^3.11.0",
|
||||
"@tiptap/extension-table-row": "^3.11.0",
|
||||
"@tiptap/extension-text-align": "^3.11.0",
|
||||
"@tiptap/extension-text-style": "^3.11.0",
|
||||
"@tiptap/pm": "^3.11.0",
|
||||
"@tiptap/react": "^3.11.0",
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"@tiptap/suggestion": "^3.11.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@xyflow/react": "^12.9.3",
|
||||
"dagre": "^0.8.5",
|
||||
"framer-motion": "^12.23.24",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "16.0.1",
|
||||
"next-seo": "^6.6.0",
|
||||
"nuqs": "^2.7.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-intl": "^7.1.14",
|
||||
"recharts": "^3.4.1",
|
||||
"sonner": "^2.0.6",
|
||||
"swr": "^2.2.5",
|
||||
"tippy.js": "^6.3.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plunk/eslint-config": "*",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "24.10.0",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.1",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// Runtime environment configuration placeholder
|
||||
// This file is overridden at Docker container startup with actual runtime values
|
||||
// In development, the app falls back to NEXT_PUBLIC_* environment variables
|
||||
window.__ENV__ = {};
|
||||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1080" height="1080" fill="white"/>
|
||||
<path d="M976 284.628C976 348.237 959.54 406.608 926.62 459.74C893.701 512.873 845.817 556.276 782.97 589.952C720.124 623.627 645.306 643.458 558.517 649.445L510.26 919.971C491.556 1023.99 440.68 1076 357.632 1076C311.993 1076 269.721 1062.53 230.816 1035.59C192.659 1008.65 161.984 967.49 138.79 912.113C115.597 856.736 104 788.637 104 707.816C104 555.902 128.316 427.187 176.947 321.671C226.327 215.407 292.166 136.082 374.466 83.6984C457.514 30.5661 548.791 4 648.299 4C718.627 4 778.107 16.3476 826.739 41.0429C876.118 65.7382 913.153 99.4136 937.843 142.069C963.281 183.976 976 231.496 976 284.628ZM578.718 533.826C732.094 514.369 808.783 434.671 808.783 294.731C808.783 245.34 792.323 205.304 759.403 174.622C727.231 143.192 677.103 127.476 609.019 127.476C531.957 127.476 464.621 151.798 407.012 200.44C350.15 249.082 306.008 316.807 274.584 403.615C243.909 489.674 228.571 588.081 228.571 698.836C228.571 745.233 233.06 786.392 242.039 822.312C251.765 858.232 263.736 886.295 277.951 906.501C292.915 925.957 307.13 935.686 320.597 935.686C339.302 935.686 353.517 909.868 363.243 858.232L400.278 646.078C371.099 641.587 358.38 639.717 362.121 640.465C339.676 636.723 325.086 629.988 318.353 620.26C311.619 609.783 308.252 596.687 308.252 580.972C308.252 564.508 312.741 551.412 321.719 541.684C331.446 531.955 344.539 527.091 360.999 527.091C368.481 527.091 374.092 527.465 377.833 528.214C395.789 531.207 409.63 533.078 419.357 533.826C429.083 475.455 442.924 397.254 460.88 299.221C465.369 273.777 475.47 255.817 491.182 245.34C507.641 234.115 526.72 228.503 548.417 228.503C573.107 228.503 590.689 233.367 601.163 243.095C612.386 252.075 617.997 266.668 617.997 286.873C617.997 298.847 617.249 308.575 615.753 316.059L578.718 533.826Z"
|
||||
fill="black"/>
|
||||
<path d="M304.835 467.541L426.099 489.952L411.851 580.937L391.091 699.816L266.088 676.643L304.835 467.541Z"
|
||||
fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 658 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
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": "Plunk",
|
||||
"short_name": "Plunk",
|
||||
"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,258 @@
|
||||
import {Button} from '@plunk/ui';
|
||||
import {network} from '../lib/network';
|
||||
import {ActivityItem} from './ActivityItem';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
export enum ActivityType {
|
||||
EVENT_TRIGGERED = 'event.triggered',
|
||||
EMAIL_SENT = 'email.sent',
|
||||
EMAIL_DELIVERED = 'email.delivered',
|
||||
EMAIL_OPENED = 'email.opened',
|
||||
EMAIL_CLICKED = 'email.clicked',
|
||||
EMAIL_BOUNCED = 'email.bounced',
|
||||
CAMPAIGN_SENT = 'campaign.sent',
|
||||
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
|
||||
WORKFLOW_STARTED = 'workflow.started',
|
||||
WORKFLOW_COMPLETED = 'workflow.completed',
|
||||
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
timestamp: string;
|
||||
contactEmail?: string;
|
||||
contactId?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PaginatedActivities {
|
||||
activities: Activity[];
|
||||
nextCursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
typeFilter?: string;
|
||||
dateRangeDays?: number;
|
||||
contactId?: string;
|
||||
}
|
||||
|
||||
export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: ActivityFeedProps) {
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [upcomingActivities, setUpcomingActivities] = useState<Activity[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>();
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Memoize start date to prevent recreation on every render
|
||||
const startDate = useMemo(() => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - dateRangeDays);
|
||||
return date.toISOString();
|
||||
}, [dateRangeDays]);
|
||||
|
||||
// Fetch activities
|
||||
const fetchActivities = useCallback(
|
||||
async (cursor?: string) => {
|
||||
try {
|
||||
if (cursor) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
setActivities([]);
|
||||
setNextCursor(undefined);
|
||||
setHasMore(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: '20', // Conservative limit to avoid overloading
|
||||
startDate: startDate,
|
||||
});
|
||||
|
||||
if (cursor) {
|
||||
params.set('cursor', cursor);
|
||||
}
|
||||
if (typeFilter) {
|
||||
params.set('types', typeFilter);
|
||||
}
|
||||
if (contactId) {
|
||||
params.set('contactId', contactId);
|
||||
}
|
||||
|
||||
const result = await network.fetch<PaginatedActivities>('GET', `/activity?${params.toString()}`);
|
||||
|
||||
if (cursor) {
|
||||
// Append to existing activities
|
||||
setActivities(prev => [...prev, ...result.activities]);
|
||||
} else {
|
||||
// Replace activities
|
||||
setActivities(result.activities);
|
||||
}
|
||||
|
||||
setNextCursor(result.nextCursor);
|
||||
setHasMore(result.hasMore);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load activities');
|
||||
console.error('Error fetching activities:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[typeFilter, startDate, contactId],
|
||||
);
|
||||
|
||||
// Fetch upcoming activities
|
||||
const fetchUpcomingActivities = useCallback(async () => {
|
||||
try {
|
||||
// Don't fetch upcoming if we're filtering by contact
|
||||
// (upcoming items aren't contact-specific)
|
||||
if (contactId) {
|
||||
setUpcomingActivities([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: '20',
|
||||
daysAhead: dateRangeDays.toString(),
|
||||
});
|
||||
|
||||
const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`);
|
||||
|
||||
setUpcomingActivities(result.activities);
|
||||
} catch (err) {
|
||||
console.error('Error fetching upcoming activities:', err);
|
||||
// Don't set error state for upcoming - just fail silently
|
||||
setUpcomingActivities([]);
|
||||
}
|
||||
}, [dateRangeDays, contactId]);
|
||||
|
||||
// Initial fetch - only run once when filters change
|
||||
useEffect(() => {
|
||||
void fetchActivities();
|
||||
void fetchUpcomingActivities();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [typeFilter, startDate, contactId]);
|
||||
|
||||
// Auto-refresh every 30 seconds for real-time updates
|
||||
useEffect(() => {
|
||||
// Don't set up auto-refresh if still loading initial data
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// Only refresh if we're on the first page and not already loading
|
||||
if (!isLoading && !isLoadingMore && activities.length > 0) {
|
||||
void fetchActivities();
|
||||
void fetchUpcomingActivities();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, isLoadingMore, activities.length]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (nextCursor && hasMore && !isLoadingMore) {
|
||||
void fetchActivities(nextCursor);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
<Button onClick={() => fetchActivities()} variant="outline" className="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activities.length === 0 && upcomingActivities.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-neutral-500 text-sm">
|
||||
No activity found for the selected filters. Activities will appear here as they happen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Past Activity Timeline */}
|
||||
{activities.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity, index) => (
|
||||
<div key={`${activity.id}-${index}`}>
|
||||
<ActivityItem activity={activity} />
|
||||
{index < activities.length - 1 && <div className="border-t border-neutral-100 my-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button onClick={loadMore} variant="outline" disabled={isLoadingMore}>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator between past and upcoming */}
|
||||
{activities.length > 0 && upcomingActivities.length > 0 && (
|
||||
<div className="relative py-6">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-neutral-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-4 text-sm font-medium text-neutral-500">Upcoming Scheduled</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming Activities */}
|
||||
{upcomingActivities.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{upcomingActivities.map((activity, index) => (
|
||||
<div key={`${activity.id}-${index}`}>
|
||||
<ActivityItem activity={activity} isUpcoming={true} />
|
||||
{index < upcomingActivities.length - 1 && <div className="border-t border-neutral-100 my-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of results indicator */}
|
||||
{!hasMore && activities.length > 0 && upcomingActivities.length === 0 && (
|
||||
<div className="text-center pt-4">
|
||||
<p className="text-sm text-neutral-400">You've reached the end of the activity feed</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import {Badge, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui';
|
||||
import type {Activity} from './ActivityFeed';
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CheckCheck,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
MousePointerClick,
|
||||
Send,
|
||||
Workflow,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
* Simple relative time formatter for past events
|
||||
*/
|
||||
function getRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes} ${diffInMinutes === 1 ? 'minute' : 'minutes'} ago`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours} ${diffInHours === 1 ? 'hour' : 'hours'} ago`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 30) {
|
||||
return `${diffInDays} ${diffInDays === 1 ? 'day' : 'days'} ago`;
|
||||
}
|
||||
|
||||
const diffInMonths = Math.floor(diffInDays / 30);
|
||||
if (diffInMonths < 12) {
|
||||
return `${diffInMonths} ${diffInMonths === 1 ? 'month' : 'months'} ago`;
|
||||
}
|
||||
|
||||
const diffInYears = Math.floor(diffInMonths / 12);
|
||||
return `${diffInYears} ${diffInYears === 1 ? 'year' : 'years'} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format upcoming time (for future events)
|
||||
*/
|
||||
function getUpcomingTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'in a moment';
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return `in ${diffInMinutes} ${diffInMinutes === 1 ? 'minute' : 'minutes'}`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `in ${diffInHours} ${diffInHours === 1 ? 'hour' : 'hours'}`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays === 1) {
|
||||
return `tomorrow at ${date.toLocaleTimeString('en-US', {hour: 'numeric', minute: '2-digit', hour12: true})}`;
|
||||
}
|
||||
|
||||
if (diffInDays < 7) {
|
||||
return `in ${diffInDays} days`;
|
||||
}
|
||||
|
||||
if (diffInDays < 30) {
|
||||
const weeks = Math.floor(diffInDays / 7);
|
||||
return `in ${weeks} ${weeks === 1 ? 'week' : 'weeks'}`;
|
||||
}
|
||||
|
||||
const diffInMonths = Math.floor(diffInDays / 30);
|
||||
return `in ${diffInMonths} ${diffInMonths === 1 ? 'month' : 'months'}`;
|
||||
}
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity;
|
||||
isUpcoming?: boolean;
|
||||
}
|
||||
|
||||
interface ActivityConfig {
|
||||
icon: React.ComponentType<{className?: string}>;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
badge?: {
|
||||
label: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
};
|
||||
jsonData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
const {type, metadata} = activity;
|
||||
|
||||
switch (type) {
|
||||
case 'event.triggered':
|
||||
return {
|
||||
icon: Zap,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100',
|
||||
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
|
||||
description: undefined,
|
||||
badge: {
|
||||
label: 'Event',
|
||||
variant: 'default',
|
||||
},
|
||||
jsonData:
|
||||
metadata.eventData && typeof metadata.eventData === 'object' && !Array.isArray(metadata.eventData)
|
||||
? (metadata.eventData as Record<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
case 'email.sent':
|
||||
return {
|
||||
icon: Send,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
: metadata.workflowName
|
||||
? `Workflow: ${String(metadata.workflowName)}`
|
||||
: typeof metadata.sourceType === 'string'
|
||||
? metadata.sourceType
|
||||
: undefined,
|
||||
badge: {
|
||||
label: 'Sent',
|
||||
variant: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
case 'email.delivered':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
: metadata.workflowName
|
||||
? `Workflow: ${String(metadata.workflowName)}`
|
||||
: undefined,
|
||||
badge: {
|
||||
label: 'Delivered',
|
||||
variant: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
case 'email.opened':
|
||||
return {
|
||||
icon: Eye,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
|
||||
description:
|
||||
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
|
||||
? `Opened ${metadata.totalOpens} times`
|
||||
: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
: metadata.workflowName
|
||||
? `Workflow: ${String(metadata.workflowName)}`
|
||||
: undefined,
|
||||
badge: {
|
||||
label: 'Opened',
|
||||
variant: 'secondary',
|
||||
},
|
||||
};
|
||||
|
||||
case 'email.clicked':
|
||||
return {
|
||||
icon: MousePointerClick,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
|
||||
description:
|
||||
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
|
||||
? `Clicked ${metadata.totalClicks} times`
|
||||
: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
: metadata.workflowName
|
||||
? `Workflow: ${String(metadata.workflowName)}`
|
||||
: undefined,
|
||||
badge: {
|
||||
label: 'Clicked',
|
||||
variant: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
case 'email.bounced':
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email bounced',
|
||||
description: (typeof metadata.error === 'string' ? metadata.error : undefined) || 'Email failed to deliver',
|
||||
badge: {
|
||||
label: 'Bounced',
|
||||
variant: 'destructive',
|
||||
},
|
||||
};
|
||||
|
||||
case 'workflow.started':
|
||||
return {
|
||||
icon: Workflow,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-100',
|
||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
|
||||
description: `Status: ${String(metadata.status || 'unknown')}`,
|
||||
badge: {
|
||||
label: 'Workflow',
|
||||
variant: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
case 'workflow.completed':
|
||||
return {
|
||||
icon: CheckCheck,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
|
||||
description: metadata.exitReason
|
||||
? `Exit: ${String(metadata.exitReason)}`
|
||||
: `Status: ${String(metadata.status || 'unknown')}`,
|
||||
badge: {
|
||||
label: 'Completed',
|
||||
variant: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
case 'campaign.scheduled':
|
||||
return {
|
||||
icon: Calendar,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
|
||||
description: metadata.subject
|
||||
? `${String(metadata.subject)}${metadata.totalRecipients ? ` • ${metadata.totalRecipients} recipients` : ''}`
|
||||
: metadata.totalRecipients
|
||||
? `${metadata.totalRecipients} recipients`
|
||||
: undefined,
|
||||
badge: {
|
||||
label: 'Scheduled',
|
||||
variant: 'outline',
|
||||
},
|
||||
};
|
||||
|
||||
case 'workflow.email.scheduled':
|
||||
return {
|
||||
icon: Calendar,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
|
||||
description: metadata.workflowName
|
||||
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? ` • ${String(metadata.subject)}` : ''}`
|
||||
: typeof metadata.subject === 'string'
|
||||
? metadata.subject
|
||||
: undefined,
|
||||
badge: {
|
||||
label: 'Scheduled',
|
||||
variant: 'outline',
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: 'Unknown activity',
|
||||
badge: {
|
||||
label: 'Unknown',
|
||||
variant: 'outline',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
|
||||
const config = getActivityConfig(activity);
|
||||
const Icon = config.icon;
|
||||
const timestamp = new Date(activity.timestamp);
|
||||
const relativeTime = isUpcoming ? getUpcomingTime(timestamp) : getRelativeTime(timestamp);
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-4 ${isUpcoming ? 'opacity-80' : ''}`}>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`h-10 w-10 rounded-lg ${config.bgColor} flex items-center justify-center flex-shrink-0 ${isUpcoming ? 'ring-2 ring-offset-2 ring-blue-200' : ''}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className={`text-sm font-medium truncate ${isUpcoming ? 'text-neutral-700' : 'text-neutral-900'}`}>
|
||||
{config.title}
|
||||
</p>
|
||||
{config.badge && <Badge variant={config.badge.variant}>{config.badge.label}</Badge>}
|
||||
</div>
|
||||
{config.description && <p className="text-sm text-neutral-500 line-clamp-2">{config.description}</p>}
|
||||
{activity.contactEmail && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{activity.contactId ? (
|
||||
<Link
|
||||
href={`/contacts/${activity.contactId}`}
|
||||
className="text-xs text-neutral-600 hover:text-neutral-900 hover:underline"
|
||||
>
|
||||
{activity.contactEmail}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-neutral-600">{activity.contactEmail}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Collapsible JSON Data */}
|
||||
{config.jsonData && (
|
||||
<Collapsible className="mt-2">
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-neutral-600 hover:text-neutral-900 transition-colors group">
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium">Event Data</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 p-3 bg-neutral-50 rounded-md border border-neutral-200 text-xs overflow-x-auto">
|
||||
<code className="text-neutral-700">{JSON.stringify(config.jsonData, null, 2)}</code>
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs flex-shrink-0 whitespace-nowrap ${isUpcoming ? 'text-blue-600 font-medium' : 'text-neutral-400'}`}
|
||||
title={timestamp.toLocaleString()}
|
||||
>
|
||||
{relativeTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {useState} from 'react';
|
||||
import {Button} from '@plunk/ui';
|
||||
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
|
||||
|
||||
interface ApiKeyDisplayProps {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
onRegenerate?: () => Promise<void>;
|
||||
showRegenerate?: boolean;
|
||||
}
|
||||
|
||||
export function ApiKeyDisplay({
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
isSecret = false,
|
||||
onRegenerate,
|
||||
showRegenerate = false,
|
||||
}: ApiKeyDisplayProps) {
|
||||
const [showKey, setShowKey] = useState(!isSecret);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!onRegenerate) return;
|
||||
|
||||
try {
|
||||
setIsRegenerating(true);
|
||||
await onRegenerate();
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate:', error);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = showKey ? value : '•'.repeat(48);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-neutral-700 block mb-2">{label}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 truncate">
|
||||
{displayValue}
|
||||
</code>
|
||||
<div className="flex items-center gap-1">
|
||||
{isSecret && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
title={showKey ? 'Hide key' : 'Show key'}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
{showRegenerate && onRegenerate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRegenerate}
|
||||
disabled={isRegenerating}
|
||||
title="Regenerate key"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRegenerating ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{description && <p className="text-xs text-neutral-500 mt-1">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert} from '@plunk/ui';
|
||||
import {AlertCircle, TrendingUp} from 'lucide-react';
|
||||
import {useBillingConsumption} from '../lib/hooks/useBillingConsumption';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
|
||||
interface BillingConsumptionProps {
|
||||
projectId: string;
|
||||
hasSubscription: boolean;
|
||||
}
|
||||
|
||||
export function BillingConsumption({projectId, hasSubscription}: BillingConsumptionProps) {
|
||||
const {data: config} = useConfig();
|
||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||
|
||||
// Always call the hook to satisfy Rules of Hooks
|
||||
const {consumptionData, isLoading, error} = useBillingConsumption(projectId, hasSubscription && billingEnabled);
|
||||
|
||||
if (!billingEnabled) {
|
||||
// If billing is globally disabled, hide the card entirely
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasSubscription) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage This Month</CardTitle>
|
||||
<CardDescription>View your current month email consumption and costs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">
|
||||
Usage tracking is only available with an active subscription. Start a subscription to track your email
|
||||
consumption.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage This Month</CardTitle>
|
||||
<CardDescription>View your current month email consumption and costs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage This Month</CardTitle>
|
||||
<CardDescription>View your current month email consumption and costs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">Failed to load consumption data. Please try again later.</p>
|
||||
</div>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!consumptionData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage This Month</CardTitle>
|
||||
<CardDescription>
|
||||
Billing period: {formatDate(consumptionData.period.start)} - {formatDate(consumptionData.period.end)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Total Usage */}
|
||||
<div className="border border-neutral-200 rounded-lg p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">Total Emails Sent</p>
|
||||
<p className="text-3xl font-bold text-neutral-900">{consumptionData.usage.total.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Invoice */}
|
||||
{consumptionData.upcomingInvoice && (
|
||||
<div className="border border-neutral-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-neutral-900 mb-4">Upcoming Invoice</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-neutral-600">Billing Period</span>
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{formatDate(consumptionData.upcomingInvoice.periodStart)} -{' '}
|
||||
{formatDate(consumptionData.upcomingInvoice.periodEnd)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-neutral-600">Subtotal</span>
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{formatCurrency(consumptionData.upcomingInvoice.subtotal, consumptionData.upcomingInvoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-neutral-200 pt-3 mt-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-base font-semibold text-neutral-900">Amount Due</span>
|
||||
<span className="text-base font-bold text-neutral-900">
|
||||
{formatCurrency(
|
||||
consumptionData.upcomingInvoice.amountDue,
|
||||
consumptionData.upcomingInvoice.currency,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No upcoming invoice message */}
|
||||
{!consumptionData.upcomingInvoice && consumptionData.usage.total === 0 && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">No usage recorded yet for this billing period.</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert, Badge, Button} from '@plunk/ui';
|
||||
import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react';
|
||||
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
|
||||
|
||||
interface BillingInvoicesProps {
|
||||
projectId: string;
|
||||
hasSubscription: boolean;
|
||||
onManageBilling?: () => void;
|
||||
}
|
||||
|
||||
export function BillingInvoices({projectId, hasSubscription, onManageBilling}: BillingInvoicesProps) {
|
||||
const {invoicesData, isLoading, error} = useBillingInvoices(projectId, hasSubscription);
|
||||
|
||||
if (!hasSubscription) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Past Invoices</CardTitle>
|
||||
<CardDescription>View and download your billing history</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">
|
||||
Invoice history is only available with an active subscription. Start a subscription to view your
|
||||
invoices.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Past Invoices</CardTitle>
|
||||
<CardDescription>View and download your billing history</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Past Invoices</CardTitle>
|
||||
<CardDescription>View and download your billing history</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">Failed to load invoices. Please try again later.</p>
|
||||
</div>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoicesData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string, paid: boolean) => {
|
||||
// Stripe invoices have status 'paid' when paid, or paid boolean is true
|
||||
if (paid || status === 'paid') {
|
||||
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Paid</Badge>;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return <Badge className="bg-orange-100 text-orange-800 hover:bg-orange-100">Unpaid</Badge>;
|
||||
case 'draft':
|
||||
return <Badge className="bg-neutral-100 text-neutral-800 hover:bg-neutral-100">Draft</Badge>;
|
||||
case 'uncollectible':
|
||||
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">Uncollectible</Badge>;
|
||||
case 'void':
|
||||
return <Badge className="bg-neutral-100 text-neutral-800 hover:bg-neutral-100">Void</Badge>;
|
||||
default:
|
||||
return <Badge className="bg-neutral-100 text-neutral-800 hover:bg-neutral-100">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const isPaid = (invoice: {status: string; paid: boolean; amountDue: number}) => {
|
||||
// Check multiple conditions: paid flag, status, or zero amount due
|
||||
return (
|
||||
invoice.paid === true || invoice.status === 'paid' || (invoice.amountDue === 0 && invoice.status !== 'draft')
|
||||
);
|
||||
};
|
||||
|
||||
// Show only the 5 most recent invoices
|
||||
const recentInvoices = invoicesData.invoices.slice(0, 5);
|
||||
const hasMoreInvoices = invoicesData.invoices.length > 5;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Invoices</CardTitle>
|
||||
<CardDescription>View your latest invoices (showing {recentInvoices.length} most recent)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{invoicesData.invoices.length === 0 ? (
|
||||
<Alert>
|
||||
<FileText className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">No invoices found. Invoices will appear here after your first billing period.</p>
|
||||
</div>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{recentInvoices.map(invoice => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="border border-neutral-200 rounded-lg p-4 hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-medium text-neutral-900">
|
||||
{invoice.number || `Invoice ${invoice.id.slice(-8)}`}
|
||||
</h3>
|
||||
{getStatusBadge(invoice.status, invoice.paid)}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-neutral-600">
|
||||
<p>
|
||||
<span className="font-medium">Date:</span> {formatDate(invoice.created)}
|
||||
</p>
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<p>
|
||||
<span className="font-medium">Period:</span> {formatDate(invoice.periodStart)} -{' '}
|
||||
{formatDate(invoice.periodEnd)}
|
||||
</p>
|
||||
)}
|
||||
{isPaid(invoice) ? (
|
||||
<p>
|
||||
<span className="font-medium">Amount Paid:</span>{' '}
|
||||
{formatCurrency(invoice.amountPaid, invoice.currency)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-medium">Total:</span>{' '}
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</p>
|
||||
<p className="text-orange-600 font-medium">
|
||||
<span className="font-medium">Amount Due:</span>{' '}
|
||||
{formatCurrency(invoice.amountDue, invoice.currency)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 ml-4">
|
||||
{invoice.hostedInvoiceUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(invoice.hostedInvoiceUrl!, '_blank')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{invoice.invoicePdf && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(invoice.invoicePdf!, '_blank')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
PDF
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Message for viewing full history */}
|
||||
{hasMoreInvoices && (
|
||||
<Alert className="mt-4">
|
||||
<FileText className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">
|
||||
Showing 5 most recent invoices. For complete billing history,{' '}
|
||||
{onManageBilling ? (
|
||||
<>
|
||||
visit the{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageBilling}
|
||||
className="underline font-medium cursor-pointer hover:text-neutral-900"
|
||||
>
|
||||
Customer Portal
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
'use the Manage Billing button above to access the Customer Portal.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {BillingLimitSchemas} from '@plunk/shared';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Progress,
|
||||
} from '@plunk/ui';
|
||||
import {AlertCircle, AlertTriangle, Check} from 'lucide-react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import type {z} from 'zod';
|
||||
import {useBillingLimits, type BillingLimitsData, type CategoryLimit} from '../lib/hooks/useBillingLimits';
|
||||
import {network} from '../lib/network';
|
||||
|
||||
interface BillingLimitsProps {
|
||||
projectId: string;
|
||||
hasSubscription: boolean;
|
||||
}
|
||||
|
||||
type LimitsFormValues = z.infer<typeof BillingLimitSchemas.update>;
|
||||
|
||||
export function BillingLimits({projectId, hasSubscription}: BillingLimitsProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Fetch billing limits using SWR
|
||||
const {limitsData, isLoading, mutate} = useBillingLimits(projectId, hasSubscription);
|
||||
|
||||
const form = useForm<LimitsFormValues>({
|
||||
resolver: zodResolver(BillingLimitSchemas.update),
|
||||
defaultValues: {
|
||||
workflows: null,
|
||||
campaigns: null,
|
||||
transactional: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when limits data changes
|
||||
useEffect(() => {
|
||||
if (limitsData) {
|
||||
form.reset({
|
||||
workflows: limitsData.workflows.limit,
|
||||
campaigns: limitsData.campaigns.limit,
|
||||
transactional: limitsData.transactional.limit,
|
||||
});
|
||||
}
|
||||
}, [limitsData, form]);
|
||||
|
||||
const onSubmit = async (values: LimitsFormValues) => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
await network.fetch<BillingLimitsData, typeof BillingLimitSchemas.update>(
|
||||
'PUT',
|
||||
`/users/@me/projects/${projectId}/billing-limits`,
|
||||
values,
|
||||
);
|
||||
|
||||
// Revalidate SWR cache
|
||||
await mutate();
|
||||
setIsEditing(false);
|
||||
setSuccessMessage('Billing limits updated successfully');
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to update billing limits');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (limitsData) {
|
||||
form.reset({
|
||||
workflows: limitsData.workflows.limit,
|
||||
campaigns: limitsData.campaigns.limit,
|
||||
transactional: limitsData.transactional.limit,
|
||||
});
|
||||
}
|
||||
setIsEditing(false);
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
if (!hasSubscription) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing Limits</CardTitle>
|
||||
<CardDescription>Set monthly limits for each email category</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">
|
||||
Billing limits are only available with an active subscription. Start a subscription to set limits for
|
||||
your email usage.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing Limits</CardTitle>
|
||||
<CardDescription>Set monthly limits for each email category</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing Limits</CardTitle>
|
||||
<CardDescription>
|
||||
Set monthly limits for each email category. Limits reset on the 1st of each month.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence mode="wait">
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Usage Display (when not editing) */}
|
||||
{!isEditing && limitsData && (
|
||||
<div className="space-y-4">
|
||||
<UsageDisplay category="Workflows" usage={limitsData.workflows} />
|
||||
<UsageDisplay category="Campaigns" usage={limitsData.campaigns} />
|
||||
<UsageDisplay category="Transactional" usage={limitsData.transactional} />
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={() => setIsEditing(true)}>Edit Limits</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Form */}
|
||||
{isEditing && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="workflows"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Workflow Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Maximum workflow emails per month. Leave empty for unlimited.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="campaigns"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Campaign Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Maximum campaign emails per month. Leave empty for unlimited.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="transactional"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Transactional Emails Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum transactional emails per month. Leave empty for unlimited.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsageDisplayProps {
|
||||
category: string;
|
||||
usage: CategoryLimit;
|
||||
}
|
||||
|
||||
function UsageDisplay({category, usage}: UsageDisplayProps) {
|
||||
const getStatusColor = () => {
|
||||
if (usage.isBlocked) return 'text-red-600';
|
||||
if (usage.isWarning) return 'text-orange-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const getProgressColor = () => {
|
||||
if (usage.isBlocked) return 'bg-red-600';
|
||||
if (usage.isWarning) return 'bg-orange-500';
|
||||
return 'bg-green-600';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (usage.isBlocked) return <AlertCircle className="h-4 w-4" />;
|
||||
if (usage.isWarning) return <AlertTriangle className="h-4 w-4" />;
|
||||
return <Check className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const limitText = usage.limit === null ? 'Unlimited' : usage.limit.toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="border border-neutral-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-neutral-900">{category}</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{usage.usage.toLocaleString()} / {limitText} emails this month
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${getStatusColor()}`}>
|
||||
{getStatusIcon()}
|
||||
<span className="text-sm font-medium">
|
||||
{usage.limit === null ? 'Unlimited' : `${Math.round(usage.percentage)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usage.limit !== null && (
|
||||
<>
|
||||
<Progress value={Math.min(usage.percentage, 100)} className="h-2" indicatorClassName={getProgressColor()} />
|
||||
|
||||
{usage.isBlocked && (
|
||||
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-xs">
|
||||
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{usage.isWarning && !usage.isBlocked && (
|
||||
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-xs">
|
||||
<strong>Warning:</strong> You've used {Math.round(usage.percentage)}% of your{' '}
|
||||
{category.toLowerCase()} email limit.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useUser} from '../lib/hooks/useUser';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Plus,
|
||||
Settings,
|
||||
User,
|
||||
Users,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{className?: string}>;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title?: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const navigation: NavSection[] = [
|
||||
{
|
||||
items: [
|
||||
{name: 'Dashboard', href: '/', icon: LayoutDashboard},
|
||||
{name: 'Contacts', href: '/contacts', icon: Users},
|
||||
{name: 'Segments', href: '/segments', icon: Layers},
|
||||
{name: 'Activity', href: '/activity', icon: Activity},
|
||||
{name: 'Analytics', href: '/analytics', icon: BarChart3},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Automations',
|
||||
items: [
|
||||
{name: 'Templates', href: '/templates', icon: FileText},
|
||||
{name: 'Workflows', href: '/workflows', icon: Workflow},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Campaigns',
|
||||
items: [{name: 'Campaigns', href: '/campaigns', icon: Megaphone}],
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
const router = useRouter();
|
||||
const {data: user} = useUser();
|
||||
const {activeProject, availableProjects, setActiveProject} = useActiveProject();
|
||||
const [showProjectMenu, setShowProjectMenu] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const projectMenuRef = useRef<HTMLDivElement>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside for project menu
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (projectMenuRef.current && !projectMenuRef.current.contains(event.target as Node)) {
|
||||
setShowProjectMenu(false);
|
||||
}
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (showProjectMenu || showUserMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [showProjectMenu, showUserMenu]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('activeProjectId');
|
||||
void router.push('/auth/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-neutral-50">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-white border-r border-neutral-200 flex flex-col">
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
|
||||
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
|
||||
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
|
||||
</div>
|
||||
|
||||
{/* Project Switcher */}
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="relative" ref={projectMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowProjectMenu(!showProjectMenu)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="h-8 w-8 rounded-lg bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{activeProject?.name.charAt(0).toUpperCase() || 'P'}
|
||||
</div>
|
||||
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* Project Dropdown */}
|
||||
{showProjectMenu && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||
{availableProjects.map(project => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
setActiveProject(project);
|
||||
setShowProjectMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-neutral-900">{project.name}</span>
|
||||
{activeProject?.id === project.id && (
|
||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-neutral-200 my-1" />
|
||||
<Link
|
||||
href="/projects/create"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Create project</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
||||
{navigation.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className={sectionIndex > 0 ? 'mt-6' : ''}>
|
||||
{section.title && (
|
||||
<p className="px-3 mb-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
|
||||
{section.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.items.map(item => {
|
||||
const isActive = router.pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
||||
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Settings & User Menu */}
|
||||
<div className="border-t border-neutral-200 p-3 space-y-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
||||
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
Settings
|
||||
</Link>
|
||||
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<User className="h-5 w-5" />
|
||||
<span className="flex-1 text-left truncate">{user?.email}</span>
|
||||
<ChevronDown className="h-4 w-4 text-neutral-500" />
|
||||
</button>
|
||||
|
||||
{/* User Dropdown */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto p-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {DomainSchemas} from '@plunk/shared';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {Check, CheckCircle2, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
|
||||
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
|
||||
|
||||
interface DomainsSettingsProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
const {domains, mutate: mutateDomains, isLoading} = useDomains(projectId);
|
||||
const {addDomain} = useAddDomain();
|
||||
const {checkVerification} = useCheckDomainVerification();
|
||||
const {removeDomain} = useRemoveDomain();
|
||||
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedDomain, setSelectedDomain] = useState<string | null>(null);
|
||||
const [verificationStatus, setVerificationStatus] = useState<{
|
||||
[key: string]: boolean | string | {tokens: string[] | null; status: string; verified: boolean};
|
||||
}>({});
|
||||
const [checkingVerification, setCheckingVerification] = useState<string | null>(null);
|
||||
const [copiedToken, setCopiedToken] = useState<string | null>(null);
|
||||
const [lastVerificationCheck, setLastVerificationCheck] = useState<{[key: string]: number}>({});
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({});
|
||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||
const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null);
|
||||
|
||||
const form = useForm<{domain: string}>({
|
||||
resolver: zodResolver(DomainSchemas.create.omit({projectId: true})),
|
||||
defaultValues: {
|
||||
domain: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Handle cooldown timer
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const newCooldowns: {[key: string]: number} = {};
|
||||
let hasActiveCooldowns = false;
|
||||
|
||||
Object.keys(lastVerificationCheck).forEach(domainId => {
|
||||
const lastCheck = lastVerificationCheck[domainId];
|
||||
if (lastCheck === undefined) return;
|
||||
|
||||
const elapsedSeconds = Math.floor((now - lastCheck) / 1000);
|
||||
const remainingSeconds = 10 - elapsedSeconds;
|
||||
|
||||
if (remainingSeconds > 0) {
|
||||
newCooldowns[domainId] = remainingSeconds;
|
||||
hasActiveCooldowns = true;
|
||||
}
|
||||
});
|
||||
|
||||
setCooldownSeconds(newCooldowns);
|
||||
|
||||
// Clear interval if no active cooldowns
|
||||
if (!hasActiveCooldowns && Object.keys(newCooldowns).length === 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 100); // Update every 100ms for smooth countdown
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [lastVerificationCheck]);
|
||||
|
||||
const showMessage = (type: 'success' | 'error', message: string) => {
|
||||
if (type === 'success') {
|
||||
setSuccessMessage(message);
|
||||
setErrorMessage(null);
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(message);
|
||||
setSuccessMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: {domain: string}) => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
const newDomain = await addDomain(projectId, values.domain);
|
||||
|
||||
// Store DKIM tokens for display
|
||||
if (newDomain.dkimTokens) {
|
||||
setVerificationStatus(prev => ({
|
||||
...prev,
|
||||
[newDomain.id]: {
|
||||
tokens: newDomain.dkimTokens,
|
||||
status: 'Pending',
|
||||
verified: false,
|
||||
},
|
||||
}));
|
||||
setSelectedDomain(newDomain.id);
|
||||
}
|
||||
|
||||
await mutateDomains();
|
||||
form.reset();
|
||||
showMessage('success', `Domain ${values.domain} added successfully. Please configure DNS records.`);
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : 'Failed to add domain');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckVerification = async (domainId: string) => {
|
||||
// Check if cooldown is active
|
||||
const now = Date.now();
|
||||
const lastCheck = lastVerificationCheck[domainId];
|
||||
if (lastCheck) {
|
||||
const elapsedSeconds = Math.floor((now - lastCheck) / 1000);
|
||||
if (elapsedSeconds < 10) {
|
||||
return; // Still in cooldown, do nothing
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setCheckingVerification(domainId);
|
||||
setLastVerificationCheck(prev => ({
|
||||
...prev,
|
||||
[domainId]: now,
|
||||
}));
|
||||
|
||||
const status = await checkVerification(domainId);
|
||||
|
||||
setVerificationStatus(prev => ({
|
||||
...prev,
|
||||
[domainId]: status,
|
||||
}));
|
||||
|
||||
await mutateDomains();
|
||||
|
||||
if (status.verified) {
|
||||
showMessage('success', `Domain ${status.domain} is verified!`);
|
||||
} else {
|
||||
showMessage('error', `Domain ${status.domain} is not yet verified. Please check your DNS records.`);
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : 'Failed to check verification');
|
||||
} finally {
|
||||
setCheckingVerification(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDomain = async () => {
|
||||
if (!domainToRemove) return;
|
||||
|
||||
try {
|
||||
await removeDomain(domainToRemove.id);
|
||||
await mutateDomains();
|
||||
if (selectedDomain === domainToRemove.id) {
|
||||
setSelectedDomain(null);
|
||||
}
|
||||
showMessage('success', `Domain ${domainToRemove.name} removed successfully`);
|
||||
} catch (error) {
|
||||
showMessage('error', error instanceof Error ? error.message : 'Failed to remove domain');
|
||||
} finally {
|
||||
setDomainToRemove(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyToken = async (token: string, index: number) => {
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopiedToken(`${token}-${index}`);
|
||||
setTimeout(() => setCopiedToken(null), 2000);
|
||||
};
|
||||
|
||||
const getDomainStatus = (domain: {
|
||||
id: string;
|
||||
verified: boolean;
|
||||
dkimTokens: unknown;
|
||||
}): {verified: boolean; tokens: unknown; status: string} => {
|
||||
const status = verificationStatus[domain.id];
|
||||
if (status && typeof status === 'object' && 'verified' in status) {
|
||||
return status;
|
||||
}
|
||||
return {verified: domain.verified, tokens: domain.dkimTokens, status: domain.verified ? 'Success' : 'Pending'};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add Domain Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Domain</CardTitle>
|
||||
<CardDescription>Add a custom domain to send emails from</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence mode="wait">
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Adding...' : 'Add Domain'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Domains List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Domains</CardTitle>
|
||||
<CardDescription>Manage your verified domains</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
) : !domains || domains.length === 0 ? (
|
||||
<div className="text-center py-8 text-neutral-500">
|
||||
<p>No domains added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{domains.map(domain => {
|
||||
const status = getDomainStatus(domain);
|
||||
return (
|
||||
<div key={domain.id} className="border border-neutral-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-medium text-neutral-900">{domain.domain}</h3>
|
||||
{status.verified ? (
|
||||
<Badge variant="success" className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Verified
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning" className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCheckVerification(domain.id)}
|
||||
disabled={checkingVerification === domain.id || (cooldownSeconds[domain.id] ?? 0) > 0}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{checkingVerification === domain.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (cooldownSeconds[domain.id] ?? 0) > 0 ? (
|
||||
<span className="text-xs">{cooldownSeconds[domain.id]}s</span>
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDomainToRemove({id: domain.id, name: domain.domain});
|
||||
setShowRemoveDialog(true);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.verified && Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-medium text-sm mb-1">DNS Configuration Required</p>
|
||||
<p className="text-xs text-neutral-600">
|
||||
Add the following DNS records to verify your domain. DNS changes can take up to 48
|
||||
hours to propagate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* DNS Records Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||
Type
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||
Name
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200">
|
||||
{/* DKIM Records */}
|
||||
{status.tokens.map((token: string, index: number) => (
|
||||
<tr key={index} className="hover:bg-neutral-50/50">
|
||||
<td className="py-3 px-3">
|
||||
<code className="text-xs font-medium text-neutral-900">CNAME</code>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||
{token}._domainkey.{domain.domain}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleCopyToken(`${token}._domainkey.${domain.domain}`, index + 2000)
|
||||
}
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
>
|
||||
{copiedToken ===
|
||||
`${token}._domainkey.${domain.domain}-${index + 2000}` ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||
{token}.dkim.amazonses.com
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken(`${token}.dkim.amazonses.com`, index)}
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
>
|
||||
{copiedToken === `${token}.dkim.amazonses.com-${index}` ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* MX Record */}
|
||||
<tr className="hover:bg-neutral-50/50">
|
||||
<td className="py-3 px-3">
|
||||
<code className="text-xs font-medium text-neutral-900">MX</code>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||
plunk.{domain.domain}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
>
|
||||
{copiedToken === `plunk.${domain.domain}-3000` ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||
10 feedback-smtp.eu-north-1.amazonses.com
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleCopyToken('10 feedback-smtp.eu-north-1.amazonses.com', 1000)
|
||||
}
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
>
|
||||
{copiedToken === '10 feedback-smtp.eu-north-1.amazonses.com-1000' ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* TXT Record (SPF) */}
|
||||
<tr className="hover:bg-neutral-50/50">
|
||||
<td className="py-3 px-3">
|
||||
<code className="text-xs font-medium text-neutral-900">TXT</code>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||
plunk.{domain.domain}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
>
|
||||
{copiedToken === `plunk.${domain.domain}-3001` ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||
"v=spf1 include:amazonses.com ~all"
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)}
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
>
|
||||
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="text-blue-600 mt-0.5">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-blue-900">
|
||||
Click the copy icon to copy record values. After adding all records to your DNS
|
||||
provider, use the refresh button above to verify your domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRemoveDialog}
|
||||
onOpenChange={setShowRemoveDialog}
|
||||
onConfirm={handleRemoveDomain}
|
||||
title="Remove Domain"
|
||||
description={`Are you sure you want to remove ${domainToRemove?.name}?`}
|
||||
confirmText="Remove"
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import {AlertCircle} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useDomains} from '../lib/hooks/useDomains';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
|
||||
interface EmailDomainInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function EmailDomainInput({value, onChange, id, placeholder, required, label}: EmailDomainInputProps) {
|
||||
const {activeProject} = useActiveProject();
|
||||
const {domains, isLoading} = useDomains(activeProject?.id);
|
||||
|
||||
// Split the email into local part and domain
|
||||
const [localPart, setLocalPart] = useState('');
|
||||
const [selectedDomain, setSelectedDomain] = useState('');
|
||||
|
||||
// Get verified domains only
|
||||
const verifiedDomains = domains?.filter(d => d.verified) || [];
|
||||
|
||||
// Initialize from value
|
||||
useEffect(() => {
|
||||
if (value && value.includes('@')) {
|
||||
const [local, domain] = value.split('@');
|
||||
|
||||
setLocalPart(local ?? '');
|
||||
|
||||
// Check if the domain is in our verified list
|
||||
const domainPart = domain ?? '';
|
||||
const matchingDomain = verifiedDomains.find(d => d.domain === domainPart);
|
||||
if (matchingDomain) {
|
||||
setSelectedDomain(domainPart);
|
||||
} else {
|
||||
// If domain not verified, keep it in the domain field
|
||||
|
||||
setSelectedDomain(domainPart);
|
||||
}
|
||||
} else if (value) {
|
||||
// If no @ sign, treat entire value as local part
|
||||
|
||||
setLocalPart(value);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
// Initialize selected domain with first verified domain if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedDomain && verifiedDomains.length > 0) {
|
||||
const firstDomain = verifiedDomains[0];
|
||||
if (firstDomain) {
|
||||
setSelectedDomain(firstDomain.domain);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDomain]);
|
||||
|
||||
// Update parent component when local part or domain changes
|
||||
const handleUpdate = (newLocal: string, newDomain: string) => {
|
||||
if (newLocal && newDomain) {
|
||||
onChange(`${newLocal}@${newDomain}`);
|
||||
} else if (newLocal) {
|
||||
onChange(newLocal);
|
||||
} else {
|
||||
onChange('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalPartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newLocal = e.target.value;
|
||||
setLocalPart(newLocal);
|
||||
handleUpdate(newLocal, selectedDomain);
|
||||
};
|
||||
|
||||
const handleDomainChange = (newDomain: string) => {
|
||||
setSelectedDomain(newDomain);
|
||||
handleUpdate(localPart, newDomain);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input id={id} type="text" placeholder="Loading..." disabled className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (verifiedDomains.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-medium text-sm">No verified domains</p>
|
||||
<p className="text-xs mt-1">
|
||||
Please add and verify a domain in{' '}
|
||||
<Link href="/settings?tab=domains" className="underline hover:text-red-800">
|
||||
Settings → Domains
|
||||
</Link>{' '}
|
||||
before creating templates.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/* Fallback to regular email input */}
|
||||
<Input
|
||||
id={id}
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder || '[email protected]'}
|
||||
required={required}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={localPart}
|
||||
onChange={handleLocalPartChange}
|
||||
placeholder={placeholder || 'hello'}
|
||||
required={required}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-neutral-500">@</span>
|
||||
<Select value={selectedDomain} onValueChange={handleDomainChange} required={required}>
|
||||
<SelectTrigger className="w-[200px] shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{verifiedDomains.map(domain => (
|
||||
<SelectItem key={domain.id} value={domain.domain}>
|
||||
{domain.domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {Node, mergeAttributes} from '@tiptap/core';
|
||||
import {ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps} from '@tiptap/react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface ImageAttrs {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
function ResizableImageComponent({node, updateAttributes, selected}: ReactNodeViewProps) {
|
||||
const attrs = node.attrs as ImageAttrs;
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeDirection, setResizeDirection] = useState<'se' | 'sw' | 'ne' | 'nw' | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const startPos = useRef({x: 0, y: 0});
|
||||
const startSize = useRef({width: 0, height: 0});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!imageRef.current || !resizeDirection) return;
|
||||
|
||||
const deltaX = e.clientX - startPos.current.x;
|
||||
|
||||
let newWidth = startSize.current.width;
|
||||
let newHeight = startSize.current.height;
|
||||
|
||||
// Calculate new dimensions based on resize direction
|
||||
if (resizeDirection.includes('e')) {
|
||||
newWidth = startSize.current.width + deltaX;
|
||||
} else if (resizeDirection.includes('w')) {
|
||||
newWidth = startSize.current.width - deltaX;
|
||||
}
|
||||
|
||||
// Maintain aspect ratio
|
||||
const aspectRatio = startSize.current.width / startSize.current.height;
|
||||
newHeight = newWidth / aspectRatio;
|
||||
|
||||
// Enforce minimum size
|
||||
newWidth = Math.max(50, newWidth);
|
||||
newHeight = Math.max(50, newHeight);
|
||||
|
||||
updateAttributes({
|
||||
width: Math.round(newWidth),
|
||||
height: Math.round(newHeight),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
setResizeDirection(null);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isResizing, resizeDirection, updateAttributes]);
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent, direction: 'se' | 'sw' | 'ne' | 'nw') => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!imageRef.current) return;
|
||||
|
||||
const rect = imageRef.current.getBoundingClientRect();
|
||||
startPos.current = {x: e.clientX, y: e.clientY};
|
||||
startSize.current = {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
|
||||
setIsResizing(true);
|
||||
setResizeDirection(direction);
|
||||
};
|
||||
|
||||
const {src, alt, title, width, height} = attrs;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="resizable-image-wrapper">
|
||||
<div
|
||||
className={`resizable-image-container ${selected ? 'selected' : ''}`}
|
||||
style={{display: 'inline-block', position: 'relative', maxWidth: '100%'}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
title={title || ''}
|
||||
width={width}
|
||||
height={height}
|
||||
className="email-image"
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
width: width ? `${width}px` : 'auto',
|
||||
}}
|
||||
/>
|
||||
{selected && (
|
||||
<>
|
||||
{/* Resize handles */}
|
||||
<div
|
||||
className="resize-handle resize-handle-se"
|
||||
onMouseDown={e => handleResizeStart(e, 'se')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
right: '-4px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: '#3b82f6',
|
||||
border: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
cursor: 'se-resize',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle resize-handle-sw"
|
||||
onMouseDown={e => handleResizeStart(e, 'sw')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: '#3b82f6',
|
||||
border: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
cursor: 'sw-resize',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle resize-handle-ne"
|
||||
onMouseDown={e => handleResizeStart(e, 'ne')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: '#3b82f6',
|
||||
border: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
cursor: 'ne-resize',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="resize-handle resize-handle-nw"
|
||||
onMouseDown={e => handleResizeStart(e, 'nw')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
left: '-4px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: '#3b82f6',
|
||||
border: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
cursor: 'nw-resize',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const ResizableImage = Node.create({
|
||||
name: 'image',
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
inline: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: element => {
|
||||
const width = element.getAttribute('width');
|
||||
return width ? parseInt(width, 10) : null;
|
||||
},
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.width) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
width: attributes.width,
|
||||
};
|
||||
},
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: element => {
|
||||
const height = element.getAttribute('height');
|
||||
return height ? parseInt(height, 10) : null;
|
||||
},
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.height) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
height: attributes.height,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'img[src]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({HTMLAttributes}) {
|
||||
return ['img', mergeAttributes(HTMLAttributes, {class: 'email-image'})];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ResizableImageComponent);
|
||||
},
|
||||
|
||||
addCommands(): any {
|
||||
return {
|
||||
setImage:
|
||||
(options: {src: string; alt?: string; title?: string; width?: number; height?: number}) =>
|
||||
({commands}: {commands: {insertContent: (content: unknown) => boolean}}) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import {type Editor} from '@tiptap/react';
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignJustify,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Image,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Palette,
|
||||
Quote,
|
||||
Redo,
|
||||
Strikethrough,
|
||||
Undo,
|
||||
Variable,
|
||||
} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
interface ToolbarProps {
|
||||
editor: Editor | null;
|
||||
onInsertVariable: () => void;
|
||||
onInsertImage: () => void;
|
||||
canUploadImages: boolean;
|
||||
}
|
||||
|
||||
export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImages}: ToolbarProps) {
|
||||
const [showLinkInput, setShowLinkInput] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [customColor, setCustomColor] = useState('');
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
// Force re-render when editor state changes
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const updateHandler = () => {
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
editor.on('selectionUpdate', updateHandler);
|
||||
editor.on('transaction', updateHandler);
|
||||
|
||||
return () => {
|
||||
editor.off('selectionUpdate', updateHandler);
|
||||
editor.off('transaction', updateHandler);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addLink = () => {
|
||||
if (linkUrl) {
|
||||
// If updating an existing link, extend selection to cover the entire link first
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().extendMarkRange('link').setLink({href: linkUrl}).run();
|
||||
} else {
|
||||
editor.chain().focus().setLink({href: linkUrl}).run();
|
||||
}
|
||||
setLinkUrl('');
|
||||
setShowLinkInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeLink = () => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
setLinkUrl('');
|
||||
setShowLinkInput(false);
|
||||
};
|
||||
|
||||
const setColor = (color: string) => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
setSelectedColor(color);
|
||||
};
|
||||
|
||||
const applyCustomColor = () => {
|
||||
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
|
||||
setColor(customColor);
|
||||
setCustomColor('');
|
||||
setShowColorPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Tailwind color palette organized by hue
|
||||
const colorGroups = [
|
||||
{
|
||||
name: 'Neutrals',
|
||||
colors: ['#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#F3F4F6', '#FFFFFF'],
|
||||
},
|
||||
{
|
||||
name: 'Reds',
|
||||
colors: ['#7F1D1D', '#991B1B', '#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FEE2E2'],
|
||||
},
|
||||
{
|
||||
name: 'Oranges',
|
||||
colors: ['#7C2D12', '#C2410C', '#EA580C', '#F97316', '#FB923C', '#FDBA74', '#FED7AA'],
|
||||
},
|
||||
{
|
||||
name: 'Yellows',
|
||||
colors: ['#713F12', '#A16207', '#CA8A04', '#EAB308', '#FACC15', '#FDE047', '#FEF08A'],
|
||||
},
|
||||
{
|
||||
name: 'Greens',
|
||||
colors: ['#14532D', '#15803D', '#16A34A', '#22C55E', '#4ADE80', '#86EFAC', '#BBF7D0'],
|
||||
},
|
||||
{
|
||||
name: 'Blues',
|
||||
colors: ['#1E3A8A', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#DBEAFE'],
|
||||
},
|
||||
{
|
||||
name: 'Purples',
|
||||
colors: ['#581C87', '#6B21A8', '#7C3AED', '#8B5CF6', '#A78BFA', '#C4B5FD', '#E9D5FF'],
|
||||
},
|
||||
{
|
||||
name: 'Pinks',
|
||||
colors: ['#831843', '#9F1239', '#DB2777', '#EC4899', '#F472B6', '#F9A8D4', '#FBCFE8'],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-10">
|
||||
{/* History */}
|
||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Text formatting */}
|
||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
data-active={editor.isActive('bold')}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
data-active={editor.isActive('italic')}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
data-active={editor.isActive('strike')}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
data-active={editor.isActive('code')}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Headings */}
|
||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleHeading({level: 1}).run()}
|
||||
data-active={editor.isActive('heading', {level: 1})}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleHeading({level: 2}).run()}
|
||||
data-active={editor.isActive('heading', {level: 2})}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleHeading({level: 3}).run()}
|
||||
data-active={editor.isActive('heading', {level: 3})}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Heading3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Lists */}
|
||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
data-active={editor.isActive('bulletList')}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
data-active={editor.isActive('orderedList')}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
data-active={editor.isActive('blockquote')}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
data-active={editor.isActive({textAlign: 'left'})}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
data-active={editor.isActive({textAlign: 'center'})}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
data-active={editor.isActive({textAlign: 'right'})}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||
data-active={editor.isActive({textAlign: 'justify'})}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<AlignJustify className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="relative pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
{showColorPicker && (
|
||||
<div
|
||||
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-20 max-h-96 overflow-y-auto"
|
||||
style={{width: '280px'}}
|
||||
>
|
||||
{/* Custom color input */}
|
||||
<div className="mb-3 pb-3 border-b border-neutral-200">
|
||||
<label className="text-xs font-medium text-neutral-600 mb-1 block">Custom Color</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={customColor}
|
||||
onChange={e => setCustomColor(e.target.value.toUpperCase())}
|
||||
placeholder="#000000"
|
||||
className="h-8 text-xs font-mono"
|
||||
maxLength={7}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
applyCustomColor();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={applyCustomColor}
|
||||
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
|
||||
className="h-8"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color palette */}
|
||||
<div className="space-y-3">
|
||||
{colorGroups.map(group => (
|
||||
<div key={group.name}>
|
||||
<label className="text-xs font-medium text-neutral-600 mb-1.5 block">{group.name}</label>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{group.colors.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setColor(color);
|
||||
setShowColorPicker(false);
|
||||
}}
|
||||
className="w-8 h-8 rounded border-2 border-neutral-300 hover:border-neutral-500 hover:scale-110 transition-all relative group"
|
||||
style={{backgroundColor: color}}
|
||||
title={color}
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link */}
|
||||
<div className="relative pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => {
|
||||
if (editor.isActive('link')) {
|
||||
// Get the current link URL and show the input to edit it
|
||||
const previousUrl = editor.getAttributes('link').href || '';
|
||||
setLinkUrl(previousUrl);
|
||||
setShowLinkInput(true);
|
||||
} else {
|
||||
setShowLinkInput(!showLinkInput);
|
||||
setLinkUrl('');
|
||||
}
|
||||
}}
|
||||
data-active={editor.isActive('link')}
|
||||
className="h-8 w-8 data-[active=true]:bg-neutral-200"
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
</Button>
|
||||
{showLinkInput && (
|
||||
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-20 min-w-max">
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={e => setLinkUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="px-2 py-1 text-sm border border-neutral-200 rounded w-64"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
addLink();
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowLinkInput(false);
|
||||
setLinkUrl('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="button" size="sm" onMouseDown={e => e.preventDefault()} onClick={addLink}>
|
||||
{editor.isActive('link') ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
{editor.isActive('link') && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={removeLink}
|
||||
>
|
||||
Remove Link
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={onInsertImage}
|
||||
disabled={!canUploadImages}
|
||||
className="h-8 w-8"
|
||||
title={canUploadImages ? 'Insert image' : 'Storage not configured'}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<Image className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Variable */}
|
||||
<div className="flex gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={onInsertVariable}
|
||||
className="h-8 w-8"
|
||||
title="Insert variable"
|
||||
>
|
||||
<Variable className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {mergeAttributes, Node} from '@tiptap/core';
|
||||
import {Plugin, PluginKey} from '@tiptap/pm/state';
|
||||
import {Decoration, DecorationSet} from '@tiptap/pm/view';
|
||||
|
||||
export interface VariableOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
variable: {
|
||||
/**
|
||||
* Insert a variable at the current position
|
||||
*/
|
||||
insertVariable: (name: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Variable = Node.create<VariableOptions>({
|
||||
name: 'variable',
|
||||
|
||||
group: 'inline',
|
||||
|
||||
inline: true,
|
||||
|
||||
selectable: true,
|
||||
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
name: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-variable'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.name) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
'data-variable': attributes.name,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-variable]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-variable': node.attrs.name,
|
||||
'class': 'variable-placeholder',
|
||||
}),
|
||||
`{{${node.attrs.name}}}`,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertVariable:
|
||||
(name: string) =>
|
||||
({commands}) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {name},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('variableAutodetect'),
|
||||
props: {
|
||||
decorations: ({doc}) => {
|
||||
const decorations: Decoration[] = [];
|
||||
const regex = /\{\{([^}]+)\}\}/g;
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
let match;
|
||||
while ((match = regex.exec(node.text)) !== null) {
|
||||
const from = pos + match.index;
|
||||
const to = from + match[0].length;
|
||||
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {
|
||||
class: 'variable-highlight',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import {Mention} from '@tiptap/extension-mention';
|
||||
import type {Editor, Range} from '@tiptap/core';
|
||||
import tippy, {Instance as TippyInstance, sticky} from 'tippy.js';
|
||||
import type {SuggestionProps} from '@tiptap/suggestion';
|
||||
|
||||
// This will be set from the component
|
||||
let availableVariables: string[] = [];
|
||||
|
||||
export function setAvailableVariables(variables: string[]) {
|
||||
availableVariables = variables || [];
|
||||
}
|
||||
|
||||
// Suggestion component that will be rendered
|
||||
class VariableSuggestionList {
|
||||
public element: HTMLDivElement;
|
||||
private items: string[];
|
||||
private selectedIndex: number;
|
||||
private command: (props: {id: string}) => void;
|
||||
|
||||
constructor(props: SuggestionProps) {
|
||||
this.items = Array.isArray(props.items) ? props.items : [];
|
||||
this.selectedIndex = 0;
|
||||
this.command = props.command;
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'variable-suggestion-list';
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!Array.isArray(this.items) || this.items.length === 0) {
|
||||
this.element.innerHTML = '<div class="suggestion-item-empty">No variables found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.element.innerHTML = this.items
|
||||
.map(
|
||||
(item, index) => `
|
||||
<div class="suggestion-item${index === this.selectedIndex ? ' is-selected' : ''}" data-index="${index}">
|
||||
<code>{{${item}}}</code>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// Add click handlers
|
||||
this.element.querySelectorAll('.suggestion-item').forEach((el, index) => {
|
||||
el.addEventListener('click', () => {
|
||||
this.selectItem(index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectItem(index: number) {
|
||||
const item = this.items[index];
|
||||
if (item && this.command) {
|
||||
this.command({id: item});
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'ArrowUp') {
|
||||
this.upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
this.enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
upHandler() {
|
||||
if (!Array.isArray(this.items) || this.items.length === 0) return;
|
||||
this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length;
|
||||
this.render();
|
||||
}
|
||||
|
||||
downHandler() {
|
||||
if (!Array.isArray(this.items) || this.items.length === 0) return;
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
|
||||
this.render();
|
||||
}
|
||||
|
||||
enterHandler() {
|
||||
if (!Array.isArray(this.items) || this.items.length === 0) return;
|
||||
this.selectItem(this.selectedIndex);
|
||||
}
|
||||
|
||||
update(props: SuggestionProps) {
|
||||
this.items = Array.isArray(props.items) ? props.items : [];
|
||||
this.selectedIndex = 0;
|
||||
this.render();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export const VariableMention = Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'variable-mention',
|
||||
},
|
||||
renderLabel({node}) {
|
||||
return `{{${node.attrs.id}}}`;
|
||||
},
|
||||
suggestion: {
|
||||
char: '{{',
|
||||
|
||||
items: ({query}) => {
|
||||
const safeVariables = Array.isArray(availableVariables) ? availableVariables : [];
|
||||
const allVariables = ['email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', ...safeVariables];
|
||||
const uniqueVariables = Array.from(new Set(allVariables)).filter(v => typeof v === 'string');
|
||||
|
||||
if (!query) {
|
||||
return uniqueVariables.slice(0, 10);
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return uniqueVariables.filter(item => item.toLowerCase().startsWith(lowerQuery)).slice(0, 10);
|
||||
},
|
||||
|
||||
command: ({editor, range, props}: {editor: Editor; range: Range; props: {id: string | null}}) => {
|
||||
// Delete the {{ trigger characters and insert the variable as plain text
|
||||
if (!props.id) return;
|
||||
editor.chain().focus().deleteRange(range).insertContent(`{{${props.id}}}`).run();
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: VariableSuggestionList;
|
||||
let popup: TippyInstance[];
|
||||
let scrollHandler: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
component = new VariableSuggestionList(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
theme: 'variable-suggestion',
|
||||
plugins: [sticky],
|
||||
sticky: 'reference',
|
||||
popperOptions: {
|
||||
strategy: 'fixed',
|
||||
},
|
||||
});
|
||||
|
||||
// Update position on scroll
|
||||
scrollHandler = () => {
|
||||
if (popup?.[0] && props.clientRect) {
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Find the scrolling editor container and add listener
|
||||
const editorContainer = document.querySelector('.overflow-y-auto');
|
||||
if (editorContainer) {
|
||||
editorContainer.addEventListener('scroll', scrollHandler);
|
||||
}
|
||||
window.addEventListener('scroll', scrollHandler, true);
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
component?.update(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup?.[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props: {event: KeyboardEvent}) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
return component?.onKeyDown(props.event) || false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
// Clean up scroll listeners
|
||||
if (scrollHandler) {
|
||||
const editorContainer = document.querySelector('.overflow-y-auto');
|
||||
if (editorContainer) {
|
||||
editorContainer.removeEventListener('scroll', scrollHandler);
|
||||
}
|
||||
window.removeEventListener('scroll', scrollHandler, true);
|
||||
}
|
||||
|
||||
popup?.[0]?.destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import juice from 'juice';
|
||||
|
||||
/**
|
||||
* Converts modern HTML from Tiptap to email-friendly HTML
|
||||
* - Inlines CSS styles
|
||||
* - Adds email-safe defaults
|
||||
* - Preserves variable placeholders like {{email}}
|
||||
*/
|
||||
export function convertToEmailHtml(html: string): string {
|
||||
// Wrap in email-safe container with basic styling
|
||||
const wrappedHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px 0;
|
||||
color: #111827;
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
color: #111827;
|
||||
}
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: #111827;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
a {
|
||||
color: #3B82F6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
ul, ol {
|
||||
margin: 0 0 16px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
blockquote {
|
||||
margin: 0 0 16px 0;
|
||||
padding-left: 16px;
|
||||
border-left: 4px solid #E5E7EB;
|
||||
color: #6B7280;
|
||||
}
|
||||
code {
|
||||
background-color: #F3F4F6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #E5E7EB;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #F3F4F6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.variable-placeholder {
|
||||
display: inline;
|
||||
background-color: #DBEAFE;
|
||||
color: #1E40AF;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #3B82F6;
|
||||
color: #FFFFFF;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${html}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// Inline CSS using juice
|
||||
const inlined = juice(wrappedHtml, {
|
||||
preserveMediaQueries: false,
|
||||
preserveFontFaces: false,
|
||||
removeStyleTags: true,
|
||||
applyStyleTags: true,
|
||||
});
|
||||
|
||||
// Extract just the body content
|
||||
const bodyMatch = inlined.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
const bodyContent = bodyMatch && bodyMatch[1] ? bodyMatch[1].trim() : inlined;
|
||||
|
||||
// Clean up Tiptap-specific artifacts
|
||||
const cleaned = bodyContent
|
||||
.replace(/\sdata-pm-slice="[^"]*"/g, '')
|
||||
.replace(/\sclass=""/g, '')
|
||||
.replace(/\sstyle=""/g, '');
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps text content in a button element for CTAs
|
||||
*/
|
||||
export function createButtonHtml(text: string, href: string, color = '#3B82F6'): string {
|
||||
return `<a href="${href}" class="button" style="display: inline-block; padding: 12px 24px; background-color: ${color}; color: #FFFFFF; text-decoration: none; border-radius: 6px; font-weight: 600; margin: 8px 0;">${text}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts plain HTML back to a format suitable for Tiptap
|
||||
* Preserves structure but removes email-specific inline styles
|
||||
*/
|
||||
export function convertFromEmailHtml(html: string): string {
|
||||
// Remove inline styles added by juice
|
||||
const cleaned = html.replace(/\sstyle="[^"]*"/g, '');
|
||||
|
||||
// Preserve basic structure elements
|
||||
return cleaned.trim();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export {EmailEditor} from './EmailEditor';
|
||||
export {convertToEmailHtml, convertFromEmailHtml, createButtonHtml} from './emailHtmlConverter';
|
||||
@@ -0,0 +1,82 @@
|
||||
import {Input, Label} from '@plunk/ui';
|
||||
import {EmailDomainInput} from './EmailDomainInput';
|
||||
|
||||
interface EmailSettingsProps {
|
||||
from: string;
|
||||
fromName: string;
|
||||
replyTo: string;
|
||||
onFromChange: (value: string) => void;
|
||||
onFromNameChange: (value: string) => void;
|
||||
onReplyToChange: (value: string) => void;
|
||||
fromPlaceholder?: string;
|
||||
fromNamePlaceholder?: string;
|
||||
replyToPlaceholder?: string;
|
||||
showFromNameHelpText?: boolean;
|
||||
layout?: 'vertical' | 'grid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable email settings component for from, fromName, and replyTo fields
|
||||
* Used in campaign and template forms
|
||||
*/
|
||||
export function EmailSettings({
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
onFromChange,
|
||||
onFromNameChange,
|
||||
onReplyToChange,
|
||||
fromPlaceholder = 'hello',
|
||||
fromNamePlaceholder = 'Your Company',
|
||||
replyToPlaceholder = 'support',
|
||||
showFromNameHelpText = false,
|
||||
layout = 'grid',
|
||||
}: EmailSettingsProps) {
|
||||
const GridWrapper = layout === 'grid' ? 'div' : 'div';
|
||||
const gridClassName = layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : 'space-y-4';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GridWrapper className={gridClassName}>
|
||||
<div>
|
||||
<EmailDomainInput
|
||||
id="from"
|
||||
label="From Email *"
|
||||
value={from}
|
||||
onChange={onFromChange}
|
||||
required
|
||||
placeholder={fromPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fromName">From Name</Label>
|
||||
<Input
|
||||
id="fromName"
|
||||
type="text"
|
||||
value={fromName}
|
||||
onChange={e => onFromNameChange(e.target.value)}
|
||||
placeholder={fromNamePlaceholder}
|
||||
/>
|
||||
{showFromNameHelpText && (
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
The sender name that appears in the recipient's inbox. Defaults to your project name if not set.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</GridWrapper>
|
||||
|
||||
<GridWrapper className={layout === 'grid' ? 'grid gap-4 md:grid-cols-2' : ''}>
|
||||
<div>
|
||||
<EmailDomainInput
|
||||
id="replyTo"
|
||||
label="Reply-To Email"
|
||||
value={replyTo}
|
||||
onChange={onReplyToChange}
|
||||
placeholder={replyToPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</GridWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {Button, Input, Label} from '@plunk/ui';
|
||||
import {Plus, Trash2} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface KeyValuePair {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface KeyValueEditorProps {
|
||||
initialData?: Record<string, string | number | boolean> | null;
|
||||
onChange?: (data: Record<string, string | number | boolean> | null) => void;
|
||||
}
|
||||
|
||||
export function KeyValueEditor({initialData, onChange}: KeyValueEditorProps) {
|
||||
const [pairs, setPairs] = useState<KeyValuePair[]>(() => {
|
||||
// Initialize state from initialData only once on mount
|
||||
if (initialData && typeof initialData === 'object') {
|
||||
return Object.entries(initialData).map(([key, value], index) => ({
|
||||
id: `initial-${index}-${Date.now()}`,
|
||||
key,
|
||||
value: String(value),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Notify parent of changes based on given pairs
|
||||
const notifyChange = (updatedPairs: KeyValuePair[]) => {
|
||||
if (!onChange) return;
|
||||
|
||||
// Filter out empty pairs
|
||||
const validPairs = updatedPairs.filter(pair => pair.key.trim() !== '');
|
||||
|
||||
if (validPairs.length === 0) {
|
||||
onChange(null);
|
||||
} else {
|
||||
const data = validPairs.reduce(
|
||||
(acc, pair) => {
|
||||
// Try to parse as number or boolean, otherwise keep as string
|
||||
let value: string | number | boolean = pair.value;
|
||||
if (pair.value === 'true') value = true;
|
||||
else if (pair.value === 'false') value = false;
|
||||
else if (!isNaN(Number(pair.value)) && pair.value.trim() !== '') value = Number(pair.value);
|
||||
|
||||
acc[pair.key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number | boolean>,
|
||||
);
|
||||
onChange(data);
|
||||
}
|
||||
};
|
||||
|
||||
const addPair = () => {
|
||||
const newPairs = [...pairs, {id: `new-${Date.now()}`, key: '', value: ''}];
|
||||
setPairs(newPairs);
|
||||
notifyChange(newPairs);
|
||||
};
|
||||
|
||||
const updateKey = (id: string, newKey: string) => {
|
||||
const updated = pairs.map(pair => (pair.id === id ? {...pair, key: newKey} : pair));
|
||||
setPairs(updated);
|
||||
notifyChange(updated);
|
||||
};
|
||||
|
||||
const updateValue = (id: string, newValue: string) => {
|
||||
const updated = pairs.map(pair => (pair.id === id ? {...pair, value: newValue} : pair));
|
||||
setPairs(updated);
|
||||
notifyChange(updated);
|
||||
};
|
||||
|
||||
const removePair = (id: string) => {
|
||||
const updated = pairs.filter(pair => pair.id !== id);
|
||||
setPairs(updated);
|
||||
notifyChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Custom Data</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addPair}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Field
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pairs.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed border-neutral-200 rounded-lg bg-neutral-50">
|
||||
<p className="text-sm text-neutral-500">No custom fields yet</p>
|
||||
<p className="text-xs text-neutral-400 mt-1">Click "Add Field" to create custom data fields</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pairs.map(pair => (
|
||||
<div key={pair.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Key"
|
||||
value={pair.key}
|
||||
onChange={e => updateKey(pair.id, e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
value={pair.value}
|
||||
onChange={e => updateValue(pair.id, e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePair(pair.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
||||
import {BookOpen, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useMemo} from 'react';
|
||||
import {LANDING_URI, WIKI_URI} from '../lib/constants';
|
||||
import type {ProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
|
||||
interface QuickStartStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
linkText: string;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
interface QuickStartProps {
|
||||
setupState: ProjectSetupState | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Help resources that always appear
|
||||
function HelpResources() {
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Link href={WIKI_URI} target="_blank" className="flex-1">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium text-neutral-700 bg-white border border-neutral-200 rounded-lg hover:bg-neutral-50 hover:border-neutral-300 transition-all">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
Documentation
|
||||
</button>
|
||||
</Link>
|
||||
<Link href={`${LANDING_URI}/discord`} target="_blank" className="flex-1">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium text-neutral-700 bg-white border border-neutral-200 rounded-lg hover:bg-neutral-50 hover:border-neutral-300 transition-all">
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
Join Discord
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
||||
// Calculate days since last campaign using useMemo to avoid impure function during render
|
||||
// Must be called before any early returns to follow Rules of Hooks
|
||||
const daysSinceLastCampaign = useMemo(() => {
|
||||
if (!setupState || !setupState.lastCampaignSentAt) return null;
|
||||
const now = new Date();
|
||||
const lastSent = new Date(setupState.lastCampaignSentAt);
|
||||
return Math.floor((now.getTime() - lastSent.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}, [setupState]);
|
||||
|
||||
const {data: config} = useConfig();
|
||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||
|
||||
if (isLoading || !setupState) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Start</CardTitle>
|
||||
<CardDescription>Get started with Plunk in minutes</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-4 p-4 rounded-lg border border-neutral-200 bg-neutral-50/50 animate-pulse"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-lg bg-neutral-200" />
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<div className="h-4 bg-neutral-200 rounded w-1/3" />
|
||||
<div className="h-3 bg-neutral-200 rounded w-2/3" />
|
||||
</div>
|
||||
<div className="h-9 w-20 bg-neutral-200 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<HelpResources />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasContacts = setupState.contactCount > 0;
|
||||
const hasSentCampaign = setupState.lastCampaignSentAt !== null;
|
||||
const hasRecentCampaign = daysSinceLastCampaign !== null && daysSinceLastCampaign <= 30;
|
||||
|
||||
// Build dynamic steps based on setup state with proper prioritization
|
||||
// Priority order: Domain → Contacts → Campaign → Workflow → Subscription
|
||||
const allSteps: QuickStartStep[] = [];
|
||||
|
||||
// Priority 1: Domain verification (critical for deliverability)
|
||||
if (!setupState.hasVerifiedDomain) {
|
||||
allSteps.push({
|
||||
id: 'domain',
|
||||
icon: Shield,
|
||||
title: 'Verify Your Domain',
|
||||
description: 'Essential for email deliverability and avoiding spam',
|
||||
link: '/settings?tab=domains',
|
||||
linkText: 'Add Domain',
|
||||
isCompleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 2: Add contacts (can't send without contacts)
|
||||
if (!hasContacts) {
|
||||
allSteps.push({
|
||||
id: 'contacts',
|
||||
icon: Users,
|
||||
title: 'Add Your First Contacts',
|
||||
description: 'Import your subscriber list to start sending emails',
|
||||
link: '/contacts',
|
||||
linkText: 'Add Contacts',
|
||||
isCompleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 3: Send campaign (core functionality, show if they have contacts but never sent)
|
||||
if (hasContacts && !hasSentCampaign) {
|
||||
allSteps.push({
|
||||
id: 'campaign',
|
||||
icon: Mail,
|
||||
title: 'Send Your First Campaign',
|
||||
description: 'Create and send your first email campaign',
|
||||
link: '/campaigns',
|
||||
linkText: 'Create Campaign',
|
||||
isCompleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 4: Set up automation (advanced feature, only show if no workflow and have contacts)
|
||||
if (hasContacts && !setupState.hasEnabledWorkflow) {
|
||||
allSteps.push({
|
||||
id: 'workflows',
|
||||
icon: Zap,
|
||||
title: 'Set Up Automation',
|
||||
description: 'Create automated workflows to engage your audience',
|
||||
link: '/workflows',
|
||||
linkText: 'Create Workflow',
|
||||
isCompleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 5: Subscription (nice to have, lower priority)
|
||||
if (billingEnabled && !setupState.hasSubscription) {
|
||||
allSteps.push({
|
||||
id: 'subscription',
|
||||
icon: Shield,
|
||||
title: 'Upgrade Your Plan',
|
||||
description: 'Remove Plunk branding and unlock more features',
|
||||
link: '/settings?tab=billing',
|
||||
linkText: 'Upgrade',
|
||||
isCompleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 6: Re-engagement (show if they've been inactive)
|
||||
if (hasSentCampaign && !hasRecentCampaign && daysSinceLastCampaign !== null && daysSinceLastCampaign > 30) {
|
||||
allSteps.push({
|
||||
id: 'campaign-reengagement',
|
||||
icon: Mail,
|
||||
title: 'Re-engage Your Audience',
|
||||
description: `It's been ${daysSinceLastCampaign} days since your last campaign`,
|
||||
link: '/campaigns',
|
||||
linkText: 'Create Campaign',
|
||||
isCompleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
// If core setup is complete and they're actively sending, show success message
|
||||
if (setupState.hasVerifiedDomain && hasContacts && hasSentCampaign && hasRecentCampaign) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Start</CardTitle>
|
||||
<CardDescription>Your project is fully set up</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-4 p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||||
<div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-700" />
|
||||
</div>
|
||||
<div className="flex-1 pt-0.5">
|
||||
<p className="text-sm font-semibold text-green-900 mb-1">All set!</p>
|
||||
<p className="text-xs text-green-700 leading-relaxed">
|
||||
Your project is fully configured and you're actively engaging your audience. Keep up the great
|
||||
work!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<HelpResources />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show only the first 3 most important steps
|
||||
const visibleSteps = allSteps.slice(0, 3);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Start</CardTitle>
|
||||
<CardDescription>
|
||||
{visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{visibleSteps.map(step => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="group relative flex items-start gap-4 p-4 rounded-lg border border-neutral-200 bg-neutral-50/50 hover:bg-neutral-50 hover:border-neutral-300 transition-all duration-200"
|
||||
>
|
||||
<div
|
||||
className={`h-10 w-10 rounded-lg ${step.isCompleted ? 'bg-green-100 border border-green-200' : 'bg-white border border-neutral-200'} flex items-center justify-center flex-shrink-0 transition-colors`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-5 w-5 ${step.isCompleted ? 'text-green-700' : 'text-neutral-600'} transition-colors`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="flex items-start justify-between gap-3 mb-1">
|
||||
<p className="text-sm font-semibold text-neutral-900">{step.title}</p>
|
||||
{step.isCompleted && <CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0 mt-0.5" />}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
<Link href={step.link} className="flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={step.isCompleted ? 'outline' : 'default'}
|
||||
className="h-9 transition-all group-hover:shadow-sm"
|
||||
>
|
||||
{step.linkText}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<HelpResources />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
||||
import {Mail, Server} from 'lucide-react';
|
||||
import {ApiKeyDisplay} from './ApiKeyDisplay';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
|
||||
interface SmtpSettingsProps {
|
||||
smtpConfig: {
|
||||
enabled: boolean;
|
||||
domain?: string;
|
||||
portSecure?: number;
|
||||
portSubmission?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function SmtpSettings({smtpConfig}: SmtpSettingsProps) {
|
||||
const {activeProject} = useActiveProject();
|
||||
|
||||
if (!smtpConfig.enabled) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
SMTP Server
|
||||
</CardTitle>
|
||||
<CardDescription>SMTP server is not configured on this instance</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-600">
|
||||
The SMTP relay server is not enabled. Contact your administrator to enable SMTP support for sending emails
|
||||
via standard mail clients.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isDevelopment = smtpConfig.domain === 'localhost';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isDevelopment && (
|
||||
<Card className="border-yellow-200 bg-yellow-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-yellow-900 text-base flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
Development Mode
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-yellow-800">
|
||||
SMTP server is running in development mode without TLS encryption. Only use for local testing. For
|
||||
production, configure the <code className="bg-yellow-100 px-1 py-0.5 rounded text-xs">SMTP_DOMAIN</code>{' '}
|
||||
environment variable and mount TLS certificates.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
SMTP Server Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Send emails through standard email clients using SMTP protocol. Works with Outlook, Thunderbird, and any
|
||||
SMTP-compatible application.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Server Details */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-neutral-700 block mb-2">SMTP Server</label>
|
||||
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 block">
|
||||
{smtpConfig.domain}
|
||||
</code>
|
||||
<p className="text-xs text-neutral-500 mt-1">Use this hostname in your email client configuration</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-neutral-700 block mb-2">
|
||||
Port (STARTTLS)
|
||||
<span className="ml-2 text-xs font-normal text-neutral-500">
|
||||
{isDevelopment ? 'Plaintext in dev' : 'Recommended'}
|
||||
</span>
|
||||
</label>
|
||||
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 block">
|
||||
{smtpConfig.portSubmission}
|
||||
</code>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{isDevelopment
|
||||
? 'Runs without encryption in development'
|
||||
: 'Submission port with STARTTLS encryption'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-neutral-700 block mb-2">
|
||||
Port (SSL/TLS)
|
||||
{isDevelopment && (
|
||||
<span className="ml-2 text-xs font-normal text-neutral-500">Not available in dev</span>
|
||||
)}
|
||||
</label>
|
||||
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 block">
|
||||
{smtpConfig.portSecure}
|
||||
</code>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{isDevelopment ? 'Requires TLS certificates in production' : 'Implicit TLS encryption'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials */}
|
||||
<div className="pt-4 border-t border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Authentication Credentials</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-neutral-700 block mb-2">Username</label>
|
||||
<code className="flex-1 px-3 py-2 bg-neutral-50 rounded-lg text-xs font-mono text-neutral-900 border border-neutral-200 block">
|
||||
plunk
|
||||
</code>
|
||||
<p className="text-xs text-neutral-500 mt-1">Always use "plunk" as the username</p>
|
||||
</div>
|
||||
|
||||
{activeProject && (
|
||||
<ApiKeyDisplay
|
||||
label="Password"
|
||||
value={activeProject.secret}
|
||||
description="Use your project secret key as the SMTP password"
|
||||
isSecret
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {CardDescription, CardTitle} from '@plunk/ui';
|
||||
|
||||
interface StepHeaderProps {
|
||||
stepNumber: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable step header component with numbered circle
|
||||
* Used in multi-step forms like campaign creation
|
||||
*/
|
||||
export function StepHeader({stepNumber, title, description}: StepHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground text-sm font-semibold">
|
||||
{stepNumber}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {Alert, Button} from '@plunk/ui';
|
||||
import {AlertTriangle, ExternalLink} from 'lucide-react';
|
||||
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
|
||||
|
||||
interface UnpaidInvoiceBannerProps {
|
||||
projectId: string;
|
||||
hasSubscription: boolean;
|
||||
}
|
||||
|
||||
export function UnpaidInvoiceBanner({projectId, hasSubscription}: UnpaidInvoiceBannerProps) {
|
||||
const {invoicesData, isLoading} = useBillingInvoices(projectId, hasSubscription);
|
||||
|
||||
// Don't show banner if not loading and either no subscription or no unpaid invoices
|
||||
if (!hasSubscription || isLoading || !invoicesData?.hasUnpaidInvoices) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Soon';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const totalUnpaid = invoicesData.unpaidInvoices.reduce((sum, invoice) => sum + invoice.amountDue, 0);
|
||||
const currency = invoicesData.unpaidInvoices[0]?.currency || 'usd';
|
||||
const oldestDueDate = invoicesData.unpaidInvoices.reduce(
|
||||
(oldest, invoice) => {
|
||||
if (!invoice.dueDate) return oldest;
|
||||
if (!oldest) return invoice.dueDate;
|
||||
return new Date(invoice.dueDate) < new Date(oldest) ? invoice.dueDate : oldest;
|
||||
},
|
||||
null as string | null,
|
||||
);
|
||||
|
||||
const handlePayNow = () => {
|
||||
const firstInvoice = invoicesData.unpaidInvoices[0];
|
||||
if (firstInvoice?.hostedInvoiceUrl) {
|
||||
window.open(firstInvoice.hostedInvoiceUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-6 bg-red-50 border-red-300">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-red-900 mb-1">
|
||||
{invoicesData.unpaidInvoices.length === 1
|
||||
? 'You have an unpaid invoice'
|
||||
: `You have ${invoicesData.unpaidInvoices.length} unpaid invoices`}
|
||||
</h3>
|
||||
<p className="text-sm text-red-800">
|
||||
Total amount due: <span className="font-semibold">{formatCurrency(totalUnpaid, currency)}</span>
|
||||
{oldestDueDate && (
|
||||
<>
|
||||
{' '}
|
||||
• Due by: <span className="font-semibold">{formatDate(oldestDueDate)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-red-700 mt-1">
|
||||
Please pay your outstanding invoices to avoid service interruption.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handlePayNow}
|
||||
className="ml-4 bg-red-600 hover:bg-red-700 text-white flex items-center gap-2"
|
||||
>
|
||||
Pay Now
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
type Edge,
|
||||
Handle,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
type Node,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import type {WorkflowStep} from '@plunk/db';
|
||||
import {Clock, GitBranch, LogOut, Mail, Plus, Settings, Trash2, UserCog, Webhook} from 'lucide-react';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import dagre from 'dagre';
|
||||
import {network} from '../lib/network';
|
||||
import {toast} from 'sonner';
|
||||
import {Button, ConfirmDialog, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@plunk/ui';
|
||||
import {WorkflowSchemas} from '@plunk/shared';
|
||||
|
||||
interface WorkflowBuilderProps {
|
||||
workflowId: string;
|
||||
steps: (WorkflowStep & {
|
||||
template?: {id: string; name: string} | null;
|
||||
outgoingTransitions: Array<{
|
||||
id: string;
|
||||
toStepId: string;
|
||||
condition: unknown;
|
||||
priority: number;
|
||||
}>;
|
||||
incomingTransitions: Array<{
|
||||
id: string;
|
||||
fromStepId: string;
|
||||
condition: unknown;
|
||||
priority: number;
|
||||
}>;
|
||||
})[];
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
const STEP_TYPE_ICONS = {
|
||||
TRIGGER: GitBranch,
|
||||
SEND_EMAIL: Mail,
|
||||
DELAY: Clock,
|
||||
WAIT_FOR_EVENT: Clock,
|
||||
CONDITION: GitBranch,
|
||||
EXIT: LogOut,
|
||||
WEBHOOK: Webhook,
|
||||
UPDATE_CONTACT: UserCog,
|
||||
};
|
||||
|
||||
const STEP_TYPE_COLORS = {
|
||||
TRIGGER: '#9333ea',
|
||||
SEND_EMAIL: '#2563eb',
|
||||
DELAY: '#ea580c',
|
||||
WAIT_FOR_EVENT: '#ca8a04',
|
||||
CONDITION: '#9333ea',
|
||||
EXIT: '#dc2626',
|
||||
WEBHOOK: '#16a34a',
|
||||
UPDATE_CONTACT: '#4f46e5',
|
||||
};
|
||||
|
||||
const STEP_TYPE_BG = {
|
||||
TRIGGER: '#f3e8ff',
|
||||
SEND_EMAIL: '#dbeafe',
|
||||
DELAY: '#ffedd5',
|
||||
WAIT_FOR_EVENT: '#fef3c7',
|
||||
CONDITION: '#f3e8ff',
|
||||
EXIT: '#fee2e2',
|
||||
WEBHOOK: '#dcfce7',
|
||||
UPDATE_CONTACT: '#e0e7ff',
|
||||
};
|
||||
|
||||
// Dagre layout function
|
||||
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const nodeWidth = 280;
|
||||
const nodeHeight = 120;
|
||||
|
||||
dagreGraph.setGraph({
|
||||
rankdir: 'TB',
|
||||
nodesep: 100,
|
||||
ranksep: 150,
|
||||
marginx: 50,
|
||||
marginy: 50,
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
dagreGraph.setNode(node.id, {width: nodeWidth, height: nodeHeight});
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = nodes.map(node => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {nodes: layoutedNodes, edges};
|
||||
}
|
||||
|
||||
// Add Step Node - appears at the end of flow paths
|
||||
function AddStepNode({data}: {data: {label: string; onClick?: () => void}}) {
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={{
|
||||
background: '#94a3b8',
|
||||
width: 14,
|
||||
height: 14,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="cursor-pointer hover:scale-110 transition-transform" onClick={data.onClick}>
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-neutral-100 to-neutral-200 border-2 border-dashed border-neutral-400 hover:border-neutral-600 hover:from-blue-50 hover:to-blue-100 hover:border-blue-400 flex items-center justify-center shadow-md hover:shadow-lg transition-all">
|
||||
<Plus className="h-8 w-8 text-neutral-500 hover:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
{data.label && <div className="text-xs text-neutral-500 text-center mt-2 font-medium">{data.label}</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom node component with action buttons
|
||||
function CustomNode({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
label: string;
|
||||
type: string;
|
||||
stepId?: string;
|
||||
icon?: any;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
template?: {name: string};
|
||||
config?: any;
|
||||
};
|
||||
}) {
|
||||
const Icon = data.icon;
|
||||
const color = data.color;
|
||||
const bgColor = data.bgColor;
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={{
|
||||
background: color,
|
||||
width: 14,
|
||||
height: 14,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all relative group"
|
||||
style={{
|
||||
borderColor: color,
|
||||
minWidth: '280px',
|
||||
maxWidth: '280px',
|
||||
}}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
{/* Action buttons - shown on hover */}
|
||||
{showActions && data.type !== 'TRIGGER' && (
|
||||
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
data.onEdit?.();
|
||||
}}
|
||||
className="p-1.5 bg-white border-2 border-neutral-300 rounded-lg shadow-md hover:border-neutral-400 hover:bg-neutral-50 transition-all"
|
||||
title="Edit step"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5 text-neutral-700" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
data.onDelete?.();
|
||||
}}
|
||||
className="p-1.5 bg-white border-2 border-red-300 rounded-lg shadow-md hover:border-red-400 hover:bg-red-50 transition-all"
|
||||
title="Delete step"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{backgroundColor: bgColor}}
|
||||
>
|
||||
<Icon className="h-5 w-5" style={{color}} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-neutral-900 text-sm leading-tight mb-1 break-words">{data.label}</h4>
|
||||
<span
|
||||
className="inline-flex items-center text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{data.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{data.template && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<span className="font-medium">📧</span>
|
||||
<span className="truncate">{data.template.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'DELAY' && data.config?.amount && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<span className="font-medium">⏱️</span>
|
||||
<span>
|
||||
Wait {data.config.amount} {data.config.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'CONDITION' && data.config && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="text-xs text-neutral-600">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="font-medium">🔀</span>
|
||||
<span className="font-mono text-[10px] truncate">{data.config.field}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.operator} "{String(data.config.value)}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'WAIT_FOR_EVENT' && data.config?.eventName && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<span className="font-medium">⏳</span>
|
||||
<span className="truncate">{data.config.eventName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'WEBHOOK' && data.config?.url && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<span className="font-medium">🔗</span>
|
||||
<span className="truncate text-[10px]">
|
||||
{data.config.method || 'POST'} {data.config.url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={{
|
||||
background: color,
|
||||
width: 14,
|
||||
height: 14,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
addStep: AddStepNode,
|
||||
};
|
||||
|
||||
// Step type options for adding new steps
|
||||
const STEP_TYPE_OPTIONS = [
|
||||
{value: 'SEND_EMAIL', label: 'Send Email', icon: Mail, color: STEP_TYPE_COLORS.SEND_EMAIL},
|
||||
{value: 'DELAY', label: 'Delay', icon: Clock, color: STEP_TYPE_COLORS.DELAY},
|
||||
{value: 'WAIT_FOR_EVENT', label: 'Wait for Event', icon: Clock, color: STEP_TYPE_COLORS.WAIT_FOR_EVENT},
|
||||
{value: 'CONDITION', label: 'Condition', icon: GitBranch, color: STEP_TYPE_COLORS.CONDITION},
|
||||
{value: 'WEBHOOK', label: 'Webhook', icon: Webhook, color: STEP_TYPE_COLORS.WEBHOOK},
|
||||
{value: 'UPDATE_CONTACT', label: 'Update Contact', icon: UserCog, color: STEP_TYPE_COLORS.UPDATE_CONTACT},
|
||||
{value: 'EXIT', label: 'Exit', icon: LogOut, color: STEP_TYPE_COLORS.EXIT},
|
||||
];
|
||||
|
||||
export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderProps) {
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const [addStepContext, setAddStepContext] = useState<{
|
||||
fromStepId: string | null;
|
||||
branch?: 'yes' | 'no';
|
||||
} | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||
|
||||
// Convert workflow steps to React Flow nodes
|
||||
|
||||
const rawNodes: Node[] = useMemo(() => {
|
||||
if (steps.length === 0) return [];
|
||||
|
||||
const nodes: Node[] = steps.map(step => {
|
||||
const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch;
|
||||
const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280';
|
||||
const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6';
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
type: 'custom',
|
||||
position: step.position ? (step.position as {x: number; y: number}) : {x: 0, y: 0},
|
||||
data: {
|
||||
label: step.name,
|
||||
type: step.type,
|
||||
icon: Icon,
|
||||
color,
|
||||
bgColor,
|
||||
template: step.template,
|
||||
config: step.config,
|
||||
onEdit: () => handleEditStep(step.id),
|
||||
onDelete: () => {
|
||||
setStepToDelete(step.id);
|
||||
setShowDeleteDialog(true);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Add "Add Step" nodes at the end of each flow path
|
||||
steps.forEach(step => {
|
||||
if (step.type === 'EXIT') return; // Exit steps can't have next steps
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
// Check for yes and no branches
|
||||
const hasYesBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
|
||||
});
|
||||
const hasNoBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
|
||||
});
|
||||
|
||||
if (!hasYesBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-yes`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
data: {
|
||||
label: 'Yes',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasNoBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-no`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
data: {
|
||||
label: 'No',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For non-condition steps, add + node if no outgoing transitions
|
||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
data: {
|
||||
label: '',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}, [steps]);
|
||||
|
||||
// Convert transitions to React Flow edges
|
||||
const rawEdges: Edge[] = useMemo(() => {
|
||||
const edges: Edge[] = [];
|
||||
|
||||
steps.forEach(step => {
|
||||
if (step.outgoingTransitions && step.outgoingTransitions.length > 0) {
|
||||
step.outgoingTransitions.forEach(transition => {
|
||||
const condition = transition.condition;
|
||||
const isConditional = condition && typeof condition === 'object' && 'branch' in condition;
|
||||
const branch =
|
||||
condition && typeof condition === 'object' && 'branch' in condition ? condition.branch : undefined;
|
||||
|
||||
edges.push({
|
||||
id: transition.id,
|
||||
source: step.id,
|
||||
target: transition.toStepId,
|
||||
type: 'smoothstep',
|
||||
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
|
||||
label: isConditional ? (branch === 'yes' ? '✓ Yes' : '✗ No') : undefined,
|
||||
labelStyle: {
|
||||
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: '#fff',
|
||||
fillOpacity: 0.95,
|
||||
},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {
|
||||
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
width: 22,
|
||||
height: 22,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add edges from steps to their "Add Step" nodes
|
||||
if (step.type === 'EXIT') return;
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
const hasYesBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
|
||||
);
|
||||
const hasNoBranch = step.outgoingTransitions?.some(
|
||||
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
|
||||
);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-yes-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-yes`,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
label: '✓ Yes',
|
||||
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: '#16a34a', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#16a34a', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasNoBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-no-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-no`,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
label: '✗ No',
|
||||
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: '#dc2626', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#dc2626', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add`,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return edges;
|
||||
}, [steps]);
|
||||
|
||||
// Apply dagre layout
|
||||
const {nodes: layoutedNodes, edges: layoutedEdges} = useMemo(() => {
|
||||
if (rawNodes.length === 0) return {nodes: [], edges: []};
|
||||
return getLayoutedElements(rawNodes, rawEdges);
|
||||
}, [rawNodes, rawEdges]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
||||
|
||||
// Update nodes/edges when layout changes
|
||||
useEffect(() => {
|
||||
setNodes(layoutedNodes);
|
||||
}, [layoutedNodes, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges(layoutedEdges);
|
||||
}, [layoutedEdges, setEdges]);
|
||||
|
||||
// Handle creating a new step from the + node
|
||||
|
||||
const handleCreateStep = useCallback(
|
||||
async (stepType: string) => {
|
||||
if (!addStepContext?.fromStepId) return;
|
||||
|
||||
try {
|
||||
// Validate that this branch doesn't already have a transition
|
||||
const fromStep = steps.find(s => s.id === addStepContext.fromStepId);
|
||||
if (!fromStep) {
|
||||
toast.error('Parent step not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// For CONDITION steps, check if the branch already exists
|
||||
if (fromStep.type === 'CONDITION' && addStepContext.branch) {
|
||||
const existingBranchTransition = fromStep.outgoingTransitions?.find(t => {
|
||||
const condition = t.condition;
|
||||
return (
|
||||
condition &&
|
||||
typeof condition === 'object' &&
|
||||
'branch' in condition &&
|
||||
condition.branch === addStepContext.branch
|
||||
);
|
||||
});
|
||||
if (existingBranchTransition) {
|
||||
toast.error(`The ${addStepContext.branch} branch already has a connection`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the new step (autoConnect: false because we manually create the transition with branch info)
|
||||
const newStep = await network.fetch<WorkflowStep, typeof WorkflowSchemas.addStep>(
|
||||
'POST',
|
||||
`/workflows/${workflowId}/steps`,
|
||||
{
|
||||
type: stepType as WorkflowStep['type'],
|
||||
name: `New ${stepType.toLowerCase().replace('_', ' ')}`,
|
||||
position: {x: 0, y: 0}, // Will be auto-positioned by dagre layout
|
||||
config: {},
|
||||
},
|
||||
);
|
||||
|
||||
const newStepId = newStep.id;
|
||||
|
||||
// Create the transition with proper condition
|
||||
const condition = addStepContext.branch ? {branch: addStepContext.branch} : null;
|
||||
const priority = addStepContext.branch === 'yes' ? 0 : addStepContext.branch === 'no' ? 1 : 0;
|
||||
|
||||
await network.fetch<unknown, typeof WorkflowSchemas.createTransition>(
|
||||
'POST',
|
||||
`/workflows/${workflowId}/transitions`,
|
||||
{
|
||||
fromStepId: addStepContext.fromStepId,
|
||||
toStepId: newStepId,
|
||||
condition,
|
||||
priority,
|
||||
},
|
||||
);
|
||||
|
||||
toast.success('Step added successfully');
|
||||
setAddStepContext(null);
|
||||
onUpdate();
|
||||
|
||||
// Trigger edit dialog for the new step after a short delay
|
||||
setTimeout(() => {
|
||||
const event = new CustomEvent('workflow-edit-step', {detail: {stepId: newStepId}});
|
||||
window.dispatchEvent(event);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to add step');
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[addStepContext, workflowId, onUpdate],
|
||||
);
|
||||
|
||||
const handleEditStep = (stepId: string) => {
|
||||
// This will be handled by the parent component
|
||||
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const handleDeleteStep = async () => {
|
||||
if (!stepToDelete) return;
|
||||
|
||||
try {
|
||||
await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`);
|
||||
toast.success('Step deleted');
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete step');
|
||||
} finally {
|
||||
setStepToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-layout on demand
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
const {nodes: newNodes, edges: newEdges} = getLayoutedElements(nodes, edges);
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
|
||||
// Fit view after layout
|
||||
setTimeout(() => {
|
||||
reactFlowInstance?.fitView({padding: 0.3});
|
||||
}, 10);
|
||||
}, [nodes, edges, setNodes, setEdges, reactFlowInstance]);
|
||||
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className="bg-neutral-50 border-2 border-dashed border-neutral-300 rounded-lg p-12 text-center">
|
||||
<GitBranch className="h-16 w-16 text-neutral-400 mx-auto mb-4" />
|
||||
<p className="text-neutral-600 font-medium">No workflow steps yet</p>
|
||||
<p className="text-sm text-neutral-500 mt-2">Add your first step to get started</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-[800px] bg-gradient-to-br from-neutral-50 to-neutral-100 rounded-lg border border-neutral-200 shadow-inner relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
padding: 0.3,
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1.2,
|
||||
}}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
nodesDraggable={true}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={true}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
}}
|
||||
deleteKeyCode={null}
|
||||
proOptions={{hideAttribution: true}}
|
||||
>
|
||||
<Background color="#e5e7eb" gap={16} size={1} />
|
||||
<Controls
|
||||
showInteractive={false}
|
||||
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={node => {
|
||||
const step = steps.find(s => s.id === node.id);
|
||||
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
|
||||
}}
|
||||
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
|
||||
maskColor="rgba(0, 0, 0, 0.05)"
|
||||
/>
|
||||
<Panel
|
||||
position="top-left"
|
||||
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GitBranch className="h-4 w-4 text-neutral-700" />
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-neutral-900">{steps.length}</span>
|
||||
<span className="text-neutral-600"> step{steps.length !== 1 ? 's' : ''}</span>
|
||||
<span className="text-neutral-400 mx-2">·</span>
|
||||
<span className="font-semibold text-neutral-900">{rawEdges.length}</span>
|
||||
<span className="text-neutral-600"> connection{rawEdges.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel position="top-right" className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAutoLayout}
|
||||
className="bg-white/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-white hover:text-neutral-900 transition-all"
|
||||
>
|
||||
Auto Layout
|
||||
</button>
|
||||
</Panel>
|
||||
{rawEdges.length === 0 && steps.length > 1 && (
|
||||
<Panel
|
||||
position="bottom-center"
|
||||
className="bg-blue-50 border border-blue-200 px-4 py-2.5 rounded-lg shadow-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-blue-900">
|
||||
<span>💡</span>
|
||||
<span>Click the + buttons to add and connect steps!</span>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Step type picker dialog */}
|
||||
<Dialog open={!!addStepContext} onOpenChange={open => !open && setAddStepContext(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Step</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3 py-4">
|
||||
{STEP_TYPE_OPTIONS.map(option => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleCreateStep(option.value)}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50 transition-all group"
|
||||
style={{
|
||||
borderColor: 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = option.color;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center transition-transform group-hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: `${option.color}15`,
|
||||
}}
|
||||
>
|
||||
<Icon className="h-6 w-6" style={{color: option.color}} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-900">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddStepContext(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDeleteStep}
|
||||
title="Delete Step"
|
||||
description="Are you sure you want to delete this step?"
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
type Edge,
|
||||
Handle,
|
||||
MarkerType,
|
||||
type Node,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import type {WorkflowStep} from '@plunk/db';
|
||||
import {Clock, GitBranch, LogOut, Mail, UserCog, Webhook} from 'lucide-react';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import dagre from 'dagre';
|
||||
|
||||
interface WorkflowVisualizerProps {
|
||||
steps: (WorkflowStep & {
|
||||
template?: {id: string; name: string} | null;
|
||||
outgoingTransitions: Array<{
|
||||
id: string;
|
||||
toStepId: string;
|
||||
condition: unknown;
|
||||
priority: number;
|
||||
}>;
|
||||
incomingTransitions: Array<{
|
||||
id: string;
|
||||
fromStepId: string;
|
||||
condition: unknown;
|
||||
priority: number;
|
||||
}>;
|
||||
})[];
|
||||
}
|
||||
|
||||
const STEP_TYPE_ICONS = {
|
||||
TRIGGER: GitBranch,
|
||||
SEND_EMAIL: Mail,
|
||||
DELAY: Clock,
|
||||
WAIT_FOR_EVENT: Clock,
|
||||
CONDITION: GitBranch,
|
||||
EXIT: LogOut,
|
||||
WEBHOOK: Webhook,
|
||||
UPDATE_CONTACT: UserCog,
|
||||
};
|
||||
|
||||
const STEP_TYPE_COLORS = {
|
||||
TRIGGER: '#9333ea', // purple-600
|
||||
SEND_EMAIL: '#2563eb', // blue-600
|
||||
DELAY: '#ea580c', // orange-600
|
||||
WAIT_FOR_EVENT: '#ca8a04', // yellow-600
|
||||
CONDITION: '#9333ea', // purple-600
|
||||
EXIT: '#dc2626', // red-600
|
||||
WEBHOOK: '#16a34a', // green-600
|
||||
UPDATE_CONTACT: '#4f46e5', // indigo-600
|
||||
};
|
||||
|
||||
const STEP_TYPE_BG = {
|
||||
TRIGGER: '#f3e8ff', // purple-50
|
||||
SEND_EMAIL: '#dbeafe', // blue-50
|
||||
DELAY: '#ffedd5', // orange-50
|
||||
WAIT_FOR_EVENT: '#fef3c7', // yellow-50
|
||||
CONDITION: '#f3e8ff', // purple-50
|
||||
EXIT: '#fee2e2', // red-50
|
||||
WEBHOOK: '#dcfce7', // green-50
|
||||
UPDATE_CONTACT: '#e0e7ff', // indigo-50
|
||||
};
|
||||
|
||||
// Dagre layout function
|
||||
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const nodeWidth = 250;
|
||||
const nodeHeight = 100;
|
||||
|
||||
dagreGraph.setGraph({
|
||||
rankdir: 'TB', // Top to Bottom
|
||||
nodesep: 80, // Horizontal spacing
|
||||
ranksep: 120, // Vertical spacing
|
||||
marginx: 50,
|
||||
marginy: 50,
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
dagreGraph.setNode(node.id, {width: nodeWidth, height: nodeHeight});
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = nodes.map(node => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {nodes: layoutedNodes, edges};
|
||||
}
|
||||
|
||||
// Custom node component
|
||||
function CustomNode({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
label: string;
|
||||
type: string;
|
||||
icon?: any;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
template?: {name: string};
|
||||
config?: any;
|
||||
};
|
||||
}) {
|
||||
const Icon = data.icon;
|
||||
const color = data.color;
|
||||
const bgColor = data.bgColor;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Target Handle (top) - where edges come IN */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={{
|
||||
background: color,
|
||||
width: 12,
|
||||
height: 12,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
borderColor: color,
|
||||
minWidth: '250px',
|
||||
maxWidth: '250px',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{backgroundColor: bgColor}}
|
||||
>
|
||||
<Icon className="h-5 w-5" style={{color}} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-neutral-900 text-sm leading-tight mb-1 break-words">{data.label}</h4>
|
||||
<span
|
||||
className="inline-flex items-center text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{data.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{data.template && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<span className="font-medium">📧</span>
|
||||
<span className="truncate">{data.template.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'DELAY' && data.config?.amount && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<span className="font-medium">⏱️</span>
|
||||
<span>
|
||||
Wait {data.config.amount} {data.config.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'CONDITION' && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="text-xs text-neutral-600">
|
||||
<span className="font-medium">🔀</span> If/Else Branch
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'WAIT_FOR_EVENT' && data.config?.eventName && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<span className="font-medium">⏳</span>
|
||||
<span className="truncate">{data.config.eventName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'WEBHOOK' && data.config?.url && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<span className="font-medium">🔗</span>
|
||||
<span className="truncate">{data.config.method || 'POST'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source Handle (bottom) - where edges go OUT */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={{
|
||||
background: color,
|
||||
width: 12,
|
||||
height: 12,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
};
|
||||
|
||||
export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
|
||||
// Convert workflow steps to React Flow nodes
|
||||
const rawNodes: Node[] = useMemo(() => {
|
||||
if (steps.length === 0) return [];
|
||||
|
||||
const nodes: Node[] = steps.map(step => {
|
||||
const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch;
|
||||
const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280';
|
||||
const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6';
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
type: 'custom',
|
||||
position: {x: 0, y: 0}, // Will be set by layout
|
||||
data: {
|
||||
label: step.name,
|
||||
type: step.type,
|
||||
icon: Icon,
|
||||
color,
|
||||
bgColor,
|
||||
template: step.template,
|
||||
config: step.config,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Add END nodes for CONDITION steps with missing branches
|
||||
steps.forEach(step => {
|
||||
if (step.type === 'CONDITION') {
|
||||
const hasYesBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
|
||||
);
|
||||
const hasNoBranch = step.outgoingTransitions?.some(
|
||||
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
|
||||
);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-yes-end`,
|
||||
type: 'custom',
|
||||
position: {x: 0, y: 0},
|
||||
data: {
|
||||
label: 'End Workflow',
|
||||
type: 'END',
|
||||
icon: LogOut,
|
||||
color: '#9ca3af',
|
||||
bgColor: '#f3f4f6',
|
||||
template: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasNoBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-no-end`,
|
||||
type: 'custom',
|
||||
position: {x: 0, y: 0},
|
||||
data: {
|
||||
label: 'End Workflow',
|
||||
type: 'END',
|
||||
icon: LogOut,
|
||||
color: '#9ca3af',
|
||||
bgColor: '#f3f4f6',
|
||||
template: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}, [steps]);
|
||||
|
||||
// Convert transitions to React Flow edges
|
||||
const rawEdges: Edge[] = useMemo(() => {
|
||||
const edges: Edge[] = [];
|
||||
|
||||
steps.forEach(step => {
|
||||
// Add edges for existing transitions
|
||||
if (step.outgoingTransitions && step.outgoingTransitions.length > 0) {
|
||||
step.outgoingTransitions.forEach(transition => {
|
||||
const isConditional =
|
||||
transition.condition && typeof transition.condition === 'object' && 'branch' in transition.condition;
|
||||
const branch =
|
||||
transition.condition && typeof transition.condition === 'object' && 'branch' in transition.condition
|
||||
? transition.condition.branch
|
||||
: undefined;
|
||||
|
||||
edges.push({
|
||||
id: transition.id,
|
||||
source: step.id,
|
||||
target: transition.toStepId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
label: isConditional ? (branch === 'yes' ? '✓ Yes' : '✗ No') : undefined,
|
||||
labelStyle: {
|
||||
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: '#fff',
|
||||
fillOpacity: 0.95,
|
||||
},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {
|
||||
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add edges to END nodes for CONDITION steps with missing branches
|
||||
if (step.type === 'CONDITION') {
|
||||
const hasYesBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
|
||||
);
|
||||
const hasNoBranch = step.outgoingTransitions?.some(
|
||||
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
|
||||
);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-yes-end-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-yes-end`,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
label: '✓ Yes',
|
||||
labelStyle: {
|
||||
fill: '#16a34a',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: '#fff',
|
||||
fillOpacity: 0.95,
|
||||
},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {
|
||||
stroke: '#16a34a',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '5,5',
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: '#16a34a',
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasNoBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-no-end-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-no-end`,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
label: '✗ No',
|
||||
labelStyle: {
|
||||
fill: '#dc2626',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: '#fff',
|
||||
fillOpacity: 0.95,
|
||||
},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {
|
||||
stroke: '#dc2626',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '5,5',
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: '#dc2626',
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return edges;
|
||||
}, [steps]);
|
||||
|
||||
// Apply dagre layout
|
||||
const {nodes: layoutedNodes, edges: layoutedEdges} = useMemo(() => {
|
||||
if (rawNodes.length === 0) return {nodes: [], edges: []};
|
||||
return getLayoutedElements(rawNodes, rawEdges);
|
||||
}, [rawNodes, rawEdges]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
||||
|
||||
// Update nodes/edges when layout changes
|
||||
useEffect(() => {
|
||||
setNodes(layoutedNodes);
|
||||
}, [layoutedNodes, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges(layoutedEdges);
|
||||
}, [layoutedEdges, setEdges]);
|
||||
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className="bg-neutral-50 border-2 border-dashed border-neutral-300 rounded-lg p-12 text-center">
|
||||
<GitBranch className="h-16 w-16 text-neutral-400 mx-auto mb-4" />
|
||||
<p className="text-neutral-600 font-medium">No workflow steps yet</p>
|
||||
<p className="text-sm text-neutral-500 mt-2">Add steps to your workflow to see the visualization</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-[700px] bg-gradient-to-br from-neutral-50 to-neutral-100 rounded-lg border border-neutral-200 shadow-inner">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
padding: 0.3,
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1.2,
|
||||
}}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
nodesDraggable={true}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={true}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
}}
|
||||
proOptions={{hideAttribution: true}}
|
||||
>
|
||||
<Background color="#e5e7eb" gap={16} size={1} />
|
||||
<Controls
|
||||
showInteractive={false}
|
||||
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
|
||||
/>
|
||||
<Panel
|
||||
position="top-left"
|
||||
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GitBranch className="h-4 w-4 text-neutral-700" />
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-neutral-900">{steps.length}</span>
|
||||
<span className="text-neutral-600"> step{steps.length !== 1 ? 's' : ''}</span>
|
||||
<span className="text-neutral-400 mx-2">·</span>
|
||||
<span className="font-semibold text-neutral-900">{rawEdges.length}</span>
|
||||
<span className="text-neutral-600"> transition{rawEdges.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
{rawEdges.length === 0 && steps.length > 1 && (
|
||||
<Panel
|
||||
position="bottom-center"
|
||||
className="bg-amber-50 border border-amber-200 px-4 py-2.5 rounded-lg shadow-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-amber-900">
|
||||
<span>⚠️</span>
|
||||
<span>No transitions found. Connect your steps to see the flow.</span>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Environment configuration
|
||||
// In development: Uses NEXT_PUBLIC_* env vars from .env file
|
||||
// In production (Docker): Build-time values are replaced at container startup via sed
|
||||
|
||||
export const API_URI = process.env.NEXT_PUBLIC_API_URI || 'http://localhost:8080';
|
||||
export const DASHBOARD_URI = process.env.NEXT_PUBLIC_DASHBOARD_URI || 'http://localhost:3000';
|
||||
export const LANDING_URI = process.env.NEXT_PUBLIC_LANDING_URI || 'http://localhost:4000';
|
||||
export const WIKI_URI = process.env.NEXT_PUBLIC_WIKI_URI || 'http://localhost:1000';
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {Project} from '@plunk/db';
|
||||
import {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
|
||||
import {useSWRConfig} from 'swr';
|
||||
|
||||
import {useProjects} from '../hooks/useProject';
|
||||
|
||||
interface ActiveProjectContextValue {
|
||||
activeProject: Project | null;
|
||||
setActiveProject: (project: Project) => void;
|
||||
availableProjects: Project[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ActiveProjectContext = createContext<ActiveProjectContextValue | undefined>(undefined);
|
||||
|
||||
export function ActiveProjectProvider({children}: {children: ReactNode}) {
|
||||
const {data: projects, isLoading} = useProjects();
|
||||
const {mutate} = useSWRConfig();
|
||||
|
||||
// State is null until projects load, but localStorage is read synchronously by network.ts
|
||||
// This ensures consistent behavior: either all calls use stored ID, or all fall back to projects[0]
|
||||
const [activeProject, setActiveProjectState] = useState<Project | null>(null);
|
||||
|
||||
// Initialize active project from localStorage or use first project
|
||||
useEffect(() => {
|
||||
if (!projects || projects.length === 0) return;
|
||||
|
||||
const storedProjectId = localStorage.getItem('activeProjectId');
|
||||
|
||||
if (storedProjectId) {
|
||||
// Find the stored project in available projects
|
||||
const project = projects.find(p => p.id === storedProjectId);
|
||||
if (project) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveProjectState(project);
|
||||
} else if (projects[0]) {
|
||||
// Stored project not found (user might have been removed), use first project
|
||||
|
||||
setActiveProjectState(projects[0]);
|
||||
localStorage.setItem('activeProjectId', projects[0].id);
|
||||
}
|
||||
} else if (projects[0]) {
|
||||
// No stored project, initialize with first one
|
||||
|
||||
setActiveProjectState(projects[0]);
|
||||
localStorage.setItem('activeProjectId', projects[0].id);
|
||||
}
|
||||
}, [projects]);
|
||||
|
||||
const setActiveProject = (project: Project) => {
|
||||
setActiveProjectState(project);
|
||||
localStorage.setItem('activeProjectId', project.id);
|
||||
|
||||
// Invalidate all SWR cache to refetch data for new project
|
||||
void mutate(() => true, undefined, {revalidate: true});
|
||||
};
|
||||
|
||||
const value: ActiveProjectContextValue = {
|
||||
activeProject,
|
||||
setActiveProject,
|
||||
availableProjects: projects ?? [],
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return <ActiveProjectContext.Provider value={value}>{children}</ActiveProjectContext.Provider>;
|
||||
}
|
||||
|
||||
export function useActiveProject() {
|
||||
const context = useContext(ActiveProjectContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useActiveProject must be used within an ActiveProjectProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Shared date formatting and manipulation utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the user's timezone
|
||||
*/
|
||||
export function getUserTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date to datetime-local input format (YYYY-MM-DDTHH:mm)
|
||||
*/
|
||||
export function formatDateTimeLocal(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule presets for quick date/time selection
|
||||
*/
|
||||
export const schedulePresets = {
|
||||
/**
|
||||
* In 1 hour from now
|
||||
*/
|
||||
inOneHour: (): string => {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() + 1);
|
||||
return formatDateTimeLocal(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* In 3 hours from now
|
||||
*/
|
||||
inThreeHours: (): string => {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() + 3);
|
||||
return formatDateTimeLocal(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* Tomorrow at 9 AM
|
||||
*/
|
||||
tomorrowAt9AM: (): string => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 1);
|
||||
date.setHours(9, 0, 0, 0);
|
||||
return formatDateTimeLocal(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* Tomorrow at 2 PM
|
||||
*/
|
||||
tomorrowAt2PM: (): string => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 1);
|
||||
date.setHours(14, 0, 0, 0);
|
||||
return formatDateTimeLocal(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* Next Monday at 9 AM
|
||||
*/
|
||||
nextMonday: (): string => {
|
||||
const date = new Date();
|
||||
const dayOfWeek = date.getDay();
|
||||
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
||||
date.setDate(date.getDate() + daysUntilMonday);
|
||||
date.setHours(9, 0, 0, 0);
|
||||
return formatDateTimeLocal(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* In 1 week at 9 AM
|
||||
*/
|
||||
inOneWeek: (): string => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 7);
|
||||
date.setHours(9, 0, 0, 0);
|
||||
return formatDateTimeLocal(date);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a date for display (full date and time)
|
||||
*/
|
||||
export function formatFullDateTime(date: Date): string {
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date for display in UTC
|
||||
*/
|
||||
export function formatUTCDateTime(date: Date): string {
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {useMemo} from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface ActivityStats {
|
||||
totalEvents: number;
|
||||
totalEmailsSent: number;
|
||||
totalEmailsOpened: number;
|
||||
totalEmailsClicked: number;
|
||||
totalWorkflowsStarted: number;
|
||||
openRate: number;
|
||||
clickRate: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesDataPoint {
|
||||
date: string;
|
||||
emails: number;
|
||||
opens: number;
|
||||
clicks: number;
|
||||
bounces: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
stats: ActivityStats | null;
|
||||
timeSeries: TimeSeriesDataPoint[] | null;
|
||||
isLoading: boolean;
|
||||
error: Error | undefined;
|
||||
}
|
||||
|
||||
interface UseAnalyticsOptions {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
days?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch analytics data including activity stats and time series data
|
||||
*/
|
||||
export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
|
||||
const {days = 30} = options;
|
||||
|
||||
// Calculate date range - memoized to prevent infinite re-renders
|
||||
// Only recalculate when days or explicit dates change
|
||||
/* eslint-disable react-hooks/purity */
|
||||
const {startDate, endDate} = useMemo(() => {
|
||||
const end = options.endDate || new Date().toISOString();
|
||||
const now = Date.now();
|
||||
const start = options.startDate || new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
return {startDate: start, endDate: end};
|
||||
|
||||
}, [days, options.startDate, options.endDate]);
|
||||
/* eslint-enable react-hooks/purity */
|
||||
|
||||
// Fetch activity stats
|
||||
const {
|
||||
data: stats,
|
||||
error: statsError,
|
||||
isLoading: statsLoading,
|
||||
} = useSWR<ActivityStats>(`/activity/stats?startDate=${startDate}&endDate=${endDate}`, {
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 300000, // Refresh every 5 minutes
|
||||
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
|
||||
});
|
||||
|
||||
// Fetch time series data (if endpoint exists)
|
||||
const {
|
||||
data: timeSeries,
|
||||
error: timeSeriesError,
|
||||
isLoading: timeSeriesLoading,
|
||||
} = useSWR<TimeSeriesDataPoint[]>(`/analytics/timeseries?startDate=${startDate}&endDate=${endDate}`, {
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 300000, // Refresh every 5 minutes
|
||||
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
|
||||
shouldRetryOnError: false, // Don't error out if endpoint doesn't exist yet
|
||||
});
|
||||
|
||||
return {
|
||||
stats: stats || null,
|
||||
timeSeries: timeSeries || null,
|
||||
isLoading: statsLoading || timeSeriesLoading,
|
||||
error: statsError || timeSeriesError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface BillingPeriod {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface UsageRecord {
|
||||
period: BillingPeriod;
|
||||
totalUsage: number;
|
||||
}
|
||||
|
||||
export interface UpcomingInvoice {
|
||||
amountDue: number;
|
||||
currency: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
subtotal: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface BillingConsumptionData {
|
||||
period: BillingPeriod;
|
||||
usage: {
|
||||
total: number;
|
||||
records: UsageRecord[];
|
||||
};
|
||||
upcomingInvoice: UpcomingInvoice | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch current month billing consumption from Stripe
|
||||
*/
|
||||
export function useBillingConsumption(projectId: string | undefined, hasSubscription: boolean) {
|
||||
const {data, error, mutate, isLoading} = useSWR<BillingConsumptionData>(
|
||||
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-consumption` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 60000, // Refresh every minute
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
consumptionData: data,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
number: string | null;
|
||||
status: string;
|
||||
amountDue: number;
|
||||
amountPaid: number;
|
||||
currency: string;
|
||||
created: string;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
hostedInvoiceUrl: string | null;
|
||||
invoicePdf: string | null;
|
||||
subtotal: number;
|
||||
total: number;
|
||||
paid: boolean;
|
||||
}
|
||||
|
||||
export interface UnpaidInvoice {
|
||||
id: string;
|
||||
number: string | null;
|
||||
amountDue: number;
|
||||
currency: string;
|
||||
dueDate: string | null;
|
||||
hostedInvoiceUrl: string | null;
|
||||
}
|
||||
|
||||
export interface BillingInvoicesData {
|
||||
invoices: Invoice[];
|
||||
hasUnpaidInvoices: boolean;
|
||||
unpaidInvoices: UnpaidInvoice[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch billing invoices from Stripe
|
||||
*/
|
||||
export function useBillingInvoices(projectId: string | undefined, hasSubscription: boolean) {
|
||||
const {data, error, mutate, isLoading} = useSWR<BillingInvoicesData>(
|
||||
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-invoices` : null,
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
refreshInterval: 300000, // Refresh every 5 minutes
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
invoicesData: data,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface CategoryLimit {
|
||||
usage: number;
|
||||
limit: number | null;
|
||||
percentage: number;
|
||||
isWarning: boolean;
|
||||
isBlocked: boolean;
|
||||
}
|
||||
|
||||
export interface BillingLimitsData {
|
||||
workflows: CategoryLimit;
|
||||
campaigns: CategoryLimit;
|
||||
transactional: CategoryLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch billing limits for a project
|
||||
*/
|
||||
export function useBillingLimits(projectId: string | undefined, hasSubscription: boolean) {
|
||||
const {data, error, mutate, isLoading} = useSWR<BillingLimitsData>(
|
||||
projectId && hasSubscription ? `/users/@me/projects/${projectId}/billing-limits` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 30000, // Refresh every 30 seconds to keep usage updated
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
limitsData: data,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {useBeforeUnload} from '@plunk/ui';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook to handle unsaved changes warning
|
||||
* Warns user before navigating away (browser or Next.js navigation) when there are unsaved changes
|
||||
*/
|
||||
export function useChangeTracking(hasChanges: boolean, enabled: boolean = true) {
|
||||
const router = useRouter();
|
||||
|
||||
// Warn before leaving page with unsaved changes (browser navigation)
|
||||
useBeforeUnload(enabled && hasChanges);
|
||||
|
||||
// Warn before Next.js route changes
|
||||
useEffect(() => {
|
||||
if (!enabled || !hasChanges) return;
|
||||
|
||||
const handleRouteChange = (url: string) => {
|
||||
// Only show confirmation if navigating to a different page
|
||||
if (router.asPath !== url) {
|
||||
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to leave?');
|
||||
if (!confirmed) {
|
||||
router.events.emit('routeChangeError');
|
||||
throw 'Route change aborted by user';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
router.events.on('routeChangeStart', handleRouteChange);
|
||||
|
||||
return () => {
|
||||
router.events.off('routeChangeStart', handleRouteChange);
|
||||
};
|
||||
}, [hasChanges, enabled, router]);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface ConfigResponse {
|
||||
environment: string;
|
||||
urls: {
|
||||
api: string;
|
||||
dashboard: string;
|
||||
landing: string;
|
||||
wiki: string | null;
|
||||
};
|
||||
features: {
|
||||
billing: {enabled: boolean};
|
||||
storage: {s3Enabled: boolean};
|
||||
authProviders: {github: boolean; google: boolean};
|
||||
email: {trackingToggleEnabled: boolean};
|
||||
smtp: {
|
||||
enabled: boolean;
|
||||
domain: string | null;
|
||||
ports: {secure: number; submission: number} | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch global instance configuration and feature flags.
|
||||
*
|
||||
* - `data` is undefined while loading, then a ConfigResponse on success.
|
||||
* - Errors do not retry by default.
|
||||
*/
|
||||
export function useConfig() {
|
||||
return useSWR<ConfigResponse>('/config', {shouldRetryOnError: false});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
email: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ContactsResponse {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface UseContactsOptions {
|
||||
limit?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch contacts with optional search
|
||||
*/
|
||||
export function useContacts(options: UseContactsOptions = {}) {
|
||||
const {limit = 50, search} = options;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', limit.toString());
|
||||
if (search) {
|
||||
params.set('search', search);
|
||||
}
|
||||
|
||||
const {data, error, mutate, isLoading} = useSWR<ContactsResponse>(
|
||||
`/contacts?${params.toString()}`,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
contacts: data?.contacts || [],
|
||||
total: data?.total || 0,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch available contact fields for variable usage
|
||||
*/
|
||||
export function useContactFields() {
|
||||
const {data, error, mutate, isLoading} = useSWR<{fields: string[]}>(
|
||||
'/contacts/fields',
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
// Cache fields for longer since they don't change often
|
||||
dedupingInterval: 60000, // 1 minute
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
fields: data?.fields || [],
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface ActivityStats {
|
||||
totalEvents: number;
|
||||
totalEmailsSent: number;
|
||||
totalEmailsOpened: number;
|
||||
totalEmailsClicked: number;
|
||||
totalWorkflowsStarted: number;
|
||||
openRate: number;
|
||||
clickRate: number;
|
||||
}
|
||||
|
||||
export interface ContactsResponse {
|
||||
contacts: unknown[];
|
||||
total: number;
|
||||
cursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface CampaignsResponse {
|
||||
campaigns: unknown[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalContacts: number;
|
||||
totalEmailsSent: number;
|
||||
totalCampaigns: number;
|
||||
openRate: number;
|
||||
isLoading: boolean;
|
||||
error: Error | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch dashboard statistics
|
||||
* Fetches activity stats, contact count, and campaign count in parallel
|
||||
*/
|
||||
export function useDashboardStats(): DashboardStats {
|
||||
// Fetch activity stats (last 30 days by default)
|
||||
const {data: activityStats, error: activityError} = useSWR<ActivityStats>('/activity/stats');
|
||||
|
||||
// Fetch contacts (only need the total count)
|
||||
const {data: contactsData, error: contactsError} = useSWR<ContactsResponse>('/contacts?limit=1');
|
||||
|
||||
// Fetch campaigns (only need the total count)
|
||||
const {data: campaignsData, error: campaignsError} = useSWR<CampaignsResponse>('/campaigns?pageSize=1');
|
||||
|
||||
const isLoading = !activityStats && !contactsData && !campaignsData;
|
||||
const error = activityError || contactsError || campaignsError;
|
||||
|
||||
return {
|
||||
totalContacts: contactsData?.total ?? 0,
|
||||
totalEmailsSent: activityStats?.totalEmailsSent ?? 0,
|
||||
totalCampaigns: campaignsData?.total ?? 0,
|
||||
openRate: activityStats?.openRate ?? 0,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
import {DomainSchemas} from '@plunk/shared';
|
||||
import {network} from '../network';
|
||||
|
||||
export interface Domain {
|
||||
id: string;
|
||||
domain: string;
|
||||
verified: boolean;
|
||||
dkimTokens: string[] | null;
|
||||
projectId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DomainVerificationStatus {
|
||||
domain: string;
|
||||
tokens: string[];
|
||||
status: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch domains for a project
|
||||
*/
|
||||
export function useDomains(projectId: string | undefined) {
|
||||
const {data, error, mutate, isLoading} = useSWR<Domain[]>(projectId ? `/domains/project/${projectId}` : null);
|
||||
|
||||
return {
|
||||
domains: data,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to add a domain
|
||||
*/
|
||||
export function useAddDomain() {
|
||||
const addDomain = async (projectId: string, domain: string) => {
|
||||
return network.fetch<Domain, typeof DomainSchemas.create>('POST', '/domains', {
|
||||
projectId,
|
||||
domain,
|
||||
});
|
||||
};
|
||||
|
||||
return {addDomain};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check domain verification status
|
||||
*/
|
||||
export function useCheckDomainVerification() {
|
||||
const checkVerification = async (domainId: string) => {
|
||||
return network.fetch<DomainVerificationStatus>('GET', `/domains/${domainId}/verify`);
|
||||
};
|
||||
|
||||
return {checkVerification};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to remove a domain
|
||||
*/
|
||||
export function useRemoveDomain() {
|
||||
const removeDomain = async (domainId: string) => {
|
||||
return network.fetch<{success: boolean}>('DELETE', `/domains/${domainId}`);
|
||||
};
|
||||
|
||||
return {removeDomain};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {Project} from '@plunk/db';
|
||||
import useSWR from 'swr';
|
||||
|
||||
/**
|
||||
* Fetch all projects for the current user
|
||||
*/
|
||||
export function useProjects() {
|
||||
return useSWR<Project[]>('/users/@me/projects', {shouldRetryOnError: false});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
export interface ProjectSetupState {
|
||||
hasSubscription: boolean;
|
||||
hasVerifiedDomain: boolean;
|
||||
contactCount: number;
|
||||
lastCampaignSentAt: string | null;
|
||||
hasEnabledWorkflow: boolean;
|
||||
}
|
||||
|
||||
export interface SetupStateResponse {
|
||||
success: boolean;
|
||||
data: ProjectSetupState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch project setup state for dashboard quick start
|
||||
*/
|
||||
export function useProjectSetupState(projectId: string | undefined) {
|
||||
const {data, error, isLoading} = useSWR<SetupStateResponse>(
|
||||
projectId ? `/projects/${projectId}/setup-state` : null,
|
||||
);
|
||||
|
||||
return {
|
||||
setupState: data?.data,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
/**
|
||||
* Fetch the current user. undefined means loading, null means logged out
|
||||
*
|
||||
*/
|
||||
export function useUser() {
|
||||
return useSWR('/users/@me', {shouldRetryOnError: false});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {infer as ZodInfer, ZodSchema} from 'zod';
|
||||
|
||||
import {API_URI} from './constants';
|
||||
|
||||
interface Json {
|
||||
[x: string]: string | number | boolean | Date | Json | JsonArray;
|
||||
}
|
||||
|
||||
type JsonArray = (string | number | boolean | Date | Json | JsonArray)[];
|
||||
|
||||
interface TypedSchema extends ZodSchema {
|
||||
_type: unknown;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
message?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class network {
|
||||
public static async fetch<T, Schema extends TypedSchema | void = void>(
|
||||
method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'PATCH',
|
||||
path: string,
|
||||
body?: Schema extends TypedSchema ? ZodInfer<Schema> : never,
|
||||
): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : API_URI + path;
|
||||
|
||||
// Get active project ID from localStorage
|
||||
const activeProjectId = typeof window !== 'undefined' ? localStorage.getItem('activeProjectId') : null;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
if (activeProjectId) {
|
||||
headers['X-Project-Id'] = activeProjectId;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body: body && JSON.stringify(body),
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Handle 204 No Content responses (no body to parse)
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const res = (await response.json()) as ApiResponse;
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(res.message ?? 'Something went wrong!');
|
||||
}
|
||||
|
||||
return res as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file using FormData (multipart/form-data)
|
||||
* Used for file uploads where Content-Type must be set by browser
|
||||
*/
|
||||
public static async upload<T>(method: 'POST' | 'PUT' | 'PATCH', path: string, formData: FormData): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : API_URI + path;
|
||||
|
||||
// Get active project ID from localStorage
|
||||
const activeProjectId = typeof window !== 'undefined' ? localStorage.getItem('activeProjectId') : null;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
// DO NOT set Content-Type - browser will set it automatically with boundary
|
||||
if (activeProjectId) {
|
||||
headers['X-Project-Id'] = activeProjectId;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body: formData,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const res = (await response.json()) as ApiResponse;
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(res.message ?? 'Something went wrong!');
|
||||
}
|
||||
|
||||
return res as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Shared validation utilities for email-related forms
|
||||
*/
|
||||
|
||||
export interface EmailFormValidation {
|
||||
name?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
export class EmailFormValidator {
|
||||
/**
|
||||
* Validate email form fields (campaigns, templates)
|
||||
*/
|
||||
static validate(fields: EmailFormValidation, options: {requireSegment?: boolean} = {}): string | null {
|
||||
if (fields.name !== undefined && !fields.name.trim()) {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (fields.subject !== undefined && !fields.subject.trim()) {
|
||||
return 'Email subject is required';
|
||||
}
|
||||
|
||||
if (fields.body !== undefined && !fields.body.trim()) {
|
||||
return 'Email body is required';
|
||||
}
|
||||
|
||||
if (fields.from !== undefined && !fields.from.trim()) {
|
||||
return 'From address is required';
|
||||
}
|
||||
|
||||
if (options.requireSegment && fields.segmentId !== undefined && !fields.segmentId) {
|
||||
return 'Please select a segment';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate campaign-specific fields
|
||||
*/
|
||||
static validateCampaign(fields: EmailFormValidation & {segmentId?: string}, audienceType: string): string | null {
|
||||
const baseError = this.validate(fields);
|
||||
if (baseError) return baseError;
|
||||
|
||||
if (audienceType === 'SEGMENT' && !fields.segmentId) {
|
||||
return 'Please select a segment';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate template-specific fields
|
||||
*/
|
||||
static validateTemplate(fields: EmailFormValidation): string | null {
|
||||
return this.validate(fields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import '../styles/globals.css';
|
||||
import type {AppProps} from 'next/app';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useEffect} from 'react';
|
||||
import {Toaster} from 'sonner';
|
||||
import {SWRConfig} from 'swr';
|
||||
import {DefaultSeo} from 'next-seo';
|
||||
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useProjects} from '../lib/hooks/useProject';
|
||||
import {useUser} from '../lib/hooks/useUser';
|
||||
import {network} from '../lib/network';
|
||||
|
||||
// Routes that don't require authentication
|
||||
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset'];
|
||||
|
||||
// Routes that don't require a project
|
||||
const NO_PROJECT_ROUTES = ['/projects/create'];
|
||||
|
||||
function App({Component, pageProps}: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthGuard({children}: {children: React.ReactNode}) {
|
||||
const {data: user, isLoading} = useUser();
|
||||
const router = useRouter();
|
||||
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
// If not loading, no user, and trying to access a protected route, redirect to login
|
||||
if (!isLoading && !user && !isPublicRoute) {
|
||||
void router.push('/auth/login');
|
||||
}
|
||||
|
||||
// If user is logged in and trying to access login/signup, redirect to home
|
||||
if (!isLoading && user && (router.pathname === '/auth/login' || router.pathname === '/auth/signup')) {
|
||||
void router.push('/');
|
||||
}
|
||||
}, [user, isLoading, router, isPublicRoute]);
|
||||
|
||||
// Show loading state while checking authentication (only for protected routes)
|
||||
if (isLoading && !isPublicRoute) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render protected content if redirecting
|
||||
if (!user && !isPublicRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function ProjectGuard({children}: {children: React.ReactNode}) {
|
||||
const {data: projects, isLoading} = useProjects();
|
||||
const router = useRouter();
|
||||
const isNoProjectRoute = NO_PROJECT_ROUTES.includes(router.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
// If not loading, user has no projects, and not already on project creation page
|
||||
if (!isLoading && projects && projects.length === 0 && !isNoProjectRoute) {
|
||||
void router.push('/projects/create');
|
||||
}
|
||||
}, [projects, isLoading, router, isNoProjectRoute]);
|
||||
|
||||
// Show loading state while checking projects (only for routes that need a project)
|
||||
if (isLoading && !isNoProjectRoute) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render protected content if redirecting
|
||||
if (!isLoading && projects && projects.length === 0 && !isNoProjectRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
shouldRetryOnError: false,
|
||||
}}
|
||||
>
|
||||
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
|
||||
<ActiveProjectProvider>
|
||||
<Root {...props} />
|
||||
</ActiveProjectProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
|
||||
function Root(props: AppProps) {
|
||||
const router = useRouter();
|
||||
const isPublicRoute = PUBLIC_ROUTES.includes(router.pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position={'top-right'} />
|
||||
|
||||
<div>
|
||||
<AuthGuard>
|
||||
{isPublicRoute ? (
|
||||
<App {...props} />
|
||||
) : (
|
||||
<ProjectGuard>
|
||||
<App {...props} />
|
||||
</ProjectGuard>
|
||||
)}
|
||||
</AuthGuard>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {Head, Html, Main, NextScript} from 'next/document';
|
||||
|
||||
function Document({locale}: {locale: string}) {
|
||||
return (
|
||||
<Html lang={locale}>
|
||||
<Head>
|
||||
{/* Primary Meta Tags */}
|
||||
<meta name="title" content="Plunk | Email Platform Dashboard" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Manage your email campaigns, contacts, and analytics with Plunk - the open-source email platform."
|
||||
/>
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Plunk | Email Platform Dashboard" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Manage your email campaigns, contacts, and analytics with Plunk - the open-source email platform."
|
||||
/>
|
||||
<meta property="og:image" content="/assets/card.png" />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content="Plunk | Email Platform Dashboard" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Manage your email campaigns, contacts, and analytics with Plunk - the open-source email platform."
|
||||
/>
|
||||
<meta property="twitter:image" content="/assets/card.png" />
|
||||
|
||||
{/* Fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
{/* Favicon */}
|
||||
<link rel="icon" type="image/png" href="/favicon/favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon/favicon-16x16.png" sizes="16x16" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<meta name="apple-mobile-web-app-title" content="Plunk" />
|
||||
<meta name="application-name" content="Plunk" />
|
||||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
</Head>
|
||||
<body className="antialiased cursor-default scroll-smooth text-neutral-800">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
export default Document;
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {ActivityFeed} from '../../components/ActivityFeed';
|
||||
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {useState} from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface ActivityStats {
|
||||
totalEvents: number;
|
||||
totalEmailsSent: number;
|
||||
totalEmailsOpened: number;
|
||||
totalEmailsClicked: number;
|
||||
totalWorkflowsStarted: number;
|
||||
openRate: number;
|
||||
clickRate: number;
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [typeFilter, setTypeFilter] = useState<string>('ALL');
|
||||
const [dateRange, setDateRange] = useState<string>('30');
|
||||
|
||||
// Fetch activity stats
|
||||
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const statsCards = [
|
||||
{
|
||||
name: 'Events Triggered',
|
||||
value: stats?.totalEvents?.toLocaleString() || '0',
|
||||
icon: Zap,
|
||||
description: 'Last 30 days',
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100',
|
||||
},
|
||||
{
|
||||
name: 'Emails Sent',
|
||||
value: stats?.totalEmailsSent?.toLocaleString() || '0',
|
||||
icon: Send,
|
||||
description: 'Last 30 days',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
},
|
||||
{
|
||||
name: 'Open Rate',
|
||||
value: stats?.openRate ? `${stats.openRate.toFixed(1)}%` : '0%',
|
||||
icon: Eye,
|
||||
description: `${stats?.totalEmailsOpened?.toLocaleString() || '0'} opens`,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100',
|
||||
},
|
||||
{
|
||||
name: 'Click Rate',
|
||||
value: stats?.clickRate ? `${stats.clickRate.toFixed(1)}%` : '0%',
|
||||
icon: MousePointerClick,
|
||||
description: `${stats?.totalEmailsClicked?.toLocaleString() || '0'} clicks`,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Activity" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Activity</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Real-time overview of events, emails, and workflow executions across your project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statsCards.map(stat => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}>
|
||||
<Icon className={`h-5 w-5 ${stat.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{stat.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-neutral-500">{stat.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Activity Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All Activity Types</SelectItem>
|
||||
<SelectItem value="event.triggered">Events</SelectItem>
|
||||
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
|
||||
Emails
|
||||
</SelectItem>
|
||||
<SelectItem value="email.sent">Emails Sent</SelectItem>
|
||||
<SelectItem value="email.opened">Emails Opened</SelectItem>
|
||||
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
|
||||
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Last 30 days" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Last 24 hours</SelectItem>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Live feed of all activities happening across your project. Updates automatically as new activities occur.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityFeed
|
||||
typeFilter={typeFilter === 'ALL' ? undefined : typeFilter}
|
||||
dateRangeDays={parseInt(dateRange)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {useAnalytics} from '../../lib/hooks/useAnalytics';
|
||||
import {AlertCircle, BarChart3, CheckCircle2, Eye, Mail, MousePointerClick, Send, TrendingUp, Zap} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis} from 'recharts';
|
||||
|
||||
// Chart configurations
|
||||
const volumeChartConfig = {
|
||||
emails: {
|
||||
label: 'Emails Sent',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
opens: {
|
||||
label: 'Opens',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
clicks: {
|
||||
label: 'Clicks',
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const engagementChartConfig = {
|
||||
openRate: {
|
||||
label: 'Open Rate',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [dateRange, setDateRange] = useState<string>('30');
|
||||
const days = parseInt(dateRange);
|
||||
|
||||
const {stats, timeSeries, isLoading, error} = useAnalytics({days});
|
||||
|
||||
// Calculate engagement rate
|
||||
const engagementRate = useMemo(() => {
|
||||
if (!stats?.totalEmailsSent) return 0;
|
||||
const engaged = stats.totalEmailsOpened + stats.totalEmailsClicked;
|
||||
return (engaged / stats.totalEmailsSent) * 100;
|
||||
}, [stats]);
|
||||
|
||||
// Process time series data for charts
|
||||
const chartData = useMemo(() => {
|
||||
if (timeSeries && timeSeries.length > 0) {
|
||||
return timeSeries.map(point => ({
|
||||
date: new Date(point.date).toLocaleDateString('en-US', {month: 'short', day: 'numeric'}),
|
||||
emails: point.emails,
|
||||
opens: point.opens,
|
||||
clicks: point.clicks,
|
||||
openRate: point.emails > 0 ? Number(((point.opens / point.emails) * 100).toFixed(1)) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// Return empty array if no data
|
||||
return [];
|
||||
}, [timeSeries]);
|
||||
|
||||
// Check if we have any real data
|
||||
const hasData = useMemo(() => {
|
||||
return chartData.some(point => point.emails > 0 || point.opens > 0 || point.clicks > 0);
|
||||
}, [chartData]);
|
||||
|
||||
// Calculate cumulative totals
|
||||
const cumulativeTotals = useMemo(() => {
|
||||
return chartData.reduce(
|
||||
(acc, day) => ({
|
||||
emails: acc.emails + (day.emails || 0),
|
||||
opens: acc.opens + (day.opens || 0),
|
||||
clicks: acc.clicks + (day.clicks || 0),
|
||||
}),
|
||||
{emails: 0, opens: 0, clicks: 0},
|
||||
);
|
||||
}, [chartData]);
|
||||
|
||||
const statsCards = [
|
||||
{
|
||||
name: 'Total Emails',
|
||||
value: stats?.totalEmailsSent?.toLocaleString() || cumulativeTotals.emails.toLocaleString(),
|
||||
icon: Send,
|
||||
description: `Last ${days} days`,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100',
|
||||
trend: stats?.totalEmailsSent ? (stats.totalEmailsSent > 0 ? 'positive' : 'neutral') : 'neutral',
|
||||
},
|
||||
{
|
||||
name: 'Open Rate',
|
||||
value: stats?.openRate ? `${stats.openRate.toFixed(1)}%` : '0%',
|
||||
icon: Eye,
|
||||
description: `${stats?.totalEmailsOpened?.toLocaleString() || cumulativeTotals.opens.toLocaleString()} opens`,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
trend: stats?.openRate && stats.openRate > 20 ? 'positive' : 'neutral',
|
||||
},
|
||||
{
|
||||
name: 'Click Rate',
|
||||
value: stats?.clickRate ? `${stats.clickRate.toFixed(1)}%` : '0%',
|
||||
icon: MousePointerClick,
|
||||
description: `${stats?.totalEmailsClicked?.toLocaleString() || cumulativeTotals.clicks.toLocaleString()} clicks`,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100',
|
||||
trend: stats?.clickRate && stats.clickRate > 3 ? 'positive' : 'neutral',
|
||||
},
|
||||
{
|
||||
name: 'Engagement',
|
||||
value: `${engagementRate.toFixed(1)}%`,
|
||||
icon: TrendingUp,
|
||||
description: 'Opens + Clicks',
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100',
|
||||
trend: engagementRate > 20 ? 'positive' : 'neutral',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Analytics" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Analytics</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Comprehensive insights into your email performance, engagement metrics, and delivery statistics.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3 text-red-700">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>Failed to load analytics data. Please try again.</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statsCards.map(stat => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}>
|
||||
<Icon className={`h-5 w-5 ${stat.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{isLoading ? '-' : stat.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-neutral-500">{stat.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Email Volume Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Volume Trends</CardTitle>
|
||||
<CardDescription>Daily email sends, opens, and clicks over the selected period</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasData ? (
|
||||
<div className="flex h-[400px] w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Mail className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No email data yet</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Send your first email to see analytics here</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={volumeChartConfig} className="h-[400px] w-full">
|
||||
<AreaChart data={chartData} margin={{top: 10, right: 10, left: 10, bottom: 0}}>
|
||||
<defs>
|
||||
<linearGradient id="fillEmails" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-emails)" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="var(--color-emails)" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
<linearGradient id="fillOpens" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-opens)" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="var(--color-opens)" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
<linearGradient id="fillClicks" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-clicks)" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="var(--color-clicks)" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip content={<ChartTooltipContent className="w-[150px]" />} />
|
||||
<Area
|
||||
dataKey="emails"
|
||||
type="monotone"
|
||||
fill="url(#fillEmails)"
|
||||
stroke="var(--color-emails)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="opens"
|
||||
type="monotone"
|
||||
fill="url(#fillOpens)"
|
||||
stroke="var(--color-opens)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="clicks"
|
||||
type="monotone"
|
||||
fill="url(#fillClicks)"
|
||||
stroke="var(--color-clicks)"
|
||||
stackId="a"
|
||||
/>
|
||||
<ChartLegend content={ChartLegendContent as any} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Engagement Rate Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Engagement Rate Trends</CardTitle>
|
||||
<CardDescription>Open rate percentage over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasData ? (
|
||||
<div className="flex h-[300px] w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Eye className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No engagement data</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Engagement metrics will appear once emails are opened
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={engagementChartConfig} className="h-[300px] w-full">
|
||||
<LineChart data={chartData} margin={{top: 10, right: 10, left: 10, bottom: 0}}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={value => `${value}%`}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent className="w-[150px]" hideLabel />} cursor={false} />
|
||||
<Line
|
||||
dataKey="openRate"
|
||||
type="monotone"
|
||||
stroke="var(--color-openRate)"
|
||||
strokeWidth={2}
|
||||
dot={{
|
||||
fill: 'var(--color-openRate)',
|
||||
r: 4,
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Key Insights */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Performance Insights</CardTitle>
|
||||
<CardDescription>Key metrics and recommendations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">Open Rate</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{stats?.openRate && stats.openRate > 20
|
||||
? 'Your open rate is above industry average!'
|
||||
: 'Consider improving subject lines to increase open rates.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<BarChart3 className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">Click Rate</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{stats?.clickRate && stats.clickRate > 3
|
||||
? 'Great click-through performance!'
|
||||
: 'Add more compelling calls-to-action to boost clicks.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-purple-100 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">Engagement</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{stats?.totalWorkflowsStarted
|
||||
? `${stats.totalWorkflowsStarted.toLocaleString()} workflows started`
|
||||
: 'Set up workflows to automate your email campaigns.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Activity</CardTitle>
|
||||
<CardDescription>Custom events and triggers</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-neutral-900">{stats?.totalEvents?.toLocaleString() || '0'}</p>
|
||||
<p className="text-sm text-neutral-500">Total Events</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-orange-100 flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Events triggered by your contacts over the last {days} days. These can trigger workflows and
|
||||
automations.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {AuthenticationSchemas} from '@plunk/shared';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import type {z} from 'zod';
|
||||
|
||||
import {API_URI} from '../../lib/constants';
|
||||
import {useProjects} from '../../lib/hooks/useProject';
|
||||
import {useUser} from '../../lib/hooks/useUser';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
export default function Login() {
|
||||
const {mutate: userMutate} = useUser();
|
||||
const {mutate: projectsMutate} = useProjects();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<z.infer<typeof AuthenticationSchemas.login>>({
|
||||
resolver: zodResolver(AuthenticationSchemas.login),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
const [resetEmail, setResetEmail] = useState('');
|
||||
const [resetStatus, setResetStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const {data: config} = useConfig();
|
||||
const oauthConfig = {
|
||||
github: config?.features.authProviders.github ?? false,
|
||||
google: config?.features.authProviders.google ?? false,
|
||||
};
|
||||
|
||||
async function onSubmit(values: z.infer<typeof AuthenticationSchemas.login>) {
|
||||
try {
|
||||
const response = await network.fetch<
|
||||
{
|
||||
success: boolean;
|
||||
data: {id: string; email: string};
|
||||
},
|
||||
typeof AuthenticationSchemas.login
|
||||
>('POST', '/auth/login', values);
|
||||
|
||||
if (!response.success) {
|
||||
setErrorMessage('Email or password is not correct');
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
|
||||
await userMutate();
|
||||
await projectsMutate();
|
||||
|
||||
await router.push('/');
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResetStatus('loading');
|
||||
setResetError(null);
|
||||
try {
|
||||
const response = await network.fetch<{success: boolean}, typeof AuthenticationSchemas.resetPassword>(
|
||||
'POST',
|
||||
'/users/reset-password',
|
||||
{
|
||||
email: resetEmail,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
setResetStatus('success');
|
||||
} else {
|
||||
setResetStatus('error');
|
||||
setResetError('Something went wrong.');
|
||||
}
|
||||
} catch {
|
||||
setResetStatus('error');
|
||||
setResetError('Something went wrong.');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Login" />
|
||||
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
|
||||
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="p-8"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
|
||||
<p className="text-neutral-600">Enter your credentials to access your account</p>
|
||||
</div>
|
||||
|
||||
{(oauthConfig.github || oauthConfig.google) && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
{oauthConfig.google && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
window.location.href = `${API_URI}/oauth/google/outbound`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
)}
|
||||
{oauthConfig.github && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
window.location.href = `${API_URI}/oauth/github/outbound`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="[email protected]" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="password" type={'password'} {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline mt-1 text-left text-neutral-500"
|
||||
onClick={() => setShowReset(true)}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div layout>
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 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>
|
||||
</>
|
||||
) : (
|
||||
'Login'
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="text-center text-sm text-neutral-500">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/auth/signup" className="underline underline-offset-4 hover:text-neutral-900">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={showReset}
|
||||
onOpenChange={open => {
|
||||
setShowReset(open);
|
||||
if (!open) {
|
||||
setResetStatus('idle');
|
||||
setResetEmail('');
|
||||
setResetError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset your password</DialogTitle>
|
||||
<DialogDescription>Enter your email to receive a password reset link.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
void handleResetPassword(e);
|
||||
}}
|
||||
className="flex flex-col gap-3 mt-2"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={resetEmail}
|
||||
onChange={e => setResetEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<DialogFooter>
|
||||
<div className={'w-full space-y-2'}>
|
||||
<Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}>
|
||||
{resetStatus === 'loading' ? 'Sending...' : 'Send reset link'}
|
||||
</Button>
|
||||
{resetStatus === 'success' && (
|
||||
<p className="text-green-600 text-sm">
|
||||
If an account exists, a reset link has been sent to your email.
|
||||
</p>
|
||||
)}
|
||||
{resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {AuthenticationSchemas} from '@plunk/shared';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import type {z} from 'zod';
|
||||
|
||||
import {API_URI} from '../../lib/constants';
|
||||
import {useProjects} from '../../lib/hooks/useProject';
|
||||
import {useUser} from '../../lib/hooks/useUser';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
export default function Signup() {
|
||||
const {mutate: userMutate} = useUser();
|
||||
const {mutate: projectsMutate} = useProjects();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<z.infer<typeof AuthenticationSchemas.signup>>({
|
||||
resolver: zodResolver(AuthenticationSchemas.signup),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const {data: config} = useConfig();
|
||||
const oauthConfig = {
|
||||
github: config?.features.authProviders.github ?? false,
|
||||
google: config?.features.authProviders.google ?? false,
|
||||
};
|
||||
|
||||
async function onSubmit(values: z.infer<typeof AuthenticationSchemas.signup>) {
|
||||
try {
|
||||
const response = await network.fetch<
|
||||
{
|
||||
success: boolean;
|
||||
data: {id: string; email: string} | string;
|
||||
},
|
||||
typeof AuthenticationSchemas.signup
|
||||
>('POST', '/auth/signup', values);
|
||||
|
||||
if (!response.success) {
|
||||
// Handle error message from API
|
||||
const errorData = typeof response.data === 'string' ? response.data : 'Something went wrong';
|
||||
setErrorMessage(errorData);
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
|
||||
await userMutate();
|
||||
await projectsMutate();
|
||||
|
||||
await router.push('/projects/create');
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Sign Up" />
|
||||
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
|
||||
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="p-8"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Create an account</h1>
|
||||
<p className="text-neutral-600">Get started with Plunk today</p>
|
||||
</div>
|
||||
|
||||
{(oauthConfig.github || oauthConfig.google) && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
{oauthConfig.google && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
window.location.href = `${API_URI}/oauth/google/outbound`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
)}
|
||||
{oauthConfig.github && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
window.location.href = `${API_URI}/oauth/github/outbound`;
|
||||
}}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="[email protected]" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div layout>
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 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 up'
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="text-center text-sm text-neutral-500">
|
||||
Already have an account?{' '}
|
||||
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
StickySaveBar,
|
||||
} from '@plunk/ui';
|
||||
import type {Campaign, Segment} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignSchemas} from '@plunk/shared';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {EmailSettings} from '../../components/EmailSettings';
|
||||
import {EmailEditor} from '../../components/EmailEditor';
|
||||
import {network} from '../../lib/network';
|
||||
import {formatFullDateTime, formatUTCDateTime, getUserTimezone, schedulePresets} from '../../lib/dateUtils';
|
||||
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
|
||||
import {ArrowLeft, Calendar, Mail, MousePointer, Save, Send, TestTube, TrendingUp, Users, XCircle} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
|
||||
interface CampaignStats {
|
||||
totalRecipients: number;
|
||||
sentCount: number;
|
||||
deliveredCount: number;
|
||||
openedCount: number;
|
||||
clickedCount: number;
|
||||
bouncedCount: number;
|
||||
openRate: number;
|
||||
clickRate: number;
|
||||
bounceRate: number;
|
||||
deliveryRate: number;
|
||||
}
|
||||
|
||||
export default function CampaignDetailsPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
const {activeProject} = useActiveProject();
|
||||
|
||||
const {
|
||||
data: campaign,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useSWR<{data: Campaign}>(id ? `/campaigns/${id}` : null, {revalidateOnFocus: false});
|
||||
|
||||
const {data: stats} = useSWR<{data: CampaignStats}>(
|
||||
id && campaign?.data.status !== CampaignStatus.DRAFT ? `/campaigns/${id}/stats` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: campaign?.data.status === CampaignStatus.SENDING ? 5000 : 0, // Refresh every 5s if sending
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch segments for audience selection
|
||||
const {data: segments} = useSWR<Segment[]>('/segments', {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
// Fetch project members for test email
|
||||
const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>(
|
||||
id ? `/projects/${campaign?.data.projectId}/members` : null,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
|
||||
const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false);
|
||||
const [scheduledDateTime, setScheduledDateTime] = useState('');
|
||||
const [isTestEmailDialogOpen, setIsTestEmailDialogOpen] = useState(false);
|
||||
const [testEmailAddress, setTestEmailAddress] = useState('');
|
||||
const [sendingTestEmail, setSendingTestEmail] = useState(false);
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [showSendDialog, setShowSendDialog] = useState(false);
|
||||
|
||||
// Automatically initialize edit fields when campaign is loaded and is a draft
|
||||
const isEditMode = campaign?.data.status === CampaignStatus.DRAFT;
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
await network.fetch('POST', `/campaigns/${id}/cancel`);
|
||||
toast.success('Campaign cancelled successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to cancel campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
await network.fetch<void>('POST', `/campaigns/${id}/send`);
|
||||
toast.success('Campaign is being sent!');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to send campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedule = async () => {
|
||||
if (!scheduledDateTime) {
|
||||
toast.error('Please select a date and time');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the datetime-local value as local time, then convert to UTC
|
||||
const scheduledDate = new Date(scheduledDateTime);
|
||||
const now = new Date();
|
||||
|
||||
if (scheduledDate.getTime() <= now.getTime()) {
|
||||
toast.error('Scheduled time must be in the future');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send as ISO string (UTC)
|
||||
await network.fetch<void, typeof CampaignSchemas.schedule>('POST', `/campaigns/${id}/send`, {
|
||||
scheduledFor: scheduledDate.toISOString(),
|
||||
});
|
||||
|
||||
// Show confirmation with user's local time
|
||||
const localTimeString = formatFullDateTime(scheduledDate);
|
||||
toast.success(`Campaign scheduled for ${localTimeString}`);
|
||||
setIsScheduleDialogOpen(false);
|
||||
setScheduledDateTime('');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
if (!testEmailAddress) {
|
||||
toast.error('Please select a project member');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingTestEmail(true);
|
||||
|
||||
try {
|
||||
await network.fetch<{success: boolean; message: string}>('POST', `/campaigns/${id}/test`, {
|
||||
email: testEmailAddress,
|
||||
} as any);
|
||||
|
||||
toast.success(`Test email sent to ${testEmailAddress}`);
|
||||
setIsTestEmailDialogOpen(false);
|
||||
setTestEmailAddress('');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to send test email');
|
||||
} finally {
|
||||
setSendingTestEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const handleSave = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<Campaign, typeof CampaignSchemas.update>('PUT', `/campaigns/${id}`, {
|
||||
name: editedCampaign.name,
|
||||
description: editedCampaign.description || undefined,
|
||||
subject: editedCampaign.subject,
|
||||
body: editedCampaign.body,
|
||||
from: editedCampaign.from,
|
||||
fromName: editedCampaign.fromName || undefined,
|
||||
replyTo: editedCampaign.replyTo || undefined,
|
||||
audienceType: editedCampaign.audienceType,
|
||||
segmentId: editedCampaign.segmentId || undefined,
|
||||
});
|
||||
// Silent save - no toast notification
|
||||
setHasChanges(false);
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update campaign');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize edit fields when campaign loads and is a draft
|
||||
useEffect(() => {
|
||||
if (campaign?.data && isEditMode && Object.keys(editedCampaign).length === 0) {
|
||||
setEditedCampaign({
|
||||
name: campaign.data.name,
|
||||
description: campaign.data.description || '',
|
||||
subject: campaign.data.subject,
|
||||
body: campaign.data.body,
|
||||
from: campaign.data.from,
|
||||
fromName: campaign.data.fromName || '',
|
||||
replyTo: campaign.data.replyTo || '',
|
||||
audienceType: campaign.data.audienceType,
|
||||
segmentId: campaign.data.segmentId || undefined,
|
||||
});
|
||||
// Reset hasChanges when loading fresh data
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [campaign, isEditMode, editedCampaign]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (!campaign?.data || Object.keys(editedCampaign).length === 0) return;
|
||||
|
||||
const changed =
|
||||
editedCampaign.name !== campaign.data.name ||
|
||||
(editedCampaign.description || '') !== (campaign.data.description || '') ||
|
||||
editedCampaign.subject !== campaign.data.subject ||
|
||||
editedCampaign.body !== campaign.data.body ||
|
||||
editedCampaign.from !== campaign.data.from ||
|
||||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
|
||||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
|
||||
editedCampaign.audienceType !== campaign.data.audienceType ||
|
||||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
|
||||
|
||||
setHasChanges(changed);
|
||||
}, [editedCampaign, campaign]);
|
||||
|
||||
// Warn before leaving page with unsaved changes (only in edit mode)
|
||||
useChangeTracking(hasChanges, isEditMode);
|
||||
|
||||
const getStatusBadge = (status: CampaignStatus) => {
|
||||
const variants: Record<
|
||||
CampaignStatus,
|
||||
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string}
|
||||
> = {
|
||||
DRAFT: {variant: 'secondary', label: 'Draft'},
|
||||
SCHEDULED: {variant: 'default', label: 'Scheduled'},
|
||||
SENDING: {variant: 'default', label: 'Sending'},
|
||||
SENT: {variant: 'default', label: 'Sent'},
|
||||
CANCELLED: {variant: 'destructive', label: 'Cancelled'},
|
||||
};
|
||||
|
||||
const config = variants[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin mx-auto border-4 border-neutral-200 border-t-neutral-900 rounded-full" />
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading campaign...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-neutral-500">Campaign not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const c = campaign.data;
|
||||
const s = stats?.data;
|
||||
|
||||
// Get recipient count for draft campaigns
|
||||
const getDraftRecipientCount = () => {
|
||||
if (!campaign?.data) return 0;
|
||||
const c = campaign.data;
|
||||
|
||||
if (c.audienceType === CampaignAudienceType.SEGMENT && c.segmentId && segments) {
|
||||
const segment = segments.find(s => s.id === c.segmentId);
|
||||
return segment?.memberCount || 0;
|
||||
}
|
||||
return 0; // We'd need total contact count for ALL audience type
|
||||
};
|
||||
|
||||
const draftRecipientCount = isEditMode ? getDraftRecipientCount() : 0;
|
||||
|
||||
// Render edit form for drafts
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/campaigns">
|
||||
<Button type="button" variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{c.name}</h1>
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
</div>
|
||||
<p className="text-neutral-500 mt-1">Make changes to your campaign before sending</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!hasChanges && !isSubmitting && <span className="text-sm text-neutral-500">All changes saved</span>}
|
||||
{hasChanges && !isSubmitting && <span className="text-sm text-amber-600">Unsaved changes</span>}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={!hasChanges || isSubmitting} variant="outline">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsTestEmailDialogOpen(true)}>
|
||||
<TestTube className="h-4 w-4" />
|
||||
Send Test
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setShowSendDialog(true)}>
|
||||
<Send className="h-4 w-4" />
|
||||
Send Now
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsScheduleDialogOpen(true)}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Settings - Horizontal Layout */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Campaign Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Settings</CardTitle>
|
||||
<CardDescription>Basic information about your campaign</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Campaign Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={editedCampaign.name || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
|
||||
required
|
||||
placeholder="Spring Sale Campaign"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
value={editedCampaign.description || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
|
||||
placeholder="Optional description for internal use"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject Line *</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
type="text"
|
||||
value={editedCampaign.subject || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
|
||||
required
|
||||
placeholder="Introducing our Spring Sale!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmailSettings
|
||||
from={editedCampaign.from || ''}
|
||||
fromName={editedCampaign.fromName || ''}
|
||||
replyTo={editedCampaign.replyTo || ''}
|
||||
onFromChange={value => setEditedCampaign({...editedCampaign, from: value})}
|
||||
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
|
||||
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
showFromNameHelpText
|
||||
layout="vertical"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audience</CardTitle>
|
||||
<CardDescription>Who will receive this campaign</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="audienceType">Audience Type *</Label>
|
||||
<Select
|
||||
value={editedCampaign.audienceType ?? c.audienceType}
|
||||
onValueChange={(value: CampaignAudienceType) => {
|
||||
setEditedCampaign({
|
||||
...editedCampaign,
|
||||
audienceType: value,
|
||||
// Clear segmentId if changing away from SEGMENT
|
||||
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="audienceType">
|
||||
<SelectValue placeholder="Select audience type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CampaignAudienceType.ALL}>All Subscribed Contacts</SelectItem>
|
||||
<SelectItem value={CampaignAudienceType.SEGMENT}>Segment</SelectItem>
|
||||
<SelectItem value={CampaignAudienceType.FILTERED}>Filtered</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
|
||||
<div>
|
||||
<Label htmlFor="segment">Select Segment *</Label>
|
||||
<Select
|
||||
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
|
||||
onValueChange={(value: string) => {
|
||||
setEditedCampaign({
|
||||
...editedCampaign,
|
||||
segmentId: value,
|
||||
});
|
||||
}}
|
||||
disabled={!segments || segments.length === 0}
|
||||
>
|
||||
<SelectTrigger id="segment">
|
||||
<SelectValue
|
||||
placeholder={segments && segments.length > 0 ? 'Choose a segment' : 'No segments available'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{segments &&
|
||||
segments.length > 0 &&
|
||||
segments.map(segment => (
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.name} ({segment.memberCount.toLocaleString()} contacts)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{segments && segments.length === 0 && (
|
||||
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
Filtered audiences are configured with advanced filter conditions
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Show recipient count */}
|
||||
{draftRecipientCount > 0 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">
|
||||
{draftRecipientCount.toLocaleString()} recipients
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Email Editor - Full Width */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Content</CardTitle>
|
||||
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor
|
||||
value={editedCampaign.body || ''}
|
||||
onChange={body => {
|
||||
setEditedCampaign({...editedCampaign, body});
|
||||
setHasChanges(true);
|
||||
}}
|
||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
||||
canUploadImages={true}
|
||||
subject={editedCampaign.subject}
|
||||
from={editedCampaign.from}
|
||||
replyTo={editedCampaign.replyTo || undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test Email Dialog */}
|
||||
<Dialog open={isTestEmailDialogOpen} onOpenChange={setIsTestEmailDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a test version of this campaign to a project member to verify how it looks. The test email will
|
||||
be prefixed with [TEST] in the subject line.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="testEmail">Project Member</Label>
|
||||
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
|
||||
<SelectTrigger id="testEmail" className="mt-2">
|
||||
<SelectValue placeholder="Select a project member..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectMembers?.data.map(member => (
|
||||
<SelectItem key={member.userId} value={member.email}>
|
||||
{member.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
For security reasons, test emails can only be sent to project members.
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsTestEmailDialogOpen(false);
|
||||
setTestEmailAddress('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSendTestEmail} disabled={sendingTestEmail || !testEmailAddress}>
|
||||
{sendingTestEmail ? 'Sending...' : 'Send Test Email'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Schedule Dialog */}
|
||||
<Dialog open={isScheduleDialogOpen} onOpenChange={setIsScheduleDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Campaign</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()}
|
||||
)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<Label>Quick Schedule</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())}
|
||||
>
|
||||
In 1 hour
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())}
|
||||
>
|
||||
In 3 hours
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())}
|
||||
>
|
||||
Tomorrow at 9 AM
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt2PM())}
|
||||
>
|
||||
Tomorrow at 2 PM
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.nextMonday())}
|
||||
>
|
||||
Next Monday
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inOneWeek())}
|
||||
>
|
||||
In 1 week
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Date/Time */}
|
||||
<div>
|
||||
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
|
||||
<Input
|
||||
id="scheduledDateTime"
|
||||
type="datetime-local"
|
||||
value={scheduledDateTime}
|
||||
onChange={e => setScheduledDateTime(e.target.value)}
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
className="mt-2"
|
||||
/>
|
||||
{scheduledDateTime && (
|
||||
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-xs font-medium text-blue-900 mb-1">Scheduled for:</p>
|
||||
<p className="text-sm text-blue-800">
|
||||
<span className="font-medium">{formatFullDateTime(new Date(scheduledDateTime))}</span>
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsScheduleDialogOpen(false);
|
||||
setScheduledDateTime('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSchedule}>
|
||||
Schedule Campaign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
|
||||
{/* Sticky Save Bar */}
|
||||
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={showSendDialog}
|
||||
onOpenChange={setShowSendDialog}
|
||||
onConfirm={handleSend}
|
||||
title="Send Campaign"
|
||||
description="Are you sure you want to send this campaign now? This action cannot be undone."
|
||||
confirmText="Send Now"
|
||||
variant="default"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Render stats view for sent/scheduled campaigns
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/campaigns">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{c.name}</h1>
|
||||
{getStatusBadge(c.status)}
|
||||
</div>
|
||||
{c.description && <p className="text-neutral-500">{c.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.SENDING) && (
|
||||
<Button variant="destructive" onClick={() => setShowCancelDialog(true)}>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Cancel Campaign
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sending Progress Banner */}
|
||||
{c.status === CampaignStatus.SENDING && s && (
|
||||
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-900 text-lg">Sending in progress</h3>
|
||||
<p className="text-sm text-neutral-600 mt-1">
|
||||
{s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{((s.sentCount / s.totalRecipients) * 100).toFixed(0)}%
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">Complete</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-indigo-500 h-3 rounded-full transition-all duration-500"
|
||||
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">This page updates automatically every 5 seconds</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
{s && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-neutral-600">Total Recipients</CardTitle>
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Users className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.totalRecipients.toLocaleString()}</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
{s.sentCount.toLocaleString()} sent ({((s.sentCount / s.totalRecipients) * 100).toFixed(1)}%)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-neutral-600">Delivery Rate</CardTitle>
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<Mail className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.deliveryRate.toFixed(1)}%</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
{s.deliveredCount.toLocaleString()} delivered
|
||||
{s.bouncedCount > 0 && `, ${s.bouncedCount} bounced`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-neutral-600">Open Rate</CardTitle>
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<TrendingUp className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.openRate.toFixed(1)}%</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">{s.openedCount.toLocaleString()} opened</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-orange-500">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-neutral-600">Click Rate</CardTitle>
|
||||
<div className="p-2 bg-orange-100 rounded-lg">
|
||||
<MousePointer className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-neutral-900">{s.clickRate.toFixed(1)}%</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">{s.clickedCount.toLocaleString()} clicked</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign Details in Grid */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Email Content - Takes 2 columns */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Preview</CardTitle>
|
||||
<CardDescription>How your email will appear to recipients</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Email Header Info */}
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-neutral-500 uppercase tracking-wide font-medium">Subject</p>
|
||||
<p className="text-base font-semibold text-neutral-900 mt-1">{c.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6 pt-2 border-t border-neutral-200">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">From</p>
|
||||
<p className="text-sm text-neutral-900 mt-0.5">{c.from}</p>
|
||||
</div>
|
||||
{c.replyTo && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">Reply-To</p>
|
||||
<p className="text-sm text-neutral-900 mt-0.5">{c.replyTo}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Body Preview */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 mb-3">Message Content</p>
|
||||
<div className="border-2 border-neutral-200 rounded-lg overflow-hidden bg-white">
|
||||
<div className="p-6 max-h-96 overflow-y-auto">
|
||||
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{__html: c.body}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Campaign Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Info</CardTitle>
|
||||
<CardDescription>Configuration and metadata</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Audience */}
|
||||
<div className="pb-3 border-b border-neutral-100">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Audience</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-neutral-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{c.audienceType === CampaignAudienceType.ALL && 'All Subscribed Contacts'}
|
||||
{c.audienceType === CampaignAudienceType.SEGMENT &&
|
||||
(segments?.find(s => s.id === c.segmentId)?.name || 'Selected Segment')}
|
||||
{c.audienceType === CampaignAudienceType.FILTERED && 'Filtered Contacts'}
|
||||
</p>
|
||||
{c.audienceType === CampaignAudienceType.SEGMENT &&
|
||||
segments?.find(s => s.id === c.segmentId)?.memberCount && (
|
||||
<p className="text-xs text-neutral-500">
|
||||
{segments.find(s => s.id === c.segmentId)!.memberCount.toLocaleString()} contacts
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduling Info */}
|
||||
{c.scheduledFor && (
|
||||
<div className="pb-3 border-b border-neutral-100">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Scheduled For</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-4 w-4 text-neutral-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{formatFullDateTime(new Date(c.scheduledFor))}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
UTC: {formatUTCDateTime(new Date(c.scheduledFor))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sent At */}
|
||||
{c.sentAt && (
|
||||
<div className="pb-3 border-b border-neutral-100">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent At</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-neutral-400" />
|
||||
<p className="text-sm font-medium text-neutral-900">
|
||||
{new Date(c.sentAt).toLocaleDateString()} at {new Date(c.sentAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Created */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Created</p>
|
||||
<p className="text-sm text-neutral-900">{new Date(c.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showCancelDialog}
|
||||
onOpenChange={setShowCancelDialog}
|
||||
onConfirm={handleCancel}
|
||||
title="Cancel Campaign"
|
||||
description="Are you sure you want to cancel this campaign?"
|
||||
confirmText="Cancel Campaign"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Segment} from '@plunk/db';
|
||||
import {CampaignAudienceType} from '@plunk/db';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {EmailSettings} from '../../components/EmailSettings';
|
||||
import {EmailEditor} from '../../components/EmailEditor';
|
||||
import {StepHeader} from '../../components/StepHeader';
|
||||
import {network} from '../../lib/network';
|
||||
import {EmailFormValidator} from '../../lib/validation';
|
||||
import {ArrowLeft, Save, Users} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
|
||||
export default function CreateCampaignPage() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [fromName, setFromName] = useState('');
|
||||
const [replyTo, setReplyTo] = useState('');
|
||||
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
||||
const [segmentId, setSegmentId] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationError = EmailFormValidator.validateCampaign({name, subject, body, from, segmentId}, audienceType);
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await network.fetch<{data: {id: string}}>('POST', '/campaigns', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName: fromName || undefined,
|
||||
replyTo: replyTo || undefined,
|
||||
audienceType,
|
||||
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
||||
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
||||
} as any);
|
||||
|
||||
toast.success('Campaign created successfully');
|
||||
void router.push(`/campaigns/${response.data.id}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create campaign');
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate estimated recipients
|
||||
const getEstimatedRecipients = () => {
|
||||
if (audienceType === CampaignAudienceType.SEGMENT && segmentId && segments) {
|
||||
const segment = segments.find(s => s.id === segmentId);
|
||||
return segment?.memberCount || 0;
|
||||
}
|
||||
return 0; // We don't have total contact count here, but in a real scenario you'd fetch it
|
||||
};
|
||||
|
||||
const estimatedRecipients = getEstimatedRecipients();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Create Campaign" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/campaigns">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Create Campaign</h1>
|
||||
<p className="text-neutral-500 mt-1">Create a new email campaign to send to your contacts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Left Column - Settings (2/3 width) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={1} title="Basic Information" description="Name and describe your campaign" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
Campaign Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Spring Sale Announcement"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (Optional)</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
placeholder="Internal notes about this campaign"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-neutral-200 rounded-lg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader
|
||||
stepNumber={2}
|
||||
title="Email Settings"
|
||||
description="Configure sender information and subject"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<EmailSettings
|
||||
from={from}
|
||||
fromName={fromName}
|
||||
replyTo={replyTo}
|
||||
onFromChange={setFrom}
|
||||
onFromNameChange={setFromName}
|
||||
onReplyToChange={setReplyTo}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">
|
||||
Email Subject <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
placeholder="e.g., Introducing our Spring Sale!"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Content */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="body">
|
||||
Email Body <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<EmailEditor
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="<h1>Welcome!</h1><p>Your email content here...</p>"
|
||||
canUploadImages={true}
|
||||
subject={subject}
|
||||
from={from}
|
||||
replyTo={replyTo}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={4} title="Audience" description="Choose who will receive this campaign" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audienceType">
|
||||
Audience Type <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={audienceType}
|
||||
onValueChange={value => setAudienceType(value as CampaignAudienceType)}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select audience type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CampaignAudienceType.ALL}>All Subscribed Contacts</SelectItem>
|
||||
<SelectItem value={CampaignAudienceType.SEGMENT}>Specific Segment</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{audienceType === CampaignAudienceType.SEGMENT && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="segment">
|
||||
Select Segment <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={segmentId} onValueChange={setSegmentId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a segment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{segments?.map(segment => (
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.name} ({segment.memberCount.toLocaleString()} contacts)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{segments?.length === 0 && (
|
||||
<p className="text-sm text-neutral-500 mt-2">
|
||||
No segments found.{' '}
|
||||
<Link href="/segments/new" className="text-primary hover:underline">
|
||||
Create one first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audienceType === CampaignAudienceType.SEGMENT && estimatedRecipients > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
{estimatedRecipients.toLocaleString()} recipients
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
This campaign will be sent to all contacts in the selected segment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Summary & Actions (1/3 width) */}
|
||||
<div className="space-y-6">
|
||||
{/* Campaign Summary */}
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Campaign Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">Status</span>
|
||||
<span className="font-medium">Draft</span>
|
||||
</div>
|
||||
|
||||
{name && (
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">Name</span>
|
||||
<span className="font-medium text-right truncate ml-2" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subject && (
|
||||
<div className="py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500 block mb-1">Subject</span>
|
||||
<span className="font-medium text-sm">{subject}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{from && (
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">From</span>
|
||||
<span className="font-medium text-right truncate ml-2" title={from}>
|
||||
{from}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||
<span className="text-neutral-500">Audience</span>
|
||||
<span className="font-medium">
|
||||
{audienceType === CampaignAudienceType.ALL ? 'All Contacts' : 'Segment'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{audienceType === CampaignAudienceType.SEGMENT && estimatedRecipients > 0 && (
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-neutral-500">Recipients</span>
|
||||
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Note */}
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3 mt-4">
|
||||
<p className="text-xs text-neutral-600 leading-relaxed">
|
||||
After creating this campaign, you'll be able to review it and choose when to send it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Button type="submit" disabled={saving} className="w-full">
|
||||
{saving ? (
|
||||
<>Creating...</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Create Campaign
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Link href="/campaigns" className="w-full">
|
||||
<Button type="button" variant="outline" className="w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Campaign} from '@plunk/db';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {Calendar, Copy, Mail, Plus, Users} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface PaginatedCampaigns {
|
||||
campaigns: Campaign[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
||||
|
||||
const {data, mutate, isLoading} = useSWR<PaginatedCampaigns>(
|
||||
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: CampaignStatus) => {
|
||||
const variants: Record<
|
||||
CampaignStatus,
|
||||
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
|
||||
> = {
|
||||
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
|
||||
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
|
||||
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
|
||||
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
|
||||
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
|
||||
};
|
||||
|
||||
const config = variants[status];
|
||||
return (
|
||||
<Badge variant={config.variant} className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!campaignToCancel) return;
|
||||
|
||||
try {
|
||||
await network.fetch('POST', `/campaigns/${campaignToCancel}/cancel`);
|
||||
toast.success('Campaign cancelled successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to cancel campaign');
|
||||
} finally {
|
||||
setCampaignToCancel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (campaignId: string) => {
|
||||
try {
|
||||
await network.fetch('POST', `/campaigns/${campaignId}/duplicate`);
|
||||
toast.success('Campaign duplicated successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to duplicate campaign');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Campaigns" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Campaigns</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Send one-time email broadcasts to your contacts. {data?.total ? `${data.total} total campaigns` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/campaigns/create">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Campaign
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All Statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
|
||||
<SelectItem value="SENDING">Sending</SelectItem>
|
||||
<SelectItem value="SENT">Sent</SelectItem>
|
||||
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Campaigns List */}
|
||||
<div className="space-y-4">
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-neutral-500">Loading campaigns...</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && data?.campaigns.length === 0 && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="py-16 text-center">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Mail className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">
|
||||
{statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
||||
</h3>
|
||||
<p className="text-neutral-500 mb-6">
|
||||
{statusFilter !== 'ALL'
|
||||
? 'Try adjusting your filters or create a new campaign.'
|
||||
: 'Create your first campaign to send emails to your contacts.'}
|
||||
</p>
|
||||
<Link href="/campaigns/create">
|
||||
<Button size="lg">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Campaign
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data?.campaigns.map(campaign => {
|
||||
const openRate = campaign.sentCount > 0 ? (campaign.openedCount / campaign.sentCount) * 100 : 0;
|
||||
const clickRate = campaign.sentCount > 0 ? (campaign.clickedCount / campaign.sentCount) * 100 : 0;
|
||||
const deliveryProgress =
|
||||
campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Card key={campaign.id} className="hover:shadow-lg transition-all hover:border-primary/20">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Link
|
||||
href={`/campaigns/${campaign.id}`}
|
||||
className="hover:text-primary transition-colors flex-1 min-w-0"
|
||||
>
|
||||
<CardTitle className="text-xl truncate">{campaign.name}</CardTitle>
|
||||
</Link>
|
||||
{getStatusBadge(campaign.status)}
|
||||
</div>
|
||||
{campaign.description && (
|
||||
<CardDescription className="line-clamp-2">{campaign.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{/* Recipients */}
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Users className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span className="text-xs font-medium text-blue-900">Recipients</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
|
||||
{campaign.totalRecipients > 0 && (
|
||||
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Rate */}
|
||||
{campaign.sentCount > 0 && (
|
||||
<div className="bg-purple-50 border border-purple-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Mail className="h-3.5 w-3.5 text-purple-600" />
|
||||
<span className="text-xs font-medium text-purple-900">Opens</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-purple-900">{openRate.toFixed(1)}%</p>
|
||||
<p className="text-xs text-purple-700 mt-1">{campaign.openedCount.toLocaleString()} opened</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Click Rate */}
|
||||
{campaign.clickedCount > 0 && (
|
||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Mail className="h-3.5 w-3.5 text-orange-600" />
|
||||
<span className="text-xs font-medium text-orange-900">Clicks</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-orange-900">{clickRate.toFixed(1)}%</p>
|
||||
<p className="text-xs text-orange-700 mt-1">{campaign.clickedCount.toLocaleString()} clicked</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scheduled For */}
|
||||
{campaign.scheduledFor && (
|
||||
<div className="bg-green-50 border border-green-100 rounded-lg p-3 md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-xs font-medium text-green-900">Scheduled</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-green-900">
|
||||
{new Date(campaign.scheduledFor).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-green-700 mt-1">
|
||||
{new Date(campaign.scheduledFor).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2 border-t border-neutral-100">
|
||||
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{campaign.status === 'DRAFT' ? 'Edit Campaign' : 'View Details'}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => handleDuplicate(campaign.id)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCampaignToCancel(campaign.id);
|
||||
setShowCancelDialog(true);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-4 text-sm text-neutral-600">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.min(data.totalPages, p + 1))}
|
||||
disabled={page === data.totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showCancelDialog}
|
||||
onOpenChange={setShowCancelDialog}
|
||||
onConfirm={handleCancel}
|
||||
title="Cancel Campaign"
|
||||
description="Are you sure you want to cancel this campaign?"
|
||||
confirmText="Cancel Campaign"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
Label,
|
||||
} from '@plunk/ui';
|
||||
import type {Contact} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||
import {network} from '../../lib/network';
|
||||
import {ArrowLeft, Calendar, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {ContactSchemas} from '@plunk/shared';
|
||||
|
||||
export default function ContactDetailPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
const {data: contact, mutate, isLoading} = useSWR<Contact>(id ? `/contacts/${id}` : null);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [subscribed, setSubscribed] = useState(true);
|
||||
const [customData, setCustomData] = useState<Record<string, string | number | boolean> | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Initialize form when contact loads
|
||||
useEffect(() => {
|
||||
if (contact) {
|
||||
setEmail(contact.email);
|
||||
setSubscribed(contact.subscribed);
|
||||
setCustomData(contact.data as Record<string, string | number | boolean> | null);
|
||||
}
|
||||
}, [contact]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<
|
||||
{
|
||||
success: boolean;
|
||||
},
|
||||
typeof ContactSchemas.create
|
||||
>('PATCH', `/contacts/${id}`, {email, subscribed, data: customData});
|
||||
toast.success('Contact updated successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update contact');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await network.fetch('DELETE', `/contacts/${id}`);
|
||||
toast.success('Contact deleted successfully');
|
||||
void router.push('/contacts');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete contact');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (url: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success(`${label} link copied to clipboard`);
|
||||
} catch {
|
||||
toast.error('Failed to copy link');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading contact...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-neutral-900 mb-2">Contact not found</h3>
|
||||
<p className="text-neutral-500 mb-6">
|
||||
The contact you're looking for doesn't exist or has been deleted.
|
||||
</p>
|
||||
<Link href="/contacts">
|
||||
<Button>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Contacts
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/contacts">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{contact.email}</h1>
|
||||
<p className="text-neutral-500 mt-1">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
contact.subscribed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete Contact
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Edit Form */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contact Information</CardTitle>
|
||||
<CardDescription>Update contact details and subscription status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="subscribed" className="text-sm font-medium text-neutral-900 cursor-pointer">
|
||||
Subscribed to emails
|
||||
</Label>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">
|
||||
{subscribed ? 'Contact will receive emails' : 'Contact will not receive emails'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id="subscribed"
|
||||
onClick={() => setSubscribed(!subscribed)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2 ${
|
||||
subscribed ? 'bg-neutral-900' : 'bg-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
subscribed ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{contact && (
|
||||
<KeyValueEditor
|
||||
key={`${contact.id}-${JSON.stringify(contact.data)}`}
|
||||
initialData={contact.data as Record<string, string | number | boolean> | null}
|
||||
onChange={setCustomData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Metadata Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-900">Email</p>
|
||||
<p className="text-sm text-neutral-500 break-all">{contact.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-neutral-900">Created</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{new Date(contact.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{new Date(contact.updatedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-900">Contact ID</p>
|
||||
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardDescription>Email engagement statistics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600">Emails Sent</span>
|
||||
<span className="text-sm font-medium text-neutral-900">0</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600">Emails Opened</span>
|
||||
<span className="text-sm font-medium text-neutral-900">0</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600">Links Clicked</span>
|
||||
<span className="text-sm font-medium text-neutral-900">0</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Public Links Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Public Links</CardTitle>
|
||||
<CardDescription>Share these links with the contact</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-neutral-700">Subscribe Page</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 justify-start text-xs"
|
||||
onClick={() => window.open(`${window.location.origin}/subscribe/${contact.id}`, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(`${window.location.origin}/subscribe/${contact.id}`, 'Subscribe')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-neutral-700">Unsubscribe Page</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 justify-start text-xs"
|
||||
onClick={() => window.open(`${window.location.origin}/unsubscribe/${contact.id}`, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(`${window.location.origin}/unsubscribe/${contact.id}`, 'Unsubscribe')
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-neutral-700">Manage Preferences</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 justify-start text-xs"
|
||||
onClick={() => window.open(`${window.location.origin}/manage/${contact.id}`, '_blank')}
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(`${window.location.origin}/manage/${contact.id}`, 'Manage')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-neutral-500">
|
||||
These public links allow the contact to manage their subscription without logging in.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Contact"
|
||||
description="Are you sure you want to delete this contact? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
} from '@plunk/ui';
|
||||
import type {Contact} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||
import {network} from '../../lib/network';
|
||||
import {
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Edit,
|
||||
FileUp,
|
||||
Mail,
|
||||
MailCheck,
|
||||
MailX,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {ContactSchemas} from '@plunk/shared';
|
||||
|
||||
interface PaginatedContacts {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
cursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export default function ContactsPage() {
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([undefined]);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const pageSize = 50;
|
||||
|
||||
const {data, mutate, isLoading} = useSWR<PaginatedContacts>(
|
||||
`/contacts?limit=${pageSize}${cursor ? `&cursor=${cursor}` : ''}${search ? `&search=${search}` : ''}`,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
// Update contacts when data changes
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setContacts(data.contacts);
|
||||
if (!cursor) {
|
||||
setTotalCount(data.total || data.contacts.length);
|
||||
}
|
||||
}
|
||||
}, [data, cursor]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearch(searchInput);
|
||||
setCursor(undefined);
|
||||
setCursorHistory([undefined]);
|
||||
setCurrentPage(0);
|
||||
setContacts([]);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (data?.cursor) {
|
||||
const newPage = currentPage + 1;
|
||||
setCursor(data.cursor);
|
||||
setCurrentPage(newPage);
|
||||
|
||||
// Store cursor in history if not already there
|
||||
if (cursorHistory.length <= newPage) {
|
||||
setCursorHistory(prev => [...prev, data.cursor]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (currentPage > 0) {
|
||||
const newPage = currentPage - 1;
|
||||
const previousCursor = cursorHistory[newPage];
|
||||
setCursor(previousCursor);
|
||||
setCurrentPage(newPage);
|
||||
}
|
||||
};
|
||||
|
||||
const promptDelete = (contactId: string) => {
|
||||
setContactToDelete(contactId);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!contactToDelete) return;
|
||||
|
||||
try {
|
||||
await network.fetch('DELETE', `/contacts/${contactToDelete}`);
|
||||
toast.success('Contact deleted successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete contact');
|
||||
} finally {
|
||||
setContactToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Contacts" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Contacts</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Manage your email subscribers and their data.{' '}
|
||||
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowImportDialog(true)}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import CSV
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Contact
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Search</Button>
|
||||
{search && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setSearchInput('');
|
||||
setCursor(undefined);
|
||||
setCursorHistory([undefined]);
|
||||
setCurrentPage(0);
|
||||
setContacts([]);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contacts Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Contacts</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage your contact list.
|
||||
{totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && contacts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading contacts...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : contacts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-neutral-900 mb-2">No contacts found</h3>
|
||||
<p className="text-neutral-500 mb-6">
|
||||
{search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'}
|
||||
</p>
|
||||
{!search && (
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Contact
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-neutral-200">
|
||||
{contacts.map(contact => (
|
||||
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{contact.subscribed ? (
|
||||
<MailCheck className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-neutral-900">{contact.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
contact.subscribed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
|
||||
{new Date(contact.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/contacts/${contact.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => promptDelete(contact.id)}>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{(currentPage > 0 || data?.hasMore) && (
|
||||
<div className="flex items-center justify-between mt-6 pt-6 border-t border-neutral-200">
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '}
|
||||
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span>
|
||||
{totalCount > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
of <span className="font-medium text-neutral-900">{totalCount.toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handlePreviousPage} disabled={currentPage === 0 || isLoading}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleNextPage} disabled={!data?.hasMore || isLoading}>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Create Contact Dialog */}
|
||||
<CreateContactDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} onSuccess={() => mutate()} />
|
||||
|
||||
{/* Import Contacts Dialog */}
|
||||
<ImportContactsDialog open={showImportDialog} onOpenChange={setShowImportDialog} onSuccess={() => mutate()} />
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Contact"
|
||||
description="Are you sure you want to delete this contact? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateContactDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialogProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [subscribed, setSubscribed] = useState(true);
|
||||
const [customData, setCustomData] = useState<Record<string, string | number | boolean> | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await network.fetch<
|
||||
{
|
||||
_meta?: {isNew: boolean; isUpdate: boolean};
|
||||
email: string;
|
||||
},
|
||||
typeof ContactSchemas.create
|
||||
>('POST', '/contacts', {email, subscribed, data: customData});
|
||||
|
||||
// Show appropriate message based on whether contact was new or updated
|
||||
if (response._meta?.isUpdate) {
|
||||
toast.success(`Contact ${response.email} already existed and was updated with new data`);
|
||||
} else {
|
||||
toast.success('Contact created successfully');
|
||||
}
|
||||
|
||||
setEmail('');
|
||||
setSubscribed(true);
|
||||
setCustomData(null);
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save contact');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Contact</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<Switch id="subscribed" checked={subscribed} onCheckedChange={setSubscribed} />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="subscribed" className="font-medium cursor-pointer">
|
||||
Subscribed
|
||||
</Label>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
When enabled, this contact will receive emails from your campaigns and workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<KeyValueEditor key={open ? 'create' : 'closed'} initialData={customData} onChange={setCustomData} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Contact'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImportContactsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
totalRows: number;
|
||||
successCount: number;
|
||||
createdCount: number;
|
||||
updatedCount: number;
|
||||
failureCount: number;
|
||||
errors: Array<{row: number; email: string; error: string}>;
|
||||
}
|
||||
|
||||
function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDialogProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'completed' | 'failed'>('idle');
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
||||
|
||||
// Clean up polling on unmount or dialog close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
// Reset state when dialog closes
|
||||
setTimeout(() => {
|
||||
setFile(null);
|
||||
setJobId(null);
|
||||
setProgress(0);
|
||||
setStatus('idle');
|
||||
setResult(null);
|
||||
}, 300);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
// Validate file type
|
||||
if (!selectedFile.name.endsWith('.csv')) {
|
||||
toast.error('Please select a CSV file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB max)
|
||||
if (selectedFile.size > 5 * 1024 * 1024) {
|
||||
toast.error('File size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const pollJobStatus = async (jobId: string) => {
|
||||
try {
|
||||
const response = await network.fetch<{
|
||||
id: string;
|
||||
state: string;
|
||||
progress: number;
|
||||
result: ImportResult | null;
|
||||
}>('GET', `/contacts/import/${jobId}`);
|
||||
|
||||
setProgress(response.progress || 0);
|
||||
|
||||
if (response.state === 'completed') {
|
||||
setStatus('completed');
|
||||
setResult(response.result);
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (response.result) {
|
||||
const {createdCount, updatedCount, failureCount} = response.result;
|
||||
const parts = [];
|
||||
if (createdCount > 0) parts.push(`${createdCount} created`);
|
||||
if (updatedCount > 0) parts.push(`${updatedCount} updated`);
|
||||
if (failureCount > 0) parts.push(`${failureCount} failed`);
|
||||
|
||||
toast.success(`Import completed: ${parts.join(', ')}`);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} else if (response.state === 'failed') {
|
||||
setStatus('failed');
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
toast.error('Import failed. Please try again.');
|
||||
} else if (response.state === 'active') {
|
||||
setStatus('processing');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll job status:', error);
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
setStatus('failed');
|
||||
toast.error('Failed to check import status');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
toast.error('Please select a file to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setStatus('uploading');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const data = await network.upload<{jobId: string; message: string}>('POST', '/contacts/import', formData);
|
||||
|
||||
setJobId(data.jobId);
|
||||
setStatus('processing');
|
||||
|
||||
// Start polling for job status
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
void pollJobStatus(data.jobId);
|
||||
}, 1000); // Poll every second
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload file');
|
||||
setStatus('failed');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (status === 'processing') {
|
||||
setShowCloseConfirmDialog(true);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const confirmClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Contacts from CSV</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Instructions */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-900 mb-2">CSV Format Requirements</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
|
||||
<li>First row must contain column headers</li>
|
||||
<li>
|
||||
Required column: <code className="bg-blue-100 px-1 rounded">email</code>
|
||||
</li>
|
||||
<li>Optional: Add any custom fields (e.g., firstName, lastName, plan)</li>
|
||||
<li>Maximum file size: 5MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
{status === 'idle' || status === 'failed' ? (
|
||||
<div>
|
||||
<Label htmlFor="csv-file">Select CSV File</Label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="csv-file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<FileUp className="h-4 w-4 mr-2" />
|
||||
{file ? file.name : 'Choose CSV File'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Progress */}
|
||||
{(status === 'uploading' || status === 'processing') && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-600">
|
||||
{status === 'uploading' ? 'Uploading file...' : 'Processing contacts...'}
|
||||
</span>
|
||||
<span className="text-neutral-900 font-medium">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{width: `${progress}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{status === 'completed' && result && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-neutral-900">{result.totalRows}</div>
|
||||
<div className="text-sm text-neutral-600">Total</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<div className="text-2xl font-bold text-green-900">{result.createdCount}</div>
|
||||
</div>
|
||||
<div className="text-sm text-green-700">Created</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-blue-600" />
|
||||
<div className="text-2xl font-bold text-blue-900">{result.updatedCount}</div>
|
||||
</div>
|
||||
<div className="text-sm text-blue-700">Updated</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
<div className="text-2xl font-bold text-red-900">{result.failureCount}</div>
|
||||
</div>
|
||||
<div className="text-sm text-red-700">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Details */}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||
<h4 className="font-medium text-red-900 mb-2">Import Errors</h4>
|
||||
<div className="space-y-1 text-sm text-red-800">
|
||||
{result.errors.slice(0, 10).map((error, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<span className="font-mono text-xs">Row {error.row}:</span>
|
||||
<span>
|
||||
{error.email || 'N/A'} - {error.error}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{result.errors.length > 10 && (
|
||||
<div className="text-red-700 font-medium mt-2">
|
||||
...and {result.errors.length - 10} more errors
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'failed' && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-red-900">
|
||||
<XCircle className="h-5 w-5" />
|
||||
<span className="font-medium">Import failed</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-800 mt-1">Please check your CSV file and try again.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{status === 'idle' || status === 'failed' ? (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleUpload} disabled={!file || isUploading}>
|
||||
{isUploading ? 'Uploading...' : 'Import Contacts'}
|
||||
</Button>
|
||||
</>
|
||||
) : status === 'completed' ? (
|
||||
<Button type="button" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showCloseConfirmDialog}
|
||||
onOpenChange={setShowCloseConfirmDialog}
|
||||
onConfirm={confirmClose}
|
||||
title="Close Import"
|
||||
description="Import is still in progress. Are you sure you want to close?"
|
||||
confirmText="Close Anyway"
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@plunk/ui';
|
||||
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||
import {DashboardLayout} from '../components/DashboardLayout';
|
||||
import {QuickStart} from '../components/QuickStart';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
||||
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||
|
||||
export default function Index() {
|
||||
const {activeProject} = useActiveProject();
|
||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Total Contacts',
|
||||
value: isLoading ? '-' : totalContacts.toLocaleString(),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: 'Emails Sent',
|
||||
value: isLoading ? '-' : totalEmailsSent.toLocaleString(),
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
name: 'Campaigns',
|
||||
value: isLoading ? '-' : totalCampaigns.toLocaleString(),
|
||||
icon: Send,
|
||||
},
|
||||
{
|
||||
name: 'Open Rate',
|
||||
value: isLoading ? '-' : `${openRate.toFixed(1)}%`,
|
||||
icon: TrendingUp,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Dashboard" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
{/* Project Disabled Banner */}
|
||||
{activeProject && activeProject.disabled && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Project Disabled - Read-Only Mode</AlertTitle>
|
||||
<AlertDescription>
|
||||
This project has been disabled due to security violations (high bounce or complaint rates). All scheduled
|
||||
campaigns and workflows have been cancelled. The project is now in read-only mode - you can view your data
|
||||
but cannot create, update, or delete anything. Please contact support to resolve this issue.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Subscription Warning Banner */}
|
||||
{activeProject && !activeProject.disabled && !activeProject.subscription && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Upgrade to remove Plunk branding</AlertTitle>
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>Your emails currently include Plunk branding. Upgrade to a subscription to remove it.</span>
|
||||
<Link href="/settings?tab=billing">
|
||||
<Button size="sm">Upgrade Now</Button>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Dashboard</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Welcome back to {activeProject?.name || 'Plunk'}. Here's what's happening with your emails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map(stat => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-neutral-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{stat.value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & API Keys */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Quick Start */}
|
||||
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activeProject ? (
|
||||
<>
|
||||
<ApiKeyDisplay
|
||||
label="Public Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No project selected</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import {Button, Card, CardContent} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
interface ContactInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
subscribed: boolean;
|
||||
}
|
||||
|
||||
export default function Manage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
|
||||
const [contact, setContact] = useState<ContactInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || typeof id !== 'string') return;
|
||||
|
||||
const fetchContact = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await network.fetch<ContactInfo>('GET', `/contacts/public/${id}`);
|
||||
setContact(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load contact information');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchContact();
|
||||
}, [id]);
|
||||
|
||||
const handleToggleSubscription = async () => {
|
||||
if (!id || typeof id !== 'string' || !contact) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setSaveMessage(null);
|
||||
|
||||
const endpoint = contact.subscribed ? `/contacts/public/${id}/unsubscribe` : `/contacts/public/${id}/subscribe`;
|
||||
|
||||
const data = await network.fetch<ContactInfo>('POST', endpoint);
|
||||
setContact(data);
|
||||
setSaveMessage(data.subscribed ? 'Subscribed successfully!' : 'Unsubscribed successfully!');
|
||||
setError(null);
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSaveMessage(null), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update subscription');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin text-neutral-500"
|
||||
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>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !contact) {
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">Error</h1>
|
||||
<p className="text-neutral-500">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-neutral-900">Manage Preferences</h1>
|
||||
<p className="text-neutral-500">
|
||||
Manage email preferences for <strong>{contact?.email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-6 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-neutral-900">Email Subscription</h3>
|
||||
<p className="text-sm text-neutral-500 mt-1">
|
||||
{contact?.subscribed
|
||||
? 'You are currently subscribed to receive emails'
|
||||
: 'You are currently unsubscribed from emails'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleToggleSubscription()}
|
||||
disabled={updating}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2 ${
|
||||
contact?.subscribed ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||
} ${updating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
contact?.subscribed ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{saveMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-green-600 text-center bg-green-50 p-3 rounded-lg"
|
||||
>
|
||||
{saveMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500 text-center"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{contact?.subscribed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/unsubscribe/${id as string}`)}
|
||||
>
|
||||
Unsubscribe completely
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/subscribe/${id as string}`)}
|
||||
>
|
||||
Subscribe to emails
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-xs text-neutral-400 mt-2">
|
||||
<p>
|
||||
This page allows you to manage your email preferences. Your subscription status is updated in
|
||||
real-time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {ProjectSchemas} from '@plunk/shared';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import type {z} from 'zod';
|
||||
|
||||
import {useProjects} from '../../lib/hooks/useProject';
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
export default function CreateProject() {
|
||||
const {mutate: projectsMutate} = useProjects();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<z.infer<typeof ProjectSchemas.create>>({
|
||||
resolver: zodResolver(ProjectSchemas.create),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof ProjectSchemas.create>) {
|
||||
try {
|
||||
await network.fetch<{success: boolean}, typeof ProjectSchemas.create>('POST', '/users/@me/projects', values);
|
||||
|
||||
// Refresh the projects list
|
||||
await projectsMutate();
|
||||
|
||||
// Redirect to dashboard
|
||||
await router.push('/');
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Create Project" />
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="p-6 md:p-8"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Create your first project</h1>
|
||||
<p className="text-balance text-neutral-500">
|
||||
Get started by creating a project to organize your emails
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome Project" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Choose a descriptive name for your project. You can always change it later.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div layout>
|
||||
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
'Create Project'
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Contact, Segment} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Database,
|
||||
Filter,
|
||||
MailCheck,
|
||||
MailX,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import type {SegmentFilter} from '@plunk/types';
|
||||
import {SegmentSchemas} from '@plunk/shared';
|
||||
|
||||
const FILTER_OPERATORS = [
|
||||
{value: 'equals', label: 'Equals'},
|
||||
{value: 'notEquals', label: 'Not equals'},
|
||||
{value: 'contains', label: 'Contains'},
|
||||
{value: 'notContains', label: 'Does not contain'},
|
||||
{value: 'greaterThan', label: 'Greater than'},
|
||||
{value: 'lessThan', label: 'Less than'},
|
||||
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
|
||||
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
|
||||
{value: 'exists', label: 'Exists'},
|
||||
{value: 'notExists', label: 'Does not exist'},
|
||||
{value: 'within', label: 'Within (time)'},
|
||||
] as const;
|
||||
|
||||
const TIME_UNITS = [
|
||||
{value: 'minutes', label: 'Minutes'},
|
||||
{value: 'hours', label: 'Hours'},
|
||||
{value: 'days', label: 'Days'},
|
||||
] as const;
|
||||
|
||||
const FIELD_PRESETS = [
|
||||
{value: 'email', label: 'Email', type: 'string'},
|
||||
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
|
||||
{value: 'createdAt', label: 'Created At', type: 'date'},
|
||||
{value: 'updatedAt', label: 'Updated At', type: 'date'},
|
||||
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
|
||||
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
|
||||
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
|
||||
] as const;
|
||||
|
||||
interface PaginatedContacts {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export default function SegmentDetailPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
|
||||
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
|
||||
const [contactsPage, setContactsPage] = useState(1);
|
||||
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedContacts>(
|
||||
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
|
||||
);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [trackMembership, setTrackMembership] = useState(false);
|
||||
const [filters, setFilters] = useState<SegmentFilter[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isComputing, setIsComputing] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Initialize form when segment loads
|
||||
useEffect(() => {
|
||||
if (segment) {
|
||||
setName(segment.name);
|
||||
setDescription(segment.description || '');
|
||||
setTrackMembership(segment.trackMembership);
|
||||
setFilters((segment.filters as unknown as SegmentFilter[]) || []);
|
||||
}
|
||||
}, [segment]);
|
||||
|
||||
const addFilter = () => {
|
||||
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
setFilters(filters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
|
||||
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
filters,
|
||||
trackMembership,
|
||||
});
|
||||
toast.success('Segment updated successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update segment');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComputeMembership = async () => {
|
||||
if (!trackMembership) {
|
||||
toast.error('Membership tracking must be enabled to compute membership');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsComputing(true);
|
||||
try {
|
||||
const result = await network.fetch<{added: number; removed: number; total: number}>(
|
||||
'POST',
|
||||
`/segments/${id}/compute`,
|
||||
);
|
||||
toast.success(`Membership updated: ${result.added} added, ${result.removed} removed, ${result.total} total`);
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to compute membership');
|
||||
} finally {
|
||||
setIsComputing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await network.fetch('DELETE', `/segments/${id}`);
|
||||
toast.success('Segment deleted successfully');
|
||||
void router.push('/segments');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete segment');
|
||||
}
|
||||
};
|
||||
|
||||
const needsValue = (operator: string) => {
|
||||
return !['exists', 'notExists'].includes(operator);
|
||||
};
|
||||
|
||||
const needsUnit = (operator: string) => {
|
||||
return operator === 'within';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading segment...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!segment) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-neutral-900 mb-2">Segment not found</h3>
|
||||
<p className="text-neutral-500 mb-6">
|
||||
The segment you're looking for doesn't exist or has been deleted.
|
||||
</p>
|
||||
<Link href="/segments">
|
||||
<Button>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Segments
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/segments">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
||||
<p className="text-neutral-500 mt-1">{segment.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete Segment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Edit Form */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Segment Details</CardTitle>
|
||||
<CardDescription>Update segment name and description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Segment Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., Active Pro Users"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="e.g., Users on pro plan who have been active in the last 30 days"
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<input
|
||||
id="trackMembership"
|
||||
type="checkbox"
|
||||
checked={trackMembership}
|
||||
onChange={e => setTrackMembership(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
|
||||
Track membership changes
|
||||
</Label>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>Define conditions to match contacts</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Filter
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{filters.map((filter, index) => (
|
||||
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
{/* Field */}
|
||||
<div>
|
||||
<Label className="text-xs">Field</Label>
|
||||
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_PRESETS.map(preset => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Custom field...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{filter.field === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g., data.customField"
|
||||
className="mt-2"
|
||||
onChange={e => updateFilter(index, {field: e.target.value})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operator */}
|
||||
<div>
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATORS.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
{needsValue(filter.operator) && (
|
||||
<div>
|
||||
<Label className="text-xs">Value</Label>
|
||||
{filter.field === 'subscribed' ? (
|
||||
<Select
|
||||
value={filter.value?.toString()}
|
||||
onValueChange={value => updateFilter(index, {value: value === 'true'})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={filter.value ?? ''}
|
||||
onChange={e => updateFilter(index, {value: e.target.value})}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unit */}
|
||||
{needsUnit(filter.operator) && (
|
||||
<div>
|
||||
<Label className="text-xs">Unit</Label>
|
||||
<Select
|
||||
value={filter.unit ?? 'days'}
|
||||
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_UNITS.map(unit => (
|
||||
<SelectItem key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilter(index)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end">
|
||||
<Button type="submit" disabled={isSubmitting || filters.length === 0}>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Contacts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Matching Contacts</CardTitle>
|
||||
<CardDescription>Contacts that match this segment's filters</CardDescription>
|
||||
</div>
|
||||
{trackMembership && (
|
||||
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
||||
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
||||
{isComputing ? 'Computing...' : 'Recompute'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingContacts ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-neutral-500">Loading contacts...</p>
|
||||
</div>
|
||||
) : contactsData?.contacts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<p className="text-neutral-500">No contacts match this segment</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{contactsData?.contacts.map(contact => (
|
||||
<div key={contact.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{contact.subscribed ? (
|
||||
<MailCheck className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{contact.email}</span>
|
||||
</div>
|
||||
<Link href={`/contacts/${contact.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{contactsData && contactsData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Page {contactsPage} of {contactsData.totalPages} ({contactsData.total} total)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setContactsPage(p => p - 1)}
|
||||
disabled={contactsPage === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setContactsPage(p => p + 1)}
|
||||
disabled={contactsPage === contactsData.totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Metadata Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Members</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Filters</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-900">
|
||||
{Array.isArray(segment.filters) ? segment.filters.length : 0}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-neutral-900">Created</p>
|
||||
<p className="text-sm text-neutral-500">{new Date(segment.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
|
||||
<p className="text-sm text-neutral-500">{new Date(segment.updatedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-900">Segment ID</p>
|
||||
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Segment"
|
||||
description="Are you sure you want to delete this segment? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
} from '@plunk/ui';
|
||||
import type {Segment} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function SegmentsPage() {
|
||||
// Limit to 50 segments to avoid loading thousands into the browser
|
||||
const {
|
||||
data: segments,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useSWR<Segment[]>('/segments', {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [segmentToDelete, setSegmentToDelete] = useState<string | null>(null);
|
||||
|
||||
// Show warning if there are many segments
|
||||
const showLimitWarning = segments && segments.length >= 50;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!segmentToDelete) return;
|
||||
|
||||
try {
|
||||
await network.fetch('DELETE', `/segments/${segmentToDelete}`);
|
||||
toast.success('Segment deleted successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete segment');
|
||||
} finally {
|
||||
setSegmentToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Segments" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Segments</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Create dynamic audience groups based on contact attributes and behaviors
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/segments/new">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Segment
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Warning if too many segments */}
|
||||
{showLimitWarning && (
|
||||
<Alert variant="default">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Showing first {segments?.length} segments. Consider archiving old segments to improve performance.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Segments Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading segments...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : segments?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center">
|
||||
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-neutral-900 mb-2">No segments yet</h3>
|
||||
<p className="text-neutral-500 mb-6">
|
||||
Create your first segment to group contacts based on attributes and behaviors
|
||||
</p>
|
||||
<Link href="/segments/new">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Segment
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{segments?.map(segment => (
|
||||
<Card key={segment.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
||||
{segment.description && <CardDescription className="mt-1">{segment.description}</CardDescription>}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Members</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-900">{segment.memberCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Filters</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{Array.isArray(segment.filters) ? segment.filters.length : 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-neutral-200">
|
||||
<Link href={`/segments/${segment.id}`} className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSegmentToDelete(segment.id);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-xs text-neutral-500 pt-2 border-t border-neutral-200">
|
||||
Created {new Date(segment.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Segment"
|
||||
description="Are you sure you want to delete this segment? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {ArrowLeft, Filter, Plus, Save, Trash2} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import type {SegmentFilter} from '@plunk/types';
|
||||
import type {Segment} from '@plunk/db';
|
||||
import {SegmentSchemas} from '@plunk/shared';
|
||||
|
||||
const FILTER_OPERATORS = [
|
||||
{value: 'equals', label: 'Equals'},
|
||||
{value: 'notEquals', label: 'Not equals'},
|
||||
{value: 'contains', label: 'Contains'},
|
||||
{value: 'notContains', label: 'Does not contain'},
|
||||
{value: 'greaterThan', label: 'Greater than'},
|
||||
{value: 'lessThan', label: 'Less than'},
|
||||
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
|
||||
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
|
||||
{value: 'exists', label: 'Exists'},
|
||||
{value: 'notExists', label: 'Does not exist'},
|
||||
{value: 'within', label: 'Within (time)'},
|
||||
] as const;
|
||||
|
||||
const TIME_UNITS = [
|
||||
{value: 'minutes', label: 'Minutes'},
|
||||
{value: 'hours', label: 'Hours'},
|
||||
{value: 'days', label: 'Days'},
|
||||
] as const;
|
||||
|
||||
const FIELD_PRESETS = [
|
||||
{value: 'email', label: 'Email', type: 'string'},
|
||||
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
|
||||
{value: 'createdAt', label: 'Created At', type: 'date'},
|
||||
{value: 'updatedAt', label: 'Updated At', type: 'date'},
|
||||
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
|
||||
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
|
||||
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
|
||||
] as const;
|
||||
|
||||
export default function NewSegmentPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [trackMembership, setTrackMembership] = useState(false);
|
||||
const [filters, setFilters] = useState<SegmentFilter[]>([{field: 'subscribed', operator: 'equals', value: true}]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const addFilter = () => {
|
||||
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
setFilters(filters.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
|
||||
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
filters,
|
||||
trackMembership,
|
||||
});
|
||||
toast.success('Segment created successfully');
|
||||
void router.push('/segments');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const needsValue = (operator: string) => {
|
||||
return !['exists', 'notExists'].includes(operator);
|
||||
};
|
||||
|
||||
const needsUnit = (operator: string) => {
|
||||
return operator === 'within';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Create Segment" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/segments">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
|
||||
<p className="text-neutral-500 mt-1">Define filters to automatically group contacts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Segment Details</CardTitle>
|
||||
<CardDescription>Give your segment a name and description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Segment Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., Active Pro Users"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="e.g., Users on pro plan who have been active in the last 30 days"
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<input
|
||||
id="trackMembership"
|
||||
type="checkbox"
|
||||
checked={trackMembership}
|
||||
onChange={e => setTrackMembership(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
|
||||
Track membership changes
|
||||
</Label>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>Define conditions to match contacts (all filters must match)</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Filter
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{filters.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<p className="text-neutral-500 mb-4">No filters defined. Add at least one filter.</p>
|
||||
<Button type="button" variant="outline" onClick={addFilter}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add First Filter
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
filters.map((filter, index) => (
|
||||
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
{/* Field */}
|
||||
<div>
|
||||
<Label className="text-xs">Field</Label>
|
||||
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_PRESETS.map(preset => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Custom field...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{filter.field === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g., data.customField"
|
||||
className="mt-2"
|
||||
onChange={e => updateFilter(index, {field: e.target.value})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operator */}
|
||||
<div>
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATORS.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
{needsValue(filter.operator) && (
|
||||
<div>
|
||||
<Label className="text-xs">Value</Label>
|
||||
{filter.field === 'subscribed' ? (
|
||||
<Select
|
||||
value={filter.value?.toString()}
|
||||
onValueChange={value => updateFilter(index, {value: value === 'true'})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={filter.value ?? ''}
|
||||
onChange={e => updateFilter(index, {value: e.target.value})}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unit (for within operator) */}
|
||||
{needsUnit(filter.operator) && (
|
||||
<div>
|
||||
<Label className="text-xs">Unit</Label>
|
||||
<Select
|
||||
value={filter.unit ?? 'days'}
|
||||
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_UNITS.map(unit => (
|
||||
<SelectItem key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilter(index)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href="/segments">
|
||||
<Button type="button" variant="outline" disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting || filters.length === 0}>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSubmitting ? 'Creating...' : 'Create Segment'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {ProjectSchemas} from '@plunk/shared';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, CreditCard, Globe, Mail, Settings as SettingsIcon} from 'lucide-react';
|
||||
import type {z} from 'zod';
|
||||
import {useRouter} from 'next/router';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {DomainsSettings} from '../../components/DomainsSettings';
|
||||
import {BillingLimits} from '../../components/BillingLimits';
|
||||
import {BillingConsumption} from '../../components/BillingConsumption';
|
||||
import {BillingInvoices} from '../../components/BillingInvoices';
|
||||
import {UnpaidInvoiceBanner} from '../../components/UnpaidInvoiceBanner';
|
||||
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
||||
import {SmtpSettings} from '../../components/SmtpSettings';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {network} from '../../lib/network';
|
||||
import {useProjects} from '../../lib/hooks/useProject';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
|
||||
type TabId = 'general' | 'billing' | 'domains' | 'smtp';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: typeof SettingsIcon;
|
||||
condition?: boolean;
|
||||
}
|
||||
|
||||
const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Tab[] => {
|
||||
const {billingEnabled, smtpEnabled} = options;
|
||||
const allTabs: Tab[] = [
|
||||
{id: 'general', label: 'General', icon: SettingsIcon},
|
||||
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
||||
{id: 'domains', label: 'Domains', icon: Globe},
|
||||
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
|
||||
];
|
||||
return allTabs.filter(tab => tab.condition !== false);
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const router = useRouter();
|
||||
const {activeProject, setActiveProject} = useActiveProject();
|
||||
const {mutate: projectsMutate} = useProjects();
|
||||
const {data: config} = useConfig();
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||
const [isLoadingBilling, setIsLoadingBilling] = useState(false);
|
||||
|
||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
||||
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
||||
const smtpConfig = smtpEnabled
|
||||
? {
|
||||
enabled: true as const,
|
||||
domain: config?.features.smtp.domain ?? undefined,
|
||||
portSecure: config?.features.smtp.ports?.secure,
|
||||
portSubmission: config?.features.smtp.ports?.submission,
|
||||
}
|
||||
: {enabled: false as const};
|
||||
|
||||
// Get current tab from URL or default to 'general'
|
||||
const currentTab = (router.query.tab as TabId) || 'general';
|
||||
|
||||
// Set default tab in URL if none is present
|
||||
useEffect(() => {
|
||||
if (!router.query.tab && router.isReady) {
|
||||
router.replace('/settings?tab=general', undefined, {shallow: true});
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Handler to change tabs and update URL
|
||||
const handleTabChange = (newTab: string) => {
|
||||
router.push(`/settings?tab=${newTab}`, undefined, {shallow: true});
|
||||
};
|
||||
|
||||
// Handle Stripe redirect success/cancel messages
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
|
||||
if (router.query.success === 'true') {
|
||||
// Use setTimeout to defer state update, avoiding synchronous setState in effect
|
||||
const timer = setTimeout(() => {
|
||||
setSuccessMessage('Subscription activated successfully! It may take a moment to update.');
|
||||
// Clear message and URL after 5 seconds
|
||||
setTimeout(() => {
|
||||
setSuccessMessage(null);
|
||||
router.replace('/settings?tab=billing', undefined, {shallow: true});
|
||||
}, 5000);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (router.query.canceled === 'true') {
|
||||
// Use setTimeout to defer state update, avoiding synchronous setState in effect
|
||||
const timer = setTimeout(() => {
|
||||
setErrorMessage('Checkout was canceled. You can try again anytime.');
|
||||
// Clear message and URL after 5 seconds
|
||||
setTimeout(() => {
|
||||
setErrorMessage(null);
|
||||
router.replace('/settings?tab=billing', undefined, {shallow: true});
|
||||
}, 5000);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const form = useForm<z.infer<typeof ProjectSchemas.update>>({
|
||||
resolver: zodResolver(ProjectSchemas.update),
|
||||
defaultValues: {
|
||||
name: activeProject?.name || '',
|
||||
trackingEnabled: activeProject?.trackingEnabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when active project changes
|
||||
useEffect(() => {
|
||||
if (activeProject) {
|
||||
form.reset({
|
||||
name: activeProject.name,
|
||||
trackingEnabled: activeProject.trackingEnabled ?? true,
|
||||
});
|
||||
}
|
||||
}, [activeProject, form]);
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof ProjectSchemas.update>) => {
|
||||
if (!activeProject) return;
|
||||
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
const updatedProject = await network.fetch<typeof activeProject, typeof ProjectSchemas.update>(
|
||||
'PATCH',
|
||||
`/users/@me/projects/${activeProject.id}`,
|
||||
values,
|
||||
);
|
||||
|
||||
// Update the active project in context
|
||||
setActiveProject(updatedProject);
|
||||
|
||||
// Refresh projects list
|
||||
await projectsMutate();
|
||||
|
||||
setSuccessMessage('Project settings updated successfully');
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to update project settings');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateKeys = async () => {
|
||||
if (!activeProject) return;
|
||||
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
const updatedProject = await network.fetch<typeof activeProject>(
|
||||
'POST',
|
||||
`/users/@me/projects/${activeProject.id}/regenerate-keys`,
|
||||
);
|
||||
|
||||
// Update the active project in context
|
||||
setActiveProject(updatedProject);
|
||||
|
||||
// Refresh projects list
|
||||
await projectsMutate();
|
||||
|
||||
setSuccessMessage('API keys regenerated successfully');
|
||||
setShowRegenerateDialog(false);
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate API keys');
|
||||
setShowRegenerateDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
const promptRegenerateKeys = () => {
|
||||
setShowRegenerateDialog(true);
|
||||
};
|
||||
|
||||
const handleStartSubscription = async () => {
|
||||
if (!activeProject) return;
|
||||
if (!billingEnabled) {
|
||||
setErrorMessage('Billing is disabled on this instance.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingBilling(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
const response = await network.fetch<{url: string}>('POST', `/users/@me/projects/${activeProject.id}/checkout`);
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
if (response.url) {
|
||||
window.location.href = response.url;
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to start checkout');
|
||||
setIsLoadingBilling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageBilling = async () => {
|
||||
if (!activeProject) return;
|
||||
if (!billingEnabled) {
|
||||
setErrorMessage('Billing is disabled on this instance.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingBilling(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
const response = await network.fetch<{url: string}>(
|
||||
'POST',
|
||||
`/users/@me/projects/${activeProject.id}/billing-portal`,
|
||||
);
|
||||
|
||||
// Redirect to Stripe billing portal
|
||||
if (response.url) {
|
||||
window.location.href = response.url;
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to open billing portal');
|
||||
setIsLoadingBilling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Settings" />
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<p className="text-neutral-500">No project selected</p>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Settings" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Settings</h1>
|
||||
<p className="text-neutral-500 mt-2">Manage your project settings and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={currentTab} onValueChange={handleTabChange} className="max-w-4xl">
|
||||
<TabsList>
|
||||
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{/* General Tab */}
|
||||
<TabsContent value="general">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Settings</CardTitle>
|
||||
<CardDescription>Update your project name and basic information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome Project" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Tracking Toggle - only show if feature is available */}
|
||||
{trackingToggleEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trackingEnabled"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-neutral-200 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Email Tracking</FormLabel>
|
||||
<FormDescription>
|
||||
Enable open and click tracking for emails sent from this project. When disabled, emails
|
||||
will be sent without tracking pixels.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="space-y-4 pt-4 border-t border-neutral-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-neutral-900">API Keys</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={promptRegenerateKeys}
|
||||
className="text-xs"
|
||||
>
|
||||
Regenerate Keys
|
||||
</Button>
|
||||
</div>
|
||||
<ApiKeyDisplay
|
||||
label="Public API Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret API Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence mode="wait">
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Billing Tab */}
|
||||
<TabsContent value="billing">
|
||||
<div className="space-y-6">
|
||||
{/* Unpaid Invoice Banner */}
|
||||
<UnpaidInvoiceBanner projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing & Subscription</CardTitle>
|
||||
<CardDescription>Manage your subscription and billing information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence mode="wait">
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{activeProject.subscription ? (
|
||||
// Has subscription - show billing portal button
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-800 mb-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
<span className="font-medium">Active Subscription</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">
|
||||
Your subscription is active. Manage your billing details, update payment methods, or cancel
|
||||
your subscription through the billing portal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button onClick={handleManageBilling} disabled={isLoadingBilling}>
|
||||
{isLoadingBilling ? 'Loading...' : 'Manage Billing'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// No subscription - show start subscription button
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-neutral-50 border border-neutral-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-neutral-800 mb-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
<span className="font-medium">No Active Subscription</span>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Start a subscription to unlock premium features and support the development of Plunk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button onClick={handleStartSubscription} disabled={isLoadingBilling}>
|
||||
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Billing Limits */}
|
||||
<BillingLimits projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
|
||||
{/* Current Month Consumption */}
|
||||
<BillingConsumption projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
|
||||
{/* Past Invoices */}
|
||||
<BillingInvoices
|
||||
projectId={activeProject.id}
|
||||
hasSubscription={!!activeProject.subscription}
|
||||
onManageBilling={handleManageBilling}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Domains Tab */}
|
||||
<TabsContent value="domains">
|
||||
<DomainsSettings projectId={activeProject.id} />
|
||||
</TabsContent>
|
||||
|
||||
{/* SMTP Tab */}
|
||||
<TabsContent value="smtp">
|
||||
<SmtpSettings smtpConfig={smtpConfig} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Regenerate Keys Confirmation Dialog */}
|
||||
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||
Regenerate API Keys
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<p>Are you sure you want to regenerate your API keys?</p>
|
||||
<Alert className="bg-orange-50 border-orange-200 text-orange-900 text-xs">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<strong>Warning:</strong> This action will immediately invalidate your current API keys. Any
|
||||
applications using the old keys will stop working until you update them with the new keys.
|
||||
</div>
|
||||
</Alert>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleRegenerateKeys} className="bg-orange-600 hover:bg-orange-700">
|
||||
Regenerate Keys
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {Button, Card, CardContent} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
interface ContactInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
subscribed: boolean;
|
||||
}
|
||||
|
||||
export default function Subscribe() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
|
||||
const [contact, setContact] = useState<ContactInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [subscribing, setSubscribing] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || typeof id !== 'string') return;
|
||||
|
||||
const fetchContact = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await network.fetch<ContactInfo>('GET', `/contacts/public/${id}`);
|
||||
setContact(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load contact information');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchContact();
|
||||
}, [id]);
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
if (!id || typeof id !== 'string') return;
|
||||
|
||||
try {
|
||||
setSubscribing(true);
|
||||
const data = await network.fetch<ContactInfo>('POST', `/contacts/public/${id}/subscribe`);
|
||||
setContact(data);
|
||||
setSuccess(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to subscribe');
|
||||
} finally {
|
||||
setSubscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin text-neutral-500"
|
||||
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>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !contact) {
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">Error</h1>
|
||||
<p className="text-neutral-500">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success || (contact && contact.subscribed)) {
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<motion.div
|
||||
initial={{scale: 0}}
|
||||
animate={{scale: 1}}
|
||||
transition={{type: 'spring', stiffness: 200, damping: 15}}
|
||||
className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">You're subscribed!</h1>
|
||||
<p className="text-neutral-500">{contact?.email} is now subscribed to receive emails from us.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-neutral-900">Subscribe to updates</h1>
|
||||
<p className="text-neutral-500">
|
||||
Would you like to subscribe <strong>{contact?.email}</strong> to receive emails?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500 text-center"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button onClick={() => void handleSubscribe()} className="w-full" disabled={subscribing}>
|
||||
{subscribing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Subscribing...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Subscribe'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
StickySaveBar,
|
||||
} from '@plunk/ui';
|
||||
import type {Template} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {EmailSettings} from '../../components/EmailSettings';
|
||||
import {EmailEditor} from '../../components/EmailEditor';
|
||||
import {network} from '../../lib/network';
|
||||
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
|
||||
import {ArrowLeft, Save} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {TemplateSchemas} from '@plunk/shared';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
|
||||
export default function TemplateEditorPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
const {activeProject} = useActiveProject();
|
||||
|
||||
const {data: template, mutate} = useSWR<Template>(id ? `/templates/${id}` : null, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [fromName, setFromName] = useState('');
|
||||
const [replyTo, setReplyTo] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [type, setType] = useState<'MARKETING' | 'TRANSACTIONAL'>('MARKETING');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Load template data into form
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setName(template.name);
|
||||
setDescription(template.description ?? '');
|
||||
setSubject(template.subject);
|
||||
setFrom(template.from);
|
||||
setFromName(template.fromName ?? '');
|
||||
setReplyTo(template.replyTo ?? '');
|
||||
setBody(template.body);
|
||||
setType(template.type);
|
||||
// Reset hasChanges when loading fresh data
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (!template) return;
|
||||
|
||||
const changed =
|
||||
name !== template.name ||
|
||||
description !== (template.description ?? '') ||
|
||||
subject !== template.subject ||
|
||||
from !== template.from ||
|
||||
fromName !== (template.fromName ?? '') ||
|
||||
replyTo !== (template.replyTo ?? '') ||
|
||||
body !== template.body ||
|
||||
type !== template.type;
|
||||
|
||||
setHasChanges(changed);
|
||||
}, [name, description, subject, from, fromName, replyTo, body, type, template]);
|
||||
|
||||
// Warn before leaving page with unsaved changes
|
||||
useChangeTracking(hasChanges);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<Template, typeof TemplateSchemas.update>('PATCH', `/templates/${id}`, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName: fromName || undefined,
|
||||
replyTo: replyTo || undefined,
|
||||
type,
|
||||
});
|
||||
|
||||
// Silent save - no toast notification
|
||||
setHasChanges(false);
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save template');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!template) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading template...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/templates">
|
||||
<Button type="button" variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Edit Template</h1>
|
||||
<p className="text-neutral-500 mt-1">Make changes to your email template</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!hasChanges && !isSubmitting && <span className="text-sm text-neutral-500">All changes saved</span>}
|
||||
{hasChanges && !isSubmitting && <span className="text-sm text-amber-600">Unsaved changes</span>}
|
||||
<Button type="submit" disabled={!hasChanges || isSubmitting}>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Editor */}
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Template Settings */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Template Settings</CardTitle>
|
||||
<CardDescription>Configure the basic settings for your template</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Template Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Welcome Email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Sent to new subscribers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="type">Type *</Label>
|
||||
<Select value={type} onValueChange={value => setType(value as 'MARKETING' | 'TRANSACTIONAL')}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MARKETING">Marketing</SelectItem>
|
||||
<SelectItem value="TRANSACTIONAL">Transactional</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Marketing templates will automatically include a Plunk-hosted unsubscribe link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject Line *</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
required
|
||||
placeholder="Welcome to our platform!"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">Use {'{{variableName}}'} for dynamic content</p>
|
||||
</div>
|
||||
|
||||
<EmailSettings
|
||||
from={from}
|
||||
fromName={fromName}
|
||||
replyTo={replyTo}
|
||||
onFromChange={setFrom}
|
||||
onFromNameChange={setFromName}
|
||||
onReplyToChange={setReplyTo}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
showFromNameHelpText
|
||||
layout="vertical"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Email Body */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Body</CardTitle>
|
||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor
|
||||
value={body}
|
||||
onChange={newBody => {
|
||||
setBody(newBody);
|
||||
setHasChanges(true);
|
||||
}}
|
||||
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
||||
canUploadImages={true}
|
||||
subject={subject}
|
||||
from={from}
|
||||
replyTo={replyTo}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sticky Save Bar */}
|
||||
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {EmailSettings} from '../../components/EmailSettings';
|
||||
import {EmailEditor} from '../../components/EmailEditor';
|
||||
import {network} from '../../lib/network';
|
||||
import {EmailFormValidator} from '../../lib/validation';
|
||||
import {ArrowLeft, Save} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import {TemplateSchemas} from '@plunk/shared';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
|
||||
export default function CreateTemplatePage() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [fromName, setFromName] = useState('');
|
||||
const [replyTo, setReplyTo] = useState('');
|
||||
const [type, setType] = useState<'MARKETING' | 'TRANSACTIONAL'>('MARKETING');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationError = EmailFormValidator.validateTemplate({name, subject, body, from});
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const template = await network.fetch<{id: string}, typeof TemplateSchemas.create>('POST', '/templates', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName: fromName || undefined,
|
||||
replyTo: replyTo || undefined,
|
||||
type,
|
||||
});
|
||||
|
||||
toast.success('Template created successfully');
|
||||
void router.push(`/templates/${template.id}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create template');
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Create Template" />
|
||||
<DashboardLayout>
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/templates">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Create Template</h1>
|
||||
<p className="text-neutral-500 mt-1">Create a reusable email template for campaigns and workflows</p>
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? 'Creating...' : 'Create Template'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Template Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Template Settings</CardTitle>
|
||||
<CardDescription>Configure your template details and email settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Template Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Welcome Email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="type">Template Type *</Label>
|
||||
<Select value={type} onValueChange={value => setType(value as 'MARKETING' | 'TRANSACTIONAL')}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MARKETING">Marketing</SelectItem>
|
||||
<SelectItem value="TRANSACTIONAL">Transactional</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Sent to new subscribers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject Line *</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
required
|
||||
placeholder="Welcome to our platform!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmailSettings
|
||||
from={from}
|
||||
fromName={fromName}
|
||||
replyTo={replyTo}
|
||||
onFromChange={setFrom}
|
||||
onFromNameChange={setFromName}
|
||||
onReplyToChange={setReplyTo}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Body */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Body</CardTitle>
|
||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
||||
canUploadImages={true}
|
||||
subject={subject}
|
||||
from={from}
|
||||
replyTo={replyTo}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ConfirmDialog, Input} from '@plunk/ui';
|
||||
import type {Template} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface PaginatedTemplates {
|
||||
templates: Template[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING'>('ALL');
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [templateToDelete, setTemplateToDelete] = useState<string | null>(null);
|
||||
|
||||
const {data, mutate, isLoading} = useSWR<PaginatedTemplates>(
|
||||
`/templates?page=${page}&pageSize=20${search ? `&search=${search}` : ''}${typeFilter !== 'ALL' ? `&type=${typeFilter}` : ''}`,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearch(searchInput);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!templateToDelete) return;
|
||||
|
||||
try {
|
||||
await network.fetch('DELETE', `/templates/${templateToDelete}`);
|
||||
toast.success('Template deleted successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete template');
|
||||
} finally {
|
||||
setTemplateToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (templateId: string) => {
|
||||
try {
|
||||
await network.fetch('POST', `/templates/${templateId}/duplicate`);
|
||||
toast.success('Template duplicated successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to duplicate template');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Templates" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Email Templates</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Create and manage reusable email templates for your campaigns and workflows.{' '}
|
||||
{data?.total ? `${data.total} total templates` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/templates/create">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search templates..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Search</Button>
|
||||
{search && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setSearchInput('');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTypeFilter('ALL')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
typeFilter === 'ALL'
|
||||
? 'bg-neutral-900 text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
All Templates
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTypeFilter('MARKETING')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
typeFilter === 'MARKETING'
|
||||
? 'bg-neutral-900 text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
Marketing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTypeFilter('TRANSACTIONAL')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
typeFilter === 'TRANSACTIONAL'
|
||||
? 'bg-neutral-900 text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
Transactional
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Templates Grid */}
|
||||
<div className="grid gap-4">
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading templates...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : data?.templates.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-12">
|
||||
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3>
|
||||
<p className="text-neutral-500 mb-6">
|
||||
{search ? 'Try adjusting your search terms' : 'Get started by creating your first template'}
|
||||
</p>
|
||||
{!search && (
|
||||
<Link href="/templates/create">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{data?.templates.map(template => (
|
||||
<Card key={template.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>{template.name}</CardTitle>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
template.type === 'MARKETING'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-purple-100 text-purple-800'
|
||||
}`}
|
||||
>
|
||||
{template.type}
|
||||
</span>
|
||||
</div>
|
||||
{template.description && (
|
||||
<CardDescription className="mt-2">{template.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Link href={`/templates/${template.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDuplicate(template.id)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTemplateToDelete(template.id);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-1">Subject</p>
|
||||
<p className="text-sm font-medium text-neutral-900">{template.subject}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-1">From</p>
|
||||
<p className="text-sm text-neutral-700">{template.from}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-neutral-500 pt-2 border-t border-neutral-100">
|
||||
<div>Created {new Date(template.createdAt).toLocaleDateString()}</div>
|
||||
{template.replyTo && (
|
||||
<div>
|
||||
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Showing {(page - 1) * data.pageSize + 1} to {Math.min(page * data.pageSize, data.total)} of{' '}
|
||||
{data.total} templates
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-neutral-700">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page === data.totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Template"
|
||||
description="Are you sure you want to delete this template? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import {Button, Card, CardContent} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useRouter} from 'next/router';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
|
||||
import {network} from '../../lib/network';
|
||||
|
||||
interface ContactInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
subscribed: boolean;
|
||||
}
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
|
||||
const [contact, setContact] = useState<ContactInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unsubscribing, setUnsubscribing] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || typeof id !== 'string') return;
|
||||
|
||||
const fetchContact = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await network.fetch<ContactInfo>('GET', `/contacts/public/${id}`);
|
||||
setContact(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load contact information');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchContact();
|
||||
}, [id]);
|
||||
|
||||
const handleUnsubscribe = async () => {
|
||||
if (!id || typeof id !== 'string') return;
|
||||
|
||||
try {
|
||||
setUnsubscribing(true);
|
||||
const data = await network.fetch<ContactInfo>('POST', `/contacts/public/${id}/unsubscribe`);
|
||||
setContact(data);
|
||||
setSuccess(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to unsubscribe');
|
||||
} finally {
|
||||
setUnsubscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin text-neutral-500"
|
||||
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>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !contact) {
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">Error</h1>
|
||||
<p className="text-neutral-500">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success || (contact && !contact.subscribed)) {
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<motion.div
|
||||
initial={{scale: 0}}
|
||||
animate={{scale: 1}}
|
||||
transition={{type: 'spring', stiffness: 200, damping: 15}}
|
||||
className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">You're unsubscribed</h1>
|
||||
<p className="text-neutral-500">
|
||||
{contact?.email} has been unsubscribed. You won't receive any more emails from us.
|
||||
</p>
|
||||
<p className="text-sm text-neutral-400 mt-2">
|
||||
Changed your mind?{' '}
|
||||
<button
|
||||
onClick={() => router.push(`/subscribe/${id as string}`)}
|
||||
className="underline hover:text-neutral-600"
|
||||
>
|
||||
Subscribe again
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'h-screen flex items-center justify-center bg-neutral-50'}>
|
||||
<div className={'flex flex-col gap-6 max-w-2xl w-full px-4'}>
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-neutral-900">Unsubscribe</h1>
|
||||
<p className="text-neutral-500">
|
||||
We're sorry to see you go. Are you sure you want to unsubscribe <strong>{contact?.email}</strong>{' '}
|
||||
from receiving emails?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
className="text-sm font-medium text-red-500 text-center"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
onClick={() => void handleUnsubscribe()}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
disabled={unsubscribing}
|
||||
>
|
||||
{unsubscribing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Unsubscribing...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Unsubscribe'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={() => router.push(`/manage/${id as string}`)}>
|
||||
Manage preferences instead
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Workflow} from '@plunk/db';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {WorkflowSchemas} from '@plunk/shared';
|
||||
|
||||
interface PaginatedWorkflows {
|
||||
workflows: (Workflow & {_count?: {steps: number; executions: number}})[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [workflowToDelete, setWorkflowToDelete] = useState<string | null>(null);
|
||||
|
||||
const {data, mutate, isLoading} = useSWR<PaginatedWorkflows>(
|
||||
`/workflows?page=${page}&pageSize=20${search ? `&search=${search}` : ''}`,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearch(searchInput);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!workflowToDelete) return;
|
||||
|
||||
try {
|
||||
await network.fetch('DELETE', `/workflows/${workflowToDelete}`);
|
||||
toast.success('Workflow deleted successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete workflow');
|
||||
} finally {
|
||||
setWorkflowToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => {
|
||||
try {
|
||||
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, {
|
||||
enabled: !currentlyEnabled,
|
||||
});
|
||||
toast.success(`Workflow ${!currentlyEnabled ? 'enabled' : 'disabled'} successfully`);
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to toggle workflow');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Workflows" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Workflows</h1>
|
||||
<p className="text-neutral-500 mt-2">
|
||||
Automate your email campaigns with powerful workflows.{' '}
|
||||
{data?.total ? `${data.total} total workflows` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Workflow
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search workflows..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Search</Button>
|
||||
{search && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setSearchInput('');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Workflows Grid */}
|
||||
<div className="grid gap-4">
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
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>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading workflows...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : data?.workflows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-12">
|
||||
<WorkflowIcon className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-neutral-900 mb-2">No workflows found</h3>
|
||||
<p className="text-neutral-500 mb-6">
|
||||
{search ? 'Try adjusting your search terms' : 'Get started by creating your first workflow'}
|
||||
</p>
|
||||
{!search && (
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Workflow
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{data?.workflows.map(workflow => (
|
||||
<Card key={workflow.id} className={workflow.enabled ? 'border-green-200' : ''}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>{workflow.name}</CardTitle>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
workflow.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{workflow.enabled ? (
|
||||
<>
|
||||
<Power className="h-3 w-3 mr-1" />
|
||||
Active
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PowerOff className="h-3 w-3 mr-1" />
|
||||
Disabled
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{workflow.triggerConfig &&
|
||||
typeof workflow.triggerConfig === 'object' &&
|
||||
'eventName' in workflow.triggerConfig && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{String(workflow.triggerConfig.eventName)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{workflow.description && (
|
||||
<CardDescription className="mt-2">{workflow.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleToggleEnabled(workflow.id, workflow.enabled)}
|
||||
>
|
||||
{workflow.enabled ? (
|
||||
<PowerOff className="h-4 w-4 text-orange-600" />
|
||||
) : (
|
||||
<Power className="h-4 w-4 text-green-600" />
|
||||
)}
|
||||
</Button>
|
||||
<Link href={`/workflows/${workflow.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setWorkflowToDelete(workflow.id);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-6 text-sm text-neutral-500">
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900">{workflow._count?.steps ?? 0}</span> steps
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900">{workflow._count?.executions ?? 0}</span>{' '}
|
||||
executions
|
||||
</div>
|
||||
<div>Created {new Date(workflow.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Showing {(page - 1) * data.pageSize + 1} to {Math.min(page * data.pageSize, data.total)} of{' '}
|
||||
{data.total} workflows
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-neutral-700">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page === data.totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Workflow Dialog */}
|
||||
<CreateWorkflowDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} onSuccess={() => mutate()} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Workflow"
|
||||
description="Are you sure you want to delete this workflow? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateWorkflowDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [eventName, setEventName] = useState('');
|
||||
const [allowReentry, setAllowReentry] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Fetch available event names
|
||||
const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const workflow = await network.fetch<Workflow, typeof WorkflowSchemas.create>('POST', '/workflows', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
eventName: eventName.trim(),
|
||||
allowReentry,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
toast.success('Workflow created successfully');
|
||||
setName('');
|
||||
setDescription('');
|
||||
setEventName('');
|
||||
setAllowReentry(false);
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
|
||||
// Redirect to the workflow editor
|
||||
window.location.href = `/workflows/${workflow.id}`;
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create workflow');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Workflow</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Welcome Email Sequence"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Send a series of welcome emails to new subscribers"
|
||||
className="w-full px-3 py-2 border border-neutral-200 rounded-lg text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="eventName">Trigger Event *</Label>
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
|
||||
<Select value={eventName} onValueChange={setEventName} required>
|
||||
<SelectTrigger id="eventName">
|
||||
<SelectValue placeholder="Select an event..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventNamesData.eventNames.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id="eventName"
|
||||
type="text"
|
||||
value={eventName}
|
||||
onChange={e => setEventName(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., contact.created, email.opened"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0
|
||||
? 'Select from previously tracked events'
|
||||
: 'No events tracked yet. Enter the event name that will trigger this workflow.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<input
|
||||
id="allowReentry"
|
||||
type="checkbox"
|
||||
checked={allowReentry}
|
||||
onChange={e => setAllowReentry(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="allowReentry" className="font-medium cursor-pointer">
|
||||
Allow Re-entry
|
||||
</Label>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
When enabled, contacts can enter this workflow multiple times. When disabled, contacts can only enter
|
||||
once, ever.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Workflow'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
@media (width >= --theme(--breakpoint-sm)) {
|
||||
max-width: none;
|
||||
}
|
||||
@media (width >= 1400px) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-*: initial;
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 100% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 215.4 16.3% 46.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 215.4 16.3% 56.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-neutral-800 overflow-hidden;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@plunk/typescript-config/nextjs.json",
|
||||
"include": ["next-env.d.ts", "next.config.js", "**/*.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||