Selectors with description
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||||
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
|
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
// Mock MeterService
|
// Mock MeterService
|
||||||
@@ -16,7 +16,7 @@ describe('Email Processor', () => {
|
|||||||
const _serviceMocks = createServiceMocks();
|
const _serviceMocks = createServiceMocks();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const {project} = await factories.createUserWithProject({}, {trackingEnabled: true});
|
const {project} = await factories.createUserWithProject({}, {tracking: TrackingMode.ENABLED});
|
||||||
projectId = project.id;
|
projectId = project.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export function createEmailWorker() {
|
|||||||
? {name: email.toName, email: email.contact.email}
|
? {name: email.toName, email: email.contact.email}
|
||||||
: email.contact.email;
|
: email.contact.email;
|
||||||
|
|
||||||
|
// Determine tracking based on project settings and email type
|
||||||
|
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
||||||
|
|
||||||
// Send via AWS SES
|
// Send via AWS SES
|
||||||
const result = await sendRawEmail({
|
const result = await sendRawEmail({
|
||||||
from: {
|
from: {
|
||||||
@@ -101,7 +104,7 @@ export function createEmailWorker() {
|
|||||||
html: compiledHtml,
|
html: compiledHtml,
|
||||||
},
|
},
|
||||||
reply: email.replyTo || undefined,
|
reply: email.replyTo || undefined,
|
||||||
tracking: email.project.trackingEnabled, // Use project's tracking preference
|
tracking: shouldTrack,
|
||||||
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {Contact, Email, Prisma, Project} from '@plunk/db';
|
import type {Contact, Email, Prisma, Project} from '@plunk/db';
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||||
@@ -365,6 +365,9 @@ export class EmailService {
|
|||||||
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
|
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Determine tracking based on project settings and email type
|
||||||
|
const shouldTrack = this.shouldTrackEmail(email.project.tracking, email.sourceType);
|
||||||
|
|
||||||
// Send via AWS SES
|
// Send via AWS SES
|
||||||
const result = await sendRawEmail({
|
const result = await sendRawEmail({
|
||||||
from: {
|
from: {
|
||||||
@@ -379,7 +382,7 @@ export class EmailService {
|
|||||||
reply: email.replyTo || undefined,
|
reply: email.replyTo || undefined,
|
||||||
headers: customHeaders,
|
headers: customHeaders,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
tracking: email.project.trackingEnabled, // Use project's tracking preference
|
tracking: shouldTrack,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark as sent with SES message ID
|
// Mark as sent with SES message ID
|
||||||
@@ -565,6 +568,23 @@ export class EmailService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an email should be tracked based on project tracking mode and email source type
|
||||||
|
*/
|
||||||
|
public static shouldTrackEmail(trackingMode: TrackingMode, sourceType: EmailSourceType): boolean {
|
||||||
|
switch (trackingMode) {
|
||||||
|
case TrackingMode.ENABLED:
|
||||||
|
return true;
|
||||||
|
case TrackingMode.DISABLED:
|
||||||
|
return false;
|
||||||
|
case TrackingMode.MARKETING_ONLY:
|
||||||
|
// Track only campaigns and workflows (marketing), not transactional emails
|
||||||
|
return sourceType !== EmailSourceType.TRANSACTIONAL;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile HTML email with optional unsubscribe footer and badge
|
* Compile HTML email with optional unsubscribe footer and badge
|
||||||
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectItemWithDescription,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
Table,
|
Table,
|
||||||
@@ -346,8 +347,16 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="MEMBER">Member - Can view and use the project</SelectItem>
|
<SelectItemWithDescription
|
||||||
<SelectItem value="ADMIN">Admin - Can manage settings and members</SelectItem>
|
value="MEMBER"
|
||||||
|
title="Member"
|
||||||
|
description="Can view and use the project"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value="ADMIN"
|
||||||
|
title="Admin"
|
||||||
|
description="Can manage settings and members"
|
||||||
|
/>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {useEffect, useState} from 'react';
|
|||||||
import {useForm} from 'react-hook-form';
|
import {useForm} from 'react-hook-form';
|
||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {ProjectSchemas} from '@plunk/shared';
|
import {ProjectSchemas} from '@plunk/shared';
|
||||||
|
import {TrackingMode} from '@plunk/db';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -24,7 +25,11 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
Switch,
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItemWithDescription,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
@@ -158,7 +163,7 @@ export default function Settings() {
|
|||||||
resolver: zodResolver(ProjectSchemas.update),
|
resolver: zodResolver(ProjectSchemas.update),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: activeProject?.name || '',
|
name: activeProject?.name || '',
|
||||||
trackingEnabled: activeProject?.trackingEnabled ?? true,
|
tracking: activeProject?.tracking ?? TrackingMode.ENABLED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,7 +172,7 @@ export default function Settings() {
|
|||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: activeProject.name,
|
name: activeProject.name,
|
||||||
trackingEnabled: activeProject.trackingEnabled ?? true,
|
tracking: activeProject.tracking ?? TrackingMode.ENABLED,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [activeProject, form]);
|
}, [activeProject, form]);
|
||||||
@@ -395,23 +400,42 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Email Tracking Toggle - only show if feature is available */}
|
{/* Email Tracking Mode - only show if feature is available */}
|
||||||
{trackingToggleEnabled && (
|
{trackingToggleEnabled && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="trackingEnabled"
|
name="tracking"
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-neutral-200 p-4">
|
<FormItem>
|
||||||
<div className="space-y-0.5">
|
<FormLabel>Email Tracking</FormLabel>
|
||||||
<FormLabel className="text-base">Email Tracking</FormLabel>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormDescription>
|
<FormControl>
|
||||||
Enable open and click tracking for emails sent from this project. When disabled,
|
<SelectTrigger>
|
||||||
emails will be sent without tracking pixels.
|
<SelectValue placeholder="Select tracking mode" />
|
||||||
</FormDescription>
|
</SelectTrigger>
|
||||||
</div>
|
</FormControl>
|
||||||
<FormControl>
|
<SelectContent>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<SelectItemWithDescription
|
||||||
</FormControl>
|
value={TrackingMode.ENABLED}
|
||||||
|
title="Enabled"
|
||||||
|
description="Track opens and clicks for all emails"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value={TrackingMode.DISABLED}
|
||||||
|
title="Disabled"
|
||||||
|
description="No tracking for any emails"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value={TrackingMode.MARKETING_ONLY}
|
||||||
|
title="Marketing Only"
|
||||||
|
description="Track only campaigns and workflow emails, not transactional"
|
||||||
|
/>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Control how email opens and clicks are tracked for this project.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -659,7 +683,11 @@ export default function Settings() {
|
|||||||
|
|
||||||
{/* Team Tab */}
|
{/* Team Tab */}
|
||||||
<TabsContent value="team">
|
<TabsContent value="team">
|
||||||
<TeamSettings projectId={activeProject.id} currentUserRole={currentUserRole} currentUserId={user?.id || ''} />
|
<TeamSettings
|
||||||
|
projectId={activeProject.id}
|
||||||
|
currentUserRole={currentUserRole}
|
||||||
|
currentUserId={user?.id || ''}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Domains Tab */}
|
{/* Domains Tab */}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItemWithDescription,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
StickySaveBar,
|
StickySaveBar,
|
||||||
@@ -232,8 +232,16 @@ export default function TemplateEditorPage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="MARKETING">Marketing</SelectItem>
|
<SelectItemWithDescription
|
||||||
<SelectItem value="TRANSACTIONAL">Transactional</SelectItem>
|
value="MARKETING"
|
||||||
|
title="Marketing"
|
||||||
|
description="Includes unsubscribe link, respects opt-out"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value="TRANSACTIONAL"
|
||||||
|
title="Transactional"
|
||||||
|
description="For receipts, alerts - sent regardless of opt-out"
|
||||||
|
/>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItemWithDescription,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
@@ -74,117 +74,127 @@ export default function CreateTemplatePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Create Template" />
|
<NextSeo title="Create Template" />
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 sm:gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
<Link href="/templates">
|
<Link href="/templates">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Template</h1>
|
||||||
|
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
|
||||||
|
Create a reusable email template for campaigns and workflows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSubmit} disabled={saving} className="w-full sm:w-auto">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{saving ? 'Creating...' : 'Create Template'}</span>
|
||||||
|
<span className="sm:hidden">{saving ? 'Creating...' : 'Create'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Template</h1>
|
|
||||||
<p className="text-neutral-500 mt-1 text-sm sm:text-base">Create a reusable email template for campaigns and workflows</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={handleSubmit} disabled={saving} className="w-full sm:w-auto">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">{saving ? 'Creating...' : 'Create Template'}</span>
|
|
||||||
<span className="sm:hidden">{saving ? 'Creating...' : 'Create'}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Template Settings */}
|
{/* Template Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Template Settings</CardTitle>
|
<CardTitle>Template Settings</CardTitle>
|
||||||
<CardDescription>Configure your template details and email settings</CardDescription>
|
<CardDescription>Configure your template details and email settings</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-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>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value="MARKETING"
|
||||||
|
title="Marketing"
|
||||||
|
description="Includes unsubscribe link, respects opt-out"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value="TRANSACTIONAL"
|
||||||
|
title="Transactional"
|
||||||
|
description="For receipts, alerts - sent regardless of opt-out"
|
||||||
|
/>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="name">Template Name *</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="description"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={description}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
required
|
placeholder="Sent to new subscribers"
|
||||||
placeholder="Welcome Email"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="type">Template Type *</Label>
|
<Label htmlFor="subject">Subject Line *</Label>
|
||||||
<Select value={type} onValueChange={value => setType(value as 'MARKETING' | 'TRANSACTIONAL')}>
|
<Input
|
||||||
<SelectTrigger id="type">
|
id="subject"
|
||||||
<SelectValue />
|
type="text"
|
||||||
</SelectTrigger>
|
value={subject}
|
||||||
<SelectContent>
|
onChange={e => setSubject(e.target.value)}
|
||||||
<SelectItem value="MARKETING">Marketing</SelectItem>
|
required
|
||||||
<SelectItem value="TRANSACTIONAL">Transactional</SelectItem>
|
placeholder="Welcome to our platform!"
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<EmailSettings
|
||||||
<Label htmlFor="description">Description</Label>
|
from={from}
|
||||||
<Input
|
fromName={fromName}
|
||||||
id="description"
|
replyTo={replyTo}
|
||||||
type="text"
|
onFromChange={setFrom}
|
||||||
value={description}
|
onFromNameChange={setFromName}
|
||||||
onChange={e => setDescription(e.target.value)}
|
onReplyToChange={setReplyTo}
|
||||||
placeholder="Sent to new subscribers"
|
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div>
|
{/* Email Body */}
|
||||||
<Label htmlFor="subject">Subject Line *</Label>
|
<Card>
|
||||||
<Input
|
<CardHeader>
|
||||||
id="subject"
|
<CardTitle>Email Body</CardTitle>
|
||||||
type="text"
|
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||||
value={subject}
|
</CardHeader>
|
||||||
onChange={e => setSubject(e.target.value)}
|
<CardContent>
|
||||||
required
|
<EmailEditor
|
||||||
placeholder="Welcome to our platform!"
|
value={body}
|
||||||
|
onChange={setBody}
|
||||||
|
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
|
||||||
/>
|
/>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<EmailSettings
|
</form>
|
||||||
from={from}
|
</div>
|
||||||
fromName={fromName}
|
</DashboardLayout>
|
||||||
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>"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</DashboardLayout>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectItemWithDescription,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
Switch
|
Switch
|
||||||
@@ -1076,13 +1077,37 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="SEND_EMAIL">Send Email</SelectItem>
|
<SelectItemWithDescription
|
||||||
<SelectItem value="DELAY">Delay - Wait for time</SelectItem>
|
value="SEND_EMAIL"
|
||||||
<SelectItem value="WAIT_FOR_EVENT">Wait for Event</SelectItem>
|
title="Send Email"
|
||||||
<SelectItem value="CONDITION">Condition - If/else branching</SelectItem>
|
description="Send an email using a template"
|
||||||
<SelectItem value="WEBHOOK">Webhook - Call external API</SelectItem>
|
/>
|
||||||
<SelectItem value="UPDATE_CONTACT">Update Contact</SelectItem>
|
<SelectItemWithDescription
|
||||||
<SelectItem value="EXIT">Exit - End workflow</SelectItem>
|
value="DELAY"
|
||||||
|
title="Delay"
|
||||||
|
description="Wait for a specified amount of time"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value="WAIT_FOR_EVENT"
|
||||||
|
title="Wait for Event"
|
||||||
|
description="Pause until a specific event occurs"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value="CONDITION"
|
||||||
|
title="Condition"
|
||||||
|
description="If/else branching based on contact data"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value="WEBHOOK"
|
||||||
|
title="Webhook"
|
||||||
|
description="Call an external API endpoint"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription
|
||||||
|
value="UPDATE_CONTACT"
|
||||||
|
title="Update Contact"
|
||||||
|
description="Modify contact data fields"
|
||||||
|
/>
|
||||||
|
<SelectItemWithDescription value="EXIT" title="Exit" description="End the workflow for this contact" />
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
|||||||
+4
-1
@@ -7,6 +7,9 @@ CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
|
|||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "TemplateType" AS ENUM ('TRANSACTIONAL', 'MARKETING');
|
CREATE TYPE "TemplateType" AS ENUM ('TRANSACTIONAL', 'MARKETING');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TrackingMode" AS ENUM ('ENABLED', 'DISABLED', 'MARKETING_ONLY');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "CampaignStatus" AS ENUM ('DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED');
|
CREATE TYPE "CampaignStatus" AS ENUM ('DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED');
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@ CREATE TABLE "projects" (
|
|||||||
"billingLimitWorkflows" INTEGER,
|
"billingLimitWorkflows" INTEGER,
|
||||||
"billingLimitCampaigns" INTEGER,
|
"billingLimitCampaigns" INTEGER,
|
||||||
"billingLimitTransactional" INTEGER,
|
"billingLimitTransactional" INTEGER,
|
||||||
"trackingEnabled" BOOLEAN NOT NULL DEFAULT true,
|
"tracking" "TrackingMode" NOT NULL DEFAULT 'ENABLED',
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ model Project {
|
|||||||
billingLimitTransactional Int? // Max transactional emails per month
|
billingLimitTransactional Int? // Max transactional emails per month
|
||||||
|
|
||||||
// Email Tracking
|
// Email Tracking
|
||||||
trackingEnabled Boolean @default(true) // Enable/disable open and click tracking
|
tracking TrackingMode @default(ENABLED) // Open and click tracking mode
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
members Membership[]
|
members Membership[]
|
||||||
@@ -636,6 +636,12 @@ enum TemplateType {
|
|||||||
MARKETING
|
MARKETING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TrackingMode {
|
||||||
|
ENABLED // Track opens and clicks for all emails
|
||||||
|
DISABLED // No tracking for any emails
|
||||||
|
MARKETING_ONLY // Track only marketing/campaign emails, not transactional
|
||||||
|
}
|
||||||
|
|
||||||
enum CampaignStatus {
|
enum CampaignStatus {
|
||||||
DRAFT
|
DRAFT
|
||||||
SCHEDULED
|
SCHEDULED
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {CampaignAudienceType, TemplateType, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
import {CampaignAudienceType, TemplateType, TrackingMode, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
||||||
import {z} from 'zod';
|
import {z} from 'zod';
|
||||||
|
|
||||||
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null(), z.date()]);
|
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null(), z.date()]);
|
||||||
@@ -59,7 +59,7 @@ export const ProjectSchemas = {
|
|||||||
}),
|
}),
|
||||||
update: z.object({
|
update: z.object({
|
||||||
name: z.string().min(1).max(100).optional(),
|
name: z.string().min(1).max(100).optional(),
|
||||||
trackingEnabled: z.boolean().optional(),
|
tracking: z.nativeEnum(TrackingMode).optional(),
|
||||||
}),
|
}),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -223,11 +223,7 @@ export const WorkflowStepConfigSchemas = {
|
|||||||
),
|
),
|
||||||
waitForEvent: z.object({
|
waitForEvent: z.object({
|
||||||
eventName: z.string().min(1),
|
eventName: z.string().min(1),
|
||||||
timeout: z
|
timeout: z.number().positive().max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)').optional(),
|
||||||
.number()
|
|
||||||
.positive()
|
|
||||||
.max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)')
|
|
||||||
.optional(),
|
|
||||||
}),
|
}),
|
||||||
condition: z.object({
|
condition: z.object({
|
||||||
field: z.string().min(1),
|
field: z.string().min(1),
|
||||||
|
|||||||
@@ -122,6 +122,37 @@ const SelectItem = React.forwardRef<
|
|||||||
));
|
));
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
interface SelectItemWithDescriptionProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectItemWithDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
SelectItemWithDescriptionProps
|
||||||
|
>(({className, title, description, ...props}, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-start rounded-sm py-2.5 pl-8 pr-3 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 top-3 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<SelectPrimitive.ItemText className="font-medium">{title}</SelectPrimitive.ItemText>
|
||||||
|
{description && <span className="text-xs text-neutral-500 leading-tight">{description}</span>}
|
||||||
|
</div>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItemWithDescription.displayName = 'SelectItemWithDescription';
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
@@ -138,6 +169,7 @@ export {
|
|||||||
SelectContent,
|
SelectContent,
|
||||||
SelectLabel,
|
SelectLabel,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectItemWithDescription,
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PrismaClient,
|
PrismaClient,
|
||||||
Role,
|
Role,
|
||||||
TemplateType,
|
TemplateType,
|
||||||
|
TrackingMode,
|
||||||
WorkflowExecutionStatus,
|
WorkflowExecutionStatus,
|
||||||
WorkflowStepType,
|
WorkflowStepType,
|
||||||
WorkflowTriggerType
|
WorkflowTriggerType
|
||||||
@@ -34,7 +35,7 @@ export interface UserFactoryOptions {
|
|||||||
export interface ProjectFactoryOptions {
|
export interface ProjectFactoryOptions {
|
||||||
name?: string;
|
name?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
trackingEnabled?: boolean;
|
tracking?: TrackingMode;
|
||||||
billingLimitWorkflows?: number | null;
|
billingLimitWorkflows?: number | null;
|
||||||
billingLimitCampaigns?: number | null;
|
billingLimitCampaigns?: number | null;
|
||||||
billingLimitTransactional?: number | null;
|
billingLimitTransactional?: number | null;
|
||||||
@@ -127,7 +128,7 @@ export class TestFactories {
|
|||||||
public: `pk_${uniqueId()}`,
|
public: `pk_${uniqueId()}`,
|
||||||
secret: `sk_${uniqueId()}`,
|
secret: `sk_${uniqueId()}`,
|
||||||
disabled: options.disabled || false,
|
disabled: options.disabled || false,
|
||||||
trackingEnabled: options.trackingEnabled ?? true,
|
tracking: options.tracking ?? TrackingMode.ENABLED,
|
||||||
billingLimitWorkflows: options.billingLimitWorkflows,
|
billingLimitWorkflows: options.billingLimitWorkflows,
|
||||||
billingLimitCampaigns: options.billingLimitCampaigns,
|
billingLimitCampaigns: options.billingLimitCampaigns,
|
||||||
billingLimitTransactional: options.billingLimitTransactional,
|
billingLimitTransactional: options.billingLimitTransactional,
|
||||||
|
|||||||
Reference in New Issue
Block a user