Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d475c382b | ||
|
|
3fea06ba2d | ||
|
|
1d1d407a6f | ||
|
|
46b2b8390f | ||
|
|
9e4aa9443b | ||
|
|
a27d564e1a | ||
|
|
4e95c5a4e4 | ||
|
|
463301b5db | ||
|
|
f178c59b5b | ||
|
|
6195b39f3d | ||
|
|
e0bf0f628a | ||
|
|
8a26d605b4 | ||
|
|
715961c007 | ||
|
|
ccd516e4e8 | ||
|
|
649bbf6d6b | ||
|
|
e43f70d8a1 | ||
|
|
f22da4add1 | ||
|
|
de6335e999 | ||
|
|
7658a59b5d | ||
|
|
aaf5ac6530 | ||
|
|
c6340a1dc7 | ||
|
|
4ba43dd3b6 | ||
|
|
d11495061d | ||
|
|
fadc19d139 |
+13
-4
@@ -1,13 +1,22 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
**/node_modules
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist
|
# Patterns without a leading `**/` only match the build-context root, NOT
|
||||||
.next
|
# nested directories. In this monorepo the actual outputs live under
|
||||||
.turbo
|
# apps/*/{dist,.next,.turbo} and packages/*/{dist,.turbo}; without `**/`
|
||||||
out
|
# any stale local builds (from running `yarn dev` or `yarn build` on the
|
||||||
|
# host) get copied into the image and override the freshly-built outputs,
|
||||||
|
# producing silently broken builds (MODULE_NOT_FOUND for whatever the stale
|
||||||
|
# bundle still references).
|
||||||
|
**/dist
|
||||||
|
**/.next
|
||||||
|
**/.turbo
|
||||||
|
**/build
|
||||||
|
**/out
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
.env
|
.env
|
||||||
|
|||||||
+17
-14
@@ -52,6 +52,13 @@ SES_CONFIGURATION_SET=plunk-configuration-set
|
|||||||
# When set, projects can choose to disable email tracking
|
# When set, projects can choose to disable email tracking
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
|
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
|
||||||
|
|
||||||
|
# Custom MAIL FROM subdomain — used to construct `<subdomain>.<your-domain>`
|
||||||
|
# when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
|
||||||
|
# is already used for something else (e.g. an R2/CDN custom domain), since the
|
||||||
|
# MAIL FROM hostname needs MX + TXT records that can't coexist with a CNAME.
|
||||||
|
# Example: MAIL_FROM_SUBDOMAIN=emails → emails.<your-domain>
|
||||||
|
# MAIL_FROM_SUBDOMAIN=
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# OPTIONAL: OAuth Login
|
# OPTIONAL: OAuth Login
|
||||||
# ========================================
|
# ========================================
|
||||||
@@ -60,15 +67,6 @@ GITHUB_OAUTH_SECRET=
|
|||||||
GOOGLE_OAUTH_CLIENT=
|
GOOGLE_OAUTH_CLIENT=
|
||||||
GOOGLE_OAUTH_SECRET=
|
GOOGLE_OAUTH_SECRET=
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# OPTIONAL: Stripe Billing
|
|
||||||
# ========================================
|
|
||||||
STRIPE_SK=
|
|
||||||
STRIPE_WEBHOOK_SECRET=
|
|
||||||
STRIPE_PRICE_ONBOARDING=
|
|
||||||
STRIPE_PRICE_EMAIL_USAGE=
|
|
||||||
STRIPE_METER_EVENT_NAME=emails
|
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# OPTIONAL: File Storage (Minio)
|
# OPTIONAL: File Storage (Minio)
|
||||||
# ========================================
|
# ========================================
|
||||||
@@ -154,11 +152,16 @@ SMTP_DOMAIN=smtp.example.com
|
|||||||
# Default: false
|
# Default: false
|
||||||
# DISABLE_SIGNUPS=false
|
# DISABLE_SIGNUPS=false
|
||||||
|
|
||||||
# Controls whether email validation checks are performed on signup
|
# ========================================
|
||||||
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
|
# OPTIONAL: SES Sending Rate
|
||||||
# When disabled (false), skips these validation checks and allows any email format
|
# ========================================
|
||||||
# Default: false
|
# Caps the email-worker's send rate (messages per second) for the SES sandbox or
|
||||||
# VERIFY_EMAIL_ON_SIGNUP=false
|
# manually-throttled accounts. When unset, the worker probes the AWS account at
|
||||||
|
# startup via ses:GetSendQuota; if that call is denied or transiently fails,
|
||||||
|
# the worker silently falls back to 14/sec which may exceed sandbox limits and
|
||||||
|
# trigger SES throttling errors. Set this explicitly to avoid the silent fallback.
|
||||||
|
# Default: unset (auto-detect, falls back to 14)
|
||||||
|
# EMAIL_RATE_LIMIT_PER_SECOND=1
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# ADVANCED (rarely needed)
|
# ADVANCED (rarely needed)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.10.0"
|
".": "0.11.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,61 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.11.0](https://github.com/useplunk/plunk/compare/v0.10.0...v0.11.0) (2026-05-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Ability to change subscription status in workflows ([fadc19d](https://github.com/useplunk/plunk/commit/fadc19d139084550eb442a8b8368edcd6075cad0))
|
||||||
|
* add 'notTriggeredWithin' operator to segment filters for enhanced event tracking ([0f00ca1](https://github.com/useplunk/plunk/commit/0f00ca1b8c9bd95f2158af845218ef432ab0d498))
|
||||||
|
* add external link to edit email templates in SendEmailStepDialog and WorkflowBuilder ([4ba43dd](https://github.com/useplunk/plunk/commit/4ba43dd3b64776413f813e091f527aa3e56bfc84))
|
||||||
|
* add Markdown cut link to footer and page for improved accessibility ([ccd516e](https://github.com/useplunk/plunk/commit/ccd516e4e882921fb1a8a3e87c47710e51e35311))
|
||||||
|
* add project switching functionality to command palette ([5724ab9](https://github.com/useplunk/plunk/commit/5724ab9536ea4e3bb4e916e963b1f39ba51bb7f3))
|
||||||
|
* add sanitize-html for improved email content sanitization ([735acff](https://github.com/useplunk/plunk/commit/735acff45423cb537f7aa636b9a9d31830775f93))
|
||||||
|
* add search functionality to campaigns list with debounce effect ([d114950](https://github.com/useplunk/plunk/commit/d11495061d907bcee699d56987784fa7ebe31bf6))
|
||||||
|
* add segment membership operators and enhance segment filter functionality ([a3cc622](https://github.com/useplunk/plunk/commit/a3cc62213f40f6a9341113b73b52852292fb9a10))
|
||||||
|
* add SwitchOffer component to promote switching from competitors for enhanced user engagement ([6195b39](https://github.com/useplunk/plunk/commit/6195b39f3dbdd9fe783b79f9ae7f7942509a49a0))
|
||||||
|
* add workflow duplication functionality with API endpoint and UI button ([c6340a1](https://github.com/useplunk/plunk/commit/c6340a1dc7385723d4ec30779340218935d814e6))
|
||||||
|
* enhance campaign scheduling and audience settings UI for better clarity and usability ([7658a59](https://github.com/useplunk/plunk/commit/7658a59b5df4e325ad9eca37356cad3cbb70e942))
|
||||||
|
* implement bulk contact action selector for improved flexibility in bulk operations ([de6335e](https://github.com/useplunk/plunk/commit/de6335e99999242f81e9eda9a20aeccab80a2de4))
|
||||||
|
* implement caching for recent activity count to optimize performance and reduce database load ([f22da4a](https://github.com/useplunk/plunk/commit/f22da4add1e455678f380aba8c2fd02012ef6457))
|
||||||
|
* implement early fraud warning handling in webhooks ([48425d1](https://github.com/useplunk/plunk/commit/48425d1df160a3fd5edb4cbdcc9e6725628db319))
|
||||||
|
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env ([463301b](https://github.com/useplunk/plunk/commit/463301b5db27b08b483db3c56a992ac62d6653b8))
|
||||||
|
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env var ([e0bf0f6](https://github.com/useplunk/plunk/commit/e0bf0f628af2a155ca3bbc38ef5c2f4ecc0455e6))
|
||||||
|
* refactor template editing layout for improved usability and clarity ([aaf5ac6](https://github.com/useplunk/plunk/commit/aaf5ac65307266a4d9deb02986b610757e929b49))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* enhance campaign finalization process to handle pending emails and ensure accurate status updates ([2529c9b](https://github.com/useplunk/plunk/commit/2529c9b4282b003a94013259119e98654ae22b46))
|
||||||
|
* enhance email content parsing and logging for better debugging ([4587e9d](https://github.com/useplunk/plunk/commit/4587e9d670fa3a27fbc1ca7669c8fc75d804e85e))
|
||||||
|
* enhance project name validation to exclude invisible and decorative characters ([29883ff](https://github.com/useplunk/plunk/commit/29883ffc7104d4beb13965bfa65ea609367ede69))
|
||||||
|
* enhance Quick Start card layout for improved responsiveness and usability ([649bbf6](https://github.com/useplunk/plunk/commit/649bbf6d6b9db5139295a455c69425a5565d4323))
|
||||||
|
* handle undefined path in footer component for improved stability ([715961c](https://github.com/useplunk/plunk/commit/715961c007e450731434e5f343acff0debd6a851))
|
||||||
|
* implement mergeContactData method for efficient contact data updates ([a27d564](https://github.com/useplunk/plunk/commit/a27d564e1af6e5cf72c9ef405650f2475bfd0c86))
|
||||||
|
* improve iframe height adjustment logic in EmailEditor component ([9e4aa94](https://github.com/useplunk/plunk/commit/9e4aa9443b53ef38a3af1b269f4a949dd57e758f))
|
||||||
|
* pass DISABLE_SIGNUPS and EMAIL_RATE_LIMIT_PER_SECOND through compose; trim .env.self-host.example ([8a26d60](https://github.com/useplunk/plunk/commit/8a26d605b4aa5fdea0e02a17159c1cae478a7e47))
|
||||||
|
* pass missing env variables from .env.self-host.example to plunk service ([f178c59](https://github.com/useplunk/plunk/commit/f178c59b5bc461f9ba57dc6f0bac9617feaab4a3))
|
||||||
|
* replace font loading method with utility function for improved performance ([ba23a1c](https://github.com/useplunk/plunk/commit/ba23a1c6fa70ca6fa0343391fa8d642155ad45c2))
|
||||||
|
* update activity item colors and backgrounds for improved visual distinction ([e43f70d](https://github.com/useplunk/plunk/commit/e43f70d8a135b048d4e9ed013ffa25f1b2f89687))
|
||||||
|
* update middleware matcher to exclude webmanifest files ([cbb3bff](https://github.com/useplunk/plunk/commit/cbb3bffcf4f789e9c36912c9b0ce0ba9f6a1d4dd))
|
||||||
|
* update not found handling in GET route to return 404 response ([7615b62](https://github.com/useplunk/plunk/commit/7615b62945aa1939bf6a5b3638c232509090de60))
|
||||||
|
* update segment filter logic to retain value and unit, enhance activity name mapping ([d1f357c](https://github.com/useplunk/plunk/commit/d1f357c300c7798d5c8d521e1219e39e7da821dd))
|
||||||
|
* update segment filter logic to retain value and unit, enhance activity name mapping ([a126563](https://github.com/useplunk/plunk/commit/a1265635420e66b7cdef02a7136fdf9c3aec4418))
|
||||||
|
* update TemplateSearchPicker to maintain selected template name on change ([2ea1802](https://github.com/useplunk/plunk/commit/2ea1802290a4378d68e3878a44e502ea8541b301))
|
||||||
|
|
||||||
|
|
||||||
|
### Code Refactoring
|
||||||
|
|
||||||
|
* convert forwardRef components to function components for consistency ([361ec0b](https://github.com/useplunk/plunk/commit/361ec0b1eb17647696656c6a6d4cddd5b9348b45))
|
||||||
|
* implement step dialog components for workflow editing ([ed9027b](https://github.com/useplunk/plunk/commit/ed9027b4ef9c37e91c69ed5a90d5b74c08c7a054))
|
||||||
|
* remove unused .png files ([2d05c3f](https://github.com/useplunk/plunk/commit/2d05c3fbc1ded87880a307943bb93106b18cd406))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* expand documentation with new sections on importing contacts, unsubscribe pages, and API key management ([4ddafdc](https://github.com/useplunk/plunk/commit/4ddafdc0419389818de7e45eb4a0c82c2e9382eb))
|
||||||
|
* update README to clarify self-hosted alternative and add inbound emails feature ([80ef65e](https://github.com/useplunk/plunk/commit/80ef65e04b8c362f05ae5b7d5710b398a150cce5))
|
||||||
|
|
||||||
## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01)
|
## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
|
|||||||
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
||||||
|
|
||||||
|
// Custom MAIL FROM subdomain used to construct `<subdomain>.<your-domain>`
|
||||||
|
// when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
|
||||||
|
// is already used for something else (e.g. a CDN), since the MAIL FROM hostname
|
||||||
|
// needs MX + TXT records that can't coexist with a CNAME.
|
||||||
|
export const MAIL_FROM_SUBDOMAIN = validateEnv('MAIL_FROM_SUBDOMAIN', '').trim() || 'plunk';
|
||||||
|
|
||||||
// Email Processing Rate Limit (optional override)
|
// Email Processing Rate Limit (optional override)
|
||||||
// If not set, will automatically fetch from AWS SES account quota
|
// If not set, will automatically fetch from AWS SES account quota
|
||||||
// Set this to override AWS quota (useful for setting lower limits or testing)
|
// Set this to override AWS quota (useful for setting lower limits or testing)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export class Campaigns {
|
|||||||
private async list(req: Request, res: Response, _next: NextFunction) {
|
private async list(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const status = req.query.status as CampaignStatus | undefined;
|
const status = req.query.status as CampaignStatus | undefined;
|
||||||
|
const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export class Campaigns {
|
|||||||
|
|
||||||
const result = await CampaignService.list(auth.projectId, {
|
const result = await CampaignService.list(auth.projectId, {
|
||||||
status,
|
status,
|
||||||
|
search,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
LANDING_URI,
|
LANDING_URI,
|
||||||
|
MAIL_FROM_SUBDOMAIN,
|
||||||
NODE_ENV,
|
NODE_ENV,
|
||||||
S3_ENABLED,
|
S3_ENABLED,
|
||||||
SMTP_DOMAIN,
|
SMTP_DOMAIN,
|
||||||
@@ -62,6 +63,7 @@ export class Config {
|
|||||||
},
|
},
|
||||||
aws: {
|
aws: {
|
||||||
sesRegion: AWS_SES_REGION,
|
sesRegion: AWS_SES_REGION,
|
||||||
|
mailFromSubdomain: MAIL_FROM_SUBDOMAIN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import {ContactSchemas} from '@plunk/shared';
|
||||||
|
import type {BulkContactActionSelector} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
@@ -424,31 +426,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
return queueBulkAction(req, res, 'subscribe');
|
||||||
const {contactIds} = req.body;
|
|
||||||
|
|
||||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
|
||||||
return res.status(400).json({error: 'contactIds array is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate limit
|
|
||||||
if (contactIds.length > 1000) {
|
|
||||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
|
|
||||||
|
|
||||||
return res.status(202).json({
|
|
||||||
message: 'Bulk subscribe queued successfully',
|
|
||||||
jobId: job.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -459,30 +437,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
return queueBulkAction(req, res, 'unsubscribe');
|
||||||
const {contactIds} = req.body;
|
|
||||||
|
|
||||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
|
||||||
return res.status(400).json({error: 'contactIds array is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contactIds.length > 1000) {
|
|
||||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
|
|
||||||
|
|
||||||
return res.status(202).json({
|
|
||||||
message: 'Bulk unsubscribe queued successfully',
|
|
||||||
jobId: job.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -493,30 +448,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
return queueBulkAction(req, res, 'delete');
|
||||||
const {contactIds} = req.body;
|
|
||||||
|
|
||||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
|
||||||
return res.status(400).json({error: 'contactIds array is required'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contactIds.length > 1000) {
|
|
||||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
|
|
||||||
|
|
||||||
return res.status(202).json({
|
|
||||||
message: 'Bulk delete queued successfully',
|
|
||||||
jobId: job.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -550,3 +482,36 @@ export class Contacts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queueBulkAction(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||||
|
) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
|
const parsed = ContactSchemas.bulkAction.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: parsed.error.errors[0]?.message ?? 'Invalid bulk action payload',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector: BulkContactActionSelector =
|
||||||
|
parsed.data.mode === 'ids'
|
||||||
|
? {mode: 'ids', contactIds: parsed.data.contactIds}
|
||||||
|
: {mode: 'query', filter: parsed.data.filter, excludeIds: parsed.data.excludeIds};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await QueueService.queueBulkContactAction(auth.projectId!, selector, operation);
|
||||||
|
return res.status(202).json({
|
||||||
|
message: `Bulk ${operation} queued successfully`,
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[CONTACTS] Failed to queue bulk ${operation}:`, error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : `Failed to queue bulk ${operation}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -230,6 +230,14 @@ export class Users {
|
|||||||
client_reference_id: project.id, // Store project ID for webhook
|
client_reference_id: project.id, // Store project ID for webhook
|
||||||
line_items: lineItems,
|
line_items: lineItems,
|
||||||
...(checkoutCurrency && {currency: checkoutCurrency}),
|
...(checkoutCurrency && {currency: checkoutCurrency}),
|
||||||
|
custom_fields: [
|
||||||
|
{
|
||||||
|
key: 'promo_code',
|
||||||
|
label: {type: 'custom', custom: 'Promo code'},
|
||||||
|
type: 'text',
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
billing_cycle_anchor: billingCycleAnchor,
|
billing_cycle_anchor: billingCycleAnchor,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -530,10 +530,24 @@ export class Webhooks {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Base onboarding credit: refund the 1-unit card-verification charge
|
||||||
|
let creditBalance = -100;
|
||||||
|
|
||||||
|
// Switching-offer promo: 2 extra units of credit if the customer typed SWITCH
|
||||||
|
// into the Promo code custom field on Stripe Checkout.
|
||||||
|
const promoField = session.custom_fields?.find((f) => f.key === 'promo_code');
|
||||||
|
const promoCode = promoField?.text?.value?.trim().toUpperCase();
|
||||||
|
if (promoCode === 'SWITCH') {
|
||||||
|
creditBalance -= 200;
|
||||||
|
signale.success(`[WEBHOOK] SWITCH promo applied for project ${projectId}`);
|
||||||
|
} else if (promoCode) {
|
||||||
|
signale.info(`[WEBHOOK] Unknown promo code "${promoCode}" entered for project ${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Update Stripe customer name to match project name and add credit for onboarding fee
|
// Update Stripe customer name to match project name and add credit for onboarding fee
|
||||||
await stripe.customers.update(customerId, {
|
await stripe.customers.update(customerId, {
|
||||||
name: updatedProject.name,
|
name: updatedProject.name,
|
||||||
balance: -100,
|
balance: creditBalance,
|
||||||
});
|
});
|
||||||
|
|
||||||
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
|
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
|
||||||
|
|||||||
@@ -149,6 +149,26 @@ export class Workflows {
|
|||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /workflows/:id/duplicate
|
||||||
|
* Duplicate a workflow (always disabled, no execution state)
|
||||||
|
*/
|
||||||
|
@Post(':id/duplicate')
|
||||||
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
|
@CatchAsync
|
||||||
|
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
const workflowId = req.params.id;
|
||||||
|
|
||||||
|
if (!workflowId) {
|
||||||
|
return res.status(400).json({error: 'Workflow ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await WorkflowService.duplicate(auth.projectId!, workflowId);
|
||||||
|
|
||||||
|
return res.status(201).json(workflow);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /workflows/:id/steps
|
* POST /workflows/:id/steps
|
||||||
* Add a step to a workflow
|
* Add a step to a workflow
|
||||||
|
|||||||
@@ -3,73 +3,149 @@
|
|||||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {BulkContactActionJobData} from '@plunk/types';
|
import {Prisma} from '@plunk/db';
|
||||||
|
import type {BulkContactActionJobData, BulkContactActionSelector} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
|
import {prisma} from '../database/prisma.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {bulkContactQueue} from '../services/QueueService.js';
|
import {bulkContactQueue} from '../services/QueueService.js';
|
||||||
|
|
||||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
interface BulkActionResult {
|
interface BulkActionResult {
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||||
totalRequested: number;
|
totalRequested: number;
|
||||||
|
/** Contacts whose state was actually changed by this run. */
|
||||||
successCount: number;
|
successCount: number;
|
||||||
|
/** Subscribe/unsubscribe only: contacts already in the target state. */
|
||||||
|
unchangedCount: number;
|
||||||
|
/** Contacts that errored or weren't found (e.g. wrong project). */
|
||||||
failureCount: number;
|
failureCount: number;
|
||||||
errors: {contactId: string; email: string; error: string}[];
|
errors: {contactId: string; email: string; error: string}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildQueryWhere(projectId: string, selector: Extract<BulkContactActionSelector, {mode: 'query'}>): Prisma.ContactWhereInput {
|
||||||
|
const search = selector.filter?.search;
|
||||||
|
const excludeIds = selector.excludeIds ?? [];
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
...(search ? {email: {contains: search, mode: 'insensitive' as const}} : {}),
|
||||||
|
...(excludeIds.length > 0 ? {id: {notIn: excludeIds}} : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBatch(
|
||||||
|
projectId: string,
|
||||||
|
operation: BulkActionResult['operation'],
|
||||||
|
ids: string[],
|
||||||
|
): Promise<{changed: number; unchanged: number}> {
|
||||||
|
switch (operation) {
|
||||||
|
case 'subscribe': {
|
||||||
|
const r = await ContactService.bulkSubscribe(projectId, ids);
|
||||||
|
return {changed: r.updated, unchanged: r.unchanged};
|
||||||
|
}
|
||||||
|
case 'unsubscribe': {
|
||||||
|
const r = await ContactService.bulkUnsubscribe(projectId, ids);
|
||||||
|
return {changed: r.updated, unchanged: r.unchanged};
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
const r = await ContactService.bulkDelete(projectId, ids);
|
||||||
|
return {changed: r.deleted, unchanged: 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createBulkContactWorker() {
|
export function createBulkContactWorker() {
|
||||||
const worker = new Worker<BulkContactActionJobData>(
|
const worker = new Worker<BulkContactActionJobData>(
|
||||||
bulkContactQueue.name,
|
bulkContactQueue.name,
|
||||||
async (job: Job<BulkContactActionJobData>) => {
|
async (job: Job<BulkContactActionJobData>) => {
|
||||||
const {projectId, contactIds, operation} = job.data;
|
const {projectId, operation, selector} = job.data;
|
||||||
|
|
||||||
signale.info(
|
|
||||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result: BulkActionResult = {
|
const result: BulkActionResult = {
|
||||||
operation,
|
operation,
|
||||||
totalRequested: contactIds.length,
|
totalRequested: 0,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
|
unchangedCount: 0,
|
||||||
failureCount: 0,
|
failureCount: 0,
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
if (selector.mode === 'ids') {
|
||||||
// Process contacts in batches
|
const {contactIds} = selector;
|
||||||
|
result.totalRequested = contactIds.length;
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts (ids mode) in project ${projectId}`,
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
||||||
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
|
const batchIds = contactIds.slice(i, i + BATCH_SIZE);
|
||||||
|
try {
|
||||||
|
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
|
||||||
|
result.successCount += changed;
|
||||||
|
result.unchangedCount += unchanged;
|
||||||
|
const failed = batchIds.length - changed - unchanged;
|
||||||
|
if (failed > 0) result.failureCount += failed;
|
||||||
|
} catch (error) {
|
||||||
|
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
|
||||||
|
result.failureCount += batchIds.length;
|
||||||
|
result.errors.push({
|
||||||
|
contactId: 'batch',
|
||||||
|
email: '',
|
||||||
|
error: error instanceof Error ? error.message : 'Batch processing failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await job.updateProgress(Math.round(((i + batchIds.length) / contactIds.length) * 100));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const where = buildQueryWhere(projectId, selector);
|
||||||
|
const total = await prisma.contact.count({where});
|
||||||
|
result.totalRequested = total;
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${total} contacts (query mode) in project ${projectId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
await job.updateProgress(100);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor-based iteration over matching contacts. We re-evaluate the where clause
|
||||||
|
// each batch (with id < cursor) instead of Prisma's `cursor:` because for `delete`
|
||||||
|
// the rows we just processed disappear — a stable cursor would either skip survivors
|
||||||
|
// or revisit deletions. Sorting by id desc + `id < lastId` is idempotent under either.
|
||||||
|
let lastId: string | undefined;
|
||||||
|
let processedRows = 0;
|
||||||
|
|
||||||
|
// Cap the loop so a runaway query (e.g. growing table) can't spin forever.
|
||||||
|
const maxIterations = Math.ceil(total / BATCH_SIZE) + 50;
|
||||||
|
for (let iter = 0; iter < maxIterations; iter += 1) {
|
||||||
|
const batch = await prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
...(lastId ? {id: {...(where.id as object | undefined), lt: lastId}} : {}),
|
||||||
|
},
|
||||||
|
select: {id: true},
|
||||||
|
orderBy: {id: 'desc'},
|
||||||
|
take: BATCH_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
const batchIds = batch.map(c => c.id);
|
||||||
|
lastId = batchIds[batchIds.length - 1];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let batchResult: {updated?: number; deleted?: number};
|
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
|
||||||
|
result.successCount += changed;
|
||||||
switch (operation) {
|
result.unchangedCount += unchanged;
|
||||||
case 'subscribe':
|
const failed = batchIds.length - changed - unchanged;
|
||||||
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
|
if (failed > 0) result.failureCount += failed;
|
||||||
result.successCount += batchResult.updated || 0;
|
|
||||||
break;
|
|
||||||
case 'unsubscribe':
|
|
||||||
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
|
|
||||||
result.successCount += batchResult.updated || 0;
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
batchResult = await ContactService.bulkDelete(projectId, batchIds);
|
|
||||||
result.successCount += batchResult.deleted || 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If some contacts in batch weren't processed, track them as failures
|
|
||||||
const processedCount = batchResult.updated || batchResult.deleted || 0;
|
|
||||||
const failedCount = batchIds.length - processedCount;
|
|
||||||
if (failedCount > 0) {
|
|
||||||
result.failureCount += failedCount;
|
|
||||||
// Note: We don't have individual contact details for batch failures
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
|
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
|
||||||
result.failureCount += batchIds.length;
|
result.failureCount += batchIds.length;
|
||||||
result.errors.push({
|
result.errors.push({
|
||||||
contactId: 'batch',
|
contactId: 'batch',
|
||||||
@@ -78,24 +154,21 @@ export function createBulkContactWorker() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress
|
processedRows += batchIds.length;
|
||||||
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
|
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100)));
|
||||||
await job.updateProgress(progress);
|
|
||||||
|
if (batch.length < BATCH_SIZE) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
signale.info(
|
|
||||||
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connection: bulkContactQueue.opts.connection,
|
connection: bulkContactQueue.opts.connection,
|
||||||
concurrency: 3, // Process max 3 bulk operations concurrently
|
concurrency: 3,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -222,11 +222,34 @@ export class ActivityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short Redis TTL for recent-count results. Short enough to feel live on the
|
||||||
|
* dashboard live-pulse, long enough to absorb the polling load when many
|
||||||
|
* tabs are open across the user base.
|
||||||
|
*/
|
||||||
|
private static readonly RECENT_COUNT_CACHE_TTL = 10; // seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get recent activity count (for real-time updates)
|
* Get recent activity count (for real-time updates)
|
||||||
* Returns count of activities in the last N minutes
|
* Returns count of activities in the last N minutes
|
||||||
|
*
|
||||||
|
* Backed by a short Redis cache because the dashboard polls this endpoint
|
||||||
|
* every 30 seconds per open tab; without the cache, every poll would run
|
||||||
|
* three COUNT queries against the events, emails, and workflow_executions
|
||||||
|
* tables.
|
||||||
*/
|
*/
|
||||||
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
|
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
|
||||||
|
const cacheKey = Keys.Activity.recentCount(projectId, minutes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(cacheKey);
|
||||||
|
if (cached !== null) {
|
||||||
|
return parseInt(cached, 10);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
signale.warn('[ACTIVITY] Failed to read recent-count cache:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const since = new Date(Date.now() - minutes * 60 * 1000);
|
const since = new Date(Date.now() - minutes * 60 * 1000);
|
||||||
const dateFilter: Prisma.DateTimeFilter = {gte: since};
|
const dateFilter: Prisma.DateTimeFilter = {gte: since};
|
||||||
|
|
||||||
@@ -245,7 +268,15 @@ export class ActivityService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return eventCount + emailCount + workflowCount;
|
const total = eventCount + emailCount + workflowCount;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis.setex(cacheKey, this.RECENT_COUNT_CACHE_TTL, total.toString());
|
||||||
|
} catch (error) {
|
||||||
|
signale.warn('[ACTIVITY] Failed to cache recent-count:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -186,16 +186,26 @@ export class CampaignService {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
options: {
|
options: {
|
||||||
status?: CampaignStatus;
|
status?: CampaignStatus;
|
||||||
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<PaginatedResponse<Campaign>> {
|
): Promise<PaginatedResponse<Campaign>> {
|
||||||
const {status, page = 1, pageSize = 20} = options;
|
const {status, search, page = 1, pageSize = 20} = options;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: Prisma.CampaignWhereInput = {
|
const where: Prisma.CampaignWhereInput = {
|
||||||
projectId,
|
projectId,
|
||||||
...(status ? {status} : {}),
|
...(status ? {status} : {}),
|
||||||
|
...(search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{name: {contains: search, mode: 'insensitive' as const}},
|
||||||
|
{subject: {contains: search, mode: 'insensitive' as const}},
|
||||||
|
{from: {contains: search, mode: 'insensitive' as const}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [campaigns, total] = await Promise.all([
|
const [campaigns, total] = await Promise.all([
|
||||||
|
|||||||
@@ -135,6 +135,47 @@ export class ContactService {
|
|||||||
* Update a contact
|
* Update a contact
|
||||||
* Uses unique constraint violation to check for duplicates (more efficient)
|
* Uses unique constraint violation to check for duplicates (more efficient)
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Merge an incoming partial data object into existing contact data.
|
||||||
|
* - `null` value on a key deletes that key
|
||||||
|
* - empty strings are ignored
|
||||||
|
* - reserved/system-generated keys are silently filtered
|
||||||
|
* - `{value, persistent: false}` entries are skipped (non-persistent)
|
||||||
|
*/
|
||||||
|
private static mergeContactData(
|
||||||
|
existing: Prisma.JsonValue | null,
|
||||||
|
incoming: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const merged: Record<string, unknown> =
|
||||||
|
existing && typeof existing === 'object' && !Array.isArray(existing) ? {...(existing as Record<string, unknown>)} : {};
|
||||||
|
|
||||||
|
const reservedFields = ['plunk_id', 'plunk_email', 'id', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(incoming)) {
|
||||||
|
if (reservedFields.includes(key)) continue;
|
||||||
|
if (value === '') continue;
|
||||||
|
if (value === null) {
|
||||||
|
delete merged[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'locale' && typeof value !== 'string') {
|
||||||
|
throw new HttpException(400, 'Locale must be a string');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'value' in value &&
|
||||||
|
'persistent' in value &&
|
||||||
|
(value as {persistent: unknown}).persistent === false
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
public static async update(
|
public static async update(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
contactId: string,
|
contactId: string,
|
||||||
@@ -149,7 +190,14 @@ export class ContactService {
|
|||||||
updateData.email = data.email;
|
updateData.email = data.email;
|
||||||
}
|
}
|
||||||
if (data.data !== undefined) {
|
if (data.data !== undefined) {
|
||||||
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
|
if (data.data === null) {
|
||||||
|
updateData.data = Prisma.JsonNull;
|
||||||
|
} else if (typeof data.data === 'object' && !Array.isArray(data.data)) {
|
||||||
|
const merged = ContactService.mergeContactData(existing.data, data.data as Record<string, unknown>);
|
||||||
|
updateData.data = Object.keys(merged).length > 0 ? toPrismaJson(merged) : Prisma.JsonNull;
|
||||||
|
} else {
|
||||||
|
throw new HttpException(400, 'data must be an object');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (data.subscribed !== undefined) {
|
if (data.subscribed !== undefined) {
|
||||||
updateData.subscribed = data.subscribed;
|
updateData.subscribed = data.subscribed;
|
||||||
@@ -225,69 +273,7 @@ export class ContactService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process data to merge with existing data
|
const mergedData = ContactService.mergeContactData(existing?.data ?? null, data ?? {});
|
||||||
let mergedData: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
|
|
||||||
// Start with existing data
|
|
||||||
mergedData = {...existing.data};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge new data (if provided)
|
|
||||||
if (data) {
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
// Skip reserved system-generated fields
|
|
||||||
// These fields are dynamically added during template rendering and cannot be overridden
|
|
||||||
const reservedFields = [
|
|
||||||
'plunk_id',
|
|
||||||
'plunk_email',
|
|
||||||
'id',
|
|
||||||
'email',
|
|
||||||
'unsubscribeUrl',
|
|
||||||
'subscribeUrl',
|
|
||||||
'manageUrl',
|
|
||||||
];
|
|
||||||
if (reservedFields.includes(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip empty string values - they don't provide meaningful data
|
|
||||||
// and can cause issues with template rendering and data integrity
|
|
||||||
if (value === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete field if null is passed (allows removing fields from contact data)
|
|
||||||
if (value === null) {
|
|
||||||
delete mergedData[key];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate locale field (special user-settable field)
|
|
||||||
// Only validate type - any locale string is accepted since we default to English if unsupported
|
|
||||||
if (key === 'locale') {
|
|
||||||
if (value !== undefined && typeof value !== 'string') {
|
|
||||||
throw new HttpException(400, 'Locale must be a string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle non-persistent data format: { value: "...", persistent: false }
|
|
||||||
if (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'value' in value &&
|
|
||||||
'persistent' in value &&
|
|
||||||
value.persistent === false
|
|
||||||
) {
|
|
||||||
// Non-persistent fields are not stored in contact data
|
|
||||||
// They would be used only for the current operation (like email template rendering)
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the value
|
|
||||||
mergedData[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Track subscription status change
|
// Track subscription status change
|
||||||
@@ -721,99 +707,81 @@ export class ContactService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk subscribe contacts
|
* Bulk subscribe contacts
|
||||||
* Updates multiple contacts to subscribed=true in batches
|
* Updates multiple contacts to subscribed=true in batches.
|
||||||
|
* `updated` = contacts flipped from unsubscribed to subscribed.
|
||||||
|
* `unchanged` = contacts that were already subscribed (no-op, not a failure).
|
||||||
*/
|
*/
|
||||||
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
public static async bulkSubscribe(
|
||||||
// Verify all contacts belong to this project
|
projectId: string,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<{updated: number; unchanged: number}> {
|
||||||
const contacts = await prisma.contact.findMany({
|
const contacts = await prisma.contact.findMany({
|
||||||
where: {
|
where: {id: {in: contactIds}, projectId},
|
||||||
id: {in: contactIds},
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
select: {id: true, subscribed: true},
|
select: {id: true, subscribed: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
const validIds = contacts.map(c => c.id);
|
if (contacts.length === 0) {
|
||||||
|
return {updated: 0, unchanged: 0};
|
||||||
if (validIds.length === 0) {
|
|
||||||
return {updated: 0};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update contacts that are currently unsubscribed
|
|
||||||
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
|
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
|
||||||
|
const unchanged = contacts.length - unsubscribedIds.length;
|
||||||
|
|
||||||
if (unsubscribedIds.length === 0) {
|
if (unsubscribedIds.length === 0) {
|
||||||
return {updated: 0};
|
return {updated: 0, unchanged};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in a single query for performance
|
|
||||||
const result = await prisma.contact.updateMany({
|
const result = await prisma.contact.updateMany({
|
||||||
where: {
|
where: {id: {in: unsubscribedIds}, projectId},
|
||||||
id: {in: unsubscribedIds},
|
data: {subscribed: true},
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
subscribed: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
|
||||||
// Process in background to avoid blocking the API response
|
|
||||||
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
|
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
|
||||||
// Silently ignore errors in tests due to cleanup race conditions
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {updated: result.count};
|
return {updated: result.count, unchanged};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk unsubscribe contacts
|
* Bulk unsubscribe contacts.
|
||||||
|
* `updated` = contacts flipped from subscribed to unsubscribed.
|
||||||
|
* `unchanged` = contacts that were already unsubscribed (no-op, not a failure).
|
||||||
*/
|
*/
|
||||||
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
public static async bulkUnsubscribe(
|
||||||
|
projectId: string,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<{updated: number; unchanged: number}> {
|
||||||
const contacts = await prisma.contact.findMany({
|
const contacts = await prisma.contact.findMany({
|
||||||
where: {
|
where: {id: {in: contactIds}, projectId},
|
||||||
id: {in: contactIds},
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
select: {id: true, subscribed: true},
|
select: {id: true, subscribed: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
const validIds = contacts.map(c => c.id);
|
if (contacts.length === 0) {
|
||||||
|
return {updated: 0, unchanged: 0};
|
||||||
if (validIds.length === 0) {
|
|
||||||
return {updated: 0};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update contacts that are currently subscribed
|
|
||||||
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
|
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
|
||||||
|
const unchanged = contacts.length - subscribedIds.length;
|
||||||
|
|
||||||
if (subscribedIds.length === 0) {
|
if (subscribedIds.length === 0) {
|
||||||
return {updated: 0};
|
return {updated: 0, unchanged};
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.contact.updateMany({
|
const result = await prisma.contact.updateMany({
|
||||||
where: {
|
where: {id: {in: subscribedIds}, projectId},
|
||||||
id: {in: subscribedIds},
|
data: {subscribed: false},
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
subscribed: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
|
||||||
// Process in background to avoid blocking the API response
|
|
||||||
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
|
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
|
||||||
// Silently ignore errors in tests due to cleanup race conditions
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {updated: result.count};
|
return {updated: result.count, unchanged};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import signale from 'signale';
|
|||||||
import type {
|
import type {
|
||||||
ApiRequestCleanupJobData,
|
ApiRequestCleanupJobData,
|
||||||
BulkContactActionJobData,
|
BulkContactActionJobData,
|
||||||
|
BulkContactActionSelector,
|
||||||
CampaignBatchJobData,
|
CampaignBatchJobData,
|
||||||
ContactImportJobData,
|
ContactImportJobData,
|
||||||
DomainVerificationJobData,
|
DomainVerificationJobData,
|
||||||
@@ -350,12 +351,12 @@ export class QueueService {
|
|||||||
*/
|
*/
|
||||||
public static async queueBulkContactAction(
|
public static async queueBulkContactAction(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
contactIds: string[],
|
selector: BulkContactActionSelector,
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||||
): Promise<Job<BulkContactActionJobData>> {
|
): Promise<Job<BulkContactActionJobData>> {
|
||||||
return bulkContactQueue.add(
|
return bulkContactQueue.add(
|
||||||
'bulk-contact-action',
|
'bulk-contact-action',
|
||||||
{projectId, contactIds, operation},
|
{projectId, operation, selector},
|
||||||
{
|
{
|
||||||
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
AWS_SES_REGION,
|
AWS_SES_REGION,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
MAIL_FROM_SUBDOMAIN,
|
||||||
SES_CONFIGURATION_SET,
|
SES_CONFIGURATION_SET,
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||||
TRACKING_TOGGLE_ENABLED,
|
TRACKING_TOGGLE_ENABLED,
|
||||||
@@ -250,10 +251,13 @@ export const verifyDomain = async (domain: string): Promise<string[]> => {
|
|||||||
// Verify DKIM for the domain
|
// Verify DKIM for the domain
|
||||||
const DKIM = await ses.verifyDomainDkim({Domain: domain});
|
const DKIM = await ses.verifyDomainDkim({Domain: domain});
|
||||||
|
|
||||||
// Set custom MAIL FROM domain (plunk.yourdomain.com)
|
// Set custom MAIL FROM domain. The subdomain defaults to `plunk` and can be
|
||||||
|
// overridden via the MAIL_FROM_SUBDOMAIN env var — useful when `plunk.<domain>`
|
||||||
|
// is already in use for something else (e.g., a CNAME to a CDN), since the
|
||||||
|
// MAIL FROM subdomain needs MX + TXT records that conflict with a CNAME.
|
||||||
await ses.setIdentityMailFromDomain({
|
await ses.setIdentityMailFromDomain({
|
||||||
Identity: domain,
|
Identity: domain,
|
||||||
MailFromDomain: `plunk.${domain}`,
|
MailFromDomain: `${MAIL_FROM_SUBDOMAIN}.${domain}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return DKIM.DkimTokens ?? [];
|
return DKIM.DkimTokens ?? [];
|
||||||
|
|||||||
@@ -979,7 +979,7 @@ export class WorkflowExecutionService {
|
|||||||
_stepExecution: WorkflowStepExecution,
|
_stepExecution: WorkflowStepExecution,
|
||||||
config: StepConfig,
|
config: StepConfig,
|
||||||
): Promise<StepResult> {
|
): Promise<StepResult> {
|
||||||
const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config);
|
const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config);
|
||||||
|
|
||||||
const contact = execution.contact;
|
const contact = execution.contact;
|
||||||
const currentData =
|
const currentData =
|
||||||
@@ -987,24 +987,43 @@ export class WorkflowExecutionService {
|
|||||||
? (contact.data as Record<string, unknown>)
|
? (contact.data as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
// Merge updates with current data
|
const hasDataUpdates = updates && Object.keys(updates).length > 0;
|
||||||
const newData = {
|
const newData = hasDataUpdates ? {...currentData, ...updates} : currentData;
|
||||||
...currentData,
|
|
||||||
...updates,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update contact in database
|
const desiredSubscribed =
|
||||||
await prisma.contact.update({
|
subscriptionAction === 'subscribe' ? true : subscriptionAction === 'unsubscribe' ? false : undefined;
|
||||||
where: {id: contact.id},
|
const subscriptionChanging = desiredSubscribed !== undefined && desiredSubscribed !== contact.subscribed;
|
||||||
data: {
|
|
||||||
data: newData ? toPrismaJson(newData) : undefined,
|
const updateData: Prisma.ContactUpdateInput = {};
|
||||||
},
|
if (hasDataUpdates) {
|
||||||
});
|
updateData.data = toPrismaJson(newData);
|
||||||
|
}
|
||||||
|
if (subscriptionChanging) {
|
||||||
|
updateData.subscribed = desiredSubscribed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
await prisma.contact.update({
|
||||||
|
where: {id: contact.id},
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionChanging) {
|
||||||
|
const {EventService} = await import('./EventService.js');
|
||||||
|
await EventService.trackEvent(
|
||||||
|
execution.workflow.projectId,
|
||||||
|
desiredSubscribed ? 'contact.subscribed' : 'contact.unsubscribed',
|
||||||
|
contact.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updated: true,
|
updated: hasDataUpdates || subscriptionChanging,
|
||||||
updates,
|
updates,
|
||||||
newData,
|
newData,
|
||||||
|
subscriptionAction,
|
||||||
|
subscribed: desiredSubscribed ?? contact.subscribed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,72 @@ export class WorkflowService {
|
|||||||
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
|
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a workflow including all steps and transitions.
|
||||||
|
* The duplicate always starts disabled to prevent accidental triggering.
|
||||||
|
* Runtime execution state is intentionally not copied.
|
||||||
|
*/
|
||||||
|
public static async duplicate(projectId: string, workflowId: string): Promise<Workflow> {
|
||||||
|
const source = await this.get(projectId, workflowId);
|
||||||
|
|
||||||
|
const transitions = await prisma.workflowTransition.findMany({
|
||||||
|
where: {fromStep: {workflowId}},
|
||||||
|
});
|
||||||
|
|
||||||
|
return prisma.$transaction(async tx => {
|
||||||
|
const newWorkflow = await tx.workflow.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
name: `${source.name} (Copy)`,
|
||||||
|
description: source.description,
|
||||||
|
triggerType: source.triggerType,
|
||||||
|
triggerConfig:
|
||||||
|
source.triggerConfig === null
|
||||||
|
? Prisma.JsonNull
|
||||||
|
: (source.triggerConfig as Prisma.InputJsonValue),
|
||||||
|
enabled: false,
|
||||||
|
allowReentry: source.allowReentry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepIdMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const step of source.steps) {
|
||||||
|
const created = await tx.workflowStep.create({
|
||||||
|
data: {
|
||||||
|
workflowId: newWorkflow.id,
|
||||||
|
type: step.type,
|
||||||
|
name: step.name,
|
||||||
|
position: step.position as Prisma.InputJsonValue,
|
||||||
|
config: step.config as Prisma.InputJsonValue,
|
||||||
|
templateId: step.templateId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
stepIdMap.set(step.id, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transition of transitions) {
|
||||||
|
const fromStepId = stepIdMap.get(transition.fromStepId);
|
||||||
|
const toStepId = stepIdMap.get(transition.toStepId);
|
||||||
|
if (!fromStepId || !toStepId) continue;
|
||||||
|
|
||||||
|
await tx.workflowTransition.create({
|
||||||
|
data: {
|
||||||
|
fromStepId,
|
||||||
|
toStepId,
|
||||||
|
condition:
|
||||||
|
transition.condition === null
|
||||||
|
? Prisma.JsonNull
|
||||||
|
: (transition.condition as Prisma.InputJsonValue),
|
||||||
|
priority: transition.priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newWorkflow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a step to a workflow
|
* Add a step to a workflow
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export const Keys = {
|
|||||||
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
||||||
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
||||||
},
|
},
|
||||||
|
recentCount(projectId: string, minutes: number): string {
|
||||||
|
return `activity:recent-count:${projectId}:${minutes}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Analytics: {
|
Analytics: {
|
||||||
timeseries(projectId: string, startDate: string, endDate: string): string {
|
timeseries(projectId: string, startDate: string, endDate: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Plunk
|
||||||
|
|
||||||
|
> Plunk is an open-source, all-in-one email platform for developers. It unifies marketing, transactional, and broadcast email behind a single API — built to handle millions of contacts with workflows, segments, templates, and deliverability tooling out of the box.
|
||||||
|
|
||||||
|
Plunk can be used as a hosted service at useplunk.com or self-hosted via Docker. Source code lives at https://github.com/useplunk/plunk.
|
||||||
|
|
||||||
|
Pages listed below with a `.md` suffix serve a Markdown version (also available by requesting the same URL with `Accept: text/markdown`). Other pages are HTML only.
|
||||||
|
|
||||||
|
For full product documentation, see the docs site: https://docs.useplunk.com/llms.txt
|
||||||
|
|
||||||
|
## Product
|
||||||
|
|
||||||
|
- [Plunk home](https://www.useplunk.com/index.md): Product overview, features, and positioning
|
||||||
|
- [Pricing](https://www.useplunk.com/pricing.md): Plans, included volume, and overage pricing
|
||||||
|
- [Made by humans](https://www.useplunk.com/made-by-humans): The team and story behind Plunk
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [Email editor](https://www.useplunk.com/features/email-editor.md): Drag-and-drop and code-based template editor
|
||||||
|
- [Workflows](https://www.useplunk.com/features/workflows.md): Event- and segment-triggered automation flows
|
||||||
|
- [Segments](https://www.useplunk.com/features/segments.md): Dynamic and static contact segments
|
||||||
|
- [SMTP](https://www.useplunk.com/features/smtp.md): Send through Plunk over standard SMTP
|
||||||
|
- [Inbound email](https://www.useplunk.com/features/inbound-email.md): Receive email at your verified domain and turn it into events
|
||||||
|
|
||||||
|
## Comparisons
|
||||||
|
|
||||||
|
- [Plunk vs Resend](https://www.useplunk.com/vs/resend)
|
||||||
|
- [Plunk vs SendGrid](https://www.useplunk.com/vs/sendgrid)
|
||||||
|
- [Plunk vs Mailchimp](https://www.useplunk.com/vs/mailchimp)
|
||||||
|
- [Plunk vs Mailgun](https://www.useplunk.com/vs/mailgun)
|
||||||
|
- [Plunk vs Postmark](https://www.useplunk.com/vs/postmark)
|
||||||
|
- [Plunk vs Customer.io](https://www.useplunk.com/vs/customerio)
|
||||||
|
- [Plunk vs Loops](https://www.useplunk.com/vs/loops)
|
||||||
|
- [Plunk vs Brevo](https://www.useplunk.com/vs/brevo)
|
||||||
|
- [Plunk vs ActiveCampaign](https://www.useplunk.com/vs/activecampaign)
|
||||||
|
- [Plunk vs Klaviyo](https://www.useplunk.com/vs/klaviyo)
|
||||||
|
- [Plunk vs ConvertKit](https://www.useplunk.com/vs/convertkit)
|
||||||
|
- [Plunk vs Bento](https://www.useplunk.com/vs/bento)
|
||||||
|
- [Plunk vs MailerLite](https://www.useplunk.com/vs/mailerlite)
|
||||||
|
- [Plunk vs Amazon SES](https://www.useplunk.com/vs/amazon-ses)
|
||||||
|
- [Plunk vs Mailjet](https://www.useplunk.com/vs/mailjet)
|
||||||
|
- [Plunk vs Buttondown](https://www.useplunk.com/vs/buttondown)
|
||||||
|
- [All comparisons](https://www.useplunk.com/vs)
|
||||||
|
|
||||||
|
## Guides
|
||||||
|
|
||||||
|
- [Email API guide](https://www.useplunk.com/guides/email-api-guide): Choosing and integrating with an email API
|
||||||
|
- [Email deliverability](https://www.useplunk.com/guides/email-deliverability): Reaching the inbox reliably
|
||||||
|
- [Email bounce rate](https://www.useplunk.com/guides/email-bounce-rate): What it is and how to reduce it
|
||||||
|
- [Email open rate](https://www.useplunk.com/guides/email-open-rate): Benchmarks and improvement tactics
|
||||||
|
- [Email click-through rate](https://www.useplunk.com/guides/email-click-through-rate): Measuring and improving CTR
|
||||||
|
- [Email sender reputation](https://www.useplunk.com/guides/email-sender-reputation): Protecting your sending reputation
|
||||||
|
- [Email marketing best practices](https://www.useplunk.com/guides/email-marketing-best-practices)
|
||||||
|
- [Transactional vs marketing email](https://www.useplunk.com/guides/transactional-vs-marketing-email)
|
||||||
|
- [What is SPF](https://www.useplunk.com/guides/what-is-spf)
|
||||||
|
- [What is DKIM](https://www.useplunk.com/guides/what-is-dkim)
|
||||||
|
- [What is DMARC](https://www.useplunk.com/guides/what-is-dmarc)
|
||||||
|
- [All guides](https://www.useplunk.com/guides)
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- [Verify email](https://www.useplunk.com/tools/verify-email): Check a single email address for validity
|
||||||
|
- [Spam checker](https://www.useplunk.com/tools/spam-checker): Score an email for spam triggers
|
||||||
|
- [Markdown to email](https://www.useplunk.com/tools/markdown-to-email): Convert Markdown into HTML email
|
||||||
|
- [SPF checker](https://www.useplunk.com/tools/spf-checker)
|
||||||
|
- [DKIM checker](https://www.useplunk.com/tools/dkim-checker)
|
||||||
|
- [DMARC checker](https://www.useplunk.com/tools/dmarc-checker)
|
||||||
|
- [MX checker](https://www.useplunk.com/tools/mx-checker)
|
||||||
|
- [Email headers analyzer](https://www.useplunk.com/tools/email-headers)
|
||||||
|
- [All tools](https://www.useplunk.com/tools)
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [Documentation](https://docs.useplunk.com): Product, API, and self-hosting docs
|
||||||
|
- [Discord community](https://www.useplunk.com/discord)
|
||||||
|
- [Privacy policy](https://www.useplunk.com/privacy)
|
||||||
|
- [Terms of service](https://www.useplunk.com/terms)
|
||||||
|
- [Data processing agreement](https://www.useplunk.com/dpa)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import {useRouter} from 'next/router';
|
||||||
import {WIKI_URI} from '../../lib/constants';
|
import {WIKI_URI} from '../../lib/constants';
|
||||||
import logo from '../../../public/assets/logo.svg';
|
import logo from '../../../public/assets/logo.svg';
|
||||||
|
|
||||||
@@ -7,6 +8,10 @@ import logo from '../../../public/assets/logo.svg';
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const router = useRouter();
|
||||||
|
const path = (router.asPath || '/').split(/[?#]/)[0] ?? '/';
|
||||||
|
const trimmed = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||||
|
const mdHref = `${trimmed}.md`;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<footer className={'border-t border-neutral-200 bg-white'}>
|
<footer className={'border-t border-neutral-200 bg-white'}>
|
||||||
@@ -263,8 +268,15 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 border-t border-neutral-200 pt-8">
|
<div className="mt-16 flex flex-col gap-2 border-t border-neutral-200 pt-8 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm text-neutral-500">© {new Date().getFullYear()} Plunk. All rights reserved.</p>
|
<p className="text-sm text-neutral-500">© {new Date().getFullYear()} Plunk. All rights reserved.</p>
|
||||||
|
<p style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] text-neutral-400">
|
||||||
|
Reading this with electronic eyes? Append{' '}
|
||||||
|
<a href={mdHref} className="text-neutral-500 underline decoration-dotted underline-offset-2 transition hover:text-neutral-900">
|
||||||
|
<code>.md</code>
|
||||||
|
</a>{' '}
|
||||||
|
to any URL for the Markdown cut.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {ArrowRight, Gift, X} from 'lucide-react';
|
||||||
|
import {DASHBOARD_URI} from '../lib/constants';
|
||||||
|
|
||||||
|
interface SwitchOfferProps {
|
||||||
|
competitorName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchOffer({competitorName}: SwitchOfferProps) {
|
||||||
|
const storageKey = `switch-offer-dismissed:${competitorName}`;
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (window.localStorage.getItem(storageKey) === '1') return;
|
||||||
|
const t = window.setTimeout(() => setVisible(true), 800);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setVisible(false);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(storageKey, '1');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 24, scale: 0.96}}
|
||||||
|
animate={{opacity: 1, y: 0, scale: 1}}
|
||||||
|
exit={{opacity: 0, y: 16, scale: 0.97}}
|
||||||
|
transition={{duration: 0.45, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={
|
||||||
|
'pointer-events-auto fixed bottom-4 right-4 z-50 w-[calc(100vw-2rem)] max-w-sm sm:bottom-6 sm:right-6'
|
||||||
|
}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`Switch from ${competitorName} to Plunk offer`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'relative overflow-hidden rounded-2xl border border-neutral-900 bg-white shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label="Dismiss offer"
|
||||||
|
className={
|
||||||
|
'absolute right-2.5 top-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={'p-5 pr-10 sm:p-6 sm:pr-12'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-50 px-2 py-1'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Gift className="h-3 w-3 text-neutral-700" />
|
||||||
|
<span
|
||||||
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
|
className={'text-[10px] uppercase tracking-[0.18em] text-neutral-600'}
|
||||||
|
>
|
||||||
|
Switching offer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
|
className={'mt-3 text-lg font-bold leading-[1.15] tracking-[-0.02em] text-neutral-900'}
|
||||||
|
>
|
||||||
|
Switching from {competitorName}?
|
||||||
|
<br />
|
||||||
|
<span className={'text-neutral-500'}>Get 2,000 free emails.</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className={'mt-3 text-xs leading-relaxed text-neutral-600'}>
|
||||||
|
Sign up and enter this code at checkout to redeem 2,000 email credits.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={'mt-3 rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-3 py-2.5'}>
|
||||||
|
<div className={'mt-1 flex items-baseline justify-between gap-2'}>
|
||||||
|
<span
|
||||||
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
|
className={'text-base font-bold tracking-[0.08em] text-neutral-900'}
|
||||||
|
>
|
||||||
|
SWITCH
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.a
|
||||||
|
whileHover={{scale: 1.015}}
|
||||||
|
whileTap={{scale: 0.985}}
|
||||||
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
|
className={
|
||||||
|
'group mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-neutral-900 px-4 py-2.5 text-xs font-semibold text-white transition hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Claim your credits
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</motion.a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export * from './ComparisonTable';
|
|||||||
export * from './FAQSection';
|
export * from './FAQSection';
|
||||||
export * from './CodeBlock';
|
export * from './CodeBlock';
|
||||||
export * from './SectionHeader';
|
export * from './SectionHeader';
|
||||||
|
export * from './SwitchOffer';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const MARKDOWN_SLUGS: ReadonlySet<string> = new Set([
|
||||||
|
'index',
|
||||||
|
'pricing',
|
||||||
|
'features/workflows',
|
||||||
|
'features/segments',
|
||||||
|
'features/inbound-email',
|
||||||
|
'features/email-editor',
|
||||||
|
'features/smtp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function hasMarkdownVariant(pathname: string): boolean {
|
||||||
|
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
|
||||||
|
return MARKDOWN_SLUGS.has(slug);
|
||||||
|
}
|
||||||
@@ -341,3 +341,5 @@ Plunk provides SMTP credentials so you can send emails from any application or f
|
|||||||
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
|
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {MARKDOWN_SLUGS, hasMarkdownVariant} from './markdown-slugs';
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { hasMarkdownVariant } from './content/markdown-slugs';
|
||||||
|
|
||||||
type Negotiated = 'markdown' | 'html' | 'none';
|
type Negotiated = 'markdown' | 'html' | 'none';
|
||||||
|
|
||||||
function parseAccept(accept: string): Array<{ type: string; q: number }> {
|
function parseAccept(accept: string): Array<{ type: string; q: number }> {
|
||||||
@@ -59,6 +61,9 @@ export function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
const response = NextResponse.next();
|
const response = NextResponse.next();
|
||||||
response.headers.set('Vary', 'Accept');
|
response.headers.set('Vary', 'Accept');
|
||||||
|
if (hasMarkdownVariant(pathname)) {
|
||||||
|
response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`);
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import '../styles/globals.css';
|
|||||||
import React, {useEffect} from 'react';
|
import React, {useEffect} from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import {AppProps} from 'next/app';
|
import {AppProps} from 'next/app';
|
||||||
|
import {useRouter} from 'next/router';
|
||||||
import {toast, Toaster} from 'sonner';
|
import {toast, Toaster} from 'sonner';
|
||||||
import {SWRConfig} from 'swr';
|
import {SWRConfig} from 'swr';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
|
import {hasMarkdownVariant} from '../content/markdown-slugs';
|
||||||
import {DefaultSeo} from 'next-seo';
|
import {DefaultSeo} from 'next-seo';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||||
@@ -37,6 +39,10 @@ const mono = JetBrains_Mono({
|
|||||||
* @param props.pageProps
|
* @param props.pageProps
|
||||||
*/
|
*/
|
||||||
function App({Component, pageProps}: AppProps) {
|
function App({Component, pageProps}: AppProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = (router.asPath.split('?')[0] ?? '').split('#')[0] ?? '/';
|
||||||
|
const markdownHref = hasMarkdownVariant(pathname) ? `${pathname === '/' ? '/index' : pathname}.md` : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const message = searchParams.get('message');
|
const message = searchParams.get('message');
|
||||||
@@ -51,6 +57,7 @@ function App({Component, pageProps}: AppProps) {
|
|||||||
<Head>
|
<Head>
|
||||||
<title>Plunk | The Open-Source Email Platform</title>
|
<title>Plunk | The Open-Source Email Platform</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
|
||||||
|
{markdownHref && <link rel="alternate" type="text/markdown" href={markdownHref} />}
|
||||||
</Head>
|
</Head>
|
||||||
<Toaster position={'top-right'} />
|
<Toaster position={'top-right'} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -170,6 +170,8 @@ export default function ActiveCampaignComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="ActiveCampaign" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -186,6 +186,8 @@ export default function AmazonSesComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-amazon-ses" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-amazon-ses" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Amazon SES" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -169,6 +169,8 @@ export default function BentoComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Bento" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -193,6 +193,8 @@ export default function BrevoComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Brevo" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -186,6 +186,8 @@ export default function ButtondownComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-buttondown" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-buttondown" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Buttondown" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -193,6 +193,8 @@ export default function ConvertkitComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="ConvertKit" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -189,6 +189,8 @@ export default function CustomerioComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Customer.io" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -170,6 +170,8 @@ export default function KlaviyoComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Klaviyo" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -193,6 +193,8 @@ export default function LoopsComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Loops" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -195,6 +195,8 @@ export default function MailchimpComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Mailchimp" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -170,6 +170,8 @@ export default function MailerliteComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="MailerLite" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -189,6 +189,8 @@ export default function MailgunComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Mailgun" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -186,6 +186,8 @@ export default function MailjetComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailjet" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailjet" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Mailjet" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -190,6 +190,8 @@ export default function PostmarkComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Postmark" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -189,6 +189,8 @@ export default function ResendComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="Resend" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -193,6 +193,8 @@ export default function SendGridComparison() {
|
|||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
||||||
|
|
||||||
|
<SwitchOffer competitorName="SendGrid" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'event.triggered':
|
case 'event.triggered':
|
||||||
return {
|
return {
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
color: 'text-neutral-600',
|
color: 'text-amber-700',
|
||||||
bgColor: 'bg-neutral-100',
|
bgColor: 'bg-amber-50',
|
||||||
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
|
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
|
||||||
description: undefined,
|
description: undefined,
|
||||||
badge: {
|
badge: {
|
||||||
@@ -150,8 +150,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'email.sent':
|
case 'email.sent':
|
||||||
return {
|
return {
|
||||||
icon: Send,
|
icon: Send,
|
||||||
color: 'text-green-700',
|
color: 'text-neutral-700',
|
||||||
bgColor: 'bg-green-50',
|
bgColor: 'bg-neutral-100',
|
||||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
|
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
|
||||||
description: metadata.campaignName
|
description: metadata.campaignName
|
||||||
? `Campaign: ${String(metadata.campaignName)}`
|
? `Campaign: ${String(metadata.campaignName)}`
|
||||||
@@ -169,8 +169,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'email.delivered':
|
case 'email.delivered':
|
||||||
return {
|
return {
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
color: 'text-green-700',
|
color: 'text-emerald-700',
|
||||||
bgColor: 'bg-green-50',
|
bgColor: 'bg-emerald-50',
|
||||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
|
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
|
||||||
description: metadata.campaignName
|
description: metadata.campaignName
|
||||||
? `Campaign: ${String(metadata.campaignName)}`
|
? `Campaign: ${String(metadata.campaignName)}`
|
||||||
@@ -199,8 +199,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'email.opened':
|
case 'email.opened':
|
||||||
return {
|
return {
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
color: 'text-neutral-600',
|
color: 'text-emerald-700',
|
||||||
bgColor: 'bg-neutral-100',
|
bgColor: 'bg-emerald-50',
|
||||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
|
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
|
||||||
description:
|
description:
|
||||||
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
|
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
|
||||||
@@ -219,8 +219,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'email.clicked':
|
case 'email.clicked':
|
||||||
return {
|
return {
|
||||||
icon: MousePointerClick,
|
icon: MousePointerClick,
|
||||||
color: 'text-neutral-600',
|
color: 'text-sky-700',
|
||||||
bgColor: 'bg-neutral-100',
|
bgColor: 'bg-sky-50',
|
||||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
|
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
|
||||||
description:
|
description:
|
||||||
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
|
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
|
||||||
@@ -269,8 +269,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'workflow.started':
|
case 'workflow.started':
|
||||||
return {
|
return {
|
||||||
icon: Workflow,
|
icon: Workflow,
|
||||||
color: 'text-neutral-600',
|
color: 'text-amber-700',
|
||||||
bgColor: 'bg-neutral-100',
|
bgColor: 'bg-amber-50',
|
||||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
|
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
|
||||||
description: `Status: ${String(metadata.status || 'unknown')}`,
|
description: `Status: ${String(metadata.status || 'unknown')}`,
|
||||||
badge: {
|
badge: {
|
||||||
@@ -282,8 +282,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'workflow.completed':
|
case 'workflow.completed':
|
||||||
return {
|
return {
|
||||||
icon: CheckCheck,
|
icon: CheckCheck,
|
||||||
color: 'text-green-700',
|
color: 'text-amber-700',
|
||||||
bgColor: 'bg-green-50',
|
bgColor: 'bg-amber-50',
|
||||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
|
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
|
||||||
description: metadata.exitReason
|
description: metadata.exitReason
|
||||||
? `Exit: ${String(metadata.exitReason)}`
|
? `Exit: ${String(metadata.exitReason)}`
|
||||||
@@ -297,8 +297,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'campaign.scheduled':
|
case 'campaign.scheduled':
|
||||||
return {
|
return {
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
color: 'text-neutral-600',
|
color: 'text-sky-700',
|
||||||
bgColor: 'bg-neutral-100',
|
bgColor: 'bg-sky-50',
|
||||||
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
|
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
|
||||||
description: metadata.subject
|
description: metadata.subject
|
||||||
? `${String(metadata.subject)}${metadata.totalRecipients ? ` • ${metadata.totalRecipients} recipients` : ''}`
|
? `${String(metadata.subject)}${metadata.totalRecipients ? ` • ${metadata.totalRecipients} recipients` : ''}`
|
||||||
@@ -314,8 +314,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
case 'workflow.email.scheduled':
|
case 'workflow.email.scheduled':
|
||||||
return {
|
return {
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
color: 'text-neutral-600',
|
color: 'text-amber-700',
|
||||||
bgColor: 'bg-neutral-100',
|
bgColor: 'bg-amber-50',
|
||||||
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
|
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
|
||||||
description: metadata.workflowName
|
description: metadata.workflowName
|
||||||
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? ` • ${String(metadata.subject)}` : ''}`
|
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? ` • ${String(metadata.subject)}` : ''}`
|
||||||
|
|||||||
@@ -335,6 +335,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{domains.map(domain => {
|
{domains.map(domain => {
|
||||||
const status = getDomainStatus(domain);
|
const status = getDomainStatus(domain);
|
||||||
|
const mailFromSubdomain = config?.aws?.mailFromSubdomain ?? 'plunk';
|
||||||
|
const mailFromHost = `${mailFromSubdomain}.${domain.domain}`;
|
||||||
return (
|
return (
|
||||||
<div key={domain.id} className="border border-neutral-200 rounded-lg p-4">
|
<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 justify-between mb-3">
|
||||||
@@ -494,8 +496,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-600 mb-2">
|
<p className="text-xs text-neutral-600 mb-2">
|
||||||
Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and
|
Set up a custom MAIL FROM domain ({mailFromHost}) to improve deliverability and handle
|
||||||
handle bounces/complaints.
|
bounces/complaints.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -522,16 +524,16 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
<td className="py-3 px-3">
|
<td className="py-3 px-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
plunk.{domain.domain}
|
{mailFromHost}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
onClick={() => handleCopyToken(mailFromHost, 3000)}
|
||||||
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<AnimatedCopyIcon
|
<AnimatedCopyIcon
|
||||||
isCopied={copiedToken === `plunk.${domain.domain}-3000`}
|
isCopied={copiedToken === `${mailFromHost}-3000`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,16 +573,16 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
<td className="py-3 px-3">
|
<td className="py-3 px-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
plunk.{domain.domain}
|
{mailFromHost}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
onClick={() => handleCopyToken(mailFromHost, 3001)}
|
||||||
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<AnimatedCopyIcon
|
<AnimatedCopyIcon
|
||||||
isCopied={copiedToken === `plunk.${domain.domain}-3001`}
|
isCopied={copiedToken === `${mailFromHost}-3001`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -306,21 +306,31 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
|||||||
iframeDoc.write(fullHtml);
|
iframeDoc.write(fullHtml);
|
||||||
iframeDoc.close();
|
iframeDoc.close();
|
||||||
|
|
||||||
// Auto-adjust iframe height to content
|
// Auto-adjust iframe height to content. Reset to a small value first so
|
||||||
|
// body content that uses % / vh heights doesn't lock the iframe to its
|
||||||
|
// previous size (which would otherwise cause the iframe to grow by the
|
||||||
|
// padding offset on every preview-device switch).
|
||||||
const adjustHeight = () => {
|
const adjustHeight = () => {
|
||||||
if (iframe.contentWindow) {
|
if (!iframe.contentWindow) return;
|
||||||
const height = iframe.contentWindow.document.body.scrollHeight;
|
iframe.style.height = '0px';
|
||||||
iframe.style.height = `${Math.max(400, height + 40)}px`;
|
const doc = iframe.contentWindow.document;
|
||||||
}
|
const height = Math.max(
|
||||||
|
doc.body?.scrollHeight ?? 0,
|
||||||
|
doc.documentElement?.scrollHeight ?? 0,
|
||||||
|
);
|
||||||
|
iframe.style.height = `${Math.max(400, height + 40)}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Adjust height after content loads
|
const timeouts = [
|
||||||
if (iframe.contentWindow) {
|
window.setTimeout(adjustHeight, 100),
|
||||||
iframe.contentWindow.addEventListener('load', adjustHeight);
|
window.setTimeout(adjustHeight, 300),
|
||||||
// Also adjust immediately for already-loaded content
|
];
|
||||||
setTimeout(adjustHeight, 100);
|
iframe.contentWindow?.addEventListener('load', adjustHeight);
|
||||||
setTimeout(adjustHeight, 300); // Fallback for slow-loading images
|
|
||||||
}
|
return () => {
|
||||||
|
timeouts.forEach(window.clearTimeout);
|
||||||
|
iframe.contentWindow?.removeEventListener('load', adjustHeight);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function HelpResources() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
<div className="px-6 pb-6 pt-4 border-t border-neutral-200">
|
||||||
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||||
@@ -101,12 +101,12 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
|||||||
|
|
||||||
if (isLoading || !setupState) {
|
if (isLoading || !setupState) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="flex flex-col h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Quick Start</CardTitle>
|
<CardTitle>Quick Start</CardTitle>
|
||||||
<CardDescription>Get started with Plunk in minutes</CardDescription>
|
<CardDescription>Get started with Plunk in minutes</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3].map(i => (
|
{[1, 2, 3].map(i => (
|
||||||
<div
|
<div
|
||||||
@@ -122,8 +122,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<HelpResources />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<HelpResources />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -217,12 +217,12 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
|||||||
// If core setup is complete and they're actively sending, show success message
|
// If core setup is complete and they're actively sending, show success message
|
||||||
if (setupState.hasVerifiedDomain && hasContacts && hasSentCampaign && hasRecentCampaign) {
|
if (setupState.hasVerifiedDomain && hasContacts && hasSentCampaign && hasRecentCampaign) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="flex flex-col h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Quick Start</CardTitle>
|
<CardTitle>Quick Start</CardTitle>
|
||||||
<CardDescription>Your project is fully set up</CardDescription>
|
<CardDescription>Your project is fully set up</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<div className="flex items-start gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
|
<div className="flex items-start gap-4 p-4 bg-green-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">
|
<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" />
|
<CheckCircle2 className="h-5 w-5 text-green-700" />
|
||||||
@@ -234,8 +234,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HelpResources />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<HelpResources />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -244,14 +244,14 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
|||||||
const visibleSteps = allSteps.slice(0, 3);
|
const visibleSteps = allSteps.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="flex flex-col h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Quick Start</CardTitle>
|
<CardTitle>Quick Start</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'}
|
{visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visibleSteps.map(step => {
|
{visibleSteps.map(step => {
|
||||||
const Icon = step.icon;
|
const Icon = step.icon;
|
||||||
@@ -281,8 +281,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<HelpResources />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<HelpResources />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css';
|
|||||||
import type {WorkflowStep} from '@plunk/db';
|
import type {WorkflowStep} from '@plunk/db';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
@@ -243,7 +244,7 @@ function CustomNode({
|
|||||||
bgColor?: string;
|
bgColor?: string;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
template?: {name: string};
|
template?: {id: string; name: string};
|
||||||
config?: any;
|
config?: any;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
@@ -341,10 +342,19 @@ function CustomNode({
|
|||||||
{/* Details */}
|
{/* Details */}
|
||||||
{data.template && (
|
{data.template && (
|
||||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
<a
|
||||||
<Mail className="h-3 w-3" />
|
href={`/templates/${data.template.id}`}
|
||||||
<span className="truncate">{data.template.name}</span>
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
className="nodrag flex items-center gap-2 text-xs text-neutral-600 hover:text-blue-600 hover:bg-blue-50 -mx-2 px-2 py-1 rounded transition-colors group/template"
|
||||||
|
title="Open template in a new tab"
|
||||||
|
>
|
||||||
|
<Mail className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate flex-1">{data.template.name}</span>
|
||||||
|
<ExternalLink className="h-3 w-3 shrink-0 opacity-0 group-hover/template:opacity-100 transition-opacity" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.type === 'DELAY' && data.config?.amount && (
|
{data.type === 'DELAY' && data.config?.amount && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui';
|
import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui';
|
||||||
|
import {ExternalLink} from 'lucide-react';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
@@ -68,7 +69,20 @@ export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuc
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="editTemplate">Email Template</Label>
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="editTemplate">Email Template</Label>
|
||||||
|
{templateId && (
|
||||||
|
<a
|
||||||
|
href={`/templates/${templateId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
Edit template
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
|
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {Label, RadioGroup, RadioGroupItem} from '@plunk/ui';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
@@ -5,31 +6,50 @@ import {KeyValueEditor} from '../KeyValueEditor';
|
|||||||
|
|
||||||
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
|
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
|
||||||
|
|
||||||
|
type SubscriptionAction = 'none' | 'subscribe' | 'unsubscribe';
|
||||||
|
|
||||||
|
const SUBSCRIPTION_OPTIONS: Array<{value: SubscriptionAction; label: string; description: string}> = [
|
||||||
|
{value: 'none', label: 'Leave as is', description: "Don't change the contact's subscription state."},
|
||||||
|
{value: 'subscribe', label: 'Subscribe', description: 'Mark the contact as subscribed.'},
|
||||||
|
{value: 'unsubscribe', label: 'Unsubscribe', description: 'Mark the contact as unsubscribed.'},
|
||||||
|
];
|
||||||
|
|
||||||
export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
|
export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
|
||||||
const config = getStepConfig(step);
|
const config = getStepConfig(step);
|
||||||
const initialUpdates =
|
const initialUpdates =
|
||||||
config.updates && typeof config.updates === 'object'
|
config.updates && typeof config.updates === 'object'
|
||||||
? (config.updates as Record<string, string | number | boolean>)
|
? (config.updates as Record<string, string | number | boolean>)
|
||||||
: null;
|
: null;
|
||||||
|
const initialSubscriptionAction: SubscriptionAction =
|
||||||
|
config.subscriptionAction === 'subscribe' || config.subscriptionAction === 'unsubscribe'
|
||||||
|
? config.subscriptionAction
|
||||||
|
: 'none';
|
||||||
|
|
||||||
const [name, setName] = useState(step.name);
|
const [name, setName] = useState(step.name);
|
||||||
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>(
|
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>(
|
||||||
initialUpdates,
|
initialUpdates,
|
||||||
);
|
);
|
||||||
|
const [subscriptionAction, setSubscriptionAction] = useState<SubscriptionAction>(initialSubscriptionAction);
|
||||||
|
|
||||||
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
|
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) {
|
const hasUpdates = contactUpdateData && Object.keys(contactUpdateData).length > 0;
|
||||||
toast.error('At least one field to update is required');
|
const hasSubscriptionAction = subscriptionAction !== 'none';
|
||||||
|
|
||||||
|
if (!hasUpdates && !hasSubscriptionAction) {
|
||||||
|
toast.error('Add at least one field to update or choose a subscription action');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await update({
|
const ok = await update({
|
||||||
name,
|
name,
|
||||||
config: {updates: contactUpdateData},
|
config: {
|
||||||
|
updates: hasUpdates ? contactUpdateData : {},
|
||||||
|
subscriptionAction,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -48,7 +68,32 @@ export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, o
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
>
|
>
|
||||||
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
|
<div className="space-y-2">
|
||||||
|
<Label>Subscription state</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={subscriptionAction}
|
||||||
|
onValueChange={value => setSubscriptionAction(value as SubscriptionAction)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{SUBSCRIPTION_OPTIONS.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
htmlFor={`subscriptionAction-${option.value}`}
|
||||||
|
className="flex items-start gap-3 rounded-md border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={`subscriptionAction-${option.value}`} value={option.value} className="mt-0.5" />
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">{option.label}</div>
|
||||||
|
<div className="text-xs text-neutral-500">{option.description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
|
||||||
|
</div>
|
||||||
</StepDialogShell>
|
</StepDialogShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface ConfigResponse {
|
|||||||
};
|
};
|
||||||
aws: {
|
aws: {
|
||||||
sesRegion: string;
|
sesRegion: string;
|
||||||
|
mailFromSubdomain: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Info,
|
||||||
Mail,
|
Mail,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
Save,
|
Save,
|
||||||
@@ -107,6 +108,7 @@ export default function CampaignDetailsPage() {
|
|||||||
|
|
||||||
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
|
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
|
||||||
const [scheduledDateTime, setScheduledDateTime] = useState('');
|
const [scheduledDateTime, setScheduledDateTime] = useState('');
|
||||||
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||||
const [testEmailAddress, setTestEmailAddress] = useState('');
|
const [testEmailAddress, setTestEmailAddress] = useState('');
|
||||||
|
|
||||||
type CampaignDialog =
|
type CampaignDialog =
|
||||||
@@ -178,6 +180,7 @@ export default function CampaignDetailsPage() {
|
|||||||
toast.success(`Campaign scheduled for ${localTimeString}`);
|
toast.success(`Campaign scheduled for ${localTimeString}`);
|
||||||
setDialog({type: 'none'});
|
setDialog({type: 'none'});
|
||||||
setScheduledDateTime('');
|
setScheduledDateTime('');
|
||||||
|
setSelectedPreset(null);
|
||||||
void mutate();
|
void mutate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
|
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
|
||||||
@@ -436,126 +439,35 @@ export default function CampaignDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campaign Settings - Horizontal Layout */}
|
{/* Audience — surfaced first because Send lives in the header.
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
Users need to see who/how many before pressing Send. */}
|
||||||
{/* Campaign Settings */}
|
<Card>
|
||||||
<Card>
|
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||||
<CardHeader>
|
<div>
|
||||||
<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>Campaign Type</Label>
|
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
|
||||||
{([
|
|
||||||
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
|
|
||||||
{value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
|
|
||||||
{value: TemplateType.HEADLESS, label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
|
|
||||||
] as const).map(({value, label, description}) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditedCampaign({...editedCampaign, type: value})}
|
|
||||||
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
|
||||||
(editedCampaign.type ?? c.type) === value
|
|
||||||
? 'border-neutral-900 bg-neutral-50'
|
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
|
|
||||||
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{(editedCampaign.type ?? c.type) === TemplateType.HEADLESS &&
|
|
||||||
!detectUnsubscribeSignal(editedCampaign.body ?? c.body) && (
|
|
||||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
|
||||||
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
|
|
||||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
|
|
||||||
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-2.5 space-y-2">
|
|
||||||
<p className="text-xs text-amber-800 leading-relaxed">
|
|
||||||
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
|
||||||
{'{{unsubscribeUrl}}'}
|
|
||||||
</code>
|
|
||||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
|
||||||
{'{{manageUrl}}'}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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'}
|
|
||||||
layout="vertical"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Audience Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Audience</CardTitle>
|
<CardTitle>Audience</CardTitle>
|
||||||
<CardDescription>Who will receive this campaign</CardDescription>
|
<CardDescription>Who will receive this campaign when you send</CardDescription>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-4">
|
{draftRecipientCount > 0 && (
|
||||||
<div>
|
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 shrink-0">
|
||||||
<Label htmlFor="audienceType">Audience Type *</Label>
|
<Users className="h-4 w-4 text-neutral-500" />
|
||||||
|
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
|
||||||
|
{draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'recipient' : 'recipients'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="audienceType">
|
||||||
|
Audience Type <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editedCampaign.audienceType ?? c.audienceType}
|
value={editedCampaign.audienceType ?? c.audienceType}
|
||||||
onValueChange={(value: CampaignAudienceType) => {
|
onValueChange={(value: CampaignAudienceType) => {
|
||||||
setEditedCampaign({
|
setEditedCampaign({
|
||||||
...editedCampaign,
|
...editedCampaign,
|
||||||
audienceType: value,
|
audienceType: value,
|
||||||
// Clear segmentId if changing away from SEGMENT
|
|
||||||
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
|
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -571,7 +483,7 @@ export default function CampaignDetailsPage() {
|
|||||||
/>
|
/>
|
||||||
<SelectItemWithDescription
|
<SelectItemWithDescription
|
||||||
value={CampaignAudienceType.SEGMENT}
|
value={CampaignAudienceType.SEGMENT}
|
||||||
title="Segment"
|
title="Specific Segment"
|
||||||
description="Target a defined group of contacts"
|
description="Target a defined group of contacts"
|
||||||
/>
|
/>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -579,8 +491,10 @@ export default function CampaignDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
|
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label htmlFor="segment">Select Segment *</Label>
|
<Label htmlFor="segment">
|
||||||
|
Select Segment <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
|
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
|
||||||
onValueChange={(value: string) => {
|
onValueChange={(value: string) => {
|
||||||
@@ -610,44 +524,160 @@ export default function CampaignDetailsPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{segments && segments.length === 0 && (
|
{segments && segments.length === 0 && (
|
||||||
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</p>
|
<p className="text-sm text-neutral-500">
|
||||||
|
No segments found.{' '}
|
||||||
|
<Link href="/segments/new" className="underline">
|
||||||
|
Create one first
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
|
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
|
||||||
<p className="text-sm text-neutral-500">
|
<p className="text-sm text-neutral-500">
|
||||||
Filtered audiences are configured with advanced filter conditions
|
Filtered audiences are configured with advanced filter conditions
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show recipient count */}
|
{draftRecipientCount > 0 && (
|
||||||
{draftRecipientCount > 0 && (
|
<p className="text-xs text-neutral-500">
|
||||||
<div className="mt-4 p-3 bg-neutral-50 border border-neutral-200 rounded-lg space-y-1.5">
|
Recalculated at send time. Final count may differ if contacts{' '}
|
||||||
<div className="flex items-center gap-2">
|
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||||
<Users className="h-4 w-4 text-neutral-400" />
|
? 'are added or removed, or segment membership changes.'
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
: 'subscribe, unsubscribe, or segment membership changes.'
|
||||||
{draftRecipientCount.toLocaleString()} recipients
|
}
|
||||||
</span>
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Row 1: Basic Info + Campaign Type */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Basic Information</CardTitle>
|
||||||
|
<CardDescription>Name and describe your campaign</CardDescription>
|
||||||
|
</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={editedCampaign.name || ''}
|
||||||
|
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
placeholder="Internal notes about this campaign"
|
||||||
|
value={editedCampaign.description || ''}
|
||||||
|
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Campaign Type */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Campaign Type</CardTitle>
|
||||||
|
<CardDescription>Choose how this campaign should be treated</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{([
|
||||||
|
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
|
||||||
|
{value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
|
||||||
|
{value: TemplateType.HEADLESS, label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
|
||||||
|
] as const).map(({value, label, description}) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditedCampaign({...editedCampaign, type: value})}
|
||||||
|
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
||||||
|
(editedCampaign.type ?? c.type) === value
|
||||||
|
? 'border-neutral-900 bg-neutral-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
|
||||||
|
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(editedCampaign.type ?? c.type) === TemplateType.HEADLESS &&
|
||||||
|
!detectUnsubscribeSignal(editedCampaign.body ?? c.body) && (
|
||||||
|
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
|
||||||
|
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
|
||||||
|
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2.5 space-y-2">
|
||||||
|
<p className="text-xs text-amber-800 leading-relaxed">
|
||||||
|
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||||
|
{'{{unsubscribeUrl}}'}
|
||||||
|
</code>
|
||||||
|
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||||
|
{'{{manageUrl}}'}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-500 pl-6">
|
|
||||||
Recalculated at send time. Final count may differ if contacts{' '}
|
|
||||||
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
|
||||||
? 'are added or removed, or segment membership changes.'
|
|
||||||
: 'subscribe, unsubscribe, or segment membership changes.'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Editor - Full Width */}
|
{/* Email Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Email Settings</CardTitle>
|
||||||
|
<CardDescription>Configure sender information and subject</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<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'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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={editedCampaign.subject || ''}
|
||||||
|
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Email Content */}
|
||||||
<Card className="overflow-visible">
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Email Content</CardTitle>
|
<CardTitle>Email Content</CardTitle>
|
||||||
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
<CardDescription>Design your email message</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<EmailEditor
|
<EmailEditor
|
||||||
@@ -662,37 +692,51 @@ export default function CampaignDetailsPage() {
|
|||||||
|
|
||||||
{/* Test Email Dialog */}
|
{/* Test Email Dialog */}
|
||||||
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Send Test Email</DialogTitle>
|
<DialogTitle>Send a preview</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Send a test version of this campaign to a project member to verify how it looks. The test email will
|
Get a copy of this campaign in your inbox before sending it for real.
|
||||||
be prefixed with [TEST] in the subject line.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label htmlFor="testEmail">Project Member</Label>
|
<Label htmlFor="testEmail">Send to</Label>
|
||||||
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
|
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
|
||||||
<SelectTrigger id="testEmail" className="mt-2">
|
<SelectTrigger id="testEmail">
|
||||||
<SelectValue placeholder="Select a project member..." />
|
<SelectValue placeholder="Choose a teammate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{projectMembers?.data.map(member => (
|
{projectMembers?.data.map(member => (
|
||||||
<SelectItem key={member.userId} value={member.email}>
|
<SelectItem key={member.userId} value={member.email}>
|
||||||
{member.email}
|
{member.email}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-neutral-500 mt-2">
|
</div>
|
||||||
For security reasons, test emails can only be sent to project members.
|
|
||||||
</p>
|
{/* Preview of how the email will arrive */}
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<div className="space-y-2">
|
||||||
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
|
<Label className="text-neutral-500">Will arrive as</Label>
|
||||||
</p>
|
<div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200 text-sm">
|
||||||
|
<div className="grid grid-cols-[64px_1fr] gap-3 px-3 py-2.5">
|
||||||
|
<span className="text-neutral-500">From</span>
|
||||||
|
<span className="text-neutral-900 truncate">{editedCampaign.from || c.from}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[64px_1fr] gap-3 px-3 py-2.5">
|
||||||
|
<span className="text-neutral-500">Subject</span>
|
||||||
|
<span className="text-neutral-900 truncate">
|
||||||
|
<span className="font-medium">[TEST]</span> {editedCampaign.subject || c.subject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||||
|
Variables like {'{{firstName}}'} aren{"'"}t replaced in previews. You{"'"}ll see them as written.
|
||||||
|
</p>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -709,7 +753,8 @@ export default function CampaignDetailsPage() {
|
|||||||
onClick={handleSendTestEmail}
|
onClick={handleSendTestEmail}
|
||||||
disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress}
|
disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress}
|
||||||
>
|
>
|
||||||
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send Test Email'}
|
<TestTube className="h-4 w-4" />
|
||||||
|
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send preview'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -717,92 +762,105 @@ export default function CampaignDetailsPage() {
|
|||||||
|
|
||||||
{/* Schedule Dialog */}
|
{/* Schedule Dialog */}
|
||||||
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Schedule Campaign</DialogTitle>
|
<DialogTitle>Schedule for later</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()}
|
Pick a time and Plunk will send it for you. Times shown in {getUserTimezone()}.
|
||||||
)
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
{/* Quick Presets */}
|
<div className="space-y-5 py-2">
|
||||||
<div>
|
{/* Quick presets */}
|
||||||
<Label>Quick Schedule</Label>
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
<Label>Quick options</Label>
|
||||||
<Button
|
<div className="grid grid-cols-2 gap-2">
|
||||||
type="button"
|
{[
|
||||||
variant="outline"
|
{key: 'in1h', label: 'In 1 hour', getValue: schedulePresets.inOneHour},
|
||||||
size="sm"
|
{key: 'in3h', label: 'In 3 hours', getValue: schedulePresets.inThreeHours},
|
||||||
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())}
|
{key: 'tom9', label: 'Tomorrow, 9 AM', getValue: schedulePresets.tomorrowAt9AM},
|
||||||
>
|
{key: 'tom2', label: 'Tomorrow, 2 PM', getValue: schedulePresets.tomorrowAt2PM},
|
||||||
In 1 hour
|
{key: 'nextMon', label: 'Next Monday', getValue: schedulePresets.nextMonday},
|
||||||
</Button>
|
{key: 'in1w', label: 'In 1 week', getValue: schedulePresets.inOneWeek},
|
||||||
<Button
|
].map(({key, label, getValue}) => {
|
||||||
type="button"
|
const isActive = selectedPreset === key;
|
||||||
variant="outline"
|
return (
|
||||||
size="sm"
|
<button
|
||||||
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())}
|
key={key}
|
||||||
>
|
type="button"
|
||||||
In 3 hours
|
onClick={() => {
|
||||||
</Button>
|
setScheduledDateTime(getValue());
|
||||||
<Button
|
setSelectedPreset(key);
|
||||||
type="button"
|
}}
|
||||||
variant="outline"
|
className={`min-h-[40px] px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
|
||||||
size="sm"
|
isActive
|
||||||
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())}
|
? 'border-neutral-900 bg-neutral-50 text-neutral-900 font-medium'
|
||||||
>
|
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:text-neutral-900'
|
||||||
Tomorrow at 9 AM
|
}`}
|
||||||
</Button>
|
>
|
||||||
<Button
|
{label}
|
||||||
type="button"
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Date/Time */}
|
{/* Custom Date/Time */}
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
|
<Label htmlFor="scheduledDateTime">Or pick an exact time</Label>
|
||||||
<Input
|
<Input
|
||||||
id="scheduledDateTime"
|
id="scheduledDateTime"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={scheduledDateTime}
|
value={scheduledDateTime}
|
||||||
onChange={e => setScheduledDateTime(e.target.value)}
|
onChange={e => {
|
||||||
|
setScheduledDateTime(e.target.value);
|
||||||
|
setSelectedPreset(null);
|
||||||
|
}}
|
||||||
min={new Date().toISOString().slice(0, 16)}
|
min={new Date().toISOString().slice(0, 16)}
|
||||||
className="mt-2"
|
|
||||||
/>
|
/>
|
||||||
{scheduledDateTime && (
|
</div>
|
||||||
<div className="mt-2 p-3 bg-neutral-50 border border-neutral-200 rounded-lg">
|
|
||||||
<p className="text-xs font-medium text-neutral-500 mb-1">Scheduled for:</p>
|
{/* Confirmation preview — date + audience together */}
|
||||||
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p>
|
{scheduledDateTime && (
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200">
|
||||||
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-500">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide">Sending on</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-base font-semibold text-neutral-900">
|
||||||
|
{formatFullDateTime(new Date(scheduledDateTime))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{draftRecipientCount > 0 && (
|
||||||
</div>
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-500">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide">To</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-neutral-900">
|
||||||
|
<span className="font-semibold tabular-nums">{draftRecipientCount.toLocaleString()}</span>
|
||||||
|
<span className="text-neutral-600">
|
||||||
|
{draftRecipientCount === 1 ? ' recipient in ' : ' recipients in '}
|
||||||
|
</span>
|
||||||
|
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.ALL &&
|
||||||
|
((editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||||
|
? 'all contacts'
|
||||||
|
: 'all subscribed contacts')}
|
||||||
|
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT &&
|
||||||
|
(segments?.find(s => s.id === (editedCampaign.segmentId ?? c.segmentId))?.name ?? 'the selected segment')}
|
||||||
|
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.FILTERED && 'filtered contacts'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||||
|
You can edit or cancel this campaign anytime before it sends.
|
||||||
|
</p>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -810,12 +868,14 @@ export default function CampaignDetailsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDialog({type: 'none'});
|
setDialog({type: 'none'});
|
||||||
setScheduledDateTime('');
|
setScheduledDateTime('');
|
||||||
|
setSelectedPreset(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Not yet
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={handleSchedule}>
|
<Button type="button" onClick={handleSchedule} disabled={!scheduledDateTime}>
|
||||||
Schedule Campaign
|
<Calendar className="h-4 w-4" />
|
||||||
|
Schedule send
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -825,15 +885,66 @@ export default function CampaignDetailsPage() {
|
|||||||
{/* Sticky Save Bar */}
|
{/* Sticky Save Bar */}
|
||||||
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
|
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<Dialog open={dialog.type === 'send'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||||
open={dialog.type === 'send'}
|
<DialogContent className="sm:max-w-md">
|
||||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
<DialogHeader>
|
||||||
onConfirm={handleSend}
|
<DialogTitle>Ready to send?</DialogTitle>
|
||||||
title="Send Campaign"
|
<DialogDescription>Review the details below, then send when you{"'"}re ready.</DialogDescription>
|
||||||
description="Are you sure you want to send this campaign now? This action cannot be undone."
|
</DialogHeader>
|
||||||
confirmText="Send Now"
|
|
||||||
variant="default"
|
<div className="space-y-4 py-2">
|
||||||
/>
|
{/* Hero: recipient count */}
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-neutral-50 px-5 py-6 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-neutral-500">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide">Recipients</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-4xl font-bold text-neutral-900 tabular-nums">
|
||||||
|
{draftRecipientCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-neutral-500">
|
||||||
|
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.ALL &&
|
||||||
|
((editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||||
|
? 'All contacts'
|
||||||
|
: 'All subscribed contacts')}
|
||||||
|
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT &&
|
||||||
|
(segments?.find(s => s.id === (editedCampaign.segmentId ?? c.segmentId))?.name ?? 'Selected segment')}
|
||||||
|
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.FILTERED && 'Filtered contacts'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact summary */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 divide-y divide-neutral-200 text-sm">
|
||||||
|
<div className="grid grid-cols-[80px_1fr] gap-3 px-3 py-2.5">
|
||||||
|
<span className="text-neutral-500">From</span>
|
||||||
|
<span className="text-neutral-900 truncate">{editedCampaign.from || c.from}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[80px_1fr] gap-3 px-3 py-2.5">
|
||||||
|
<span className="text-neutral-500">Subject</span>
|
||||||
|
<span className="text-neutral-900 truncate">{editedCampaign.subject || c.subject}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reassurance */}
|
||||||
|
<div className="flex items-start gap-2 rounded-lg bg-neutral-50 px-3 py-2.5">
|
||||||
|
<Info className="h-4 w-4 text-neutral-500 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-neutral-600 leading-relaxed">
|
||||||
|
Sending takes a few minutes. You can cancel the campaign at any time while it{"'"}s still sending.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialog({type: 'none'})}>
|
||||||
|
Not yet
|
||||||
|
</Button>
|
||||||
|
<Button onClick={async () => { await handleSend(); setDialog({type: 'none'}); }}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Send to {draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'contact' : 'contacts'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={dialog.type === 'delete'}
|
open={dialog.type === 'delete'}
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
Select,
|
Input,
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import type {Campaign, Template} from '@plunk/db';
|
import type {Campaign, Template} from '@plunk/db';
|
||||||
import {CampaignStatus} from '@plunk/db';
|
import {CampaignStatus} from '@plunk/db';
|
||||||
@@ -23,11 +19,11 @@ import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog'
|
|||||||
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
|
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react';
|
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Search, Trash2, X} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -35,7 +31,9 @@ import dayjs from 'dayjs';
|
|||||||
export default function CampaignsPage() {
|
export default function CampaignsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'ALL' | 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'CANCELLED'>('ALL');
|
||||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||||
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
@@ -44,10 +42,18 @@ export default function CampaignsPage() {
|
|||||||
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
|
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
|
||||||
|
|
||||||
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
|
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
|
||||||
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
`/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
||||||
{revalidateOnFocus: false},
|
{revalidateOnFocus: false},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSearch(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
const getStatusBadge = (status: CampaignStatus) => {
|
const getStatusBadge = (status: CampaignStatus) => {
|
||||||
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
|
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
|
||||||
DRAFT: {label: 'Draft', variant: 'neutral'},
|
DRAFT: {label: 'Draft', variant: 'neutral'},
|
||||||
@@ -245,21 +251,45 @@ export default function CampaignsPage() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Search & Filters */}
|
||||||
<div className="w-56">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<div className="relative flex-1">
|
||||||
<SelectTrigger>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
<SelectValue placeholder="All Statuses" />
|
<Input
|
||||||
</SelectTrigger>
|
type="text"
|
||||||
<SelectContent>
|
placeholder="Search campaigns..."
|
||||||
<SelectItem value="ALL">All Statuses</SelectItem>
|
value={searchInput}
|
||||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
|
className="pl-10 pr-10"
|
||||||
<SelectItem value="SENDING">Sending</SelectItem>
|
/>
|
||||||
<SelectItem value="SENT">Sent</SelectItem>
|
{searchInput && (
|
||||||
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
<button
|
||||||
</SelectContent>
|
type="button"
|
||||||
</Select>
|
aria-label="Clear search"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchInput('');
|
||||||
|
setSearch('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 shrink-0 flex-wrap">
|
||||||
|
{(['ALL', 'DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED'] as const).map(status => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStatusFilter(status); setPage(1); }}
|
||||||
|
variant={statusFilter === status ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{status === 'ALL' ? 'All' : status.charAt(0) + status.slice(1).toLowerCase()}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campaigns List */}
|
{/* Campaigns List */}
|
||||||
@@ -275,66 +305,59 @@ export default function CampaignsPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Mail}
|
icon={Mail}
|
||||||
title={statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
title={search ? 'No campaigns match' : statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
||||||
description={
|
description={
|
||||||
statusFilter !== 'ALL'
|
search
|
||||||
? 'Adjust your filters or create a new campaign.'
|
? 'Try a different search term.'
|
||||||
: 'Send one-off emails to groups of contacts.'
|
: statusFilter !== 'ALL'
|
||||||
|
? 'Adjust your filters or create a new campaign.'
|
||||||
|
: 'Send one-off emails to groups of contacts.'
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
statusFilter === 'ALL' ? (
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button>
|
||||||
<Button>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Create Campaign
|
|
||||||
<ChevronDown className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="center" className="w-80">
|
|
||||||
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
|
||||||
<Link href="/campaigns/create" className="flex items-start gap-3">
|
|
||||||
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
|
||||||
<div className="flex flex-col gap-0.5 flex-1">
|
|
||||||
<span className="font-medium text-sm">Empty Campaign</span>
|
|
||||||
<span className="text-xs text-neutral-500 leading-snug">
|
|
||||||
Start from scratch with a blank canvas
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
|
||||||
<div className="flex flex-col gap-0.5 flex-1">
|
|
||||||
<span className="font-medium text-sm">From Template</span>
|
|
||||||
<span className="text-xs text-neutral-500 leading-snug">
|
|
||||||
Use an existing template as a starting point
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
|
||||||
<div className="flex flex-col gap-0.5 flex-1">
|
|
||||||
<span className="font-medium text-sm">From Previous Campaign</span>
|
|
||||||
<span className="text-xs text-neutral-500 leading-snug">
|
|
||||||
Copy content and settings from an existing campaign
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/campaigns/create">
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create Campaign
|
Create Campaign
|
||||||
</Link>
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="w-80">
|
||||||
|
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||||
|
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||||
|
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">Empty Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Start from scratch with a blank canvas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Template</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Use an existing template as a starting point
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1">
|
||||||
|
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||||
|
<span className="text-xs text-neutral-500 leading-snug">
|
||||||
|
Copy content and settings from an existing campaign
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -25,14 +22,18 @@ import {KeyValueEditor} from '../../components/KeyValueEditor';
|
|||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Edit,
|
Edit,
|
||||||
FileUp,
|
FileUp,
|
||||||
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
MailCheck,
|
MailCheck,
|
||||||
MailX,
|
MailX,
|
||||||
|
Minus,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -61,6 +62,8 @@ export default function ContactsPage() {
|
|||||||
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
|
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set());
|
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set());
|
||||||
|
const [selectAllMatching, setSelectAllMatching] = useState(false);
|
||||||
|
const [excludedContacts, setExcludedContacts] = useState<Set<string>>(new Set());
|
||||||
const [showBulkActionsDialog, setShowBulkActionsDialog] = useState(false);
|
const [showBulkActionsDialog, setShowBulkActionsDialog] = useState(false);
|
||||||
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
|
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@@ -87,6 +90,9 @@ export default function ContactsPage() {
|
|||||||
setCursorHistory([undefined]);
|
setCursorHistory([undefined]);
|
||||||
setCurrentPage(0);
|
setCurrentPage(0);
|
||||||
setContacts([]);
|
setContacts([]);
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
setSelectAllMatching(false);
|
||||||
|
setExcludedContacts(new Set());
|
||||||
}, 350);
|
}, 350);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchInput, search]);
|
}, [searchInput, search]);
|
||||||
@@ -96,9 +102,12 @@ export default function ContactsPage() {
|
|||||||
const newPage = currentPage + 1;
|
const newPage = currentPage + 1;
|
||||||
setCursor(data.cursor);
|
setCursor(data.cursor);
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
setSelectedContacts(new Set()); // Clear selection on page change
|
// Preserve selection across pages only when "select all matching" is on; otherwise
|
||||||
|
// clear, since per-page id sets stop being meaningful once you've left the page.
|
||||||
|
if (!selectAllMatching) {
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
// Store cursor in history if not already there
|
|
||||||
if (cursorHistory.length <= newPage) {
|
if (cursorHistory.length <= newPage) {
|
||||||
setCursorHistory(prev => [...prev, data.cursor]);
|
setCursorHistory(prev => [...prev, data.cursor]);
|
||||||
}
|
}
|
||||||
@@ -111,12 +120,38 @@ export default function ContactsPage() {
|
|||||||
const previousCursor = cursorHistory[newPage];
|
const previousCursor = cursorHistory[newPage];
|
||||||
setCursor(previousCursor);
|
setCursor(previousCursor);
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
setSelectedContacts(new Set()); // Clear selection on page change
|
if (!selectAllMatching) {
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// True when the current page's checkbox should appear "all selected"
|
||||||
|
const allOnPageSelected = contacts.length > 0 && (
|
||||||
|
selectAllMatching
|
||||||
|
? contacts.every(c => !excludedContacts.has(c.id))
|
||||||
|
: selectedContacts.size === contacts.length && contacts.every(c => selectedContacts.has(c.id))
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
if (selectedContacts.size === contacts.length && contacts.length > 0) {
|
if (selectAllMatching) {
|
||||||
|
// Toggle: exclude or re-include all on this page
|
||||||
|
if (allOnPageSelected) {
|
||||||
|
setExcludedContacts(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
contacts.forEach(c => next.add(c.id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setExcludedContacts(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
contacts.forEach(c => next.delete(c.id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allOnPageSelected) {
|
||||||
setSelectedContacts(new Set());
|
setSelectedContacts(new Set());
|
||||||
} else {
|
} else {
|
||||||
setSelectedContacts(new Set(contacts.map(c => c.id)));
|
setSelectedContacts(new Set(contacts.map(c => c.id)));
|
||||||
@@ -124,15 +159,30 @@ export default function ContactsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectContact = (contactId: string) => {
|
const handleSelectContact = (contactId: string) => {
|
||||||
const newSelected = new Set(selectedContacts);
|
if (selectAllMatching) {
|
||||||
if (newSelected.has(contactId)) {
|
setExcludedContacts(prev => {
|
||||||
newSelected.delete(contactId);
|
const next = new Set(prev);
|
||||||
} else {
|
if (next.has(contactId)) next.delete(contactId);
|
||||||
newSelected.add(contactId);
|
else next.add(contactId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setSelectedContacts(newSelected);
|
setSelectedContacts(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(contactId)) next.delete(contactId);
|
||||||
|
else next.add(contactId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isContactSelected = (contactId: string) =>
|
||||||
|
selectAllMatching ? !excludedContacts.has(contactId) : selectedContacts.has(contactId);
|
||||||
|
|
||||||
|
const effectiveSelectionCount = selectAllMatching
|
||||||
|
? Math.max(0, totalCount - excludedContacts.size)
|
||||||
|
: selectedContacts.size;
|
||||||
|
|
||||||
const handleBulkAction = (operation: 'subscribe' | 'unsubscribe' | 'delete') => {
|
const handleBulkAction = (operation: 'subscribe' | 'unsubscribe' | 'delete') => {
|
||||||
setBulkOperation(operation);
|
setBulkOperation(operation);
|
||||||
setShowBulkActionsDialog(true);
|
setShowBulkActionsDialog(true);
|
||||||
@@ -140,6 +190,14 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
setSelectedContacts(new Set());
|
setSelectedContacts(new Set());
|
||||||
|
setSelectAllMatching(false);
|
||||||
|
setExcludedContacts(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAllMatching = () => {
|
||||||
|
setSelectAllMatching(true);
|
||||||
|
setSelectedContacts(new Set());
|
||||||
|
setExcludedContacts(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptDelete = (contactId: string) => {
|
const promptDelete = (contactId: string) => {
|
||||||
@@ -172,8 +230,7 @@ export default function ContactsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Contacts</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Contacts</h1>
|
||||||
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
|
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
|
||||||
Manage your email subscribers and their data.{' '}
|
Manage your email subscribers and their data.
|
||||||
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -191,45 +248,68 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Contacts Table */}
|
||||||
<div className="relative">
|
<Card>
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
{/* Contextual header strip: idle = search + count, selecting = bulk actions.
|
||||||
<Input
|
Single fixed-min-height row prevents layout shift as state toggles.
|
||||||
type="text"
|
The select-all-matching link is folded inline into the toolbar. */}
|
||||||
placeholder="Search by email..."
|
<div
|
||||||
value={searchInput}
|
key={effectiveSelectionCount === 0 ? 'idle' : 'selecting'}
|
||||||
onChange={e => setSearchInput(e.target.value)}
|
className="border-b border-neutral-200 px-6 min-h-[68px] flex items-center py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-150"
|
||||||
className="pl-10 pr-10"
|
>
|
||||||
/>
|
{effectiveSelectionCount === 0 ? (
|
||||||
{searchInput && (
|
<div className="flex items-center gap-4 w-full">
|
||||||
<button
|
<div className="relative flex-1">
|
||||||
type="button"
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400 pointer-events-none" />
|
||||||
aria-label="Clear search"
|
<Input
|
||||||
onClick={() => {
|
type="text"
|
||||||
setSearchInput('');
|
placeholder="Search by email..."
|
||||||
setSearch('');
|
value={searchInput}
|
||||||
setCursor(undefined);
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
setCursorHistory([undefined]);
|
className="pl-10 pr-9 h-10"
|
||||||
setCurrentPage(0);
|
/>
|
||||||
setContacts([]);
|
{searchInput && (
|
||||||
}}
|
<button
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
type="button"
|
||||||
>
|
aria-label="Clear search"
|
||||||
<X className="h-4 w-4" />
|
onClick={() => {
|
||||||
</button>
|
setSearchInput('');
|
||||||
)}
|
setSearch('');
|
||||||
</div>
|
setCursor(undefined);
|
||||||
|
setCursorHistory([undefined]);
|
||||||
{/* Bulk Actions Toolbar */}
|
setCurrentPage(0);
|
||||||
{selectedContacts.size > 0 && (
|
setContacts([]);
|
||||||
<Card>
|
}}
|
||||||
<CardContent className="pt-6">
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-neutral-400 transition-colors hover:text-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400"
|
||||||
<div className="flex items-center justify-between">
|
>
|
||||||
<div className="flex items-center gap-4">
|
<X className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
</button>
|
||||||
{selectedContacts.size} contact{selectedContacts.size !== 1 ? 's' : ''} selected
|
)}
|
||||||
|
</div>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<span className="hidden sm:inline text-sm text-neutral-500 tabular-nums whitespace-nowrap">
|
||||||
|
{totalCount.toLocaleString()} {search ? 'matching' : 'total'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-3 w-full">
|
||||||
|
<div className="flex items-center gap-x-4 gap-y-2 min-w-0 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-neutral-900 tabular-nums whitespace-nowrap">
|
||||||
|
{effectiveSelectionCount.toLocaleString()} selected
|
||||||
|
</span>
|
||||||
|
{!selectAllMatching && allOnPageSelected && totalCount > contacts.length && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectAllMatching}
|
||||||
|
className="text-sm font-medium text-neutral-600 underline-offset-4 transition-colors hover:text-neutral-900 hover:underline focus-visible:outline-none focus-visible:underline focus-visible:text-neutral-900 whitespace-nowrap rounded-sm tabular-nums"
|
||||||
|
>
|
||||||
|
Select all {totalCount.toLocaleString()}
|
||||||
|
{search ? ' matching' : ''}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="hidden sm:block h-5 w-px bg-neutral-200" aria-hidden="true" />
|
||||||
|
<div className="flex gap-1.5">
|
||||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('subscribe')}>
|
<Button variant="outline" size="sm" onClick={() => handleBulkAction('subscribe')}>
|
||||||
<MailCheck className="h-4 w-4 mr-1.5" />
|
<MailCheck className="h-4 w-4 mr-1.5" />
|
||||||
Subscribe
|
Subscribe
|
||||||
@@ -238,48 +318,50 @@ export default function ContactsPage() {
|
|||||||
<MailX className="h-4 w-4 mr-1.5" />
|
<MailX className="h-4 w-4 mr-1.5" />
|
||||||
Unsubscribe
|
Unsubscribe
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('delete')}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleBulkAction('delete')}
|
||||||
|
className="text-neutral-700 transition-colors hover:bg-red-50 hover:text-red-700 hover:border-red-200"
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
<Button
|
||||||
Clear Selection
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearSelection}
|
||||||
|
aria-label="Clear selection"
|
||||||
|
className="text-neutral-500 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</div>
|
||||||
)}
|
<CardContent className="p-0">
|
||||||
|
|
||||||
{/* 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 ? (
|
{isLoading && contacts.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-16">
|
||||||
<IconSpinner />
|
<IconSpinner />
|
||||||
</div>
|
</div>
|
||||||
) : contacts.length === 0 ? (
|
) : contacts.length === 0 ? (
|
||||||
<EmptyState
|
<div className="px-6 py-12">
|
||||||
icon={Mail}
|
<EmptyState
|
||||||
title={search ? 'No contacts match' : 'No contacts yet'}
|
icon={Mail}
|
||||||
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
|
title={search ? 'No contacts match' : 'No contacts yet'}
|
||||||
action={
|
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
|
||||||
!search ? (
|
action={
|
||||||
<Button onClick={() => setShowCreateDialog(true)}>
|
!search ? (
|
||||||
<Plus className="h-4 w-4" />
|
<Button onClick={() => setShowCreateDialog(true)}>
|
||||||
Add Contact
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
Add Contact
|
||||||
) : undefined
|
</Button>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Desktop Table View - Hidden on mobile */}
|
{/* Desktop Table View - Hidden on mobile */}
|
||||||
@@ -289,7 +371,7 @@ export default function ContactsPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left w-12">
|
<th className="px-6 py-3 text-left w-12">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedContacts.size === contacts.length && contacts.length > 0}
|
checked={allOnPageSelected}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
@@ -312,7 +394,7 @@ export default function ContactsPage() {
|
|||||||
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
|
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedContacts.has(contact.id)}
|
checked={isContactSelected(contact.id)}
|
||||||
onCheckedChange={() => handleSelectContact(contact.id)}
|
onCheckedChange={() => handleSelectContact(contact.id)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
@@ -360,7 +442,7 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Card View - Only visible on mobile */}
|
{/* Mobile Card View - Only visible on mobile */}
|
||||||
<div className="md:hidden space-y-3">
|
<div className="md:hidden space-y-3 p-4">
|
||||||
{contacts.map(contact => (
|
{contacts.map(contact => (
|
||||||
<div
|
<div
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
@@ -405,7 +487,7 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination Controls */}
|
||||||
{(currentPage > 0 || data?.hasMore) && (
|
{(currentPage > 0 || data?.hasMore) && (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-6 pt-6 border-t border-neutral-200">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 px-6 py-4 border-t border-neutral-200">
|
||||||
<div className="text-xs sm:text-sm text-neutral-600 text-center sm:text-left">
|
<div className="text-xs sm:text-sm text-neutral-600 text-center sm:text-left">
|
||||||
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '}
|
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>
|
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span>
|
||||||
@@ -455,7 +537,12 @@ export default function ContactsPage() {
|
|||||||
open={showBulkActionsDialog}
|
open={showBulkActionsDialog}
|
||||||
onOpenChange={setShowBulkActionsDialog}
|
onOpenChange={setShowBulkActionsDialog}
|
||||||
operation={bulkOperation}
|
operation={bulkOperation}
|
||||||
contactIds={Array.from(selectedContacts)}
|
selector={
|
||||||
|
selectAllMatching
|
||||||
|
? {mode: 'query', filter: search ? {search} : {}, excludeIds: Array.from(excludedContacts)}
|
||||||
|
: {mode: 'ids', contactIds: Array.from(selectedContacts)}
|
||||||
|
}
|
||||||
|
targetCount={effectiveSelectionCount}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
mutate();
|
mutate();
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -885,23 +972,32 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BulkSelector =
|
||||||
|
| {mode: 'ids'; contactIds: string[]}
|
||||||
|
| {mode: 'query'; filter: {search?: string}; excludeIds: string[]};
|
||||||
|
|
||||||
interface BulkActionsDialogProps {
|
interface BulkActionsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
|
operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
|
||||||
contactIds: string[];
|
selector: BulkSelector;
|
||||||
|
targetCount: number;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BulkActionResult {
|
interface BulkActionResult {
|
||||||
operation: string;
|
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||||
totalRequested: number;
|
totalRequested: number;
|
||||||
|
/** Contacts whose state was actually changed by this run. */
|
||||||
successCount: number;
|
successCount: number;
|
||||||
|
/** Subscribe/unsubscribe only: contacts that were already in the target state. */
|
||||||
|
unchangedCount: number;
|
||||||
|
/** Contacts that errored or weren't found. */
|
||||||
failureCount: number;
|
failureCount: number;
|
||||||
errors: Array<{contactId: string; email: string; error: string}>;
|
errors: Array<{contactId: string; email: string; error: string}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess}: BulkActionsDialogProps) {
|
function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount, onSuccess}: BulkActionsDialogProps) {
|
||||||
const [, setJobId] = useState<string | null>(null);
|
const [, setJobId] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
@@ -949,8 +1045,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.result) {
|
if (response.result) {
|
||||||
const {successCount, failureCount} = response.result;
|
toast.success(buildToastSummary(response.result));
|
||||||
toast.success(`Completed: ${successCount} succeeded${failureCount > 0 ? `, ${failureCount} failed` : ''}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
@@ -988,7 +1083,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
|
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
|
||||||
'POST',
|
'POST',
|
||||||
endpoint,
|
endpoint,
|
||||||
{contactIds},
|
selector,
|
||||||
);
|
);
|
||||||
|
|
||||||
setJobId(data.jobId);
|
setJobId(data.jobId);
|
||||||
@@ -1019,103 +1114,97 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOperationLabel = () => {
|
const copy = getOperationCopy(operation);
|
||||||
switch (operation) {
|
|
||||||
case 'subscribe':
|
|
||||||
return 'Subscribe';
|
|
||||||
case 'unsubscribe':
|
|
||||||
return 'Unsubscribe';
|
|
||||||
case 'delete':
|
|
||||||
return 'Delete';
|
|
||||||
default:
|
|
||||||
return 'Process';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOperationColor = () => {
|
const isQueueing = status === 'processing' && progress === 0;
|
||||||
switch (operation) {
|
const dialogTitle =
|
||||||
case 'subscribe':
|
status === 'completed'
|
||||||
return 'green';
|
? copy.completedTitle
|
||||||
case 'unsubscribe':
|
: status === 'processing'
|
||||||
return 'yellow';
|
? copy.progressTitle
|
||||||
case 'delete':
|
: status === 'failed'
|
||||||
return 'red';
|
? copy.failedTitle
|
||||||
default:
|
: copy.title;
|
||||||
return 'blue';
|
|
||||||
}
|
const handleRetry = () => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setStatus('idle');
|
||||||
|
void handleConfirm();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{getOperationLabel()} Contacts</DialogTitle>
|
<DialogTitle className="transition-colors">{dialogTitle}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{status === 'idle' && (
|
{status === 'idle' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-3 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||||
<p className="text-sm text-neutral-700">
|
<p className="text-sm text-neutral-700 leading-relaxed">
|
||||||
{operation === 'delete' ? 'Permanently delete' : operation === 'subscribe' ? 'Subscribe' : 'Unsubscribe'}{' '}
|
{copy.confirmVerb}{' '}
|
||||||
<span className="font-medium text-neutral-900">{contactIds.length} contact{contactIds.length !== 1 ? 's' : ''}</span>?
|
<span className="font-medium text-neutral-900 tabular-nums">
|
||||||
|
{targetCount.toLocaleString()} contact{targetCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
|
{copy.skipNote && <span className="text-neutral-500"> {copy.skipNote}</span>}
|
||||||
</p>
|
</p>
|
||||||
{operation === 'delete' && (
|
{operation === 'delete' && (
|
||||||
<p className="text-xs text-red-500">This action cannot be undone.</p>
|
<div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-xs text-red-700">
|
||||||
|
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" strokeWidth={2.25} />
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
<span className="font-medium">This action cannot be undone.</span> Contacts and their event history will be permanently removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selector.mode === 'query' && (
|
||||||
|
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||||
|
Contacts are evaluated when the job runs — any added in the meantime may also be included.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'processing' && (
|
{status === 'processing' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3 py-1 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-baseline justify-between text-sm">
|
||||||
<span className="text-neutral-600">Processing contacts...</span>
|
<span className="flex items-center gap-2 text-neutral-600">
|
||||||
<span className="text-neutral-900 font-medium">{progress}%</span>
|
{isQueueing && <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />}
|
||||||
</div>
|
<span>
|
||||||
<div className="w-full bg-neutral-200 rounded-full h-1.5">
|
{isQueueing
|
||||||
<div
|
? 'Queued — starting up…'
|
||||||
className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
|
: `${copy.processingLabel} ${targetCount.toLocaleString()} contact${targetCount !== 1 ? 's' : ''}`}
|
||||||
style={{width: `${progress}%`}}
|
</span>
|
||||||
/>
|
</span>
|
||||||
</div>
|
<span
|
||||||
</div>
|
className={`tabular-nums font-medium transition-opacity ${
|
||||||
)}
|
isQueueing ? 'text-neutral-400' : 'text-neutral-900'
|
||||||
|
}`}
|
||||||
{status === 'completed' && result && (
|
>
|
||||||
<div className="space-y-3">
|
{progress}%
|
||||||
<div className="flex items-center gap-1.5 text-sm text-neutral-600">
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
|
|
||||||
<span>
|
|
||||||
<span className="font-medium text-neutral-900">{result.successCount}</span> succeeded
|
|
||||||
{result.failureCount > 0 && (
|
|
||||||
<>, <span className="text-red-600">{result.failureCount}</span> failed</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative w-full bg-neutral-100 rounded-full h-1.5 overflow-hidden">
|
||||||
{result.errors && result.errors.length > 0 && (
|
{isQueueing ? (
|
||||||
<div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
|
<div className="absolute inset-y-0 left-0 w-1/3 rounded-full bg-neutral-300 motion-safe:animate-[indeterminate_1.4s_ease-in-out_infinite]" />
|
||||||
<div className="text-xs text-neutral-600">
|
) : (
|
||||||
{result.errors.slice(0, 10).map((error, idx) => (
|
<div
|
||||||
<div key={idx} className="px-3 py-2 border-b border-neutral-100 last:border-0 text-red-600">
|
className="bg-neutral-900 h-full rounded-full transition-[width] duration-500 ease-out"
|
||||||
{error.error}
|
style={{width: `${progress}%`}}
|
||||||
</div>
|
/>
|
||||||
))}
|
)}
|
||||||
{result.errors.length > 10 && (
|
</div>
|
||||||
<div className="px-3 py-2 text-neutral-500">
|
|
||||||
+{result.errors.length - 10} more errors
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status === 'completed' && result && <BulkResultSummary result={result} />}
|
||||||
|
|
||||||
{status === 'failed' && (
|
{status === 'failed' && (
|
||||||
<div className="flex items-start gap-2 text-sm">
|
<div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||||
<XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" strokeWidth={2.25} />
|
||||||
<p className="text-red-600">{errorMessage || 'Please try again.'}</p>
|
<p className="leading-relaxed">{errorMessage || 'Something went wrong. Please try again.'}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1132,16 +1221,25 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
variant={operation === 'delete' ? 'destructive' : 'default'}
|
variant={operation === 'delete' ? 'destructive' : 'default'}
|
||||||
>
|
>
|
||||||
{isProcessing ? 'Starting...' : getOperationLabel()}
|
{isProcessing ? 'Starting…' : copy.confirmButton}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : status === 'failed' ? (
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleRetry} variant={operation === 'delete' ? 'destructive' : 'default'}>
|
||||||
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : status === 'completed' ? (
|
|
||||||
<Button type="button" onClick={handleClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" variant="outline" onClick={handleClose}>
|
<Button
|
||||||
Close
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
variant={status === 'completed' ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
{status === 'completed' ? 'Done' : 'Hide'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -1152,11 +1250,218 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
|||||||
open={showCloseConfirmDialog}
|
open={showCloseConfirmDialog}
|
||||||
onOpenChange={setShowCloseConfirmDialog}
|
onOpenChange={setShowCloseConfirmDialog}
|
||||||
onConfirm={confirmClose}
|
onConfirm={confirmClose}
|
||||||
title="Close Operation"
|
title="Hide this dialog?"
|
||||||
description="Operation is still in progress. Are you sure you want to close?"
|
description="The job will keep running in the background. You won't see the result here, but the contacts will still be updated."
|
||||||
confirmText="Close Anyway"
|
confirmText="Hide"
|
||||||
variant="destructive"
|
variant="default"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OperationCopy {
|
||||||
|
title: string;
|
||||||
|
progressTitle: string;
|
||||||
|
completedTitle: string;
|
||||||
|
failedTitle: string;
|
||||||
|
confirmVerb: string;
|
||||||
|
confirmButton: string;
|
||||||
|
processingLabel: string;
|
||||||
|
/** Past-tense verb used in result rows: "12 subscribed". */
|
||||||
|
changedVerb: string;
|
||||||
|
/** Result-state noun phrase: "contacts subscribed" — pluralisation handled separately. */
|
||||||
|
summaryNoun: string;
|
||||||
|
/** Past participle for "already X": "already subscribed". null = no skip case. */
|
||||||
|
alreadyState: string | null;
|
||||||
|
/** Note shown next to the confirm prompt for ops with skip semantics. */
|
||||||
|
skipNote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperationCopy(operation: 'subscribe' | 'unsubscribe' | 'delete' | null): OperationCopy {
|
||||||
|
switch (operation) {
|
||||||
|
case 'subscribe':
|
||||||
|
return {
|
||||||
|
title: 'Subscribe contacts',
|
||||||
|
progressTitle: 'Subscribing…',
|
||||||
|
completedTitle: 'Subscribed',
|
||||||
|
failedTitle: "Couldn't subscribe contacts",
|
||||||
|
confirmVerb: 'Subscribe',
|
||||||
|
confirmButton: 'Subscribe',
|
||||||
|
processingLabel: 'Subscribing',
|
||||||
|
changedVerb: 'subscribed',
|
||||||
|
summaryNoun: 'subscribed',
|
||||||
|
alreadyState: 'already subscribed',
|
||||||
|
skipNote: 'Already-subscribed contacts will be skipped.',
|
||||||
|
};
|
||||||
|
case 'unsubscribe':
|
||||||
|
return {
|
||||||
|
title: 'Unsubscribe contacts',
|
||||||
|
progressTitle: 'Unsubscribing…',
|
||||||
|
completedTitle: 'Unsubscribed',
|
||||||
|
failedTitle: "Couldn't unsubscribe contacts",
|
||||||
|
confirmVerb: 'Unsubscribe',
|
||||||
|
confirmButton: 'Unsubscribe',
|
||||||
|
processingLabel: 'Unsubscribing',
|
||||||
|
changedVerb: 'unsubscribed',
|
||||||
|
summaryNoun: 'unsubscribed',
|
||||||
|
alreadyState: 'already unsubscribed',
|
||||||
|
skipNote: 'Already-unsubscribed contacts will be skipped.',
|
||||||
|
};
|
||||||
|
case 'delete':
|
||||||
|
return {
|
||||||
|
title: 'Delete contacts',
|
||||||
|
progressTitle: 'Deleting…',
|
||||||
|
completedTitle: 'Deleted',
|
||||||
|
failedTitle: "Couldn't delete contacts",
|
||||||
|
confirmVerb: 'Permanently delete',
|
||||||
|
confirmButton: 'Delete',
|
||||||
|
processingLabel: 'Deleting',
|
||||||
|
changedVerb: 'deleted',
|
||||||
|
summaryNoun: 'removed',
|
||||||
|
alreadyState: null,
|
||||||
|
skipNote: null,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: 'Process contacts',
|
||||||
|
progressTitle: 'Processing…',
|
||||||
|
completedTitle: 'Done',
|
||||||
|
failedTitle: 'Operation failed',
|
||||||
|
confirmVerb: 'Process',
|
||||||
|
confirmButton: 'Process',
|
||||||
|
processingLabel: 'Processing',
|
||||||
|
changedVerb: 'processed',
|
||||||
|
summaryNoun: 'processed',
|
||||||
|
alreadyState: null,
|
||||||
|
skipNote: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToastSummary(result: BulkActionResult): string {
|
||||||
|
const copy = getOperationCopy(result.operation);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.successCount > 0) parts.push(`${result.successCount.toLocaleString()} ${copy.changedVerb}`);
|
||||||
|
if (result.unchangedCount > 0 && copy.alreadyState) {
|
||||||
|
parts.push(`${result.unchangedCount.toLocaleString()} ${copy.alreadyState}`);
|
||||||
|
}
|
||||||
|
if (result.failureCount > 0) parts.push(`${result.failureCount.toLocaleString()} failed`);
|
||||||
|
if (parts.length === 0) return 'No contacts to update';
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulkResultSummary({result}: {result: BulkActionResult}) {
|
||||||
|
const copy = getOperationCopy(result.operation);
|
||||||
|
const {successCount, unchangedCount, failureCount} = result;
|
||||||
|
const noChanges = successCount === 0 && failureCount === 0 && unchangedCount > 0;
|
||||||
|
const total = successCount + unchangedCount + failureCount;
|
||||||
|
|
||||||
|
// Build the row list. The "primary" row is the row that represents what the
|
||||||
|
// user actually got — usually the changed count, but when nothing changed we
|
||||||
|
// promote the "already in state" row so the summary still has a clear lead.
|
||||||
|
type Row = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
primary?: boolean;
|
||||||
|
tone?: 'default' | 'danger';
|
||||||
|
};
|
||||||
|
const rows: Row[] = [];
|
||||||
|
|
||||||
|
if (noChanges && copy.alreadyState) {
|
||||||
|
rows.push({key: 'already', label: copy.alreadyState, count: unchangedCount, primary: true});
|
||||||
|
} else {
|
||||||
|
rows.push({key: 'changed', label: copy.completedTitle, count: successCount, primary: true});
|
||||||
|
if (unchangedCount > 0 && copy.alreadyState) {
|
||||||
|
rows.push({key: 'already', label: copy.alreadyState, count: unchangedCount});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failureCount > 0) {
|
||||||
|
rows.push({key: 'failed', label: 'Failed', count: failureCount, tone: 'danger'});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300">
|
||||||
|
<div className="rounded-lg border border-neutral-200 overflow-hidden divide-y divide-neutral-100">
|
||||||
|
{rows.map(row => {
|
||||||
|
const isPrimary = !!row.primary;
|
||||||
|
const isDanger = row.tone === 'danger';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.key}
|
||||||
|
className={`flex items-center gap-3 px-4 ${isPrimary ? 'py-4' : 'py-2.5'}`}
|
||||||
|
>
|
||||||
|
{/* Status mark — only on the primary row. Subsequent rows leave the
|
||||||
|
same column blank to keep the labels in a single visual track. */}
|
||||||
|
<div className="w-7 shrink-0 flex items-center">
|
||||||
|
{isPrimary && (
|
||||||
|
<div
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-full ${
|
||||||
|
noChanges ? 'bg-neutral-100 text-neutral-500' : 'bg-neutral-900 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{noChanges ? (
|
||||||
|
<Minus className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex-1 first-letter:capitalize ${
|
||||||
|
isPrimary
|
||||||
|
? 'text-sm font-medium text-neutral-900'
|
||||||
|
: isDanger
|
||||||
|
? 'text-sm text-red-600'
|
||||||
|
: 'text-sm text-neutral-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`tabular-nums tracking-tight ${
|
||||||
|
isPrimary
|
||||||
|
? 'text-2xl font-semibold text-neutral-900 leading-none'
|
||||||
|
: isDanger
|
||||||
|
? 'text-sm font-medium text-red-700'
|
||||||
|
: 'text-sm font-medium text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.count.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > 1 && rows.length > 1 && (
|
||||||
|
<div className="px-4 flex items-baseline justify-between text-xs text-neutral-500">
|
||||||
|
<span>Total processed</span>
|
||||||
|
<span className="tabular-nums font-medium text-neutral-700">{total.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.errors && result.errors.length > 0 && (
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs text-neutral-500 hover:text-neutral-700 select-none px-4">
|
||||||
|
Show error details ({result.errors.length.toLocaleString()})
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 max-h-40 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100 text-xs">
|
||||||
|
{result.errors.slice(0, 10).map((error, idx) => (
|
||||||
|
<div key={idx} className="px-3 py-2 text-red-700">
|
||||||
|
{error.error}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{result.errors.length > 10 && (
|
||||||
|
<div className="px-3 py-2 text-neutral-500">
|
||||||
|
+{(result.errors.length - 10).toLocaleString()} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+504
-54
@@ -10,10 +10,30 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
|
||||||
|
import {animate, AnimatePresence, motion, useMotionValue, useTransform} from 'framer-motion';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowDownRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
Calendar,
|
||||||
|
Eye,
|
||||||
|
Inbox,
|
||||||
|
Mail,
|
||||||
|
Minus,
|
||||||
|
MousePointerClick,
|
||||||
|
Send,
|
||||||
|
ShieldCheck,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Workflow,
|
||||||
|
XCircle,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useState} from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||||
import {DashboardLayout} from '../components/DashboardLayout';
|
import {DashboardLayout} from '../components/DashboardLayout';
|
||||||
import {QuickStart} from '../components/QuickStart';
|
import {QuickStart} from '../components/QuickStart';
|
||||||
@@ -28,6 +48,195 @@ import {useConfig} from '../lib/hooks/useConfig';
|
|||||||
import {useUser} from '../lib/hooks/useUser';
|
import {useUser} from '../lib/hooks/useUser';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
|
|
||||||
|
function getGreeting(): string {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour >= 23 || hour < 5) return 'Working late';
|
||||||
|
if (hour < 12) return 'Good morning';
|
||||||
|
if (hour < 18) return 'Good afternoon';
|
||||||
|
return 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(date: Date): string {
|
||||||
|
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
|
if (seconds < 60) return 'just now';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return date.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrendDirection = 'up' | 'down' | 'flat' | 'new' | 'none';
|
||||||
|
|
||||||
|
interface TrendInfo {
|
||||||
|
direction: TrendDirection;
|
||||||
|
pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTrend(current: number, previous: number): TrendInfo {
|
||||||
|
if (previous === 0 && current === 0) return {direction: 'none', pct: 0};
|
||||||
|
if (previous === 0 && current > 0) return {direction: 'new', pct: 0};
|
||||||
|
const pct = ((current - previous) / Math.abs(previous)) * 100;
|
||||||
|
if (Math.abs(pct) < 0.5) return {direction: 'flat', pct: 0};
|
||||||
|
return {direction: pct > 0 ? 'up' : 'down', pct: Math.abs(pct)};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendChip({trend, label}: {trend: TrendInfo; label?: string}) {
|
||||||
|
if (trend.direction === 'none') {
|
||||||
|
return (
|
||||||
|
<p className="mt-1 text-xs text-neutral-400 tabular-nums">{label ?? 'No data yet'}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
up: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'},
|
||||||
|
down: {Icon: ArrowDownRight, color: 'text-red-700', bg: 'bg-red-50'},
|
||||||
|
flat: {Icon: Minus, color: 'text-neutral-600', bg: 'bg-neutral-100'},
|
||||||
|
new: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'},
|
||||||
|
}[trend.direction];
|
||||||
|
|
||||||
|
const {Icon, color, bg} = config;
|
||||||
|
const text =
|
||||||
|
trend.direction === 'new'
|
||||||
|
? 'New'
|
||||||
|
: trend.direction === 'flat'
|
||||||
|
? 'No change'
|
||||||
|
: `${trend.pct.toFixed(trend.pct >= 100 ? 0 : 1)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium tabular-nums ${bg} ${color}`}>
|
||||||
|
<Icon className="h-3 w-3" strokeWidth={2.5} />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-400">vs previous 30d</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedNumber({value, format}: {value: number; format?: (n: number) => string}) {
|
||||||
|
const motionValue = useMotionValue(0);
|
||||||
|
const rounded = useTransform(motionValue, latest =>
|
||||||
|
format ? format(Math.round(latest)) : Math.round(latest).toLocaleString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controls = animate(motionValue, value, {
|
||||||
|
duration: 1.1,
|
||||||
|
ease: [0.22, 1, 0.36, 1],
|
||||||
|
});
|
||||||
|
return () => controls.stop();
|
||||||
|
}, [value, motionValue]);
|
||||||
|
|
||||||
|
return <motion.span>{rounded}</motion.span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityVisual {
|
||||||
|
icon: React.ComponentType<{className?: string}>;
|
||||||
|
tone: 'neutral' | 'green' | 'blue' | 'amber' | 'red';
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityVisual(a: Activity): ActivityVisual {
|
||||||
|
switch (a.type) {
|
||||||
|
case 'email.sent':
|
||||||
|
return {icon: Send, tone: 'neutral', label: 'Sent'};
|
||||||
|
case 'email.delivered':
|
||||||
|
return {icon: Inbox, tone: 'green', label: 'Delivered'};
|
||||||
|
case 'email.opened':
|
||||||
|
return {icon: Eye, tone: 'green', label: 'Opened'};
|
||||||
|
case 'email.clicked':
|
||||||
|
return {icon: MousePointerClick, tone: 'blue', label: 'Clicked'};
|
||||||
|
case 'email.bounced':
|
||||||
|
return {icon: XCircle, tone: 'red', label: 'Bounced'};
|
||||||
|
case 'email.complaint':
|
||||||
|
return {icon: AlertCircle, tone: 'red', label: 'Complaint'};
|
||||||
|
case 'event.triggered':
|
||||||
|
return {icon: Zap, tone: 'amber', label: 'Event'};
|
||||||
|
case 'campaign.sent':
|
||||||
|
return {icon: Mail, tone: 'neutral', label: 'Campaign'};
|
||||||
|
case 'campaign.scheduled':
|
||||||
|
return {icon: Calendar, tone: 'blue', label: 'Scheduled'};
|
||||||
|
case 'workflow.started':
|
||||||
|
case 'workflow.completed':
|
||||||
|
case 'workflow.email.scheduled':
|
||||||
|
return {icon: Workflow, tone: 'amber', label: 'Workflow'};
|
||||||
|
default:
|
||||||
|
return {icon: Zap, tone: 'neutral', label: 'Event'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_CLASSES: Record<ActivityVisual['tone'], {bg: string; fg: string}> = {
|
||||||
|
neutral: {bg: 'bg-neutral-100', fg: 'text-neutral-700'},
|
||||||
|
green: {bg: 'bg-emerald-50', fg: 'text-emerald-700'},
|
||||||
|
blue: {bg: 'bg-sky-50', fg: 'text-sky-700'},
|
||||||
|
amber: {bg: 'bg-amber-50', fg: 'text-amber-700'},
|
||||||
|
red: {bg: 'bg-red-50', fg: 'text-red-700'},
|
||||||
|
};
|
||||||
|
|
||||||
|
function activityTitle(a: Activity): string {
|
||||||
|
const m = a.metadata;
|
||||||
|
if (typeof m.subject === 'string' && m.subject) return m.subject;
|
||||||
|
if (typeof m.eventName === 'string' && m.eventName) return m.eventName;
|
||||||
|
if (typeof m.campaignName === 'string' && m.campaignName) return m.campaignName;
|
||||||
|
if (typeof m.workflowName === 'string' && m.workflowName) return m.workflowName;
|
||||||
|
return activityVisual(a).label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LivePulse({count}: {count: number}) {
|
||||||
|
const isLive = count > 0;
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
{isLive && (
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex h-2 w-2 rounded-full ${isLive ? 'bg-emerald-500' : 'bg-neutral-300'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{isLive ? `${count.toLocaleString()} ${count === 1 ? 'event' : 'events'} in the last 5 min` : 'Quiet right now'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactActivityRow({activity}: {activity: Activity}) {
|
||||||
|
const visual = activityVisual(activity);
|
||||||
|
const Icon = visual.icon;
|
||||||
|
const tone = TONE_CLASSES[visual.tone];
|
||||||
|
const title = activityTitle(activity);
|
||||||
|
const subtitle = activity.contactEmail;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{opacity: 0, y: -8}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
exit={{opacity: 0, y: 8}}
|
||||||
|
transition={{duration: 0.35, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md ${tone.bg}`}>
|
||||||
|
<Icon className={`h-4 w-4 ${tone.fg}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="truncate text-sm font-medium text-neutral-900">{title}</p>
|
||||||
|
<span className="flex-shrink-0 text-[11px] text-neutral-400">{visual.label}</span>
|
||||||
|
</div>
|
||||||
|
{subtitle && <p className="truncate text-xs text-neutral-500">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0 tabular-nums text-xs text-neutral-400">
|
||||||
|
{relativeTime(new Date(activity.timestamp))}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const {activeProject} = useActiveProject();
|
const {activeProject} = useActiveProject();
|
||||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||||
@@ -41,29 +250,129 @@ export default function Index() {
|
|||||||
const [isResending, setIsResending] = useState(false);
|
const [isResending, setIsResending] = useState(false);
|
||||||
const [resendMessage, setResendMessage] = useState<string>('');
|
const [resendMessage, setResendMessage] = useState<string>('');
|
||||||
|
|
||||||
|
// Previous-period stats (60d ago to 30d ago) for trend comparison.
|
||||||
|
// Round to UTC day boundary so the URL — and therefore the Redis cache key —
|
||||||
|
// is identical for every user on the same UTC day, letting the 5-minute
|
||||||
|
// server-side stats cache actually be shared across the user base.
|
||||||
|
const previousRangeUrl = useMemo(() => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setUTCHours(0, 0, 0, 0);
|
||||||
|
const thirtyDaysAgo = new Date(today);
|
||||||
|
thirtyDaysAgo.setUTCDate(today.getUTCDate() - 30);
|
||||||
|
const sixtyDaysAgo = new Date(today);
|
||||||
|
sixtyDaysAgo.setUTCDate(today.getUTCDate() - 60);
|
||||||
|
return `/activity/stats?startDate=${encodeURIComponent(sixtyDaysAgo.toISOString())}&endDate=${encodeURIComponent(thirtyDaysAgo.toISOString())}`;
|
||||||
|
}, []);
|
||||||
|
const {data: previousStats} = useSWR<ActivityStats>(previousRangeUrl, {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailsTrend = useMemo(
|
||||||
|
() => computeTrend(totalEmailsSent, previousStats?.totalEmailsSent ?? 0),
|
||||||
|
[totalEmailsSent, previousStats?.totalEmailsSent],
|
||||||
|
);
|
||||||
|
const openRateTrend = useMemo(
|
||||||
|
() => computeTrend(openRate, previousStats?.openRate ?? 0),
|
||||||
|
[openRate, previousStats?.openRate],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Live pulse — refresh every 30s. This is the actual real-time signal, so it
|
||||||
|
// gets the tightest cadence. Server-side it is backed by a short Redis cache
|
||||||
|
// (see Activity controller) so the polling load stays bounded.
|
||||||
|
const {data: recentCount} = useSWR<{count: number; minutes: number}>('/activity/recent-count?minutes=5', {
|
||||||
|
refreshInterval: 30_000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live activity feed — last 10 events, refresh every 60s. Slower than the
|
||||||
|
// pulse because the heavier query doesn't need to be tracked second-by-second.
|
||||||
|
// Sized to roughly match the Quick Start card's height in the side-by-side layout.
|
||||||
|
const {data: recentActivity} = useSWR<CursorPaginatedResponse<Activity>>('/activity?limit=10', {
|
||||||
|
refreshInterval: 60_000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const greeting = useMemo(() => getGreeting(), []);
|
||||||
|
|
||||||
|
const subtitle = useMemo(() => {
|
||||||
|
if (isLoading) return 'Catching up on the last 30 days.';
|
||||||
|
if (totalEmailsSent === 0) {
|
||||||
|
if (totalContacts === 0) return `${activeProject?.name ?? 'Your project'} is fresh. Time to send the first email.`;
|
||||||
|
return `${totalContacts.toLocaleString()} ${totalContacts === 1 ? 'contact' : 'contacts'} ready. Time to send something.`;
|
||||||
|
}
|
||||||
|
const projectLabel = activeProject?.name ? `${activeProject.name} sent` : 'You sent';
|
||||||
|
const base = `${projectLabel} ${totalEmailsSent.toLocaleString()} ${totalEmailsSent === 1 ? 'email' : 'emails'} in the last 30 days.`;
|
||||||
|
if (openRate >= 40) return `${base} Open rate is well above average.`;
|
||||||
|
if (openRate >= 25) return `${base} Open rate is healthy.`;
|
||||||
|
return base;
|
||||||
|
}, [isLoading, totalEmailsSent, totalContacts, openRate, activeProject?.name]);
|
||||||
|
|
||||||
|
// Friendly console message for the developer audience. Once per session.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const w = window as unknown as {__plunkHi?: boolean};
|
||||||
|
if (w.__plunkHi) return;
|
||||||
|
w.__plunkHi = true;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
'%cPlunk%c Built for developers who care about email.\nFound a rough edge? [email protected]',
|
||||||
|
'font: 600 14px ui-sans-serif, system-ui; color: #0a0a0a; background: #f5f5f5; padding: 2px 8px; border-radius: 4px;',
|
||||||
|
'color: #525252; font: 12px ui-sans-serif, system-ui;',
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// totalCampaigns intentionally unused — replaced by Deliverability card below
|
||||||
|
void totalCampaigns;
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
name: 'Total Contacts',
|
name: 'Total Contacts',
|
||||||
value: totalContacts.toLocaleString(),
|
value: totalContacts,
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
format: (n: number) => n.toLocaleString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Emails Sent',
|
name: 'Emails Sent',
|
||||||
value: totalEmailsSent.toLocaleString(),
|
value: totalEmailsSent,
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
format: (n: number) => n.toLocaleString(),
|
||||||
{
|
|
||||||
name: 'Campaigns',
|
|
||||||
value: totalCampaigns.toLocaleString(),
|
|
||||||
icon: Send,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Open Rate',
|
name: 'Open Rate',
|
||||||
value: `${openRate.toFixed(1)}%`,
|
value: openRate,
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
|
format: (n: number) => `${n.toFixed(1)}%`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Deliverability — prefer 7-day window, fall back to all-time when no 7-day sends
|
||||||
|
const sevenDay = securityMetrics?.status.sevenDay;
|
||||||
|
const allTime = securityMetrics?.status.allTime;
|
||||||
|
const delivWindow = sevenDay && sevenDay.total > 0 ? sevenDay : allTime;
|
||||||
|
const delivWindowLabel = sevenDay && sevenDay.total > 0 ? 'Last 7 days' : 'All time';
|
||||||
|
const deliveryRate =
|
||||||
|
delivWindow && delivWindow.total > 0 ? ((delivWindow.total - delivWindow.bounces) / delivWindow.total) * 100 : 0;
|
||||||
|
const bounceRate = delivWindow?.bounceRate ?? 0;
|
||||||
|
const complaintRate = delivWindow?.complaintRate ?? 0;
|
||||||
|
const hasDelivData = !!delivWindow && delivWindow.total > 0;
|
||||||
|
|
||||||
|
const bounceLevel = securityMetrics?.levels.bounce7Day ?? 'healthy';
|
||||||
|
const complaintLevel = securityMetrics?.levels.complaint7Day ?? 'healthy';
|
||||||
|
const worstLevel: 'healthy' | 'warning' | 'critical' =
|
||||||
|
bounceLevel === 'critical' || complaintLevel === 'critical'
|
||||||
|
? 'critical'
|
||||||
|
: bounceLevel === 'warning' || complaintLevel === 'warning'
|
||||||
|
? 'warning'
|
||||||
|
: 'healthy';
|
||||||
|
const healthLabel = !hasDelivData ? 'No data yet' : worstLevel === 'healthy' ? 'Healthy' : worstLevel === 'warning' ? 'Watch' : 'Critical';
|
||||||
|
const healthDot =
|
||||||
|
!hasDelivData ? 'bg-neutral-300' : worstLevel === 'healthy' ? 'bg-emerald-500' : worstLevel === 'warning' ? 'bg-amber-500' : 'bg-red-500';
|
||||||
|
const healthText =
|
||||||
|
!hasDelivData ? 'text-neutral-500' : worstLevel === 'healthy' ? 'text-emerald-700' : worstLevel === 'warning' ? 'text-amber-700' : 'text-red-700';
|
||||||
|
|
||||||
async function handleResendVerification() {
|
async function handleResendVerification() {
|
||||||
setIsResending(true);
|
setIsResending(true);
|
||||||
setResendMessage('');
|
setResendMessage('');
|
||||||
@@ -82,6 +391,9 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recentItems = recentActivity?.data ?? [];
|
||||||
|
const liveCount = recentCount?.count ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo title="Dashboard" />
|
<NextSeo title="Dashboard" />
|
||||||
@@ -162,64 +474,202 @@ export default function Index() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<motion.div
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
|
initial={{opacity: 0, y: 8}}
|
||||||
</div>
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 tracking-tight">{greeting}</h1>
|
||||||
|
<p className="mt-1.5 text-sm text-neutral-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<LivePulse count={liveCount} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{stats.map(stat => {
|
{stats.map((stat, index) => {
|
||||||
const Icon = stat.icon;
|
const Icon = stat.icon;
|
||||||
|
const isEmails = stat.name === 'Emails Sent';
|
||||||
|
const isOpenRate = stat.name === 'Open Rate';
|
||||||
return (
|
return (
|
||||||
<Card key={stat.name}>
|
<motion.div
|
||||||
<CardHeader>
|
key={stat.name}
|
||||||
<div className="flex items-center justify-between">
|
initial={{opacity: 0, y: 12}}
|
||||||
<CardDescription>{stat.name}</CardDescription>
|
animate={{opacity: 1, y: 0}}
|
||||||
<Icon className="h-4 w-4 text-neutral-500" />
|
transition={{
|
||||||
</div>
|
duration: 0.5,
|
||||||
<CardTitle className="text-2xl tabular-nums">
|
delay: 0.05 + index * 0.06,
|
||||||
{isLoading ? <Skeleton className="h-7 w-16" /> : stat.value}
|
ease: [0.22, 1, 0.36, 1],
|
||||||
</CardTitle>
|
}}
|
||||||
</CardHeader>
|
whileHover={{y: -2}}
|
||||||
</Card>
|
className="group"
|
||||||
|
>
|
||||||
|
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardDescription>{stat.name}</CardDescription>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-neutral-50 border border-neutral-200/60 transition-colors duration-200 group-hover:bg-neutral-900 group-hover:border-neutral-900">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-neutral-500 transition-colors duration-200 group-hover:text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl tabular-nums">
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
) : (
|
||||||
|
<AnimatedNumber value={stat.value} format={stat.format} />
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
{isEmails && !isLoading && previousStats && <TrendChip trend={emailsTrend} />}
|
||||||
|
{isOpenRate && !isLoading && previousStats && <TrendChip trend={openRateTrend} />}
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Deliverability health */}
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 12}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.5, delay: 0.05 + stats.length * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
whileHover={{y: -2}}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardDescription>Deliverability</CardDescription>
|
||||||
|
<div className="inline-flex items-center gap-1.5 rounded-full bg-neutral-50 border border-neutral-200/60 px-2 py-0.5">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
{hasDelivData && worstLevel === 'healthy' && (
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||||
|
)}
|
||||||
|
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${healthDot}`} />
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] font-medium ${healthText}`}>{healthLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl tabular-nums">
|
||||||
|
{!securityMetrics ? (
|
||||||
|
<Skeleton className="h-7 w-20" />
|
||||||
|
) : hasDelivData ? (
|
||||||
|
<AnimatedNumber value={deliveryRate} format={n => `${n.toFixed(1)}%`} />
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">—</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
{hasDelivData ? 'delivered' : 'No emails sent yet'}
|
||||||
|
{hasDelivData && <span className="text-neutral-400"> · {delivWindowLabel}</span>}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
{hasDelivData && (
|
||||||
|
<div className="px-6 pb-4 -mt-1">
|
||||||
|
<div className="flex items-center gap-4 text-[11px] text-neutral-500 tabular-nums">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<ShieldCheck className="h-3 w-3 text-neutral-400" />
|
||||||
|
Bounce <span className="font-medium text-neutral-700">{bounceRate.toFixed(2)}%</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3 w-3 text-neutral-400" />
|
||||||
|
Complaint <span className="font-medium text-neutral-700">{complaintRate.toFixed(3)}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions & API Keys */}
|
{/* Quick Start + Recent Activity — 50/50 working area with a fixed
|
||||||
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
|
row height so the layout doesn't reflow as Quick Start steps are
|
||||||
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
|
completed. Both cards scroll internally. */}
|
||||||
|
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2 lg:h-[480px]'}`}>
|
||||||
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
|
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
|
||||||
|
|
||||||
{/* API Keys */}
|
<div className={!bannerActive ? 'lg:relative' : ''}>
|
||||||
<Card>
|
<Card
|
||||||
<CardHeader>
|
className={`flex flex-col h-full ${
|
||||||
<CardTitle>API Keys</CardTitle>
|
!bannerActive ? 'lg:absolute lg:inset-0' : ''
|
||||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
}`}
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent>
|
<CardHeader>
|
||||||
<div className="space-y-4">
|
<div className="flex items-center justify-between">
|
||||||
{activeProject ? (
|
<div>
|
||||||
<>
|
<CardTitle>Recent activity</CardTitle>
|
||||||
<ApiKeyDisplay
|
<CardDescription>Live feed of what’s happening across your project</CardDescription>
|
||||||
label="Public Key"
|
</div>
|
||||||
value={activeProject.public}
|
<Button asChild variant="ghost" size="sm">
|
||||||
description="Use this key for client-side integrations"
|
<Link href="/activity">View all</Link>
|
||||||
/>
|
</Button>
|
||||||
<ApiKeyDisplay
|
</div>
|
||||||
label="Secret Key"
|
</CardHeader>
|
||||||
value={activeProject.secret}
|
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||||
description="Keep this key secure and never expose it publicly"
|
{!recentActivity ? (
|
||||||
isSecret
|
<div className="space-y-2">
|
||||||
/>
|
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
|
||||||
</>
|
<div key={i} className="flex items-center gap-3 px-2 py-2">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton className="h-3.5 w-2/5" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : recentItems.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 py-10 text-center">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-100">
|
||||||
|
<Inbox className="h-5 w-5 text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-700">Nothing has happened yet</p>
|
||||||
|
<p className="text-xs text-neutral-500 max-w-xs">
|
||||||
|
Send your first email or trigger an event and you’ll see it land here in real time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-neutral-500">No project selected</p>
|
<div className="space-y-0.5">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{recentItems.map(activity => (
|
||||||
|
<CompactActivityRow key={activity.id} activity={activity} />
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* API Keys — full-width slim band with the two keys side-by-side */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>API Keys</CardTitle>
|
||||||
|
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{activeProject ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6">
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500">No project selected</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -121,57 +121,35 @@ export default function TemplateEditorPage() {
|
|||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<NextSeo title={template.name} />
|
<NextSeo title={template.name} />
|
||||||
<form onSubmit={handleSave} className={`max-w-5xl mx-auto space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
<div className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<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">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
|
||||||
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
|
</Button>
|
||||||
</Button>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Edit Template</h1>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Edit Template</h1>
|
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
|
||||||
<p className="text-neutral-500 mt-1 text-sm sm:text-base">Make changes to your email template</p>
|
{isSubmitting
|
||||||
</div>
|
? 'Saving...'
|
||||||
</div>
|
: hasChanges
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
? <span className="text-amber-600">Unsaved changes</span>
|
||||||
<div className="flex-1">
|
: 'All changes saved'}
|
||||||
{!hasChanges && !isSubmitting && (
|
</p>
|
||||||
<span className="text-xs sm:text-sm text-neutral-500">All changes saved</span>
|
|
||||||
)}
|
|
||||||
{hasChanges && !isSubmitting && (
|
|
||||||
<span className="text-xs sm:text-sm text-amber-600">Unsaved changes</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
|
||||||
className="flex-1 sm:flex-none"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Delete</span>
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={!hasChanges || isSubmitting} className="flex-1 sm:flex-none">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">{isSubmitting ? 'Saving...' : 'Save Changes'}</span>
|
|
||||||
<span className="sm:hidden">{isSubmitting ? 'Saving...' : 'Save'}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template Editor */}
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
<div className="space-y-6">
|
{/* Row 1: Basic Info + Template Type */}
|
||||||
{/* Template Settings */}
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Template Settings</CardTitle>
|
<CardTitle>Basic Information</CardTitle>
|
||||||
<CardDescription>Configure the basic settings for your template</CardDescription>
|
<CardDescription>Name and describe your template</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Template Name *</Label>
|
<Label htmlFor="name">Template Name <span className="text-red-500">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -182,7 +160,7 @@ export default function TemplateEditorPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Input
|
<Input
|
||||||
id="description"
|
id="description"
|
||||||
@@ -192,94 +170,124 @@ export default function TemplateEditorPage() {
|
|||||||
placeholder="Sent to new subscribers"
|
placeholder="Sent to new subscribers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Type *</Label>
|
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
|
||||||
{([
|
|
||||||
{value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'} ,
|
|
||||||
{value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'},
|
|
||||||
{value: 'HEADLESS', label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
|
|
||||||
] as const).map(({value, label, description}) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditedTemplate({...editedTemplate, type: value})}
|
|
||||||
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
|
||||||
editedTemplate.type === value
|
|
||||||
? 'border-neutral-900 bg-neutral-50'
|
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
|
|
||||||
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{editedTemplate.type === 'HEADLESS' && !detectUnsubscribeSignal(editedTemplate.body ?? '') && (
|
|
||||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
|
||||||
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
|
|
||||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
|
|
||||||
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-2.5 space-y-2">
|
|
||||||
<p className="text-xs text-amber-800 leading-relaxed">
|
|
||||||
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
|
||||||
{'{{unsubscribeUrl}}'}
|
|
||||||
</code>
|
|
||||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
|
||||||
{'{{manageUrl}}'}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="subject">Subject Line *</Label>
|
|
||||||
<Input
|
|
||||||
id="subject"
|
|
||||||
type="text"
|
|
||||||
value={editedTemplate.subject || ''}
|
|
||||||
onChange={e => setEditedTemplate({...editedTemplate, subject: 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={editedTemplate.from || ''}
|
|
||||||
fromName={editedTemplate.fromName || ''}
|
|
||||||
replyTo={editedTemplate.replyTo || ''}
|
|
||||||
onFromChange={value => setEditedTemplate({...editedTemplate, from: value})}
|
|
||||||
onFromNameChange={value => setEditedTemplate({...editedTemplate, fromName: value})}
|
|
||||||
onReplyToChange={value => setEditedTemplate({...editedTemplate, replyTo: value})}
|
|
||||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
|
||||||
layout="vertical"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Template Type</CardTitle>
|
||||||
|
<CardDescription>Choose how this template should be treated</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{([
|
||||||
|
{value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
|
||||||
|
{value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'},
|
||||||
|
{value: 'HEADLESS', label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
|
||||||
|
] as const).map(({value, label, description}) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditedTemplate({...editedTemplate, type: value})}
|
||||||
|
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
||||||
|
editedTemplate.type === value
|
||||||
|
? 'border-neutral-900 bg-neutral-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
|
||||||
|
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editedTemplate.type === 'HEADLESS' && !detectUnsubscribeSignal(editedTemplate.body ?? '') && (
|
||||||
|
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
|
||||||
|
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
|
||||||
|
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2.5 space-y-2">
|
||||||
|
<p className="text-xs text-amber-800 leading-relaxed">
|
||||||
|
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||||
|
{'{{unsubscribeUrl}}'}
|
||||||
|
</code>
|
||||||
|
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||||
|
{'{{manageUrl}}'}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Email Settings</CardTitle>
|
||||||
|
<CardDescription>Configure sender information and subject</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subject">Subject Line <span className="text-red-500">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
type="text"
|
||||||
|
value={editedTemplate.subject || ''}
|
||||||
|
onChange={e => setEditedTemplate({...editedTemplate, subject: e.target.value})}
|
||||||
|
required
|
||||||
|
placeholder="Welcome to our platform!"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500">Use {'{{variableName}}'} for dynamic content</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmailSettings
|
||||||
|
from={editedTemplate.from || ''}
|
||||||
|
fromName={editedTemplate.fromName || ''}
|
||||||
|
replyTo={editedTemplate.replyTo || ''}
|
||||||
|
onFromChange={value => setEditedTemplate({...editedTemplate, from: value})}
|
||||||
|
onFromNameChange={value => setEditedTemplate({...editedTemplate, fromName: value})}
|
||||||
|
onReplyToChange={value => setEditedTemplate({...editedTemplate, replyTo: value})}
|
||||||
|
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<Card className="overflow-visible">
|
<Card className="overflow-visible">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Email Body</CardTitle>
|
<CardTitle>Email Body</CardTitle>
|
||||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<EmailEditor
|
<EmailEditor
|
||||||
value={editedTemplate.body || ''}
|
value={editedTemplate.body || ''}
|
||||||
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</form>
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete Template
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!hasChanges || isSubmitting}>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sticky Save Bar */}
|
{/* Sticky Save Bar */}
|
||||||
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
|
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
|
||||||
|
|||||||
@@ -206,11 +206,18 @@ export default function WorkflowEditorPage() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'UPDATE_CONTACT':
|
case 'UPDATE_CONTACT': {
|
||||||
if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) {
|
const hasUpdates =
|
||||||
errors.push(`"${step.name}" step is missing contact updates`);
|
config.updates && typeof config.updates === 'object' && Object.keys(config.updates).length > 0;
|
||||||
|
const hasSubscriptionAction =
|
||||||
|
typeof config.subscriptionAction === 'string' &&
|
||||||
|
config.subscriptionAction !== 'none' &&
|
||||||
|
config.subscriptionAction !== '';
|
||||||
|
if (!hasUpdates && !hasSubscriptionAction) {
|
||||||
|
errors.push(`"${step.name}" step is missing contact updates or a subscription action`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {EmptyState} from '@plunk/ui';
|
|||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react';
|
import {Calendar, Copy, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -66,6 +66,16 @@ export default function WorkflowsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (workflowId: string) => {
|
||||||
|
try {
|
||||||
|
await network.fetch('POST', `/workflows/${workflowId}/duplicate`);
|
||||||
|
toast.success('Workflow duplicated successfully');
|
||||||
|
void mutate();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to duplicate workflow');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => {
|
const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, {
|
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, {
|
||||||
@@ -219,6 +229,14 @@ export default function WorkflowsPage() {
|
|||||||
<Button asChild variant="ghost" size="sm" title="Edit workflow">
|
<Button asChild variant="ghost" size="sm" title="Edit workflow">
|
||||||
<Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow"><Edit className="h-4 w-4" /></Link>
|
<Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow"><Edit className="h-4 w-4" /></Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Duplicate workflow"
|
||||||
|
onClick={() => handleDuplicate(workflow.id)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -164,4 +164,13 @@
|
|||||||
@apply bg-background text-neutral-800 overflow-hidden;
|
@apply bg-background text-neutral-800 overflow-hidden;
|
||||||
font-feature-settings: "rlig" 1, "calt" 1;
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes indeterminate {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(400%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,13 @@ export default async function Page(props: {params: Promise<{slug?: string[]}>})
|
|||||||
markdownUrl={`/llms.mdx${page.url}`}
|
markdownUrl={`/llms.mdx${page.url}`}
|
||||||
githubUrl={`https://github.com/useplunk/plunk/blob/next/apps/wiki/content/docs/${page.path}`}
|
githubUrl={`https://github.com/useplunk/plunk/blob/next/apps/wiki/content/docs/${page.path}`}
|
||||||
/>
|
/>
|
||||||
|
<p className="ml-auto hidden text-[11px] text-fd-muted-foreground sm:block">
|
||||||
|
Reading this with electronic eyes? Add{' '}
|
||||||
|
<a href={`${page.url}.md`} className="underline decoration-dotted underline-offset-2 transition hover:text-fd-foreground">
|
||||||
|
<code>.md</code>
|
||||||
|
</a>{' '}
|
||||||
|
for the Markdown cut.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocsBody>
|
<DocsBody>
|
||||||
@@ -57,6 +64,11 @@ export async function generateMetadata(props: {params: Promise<{slug?: string[]}
|
|||||||
return {
|
return {
|
||||||
title: page.data.title,
|
title: page.data.title,
|
||||||
description: page.data.description,
|
description: page.data.description,
|
||||||
|
alternates: {
|
||||||
|
types: {
|
||||||
|
'text/markdown': `${page.url}.md`,
|
||||||
|
},
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
images: [{url: ogUrl.toString(), width: 1200, height: 630}],
|
images: [{url: ogUrl.toString(), width: 1200, height: 630}],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {openapi} from '@/lib/openapi';
|
||||||
|
|
||||||
|
export const revalidate = false;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const schemas = await openapi.getSchemas();
|
||||||
|
const first = Object.values(schemas)[0];
|
||||||
|
if (!first) return new Response('Not found', {status: 404});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(first.bundled), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=300, s-maxage=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
import {renderOpenAPIOperationFromMDX} from '@/lib/render-openapi-operation';
|
||||||
import {source} from '@/lib/source';
|
import {source} from '@/lib/source';
|
||||||
import type {InferPageType} from 'fumadocs-core/source';
|
import type {InferPageType} from 'fumadocs-core/source';
|
||||||
|
|
||||||
export async function getLLMText(page: InferPageType<typeof source>) {
|
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||||
const processed = await page.data.getText('processed');
|
const isOpenAPI = Boolean((page.data as {_openapi?: unknown})._openapi);
|
||||||
|
|
||||||
|
if (isOpenAPI) {
|
||||||
|
const raw = await page.data.getText('raw');
|
||||||
|
const rendered = await renderOpenAPIOperationFromMDX(raw, page.data.title, page.url);
|
||||||
|
if (rendered) return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processed = await page.data.getText('processed');
|
||||||
return `# ${page.data.title} (${page.url})
|
return `# ${page.data.title} (${page.url})
|
||||||
|
|
||||||
${processed}`;
|
${processed}`;
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import {openapi} from '@/lib/openapi';
|
||||||
|
|
||||||
|
interface SchemaLike {
|
||||||
|
type?: string | string[];
|
||||||
|
format?: string;
|
||||||
|
enum?: unknown[];
|
||||||
|
description?: string;
|
||||||
|
default?: unknown;
|
||||||
|
example?: unknown;
|
||||||
|
required?: string[];
|
||||||
|
properties?: Record<string, SchemaLike>;
|
||||||
|
items?: SchemaLike;
|
||||||
|
oneOf?: SchemaLike[];
|
||||||
|
anyOf?: SchemaLike[];
|
||||||
|
allOf?: SchemaLike[];
|
||||||
|
nullable?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Parameter {
|
||||||
|
name: string;
|
||||||
|
in: string;
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
schema?: SchemaLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Operation {
|
||||||
|
operationId?: string;
|
||||||
|
summary?: string;
|
||||||
|
description?: string;
|
||||||
|
deprecated?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
parameters?: Parameter[];
|
||||||
|
requestBody?: {
|
||||||
|
required?: boolean;
|
||||||
|
description?: string;
|
||||||
|
content?: Record<string, {schema?: SchemaLike}>;
|
||||||
|
};
|
||||||
|
responses?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
description?: string;
|
||||||
|
content?: Record<string, {schema?: SchemaLike}>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
security?: Array<Record<string, string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(schema?: SchemaLike): string {
|
||||||
|
if (!schema) return 'unknown';
|
||||||
|
if (schema.enum) return `enum (${schema.enum.map(v => JSON.stringify(v)).join(' | ')})`;
|
||||||
|
if (schema.oneOf) return schema.oneOf.map(typeLabel).join(' | ');
|
||||||
|
if (schema.anyOf) return schema.anyOf.map(typeLabel).join(' | ');
|
||||||
|
if (schema.allOf) return schema.allOf.map(typeLabel).join(' & ');
|
||||||
|
if (Array.isArray(schema.type)) return schema.type.join(' | ');
|
||||||
|
const base = schema.type ?? 'object';
|
||||||
|
if (base === 'array' && schema.items) return `array<${typeLabel(schema.items)}>`;
|
||||||
|
if (schema.format) return `${base} (${schema.format})`;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchemaTree(schema: SchemaLike | undefined, depth = 0, lines: string[] = []): string[] {
|
||||||
|
if (!schema) return lines;
|
||||||
|
const indent = ' '.repeat(depth);
|
||||||
|
|
||||||
|
if (schema.allOf) {
|
||||||
|
for (const sub of schema.allOf) renderSchemaTree(sub, depth, lines);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'object' || schema.properties) {
|
||||||
|
const required = new Set(schema.required ?? []);
|
||||||
|
const props = schema.properties ?? {};
|
||||||
|
for (const [name, prop] of Object.entries(props)) {
|
||||||
|
const tag = required.has(name) ? ' (required)' : '';
|
||||||
|
const desc = prop.description ? ` — ${prop.description.replace(/\n+/g, ' ')}` : '';
|
||||||
|
lines.push(`${indent}- \`${name}\`: ${typeLabel(prop)}${tag}${desc}`);
|
||||||
|
if (prop.type === 'object' || prop.properties) {
|
||||||
|
renderSchemaTree(prop, depth + 1, lines);
|
||||||
|
} else if (prop.type === 'array' && prop.items && (prop.items.type === 'object' || prop.items.properties)) {
|
||||||
|
lines.push(`${indent} items:`);
|
||||||
|
renderSchemaTree(prop.items, depth + 2, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'array' && schema.items) {
|
||||||
|
lines.push(`${indent}- items: ${typeLabel(schema.items)}`);
|
||||||
|
renderSchemaTree(schema.items, depth + 1, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exampleFromSchema(schema?: SchemaLike): unknown {
|
||||||
|
if (!schema) return undefined;
|
||||||
|
if (schema.example !== undefined) return schema.example;
|
||||||
|
if (schema.default !== undefined) return schema.default;
|
||||||
|
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
|
||||||
|
if (schema.allOf) {
|
||||||
|
const merged: Record<string, unknown> = {};
|
||||||
|
for (const sub of schema.allOf) Object.assign(merged, exampleFromSchema(sub) as object ?? {});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
if (schema.oneOf?.[0]) return exampleFromSchema(schema.oneOf[0]);
|
||||||
|
if (schema.anyOf?.[0]) return exampleFromSchema(schema.anyOf[0]);
|
||||||
|
|
||||||
|
if (schema.properties) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
const required = new Set(schema.required ?? Object.keys(schema.properties));
|
||||||
|
for (const [name, prop] of Object.entries(schema.properties)) {
|
||||||
|
if (!required.has(name) && schema.required) continue;
|
||||||
|
out[name] = exampleFromSchema(prop);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'array') {
|
||||||
|
const item = exampleFromSchema(schema.items);
|
||||||
|
return item === undefined ? [] : [item];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (schema.type) {
|
||||||
|
case 'string':
|
||||||
|
if (schema.format === 'email') return '[email protected]';
|
||||||
|
if (schema.format === 'date-time') return new Date().toISOString();
|
||||||
|
if (schema.format === 'uri' || schema.format === 'url') return 'https://example.com';
|
||||||
|
return 'string';
|
||||||
|
case 'integer':
|
||||||
|
case 'number':
|
||||||
|
return 0;
|
||||||
|
case 'boolean':
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOperationByMethodPath(
|
||||||
|
doc: {paths?: Record<string, Record<string, Operation>>},
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
): Operation | undefined {
|
||||||
|
const pathItem = doc.paths?.[path];
|
||||||
|
return pathItem?.[method.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOperationRef(mdxBody: string): {method: string; path: string} | undefined {
|
||||||
|
const block = mdxBody.match(/operations=\{(\[[\s\S]*?\])\}/);
|
||||||
|
if (!block) return undefined;
|
||||||
|
const pathMatch = block[1].match(/['"]?path['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||||
|
const methodMatch = block[1].match(/['"]?method['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||||
|
if (!pathMatch || !methodMatch) return undefined;
|
||||||
|
return {path: pathMatch[1], method: methodMatch[1]};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderOpenAPIOperationFromMDX(
|
||||||
|
mdxBody: string,
|
||||||
|
fallbackTitle: string,
|
||||||
|
pageUrl: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const ref = extractOperationRef(mdxBody);
|
||||||
|
if (!ref) return undefined;
|
||||||
|
|
||||||
|
const schemas = await openapi.getSchemas();
|
||||||
|
const first = Object.values(schemas)[0];
|
||||||
|
if (!first) return undefined;
|
||||||
|
|
||||||
|
const doc = first.dereferenced as {
|
||||||
|
servers?: Array<{url: string}>;
|
||||||
|
paths?: Record<string, Record<string, Operation>>;
|
||||||
|
};
|
||||||
|
const op = findOperationByMethodPath(doc, ref.method, ref.path);
|
||||||
|
if (!op) return undefined;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const method = ref.method.toUpperCase();
|
||||||
|
const baseUrl = doc.servers?.[0]?.url ?? '';
|
||||||
|
|
||||||
|
lines.push(`# ${op.summary ?? fallbackTitle} (${pageUrl})`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`\`${method} ${ref.path}\``);
|
||||||
|
lines.push('');
|
||||||
|
if (baseUrl) {
|
||||||
|
lines.push(`Base URL: \`${baseUrl}\``);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (op.description) {
|
||||||
|
lines.push(op.description);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (op.deprecated) {
|
||||||
|
lines.push('> **Deprecated.** This endpoint should not be used in new integrations.');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.parameters && op.parameters.length > 0) {
|
||||||
|
const grouped: Record<string, Parameter[]> = {};
|
||||||
|
for (const p of op.parameters) (grouped[p.in] ??= []).push(p);
|
||||||
|
for (const [location, params] of Object.entries(grouped)) {
|
||||||
|
lines.push(`## ${location[0].toUpperCase()}${location.slice(1)} parameters`);
|
||||||
|
lines.push('');
|
||||||
|
for (const p of params) {
|
||||||
|
const req = p.required ? ' (required)' : '';
|
||||||
|
const desc = p.description ? ` — ${p.description.replace(/\n+/g, ' ')}` : '';
|
||||||
|
lines.push(`- \`${p.name}\`: ${typeLabel(p.schema)}${req}${desc}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = op.requestBody?.content?.['application/json']?.schema;
|
||||||
|
if (body) {
|
||||||
|
lines.push('## Request body');
|
||||||
|
lines.push('');
|
||||||
|
if (op.requestBody?.description) {
|
||||||
|
lines.push(op.requestBody.description);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
const tree = renderSchemaTree(body);
|
||||||
|
if (tree.length > 0) {
|
||||||
|
lines.push(...tree);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
const example = exampleFromSchema(body);
|
||||||
|
if (example !== undefined) {
|
||||||
|
lines.push('Example:');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('```json');
|
||||||
|
lines.push(JSON.stringify(example, null, 2));
|
||||||
|
lines.push('```');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.responses) {
|
||||||
|
lines.push('## Responses');
|
||||||
|
lines.push('');
|
||||||
|
for (const [status, resp] of Object.entries(op.responses)) {
|
||||||
|
const desc = resp.description ? ` — ${resp.description.replace(/\n+/g, ' ')}` : '';
|
||||||
|
lines.push(`### \`${status}\`${desc}`);
|
||||||
|
lines.push('');
|
||||||
|
const schema = resp.content?.['application/json']?.schema;
|
||||||
|
if (schema) {
|
||||||
|
const tree = renderSchemaTree(schema);
|
||||||
|
if (tree.length > 0) {
|
||||||
|
lines.push(...tree);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
const example = exampleFromSchema(schema);
|
||||||
|
if (example !== undefined) {
|
||||||
|
lines.push('```json');
|
||||||
|
lines.push(JSON.stringify(example, null, 2));
|
||||||
|
lines.push('```');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl) {
|
||||||
|
lines.push('## Example request');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('```bash');
|
||||||
|
const curlParts = [`curl -X ${method} '${baseUrl}${ref.path}'`, " -H 'Authorization: Bearer YOUR_API_KEY'"];
|
||||||
|
if (body) {
|
||||||
|
curlParts.push(" -H 'Content-Type: application/json'");
|
||||||
|
const example = exampleFromSchema(body);
|
||||||
|
curlParts.push(` -d '${JSON.stringify(example)}'`);
|
||||||
|
}
|
||||||
|
lines.push(curlParts.join(' \\\n'));
|
||||||
|
lines.push('```');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -57,9 +57,10 @@ export function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
const response = NextResponse.next();
|
const response = NextResponse.next();
|
||||||
response.headers.set('Vary', 'Accept');
|
response.headers.set('Vary', 'Accept');
|
||||||
|
response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!llms\\.mdx|api/search|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',],
|
matcher: ['/((?!llms\\.mdx|api/search|openapi\\.json|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|json|xml|txt|webmanifest)).*)',],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Plunk Documentation
|
||||||
|
|
||||||
|
> Documentation for Plunk, an open-source email platform for developers. Covers core concepts, integration guides, the REST API, and self-hosting.
|
||||||
|
|
||||||
|
Every documentation page is available as Markdown by appending `.md` to the path (for example, `https://docs.useplunk.com/concepts/contacts.md`), or by requesting the page URL with `Accept: text/markdown`. API reference endpoints render the OpenAPI operation — including parameters, request/response schemas, and a sample curl — directly into the Markdown response.
|
||||||
|
|
||||||
|
The full OpenAPI specification is available at [https://docs.useplunk.com/openapi.json](https://docs.useplunk.com/openapi.json).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
- [Welcome to Plunk](https://docs.useplunk.com/index.md): What Plunk is and how to get started
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- [Contacts](https://docs.useplunk.com/concepts/contacts.md): Manage and organize your contacts
|
||||||
|
- [Segments](https://docs.useplunk.com/concepts/segments.md): Group and target contacts with dynamic or static segments
|
||||||
|
- [Templates](https://docs.useplunk.com/concepts/templates.md): Reusable email templates for campaigns, workflows, and transactional emails
|
||||||
|
- [Campaigns](https://docs.useplunk.com/concepts/campaigns.md): One-off broadcast emails sent to a defined audience
|
||||||
|
- [Workflows](https://docs.useplunk.com/concepts/workflows.md): Automated, multi-step journeys triggered by events, segments, schedules, or manual entry
|
||||||
|
- [Transactional emails](https://docs.useplunk.com/concepts/transactional-emails.md): Send emails via API
|
||||||
|
- [Billing](https://docs.useplunk.com/concepts/billing.md): How Plunk's pricing, limits, and consumption work
|
||||||
|
|
||||||
|
## Guides
|
||||||
|
|
||||||
|
- [API keys](https://docs.useplunk.com/guides/api-keys.md): Plunk's two-key model and how to rotate or revoke keys safely
|
||||||
|
- [Verifying domains](https://docs.useplunk.com/guides/verifying-domains.md): Verify sending domains so emails reach the inbox
|
||||||
|
- [Tracking](https://docs.useplunk.com/guides/tracking.md): Track opens and clicks on emails sent through Plunk
|
||||||
|
- [Webhooks](https://docs.useplunk.com/guides/webhooks.md): Send real-time event data from Plunk to your own application
|
||||||
|
- [Receiving emails](https://docs.useplunk.com/guides/receiving-emails.md): Receive inbound email at your verified domain and turn it into events
|
||||||
|
- [Custom fields](https://docs.useplunk.com/guides/custom-fields.md): Store arbitrary data on contacts and use it for personalization and segmentation
|
||||||
|
- [Segment filter reference](https://docs.useplunk.com/guides/segment-filters.md): How to write filters for dynamic segments, campaign audiences, and workflow conditions
|
||||||
|
- [Importing contacts from CSV](https://docs.useplunk.com/guides/importing-contacts.md): Bulk-load contacts and their custom fields
|
||||||
|
- [Unsubscribe & preferences pages](https://docs.useplunk.com/guides/unsubscribe-pages.md): Hosted pages for unsubscribe, resubscribe, and preference management
|
||||||
|
- [Localization](https://docs.useplunk.com/guides/localization.md): Translate the unsubscribe footer and contact-facing pages
|
||||||
|
- [List hygiene](https://docs.useplunk.com/guides/list-hygiene.md): Maintain a healthy email list
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
- [API overview](https://docs.useplunk.com/api-reference/overview.md): Complete Plunk API documentation
|
||||||
|
- [Error codes](https://docs.useplunk.com/api-reference/errors.md): API error codes and troubleshooting
|
||||||
|
|
||||||
|
### Public API
|
||||||
|
|
||||||
|
- [Send transactional email](https://docs.useplunk.com/api-reference/public-api/sendEmail.md): POST — send a transactional email; auto-creates/updates contacts
|
||||||
|
- [Track event](https://docs.useplunk.com/api-reference/public-api/trackEvent.md): POST — track an event for a contact
|
||||||
|
- [Verify email address](https://docs.useplunk.com/api-reference/public-api/verifyEmail.md): POST — validate an address, check disposable/MX/typos
|
||||||
|
|
||||||
|
### Contacts
|
||||||
|
|
||||||
|
- [Create or update contact](https://docs.useplunk.com/api-reference/contacts/createContact.md): POST — upsert by email
|
||||||
|
- [Get contact](https://docs.useplunk.com/api-reference/contacts/getContact.md): GET — single contact by ID
|
||||||
|
- [List contacts](https://docs.useplunk.com/api-reference/contacts/listContacts.md): GET — paginated, cursor-based
|
||||||
|
- [Update contact](https://docs.useplunk.com/api-reference/contacts/updateContact.md): PATCH
|
||||||
|
- [Delete contact](https://docs.useplunk.com/api-reference/contacts/deleteContact.md): DELETE
|
||||||
|
|
||||||
|
### Campaigns
|
||||||
|
|
||||||
|
- [Create campaign](https://docs.useplunk.com/api-reference/campaigns/createCampaign.md): POST
|
||||||
|
- [List campaigns](https://docs.useplunk.com/api-reference/campaigns/listCampaigns.md): GET — paginated
|
||||||
|
- [Send or schedule campaign](https://docs.useplunk.com/api-reference/campaigns/sendCampaign.md): POST — send immediately or schedule
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
|
||||||
|
- [Create template](https://docs.useplunk.com/api-reference/templates/createTemplate.md): POST
|
||||||
|
- [List templates](https://docs.useplunk.com/api-reference/templates/listTemplates.md): GET — paginated
|
||||||
|
|
||||||
|
### Segments
|
||||||
|
|
||||||
|
- [Create segment](https://docs.useplunk.com/api-reference/segments/createSegment.md): POST
|
||||||
|
- [List segments](https://docs.useplunk.com/api-reference/segments/listSegments.md): GET
|
||||||
|
|
||||||
|
## Self-Hosting
|
||||||
|
|
||||||
|
- [Self-hosting introduction](https://docs.useplunk.com/self-hosting/introduction.md): Deploy Plunk on your own infrastructure
|
||||||
|
- [Docker deployment](https://docs.useplunk.com/self-hosting/docker.md): Deploy with Docker Compose
|
||||||
|
- [Environment variables](https://docs.useplunk.com/self-hosting/environment-variables.md): Configuration reference
|
||||||
|
- [AWS SES setup](https://docs.useplunk.com/self-hosting/email-setup.md): Configure email delivery
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [Marketing site](https://www.useplunk.com)
|
||||||
|
- [GitHub repository](https://github.com/useplunk/plunk)
|
||||||
|
- [Discord community](https://www.useplunk.com/discord)
|
||||||
@@ -154,6 +154,10 @@ services:
|
|||||||
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET}
|
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET}
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING: ${SES_CONFIGURATION_SET_NO_TRACKING:-}
|
SES_CONFIGURATION_SET_NO_TRACKING: ${SES_CONFIGURATION_SET_NO_TRACKING:-}
|
||||||
|
|
||||||
|
# Custom MAIL FROM subdomain (defaults to 'plunk'; override when
|
||||||
|
# plunk.<your-domain> is already used for something else, e.g. a CDN)
|
||||||
|
MAIL_FROM_SUBDOMAIN: ${MAIL_FROM_SUBDOMAIN:-}
|
||||||
|
|
||||||
# Optional: OAuth
|
# Optional: OAuth
|
||||||
GITHUB_OAUTH_CLIENT: ${GITHUB_OAUTH_CLIENT:-}
|
GITHUB_OAUTH_CLIENT: ${GITHUB_OAUTH_CLIENT:-}
|
||||||
GITHUB_OAUTH_SECRET: ${GITHUB_OAUTH_SECRET:-}
|
GITHUB_OAUTH_SECRET: ${GITHUB_OAUTH_SECRET:-}
|
||||||
@@ -190,6 +194,14 @@ services:
|
|||||||
# Security
|
# Security
|
||||||
AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false}
|
AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false}
|
||||||
|
|
||||||
|
# Self-hosting user management (documented in .env.self-host.example
|
||||||
|
# and read by apps/api/src/app/constants.ts at import time)
|
||||||
|
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
|
||||||
|
|
||||||
|
# Explicit SES sending rate (avoid silent fallback to 14/sec when
|
||||||
|
# ses:GetSendQuota is denied or transiently fails at worker startup)
|
||||||
|
EMAIL_RATE_LIMIT_PER_SECOND: ${EMAIL_RATE_LIMIT_PER_SECOND:-}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent storage for application data
|
# Persistent storage for application data
|
||||||
- plunk_data:/app/data
|
- plunk_data:/app/data
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plunk",
|
"name": "plunk",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
|||||||
@@ -96,9 +96,21 @@ export const ContactSchemas = {
|
|||||||
subscribed: z.boolean().default(true),
|
subscribed: z.boolean().default(true),
|
||||||
data: jsonSchema.optional(),
|
data: jsonSchema.optional(),
|
||||||
}),
|
}),
|
||||||
bulkAction: z.object({
|
bulkAction: z.discriminatedUnion('mode', [
|
||||||
contactIds: z.array(uuid).min(1).max(1000),
|
z.object({
|
||||||
}),
|
mode: z.literal('ids'),
|
||||||
|
contactIds: z.array(uuid).min(1).max(1000),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
mode: z.literal('query'),
|
||||||
|
filter: z
|
||||||
|
.object({
|
||||||
|
search: z.string().max(255).optional(),
|
||||||
|
})
|
||||||
|
.default({}),
|
||||||
|
excludeIds: z.array(uuid).max(10000).optional(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
lookup: z.object({
|
lookup: z.object({
|
||||||
emails: z.array(z.string().email()).min(1).max(500),
|
emails: z.array(z.string().email()).min(1).max(500),
|
||||||
}),
|
}),
|
||||||
@@ -334,9 +346,17 @@ export const WorkflowStepConfigSchemas = {
|
|||||||
headers: z.record(z.string()).optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
body: jsonSchema.optional(),
|
body: jsonSchema.optional(),
|
||||||
}),
|
}),
|
||||||
updateContact: z.object({
|
updateContact: z
|
||||||
updates: z.record(z.any()),
|
.object({
|
||||||
}),
|
updates: z.record(z.any()).optional(),
|
||||||
|
subscriptionAction: z.enum(['none', 'subscribe', 'unsubscribe']).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
value =>
|
||||||
|
(value.updates && Object.keys(value.updates).length > 0) ||
|
||||||
|
(value.subscriptionAction && value.subscriptionAction !== 'none'),
|
||||||
|
{message: 'Provide at least one field to update or a subscription action'},
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DomainSchemas = {
|
export const DomainSchemas = {
|
||||||
|
|||||||
@@ -12,12 +12,23 @@ export interface ContactImportJobData {
|
|||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector describing which contacts a bulk action should target.
|
||||||
|
* - `ids`: explicit list, hard-capped at 1000.
|
||||||
|
* - `query`: every contact matching the filter, optionally excluding specific ids.
|
||||||
|
* Snapshot semantics: the worker iterates current matches at execution time, so
|
||||||
|
* contacts created after the job is queued may or may not be included.
|
||||||
|
*/
|
||||||
|
export type BulkContactActionSelector =
|
||||||
|
| {mode: 'ids'; contactIds: string[]}
|
||||||
|
| {mode: 'query'; filter: {search?: string}; excludeIds?: string[]};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job data for bulk contact actions (subscribe, unsubscribe, delete)
|
* Job data for bulk contact actions (subscribe, unsubscribe, delete)
|
||||||
* Used by: bulkContactQueue worker
|
* Used by: bulkContactQueue worker
|
||||||
*/
|
*/
|
||||||
export interface BulkContactActionJobData {
|
export interface BulkContactActionJobData {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
contactIds: string[];
|
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||||
|
selector: BulkContactActionSelector;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user