feat: Add type to campaign
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
|
||||||
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async create(req: Request, res: Response, _next: NextFunction) {
|
private async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
|
||||||
CampaignSchemas.create.parse(req.body);
|
CampaignSchemas.create.parse(req.body);
|
||||||
|
|
||||||
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
||||||
@@ -42,6 +42,7 @@ export class Campaigns {
|
|||||||
from,
|
from,
|
||||||
fromName,
|
fromName,
|
||||||
replyTo,
|
replyTo,
|
||||||
|
type,
|
||||||
audienceType,
|
audienceType,
|
||||||
audienceCondition,
|
audienceCondition,
|
||||||
segmentId,
|
segmentId,
|
||||||
@@ -109,7 +110,7 @@ export class Campaigns {
|
|||||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
|
||||||
req.body;
|
req.body;
|
||||||
|
|
||||||
// Validate audience-specific fields if audienceType is being updated
|
// Validate audience-specific fields if audienceType is being updated
|
||||||
@@ -134,6 +135,7 @@ export class Campaigns {
|
|||||||
from,
|
from,
|
||||||
fromName,
|
fromName,
|
||||||
replyTo,
|
replyTo,
|
||||||
|
type: type as TemplateType | undefined,
|
||||||
audienceType,
|
audienceType,
|
||||||
audienceCondition,
|
audienceCondition,
|
||||||
segmentId,
|
segmentId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
|
||||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
@@ -59,6 +59,7 @@ export class CampaignService {
|
|||||||
from: data.from,
|
from: data.from,
|
||||||
fromName: data.fromName,
|
fromName: data.fromName,
|
||||||
replyTo: data.replyTo,
|
replyTo: data.replyTo,
|
||||||
|
type: data.type ?? TemplateType.MARKETING,
|
||||||
audienceType: data.audienceType,
|
audienceType: data.audienceType,
|
||||||
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
||||||
segmentId: data.segmentId,
|
segmentId: data.segmentId,
|
||||||
@@ -100,6 +101,10 @@ export class CampaignService {
|
|||||||
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
|
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
|
||||||
|
|
||||||
// Handle campaign-specific fields
|
// Handle campaign-specific fields
|
||||||
|
if (data.type !== undefined) {
|
||||||
|
updateData.type = data.type;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.audienceType !== undefined) {
|
if (data.audienceType !== undefined) {
|
||||||
updateData.audienceType = data.audienceType;
|
updateData.audienceType = data.audienceType;
|
||||||
}
|
}
|
||||||
@@ -262,6 +267,7 @@ export class CampaignService {
|
|||||||
from: campaign.from,
|
from: campaign.from,
|
||||||
fromName: campaign.fromName,
|
fromName: campaign.fromName,
|
||||||
replyTo: campaign.replyTo,
|
replyTo: campaign.replyTo,
|
||||||
|
type: campaign.type,
|
||||||
audienceType: campaign.audienceType,
|
audienceType: campaign.audienceType,
|
||||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||||
segmentId: campaign.segmentId,
|
segmentId: campaign.segmentId,
|
||||||
@@ -482,6 +488,7 @@ export class CampaignService {
|
|||||||
from: campaign.from,
|
from: campaign.from,
|
||||||
fromName: campaign.fromName || undefined,
|
fromName: campaign.fromName || undefined,
|
||||||
replyTo: campaign.replyTo || undefined,
|
replyTo: campaign.replyTo || undefined,
|
||||||
|
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||||
@@ -708,7 +715,8 @@ export class CampaignService {
|
|||||||
): Promise<Prisma.ContactWhereInput> {
|
): Promise<Prisma.ContactWhereInput> {
|
||||||
const baseWhere: Prisma.ContactWhereInput = {
|
const baseWhere: Prisma.ContactWhereInput = {
|
||||||
projectId,
|
projectId,
|
||||||
subscribed: true, // Only send to subscribed contacts
|
// Transactional campaigns send to all contacts regardless of subscription status
|
||||||
|
...(campaign.type !== TemplateType.TRANSACTIONAL && {subscribed: true}),
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (campaign.audienceType) {
|
switch (campaign.audienceType) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface SendEmailParams {
|
|||||||
workflowExecutionId?: string;
|
workflowExecutionId?: string;
|
||||||
workflowStepExecutionId?: string;
|
workflowStepExecutionId?: string;
|
||||||
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
||||||
|
isTransactional?: boolean; // Override source type to TRANSACTIONAL (e.g. for transactional campaigns)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,10 +117,12 @@ export class EmailService {
|
|||||||
* Send a campaign email
|
* Send a campaign email
|
||||||
*/
|
*/
|
||||||
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
|
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
|
||||||
// Check if template is transactional to determine source type
|
// Check if campaign or template is transactional to determine source type
|
||||||
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
|
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
|
||||||
|
|
||||||
if (params.templateId) {
|
if (params.isTransactional) {
|
||||||
|
sourceType = EmailSourceType.TRANSACTIONAL;
|
||||||
|
} else if (params.templateId) {
|
||||||
const template = await prisma.template.findUnique({
|
const template = await prisma.template.findUnique({
|
||||||
where: {id: params.templateId},
|
where: {id: params.templateId},
|
||||||
select: {type: true},
|
select: {type: true},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
StickySaveBar,
|
StickySaveBar,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Campaign, Segment} from '@plunk/db';
|
import type {Campaign, Segment} from '@plunk/db';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
|
||||||
import {CampaignSchemas} from '@plunk/shared';
|
import {CampaignSchemas} from '@plunk/shared';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {EmailSettings} from '../../components/EmailSettings';
|
import {EmailSettings} from '../../components/EmailSettings';
|
||||||
@@ -216,6 +216,7 @@ export default function CampaignDetailsPage() {
|
|||||||
from: editedCampaign.from,
|
from: editedCampaign.from,
|
||||||
fromName: editedCampaign.fromName || null,
|
fromName: editedCampaign.fromName || null,
|
||||||
replyTo: editedCampaign.replyTo || null,
|
replyTo: editedCampaign.replyTo || null,
|
||||||
|
type: editedCampaign.type,
|
||||||
audienceType: editedCampaign.audienceType,
|
audienceType: editedCampaign.audienceType,
|
||||||
segmentId: editedCampaign.segmentId || undefined,
|
segmentId: editedCampaign.segmentId || undefined,
|
||||||
});
|
});
|
||||||
@@ -232,6 +233,7 @@ export default function CampaignDetailsPage() {
|
|||||||
from: updated.data.from,
|
from: updated.data.from,
|
||||||
fromName: updated.data.fromName || '',
|
fromName: updated.data.fromName || '',
|
||||||
replyTo: updated.data.replyTo || '',
|
replyTo: updated.data.replyTo || '',
|
||||||
|
type: updated.data.type,
|
||||||
audienceType: updated.data.audienceType,
|
audienceType: updated.data.audienceType,
|
||||||
segmentId: updated.data.segmentId || undefined,
|
segmentId: updated.data.segmentId || undefined,
|
||||||
});
|
});
|
||||||
@@ -254,6 +256,7 @@ export default function CampaignDetailsPage() {
|
|||||||
from: campaign.data.from,
|
from: campaign.data.from,
|
||||||
fromName: campaign.data.fromName || '',
|
fromName: campaign.data.fromName || '',
|
||||||
replyTo: campaign.data.replyTo || '',
|
replyTo: campaign.data.replyTo || '',
|
||||||
|
type: campaign.data.type,
|
||||||
audienceType: campaign.data.audienceType,
|
audienceType: campaign.data.audienceType,
|
||||||
segmentId: campaign.data.segmentId || undefined,
|
segmentId: campaign.data.segmentId || undefined,
|
||||||
});
|
});
|
||||||
@@ -274,6 +277,7 @@ export default function CampaignDetailsPage() {
|
|||||||
editedCampaign.from !== campaign.data.from ||
|
editedCampaign.from !== campaign.data.from ||
|
||||||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
|
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
|
||||||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
|
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
|
||||||
|
editedCampaign.type !== campaign.data.type ||
|
||||||
editedCampaign.audienceType !== campaign.data.audienceType ||
|
editedCampaign.audienceType !== campaign.data.audienceType ||
|
||||||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
|
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
|
||||||
|
|
||||||
@@ -461,6 +465,40 @@ export default function CampaignDetailsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Campaign Type</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditedCampaign({...editedCampaign, type: TemplateType.MARKETING})}
|
||||||
|
className={`text-left p-3 rounded-lg border-2 transition-colors ${
|
||||||
|
(editedCampaign.type ?? c.type) === TemplateType.MARKETING
|
||||||
|
? 'border-neutral-900 bg-neutral-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm text-neutral-900">Marketing</p>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
Subscribed contacts only, includes unsubscribe link.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditedCampaign({...editedCampaign, type: TemplateType.TRANSACTIONAL})}
|
||||||
|
className={`text-left p-3 rounded-lg border-2 transition-colors ${
|
||||||
|
(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||||
|
? 'border-neutral-900 bg-neutral-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm text-neutral-900">Transactional</p>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
All contacts regardless of subscription. No unsubscribe footer.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="subject">Subject Line *</Label>
|
<Label htmlFor="subject">Subject Line *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -513,8 +551,8 @@ export default function CampaignDetailsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItemWithDescription
|
<SelectItemWithDescription
|
||||||
value={CampaignAudienceType.ALL}
|
value={CampaignAudienceType.ALL}
|
||||||
title="All Subscribed Contacts"
|
title={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
|
||||||
description="Send to everyone who hasn't unsubscribed"
|
description={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
|
||||||
/>
|
/>
|
||||||
<SelectItemWithDescription
|
<SelectItemWithDescription
|
||||||
value={CampaignAudienceType.SEGMENT}
|
value={CampaignAudienceType.SEGMENT}
|
||||||
@@ -581,7 +619,10 @@ export default function CampaignDetailsPage() {
|
|||||||
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
|
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-xs text-blue-800">
|
<p className="text-xs text-blue-800">
|
||||||
This count will be recalculated right before sending to ensure accuracy. The final number may
|
This count will be recalculated right before sending to ensure accuracy. The final number may
|
||||||
differ if contacts subscribe, unsubscribe, or segment membership changes.
|
differ if contacts{' '}
|
||||||
|
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||||
|
? 'are added or removed, or segment membership changes.'
|
||||||
|
: 'subscribe, unsubscribe, or segment membership changes.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Segment, Template} from '@plunk/db';
|
import type {Segment, Template} from '@plunk/db';
|
||||||
import {CampaignAudienceType} from '@plunk/db';
|
import {CampaignAudienceType, TemplateType} from '@plunk/db';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {EmailSettings} from '../../components/EmailSettings';
|
import {EmailSettings} from '../../components/EmailSettings';
|
||||||
@@ -41,6 +41,7 @@ export default function CreateCampaignPage() {
|
|||||||
const [from, setFrom] = useState('');
|
const [from, setFrom] = useState('');
|
||||||
const [fromName, setFromName] = useState('');
|
const [fromName, setFromName] = useState('');
|
||||||
const [replyTo, setReplyTo] = useState('');
|
const [replyTo, setReplyTo] = useState('');
|
||||||
|
const [campaignType, setCampaignType] = useState<TemplateType>(TemplateType.MARKETING);
|
||||||
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
||||||
const [segmentId, setSegmentId] = useState('');
|
const [segmentId, setSegmentId] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -150,6 +151,7 @@ export default function CreateCampaignPage() {
|
|||||||
from,
|
from,
|
||||||
fromName: fromName || null,
|
fromName: fromName || null,
|
||||||
replyTo: replyTo || null,
|
replyTo: replyTo || null,
|
||||||
|
type: campaignType,
|
||||||
audienceType,
|
audienceType,
|
||||||
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
||||||
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
||||||
@@ -256,11 +258,54 @@ export default function CreateCampaignPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Email Settings */}
|
{/* Campaign Type */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
stepNumber={2}
|
stepNumber={2}
|
||||||
|
title="Campaign Type"
|
||||||
|
description="Choose how this campaign should be treated"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCampaignType(TemplateType.MARKETING)}
|
||||||
|
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||||
|
campaignType === TemplateType.MARKETING
|
||||||
|
? 'border-neutral-900 bg-neutral-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm text-neutral-900">Marketing</p>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
Sent to subscribed contacts only. Includes unsubscribe link.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCampaignType(TemplateType.TRANSACTIONAL)}
|
||||||
|
className={`text-left p-4 rounded-lg border-2 transition-colors ${
|
||||||
|
campaignType === TemplateType.TRANSACTIONAL
|
||||||
|
? 'border-neutral-900 bg-neutral-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm text-neutral-900">Transactional</p>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
Sent to all contacts regardless of subscription status. No unsubscribe footer.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Email Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<StepHeader
|
||||||
|
stepNumber={3}
|
||||||
title="Email Settings"
|
title="Email Settings"
|
||||||
description="Configure sender information and subject"
|
description="Configure sender information and subject"
|
||||||
/>
|
/>
|
||||||
@@ -294,7 +339,7 @@ export default function CreateCampaignPage() {
|
|||||||
{/* Email Content */}
|
{/* Email Content */}
|
||||||
<Card className="overflow-visible">
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
|
<StepHeader stepNumber={4} title="Email Content" description="Design your email message" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -309,7 +354,7 @@ export default function CreateCampaignPage() {
|
|||||||
{/* Audience Selection */}
|
{/* Audience Selection */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<StepHeader stepNumber={4} title="Audience" description="Choose who will receive this campaign" />
|
<StepHeader stepNumber={5} title="Audience" description="Choose who will receive this campaign" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -327,8 +372,8 @@ export default function CreateCampaignPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItemWithDescription
|
<SelectItemWithDescription
|
||||||
value={CampaignAudienceType.ALL}
|
value={CampaignAudienceType.ALL}
|
||||||
title="All Subscribed Contacts"
|
title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
|
||||||
description="Send to everyone who hasn't unsubscribed"
|
description={campaignType === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
|
||||||
/>
|
/>
|
||||||
<SelectItemWithDescription
|
<SelectItemWithDescription
|
||||||
value={CampaignAudienceType.SEGMENT}
|
value={CampaignAudienceType.SEGMENT}
|
||||||
@@ -388,9 +433,13 @@ export default function CreateCampaignPage() {
|
|||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
<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" />
|
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-blue-900">All subscribed contacts</p>
|
<p className="text-sm font-medium text-blue-900">
|
||||||
|
{campaignType === TemplateType.TRANSACTIONAL ? 'All contacts' : 'All subscribed contacts'}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-blue-700 mt-1">
|
<p className="text-xs text-blue-700 mt-1">
|
||||||
This campaign will be sent to all contacts who haven't unsubscribed
|
{campaignType === TemplateType.TRANSACTIONAL
|
||||||
|
? 'This campaign will be sent to all contacts regardless of subscription status'
|
||||||
|
: "This campaign will be sent to all contacts who haven't unsubscribed"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,6 +487,13 @@ export default function CreateCampaignPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||||
|
<span className="text-neutral-500">Type</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{campaignType === TemplateType.MARKETING ? 'Marketing' : 'Transactional'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between py-2 border-b border-neutral-100">
|
<div className="flex justify-between py-2 border-b border-neutral-100">
|
||||||
<span className="text-neutral-500">Audience</span>
|
<span className="text-neutral-500">Audience</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "campaigns" ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'MARKETING';
|
||||||
@@ -291,6 +291,9 @@ model Campaign {
|
|||||||
fromName String?
|
fromName String?
|
||||||
replyTo String?
|
replyTo String?
|
||||||
|
|
||||||
|
// Campaign type
|
||||||
|
type TemplateType @default(MARKETING)
|
||||||
|
|
||||||
// Audience selection
|
// Audience selection
|
||||||
audienceType CampaignAudienceType @default(ALL)
|
audienceType CampaignAudienceType @default(ALL)
|
||||||
audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition)
|
audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition)
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ export const CampaignSchemas = {
|
|||||||
from: email,
|
from: email,
|
||||||
fromName: z.string().max(100).nullish(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: email.nullish(),
|
replyTo: email.nullish(),
|
||||||
|
type: z.nativeEnum(TemplateType).default(TemplateType.MARKETING),
|
||||||
audienceType: z.nativeEnum(CampaignAudienceType),
|
audienceType: z.nativeEnum(CampaignAudienceType),
|
||||||
audienceCondition: filterConditionSchema.optional(),
|
audienceCondition: filterConditionSchema.optional(),
|
||||||
segmentId: uuid.optional(),
|
segmentId: uuid.optional(),
|
||||||
@@ -364,6 +365,7 @@ export const CampaignSchemas = {
|
|||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
fromName: z.string().max(100).nullish(),
|
fromName: z.string().max(100).nullish(),
|
||||||
replyTo: z.string().nullish(),
|
replyTo: z.string().nullish(),
|
||||||
|
type: z.nativeEnum(TemplateType).optional(),
|
||||||
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
|
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
|
||||||
audienceCondition: filterConditionSchema.optional(),
|
audienceCondition: filterConditionSchema.optional(),
|
||||||
segmentId: z.string().optional(),
|
segmentId: z.string().optional(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Campaign service types
|
* Campaign service types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {CampaignAudienceType} from '@plunk/db';
|
import type {CampaignAudienceType, TemplateType} from '@plunk/db';
|
||||||
import type {FilterCondition} from '../segments/index.js';
|
import type {FilterCondition} from '../segments/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +16,7 @@ export interface CreateCampaignData {
|
|||||||
from: string;
|
from: string;
|
||||||
fromName?: string | null;
|
fromName?: string | null;
|
||||||
replyTo?: string | null;
|
replyTo?: string | null;
|
||||||
|
type?: TemplateType;
|
||||||
audienceType: CampaignAudienceType;
|
audienceType: CampaignAudienceType;
|
||||||
audienceCondition?: FilterCondition;
|
audienceCondition?: FilterCondition;
|
||||||
segmentId?: string;
|
segmentId?: string;
|
||||||
@@ -32,6 +33,7 @@ export interface UpdateCampaignData {
|
|||||||
from?: string;
|
from?: string;
|
||||||
fromName?: string | null;
|
fromName?: string | null;
|
||||||
replyTo?: string | null;
|
replyTo?: string | null;
|
||||||
|
type?: TemplateType;
|
||||||
audienceType?: CampaignAudienceType;
|
audienceType?: CampaignAudienceType;
|
||||||
audienceCondition?: FilterCondition;
|
audienceCondition?: FilterCondition;
|
||||||
segmentId?: string;
|
segmentId?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user