Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+5
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
import config from '@plunk/eslint-config/next';
export default config;
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.swyp.be',
generateRobotsTxt: true,
};
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
module.exports = {
transpilePackages: ['@plunk/ui'],
output: 'standalone', // Optimized for Docker
};
+70
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
+4
View File
@@ -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__ = {};
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+7
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

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

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

+19
View File
@@ -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"
}
+258
View File
@@ -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&apos;ve reached the end of the activity feed</p>
</div>
)}
</div>
);
}
+361
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+240
View File
@@ -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>
);
}
+341
View File
@@ -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&apos;ve used {Math.round(usage.percentage)}% of your{' '}
{category.toLowerCase()} email limit.
</p>
</div>
</Alert>
)}
</>
)}
</div>
);
}
+235
View File
@@ -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>
);
}
+539
View File
@@ -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">
&quot;v=spf1 include:amazonses.com ~all&quot;
</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>
);
}
File diff suppressed because it is too large Load Diff
@@ -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';
+82
View File
@@ -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&apos;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>
);
}
+132
View File
@@ -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 &quot;Add Field&quot; 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>
);
}
+253
View File
@@ -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&apos;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>
);
}
+145
View File
@@ -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 &quot;plunk&quot; 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>
);
}
+25
View File
@@ -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>
);
}
+796
View File
@@ -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} &quot;{String(data.config.value)}&quot;
</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>
);
}
+8
View File
@@ -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;
}
+108
View File
@@ -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',
});
}
+83
View File
@@ -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]);
}
+32
View File
@@ -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});
}
+67
View File
@@ -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,
};
}
+71
View File
@@ -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};
}
+9
View File
@@ -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,
};
}
+9
View File
@@ -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});
}
+92
View File
@@ -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;
}
}
+61
View File
@@ -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);
}
}
+161
View File
@@ -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>
</>
);
}
+60
View File
@@ -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;
+168
View File
@@ -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>
</>
);
}
+388
View File
@@ -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>
</>
);
}
+334
View File
@@ -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&apos;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>
</>
);
}
+247
View File
@@ -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>
</>
);
}
+934
View File
@@ -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>
);
}
+367
View File
@@ -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&apos;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>
</>
);
}
+320
View File
@@ -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>
</>
);
}
+395
View File
@@ -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&apos;re looking for doesn&apos;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>
);
}
+767
View File
@@ -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"
/>
</>
);
}
+146
View File
@@ -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&apos;s what&apos;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>
</>
);
}
+222
View File
@@ -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>
);
}
+146
View File
@@ -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>
</>
);
}
+590
View File
@@ -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&apos;re looking for doesn&apos;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&apos;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>
);
}
+199
View File
@@ -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>
</>
);
}
+335
View File
@@ -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>
</>
);
}
+551
View File
@@ -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>
</>
);
}
+207
View File
@@ -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&apos;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>
);
}
+272
View File
@@ -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>
);
}
+189
View File
@@ -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>
</>
);
}
+311
View File
@@ -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>
</>
);
}
+229
View File
@@ -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&apos;re unsubscribed</h1>
<p className="text-neutral-500">
{contact?.email} has been unsubscribed. You won&apos;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&apos;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>
);
}
File diff suppressed because it is too large Load Diff
+453
View File
@@ -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>
);
}
+166
View File
@@ -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;
}
}
+5
View File
@@ -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"]
}