Compare commits

..
Author SHA1 Message Date
dependabot[bot] 754461d4a8 build(deps): bump next from 16.2.3 to 16.2.6
Bumps [next](https://github.com/vercel/next.js) from 16.2.3 to 16.2.6.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.3...v16.2.6)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-05-11 17:07:53 +00:00
Dries Augustyns 649bbf6d6b fix: enhance Quick Start card layout for improved responsiveness and usability 2026-05-10 13:57:46 +02:00
Dries Augustyns e43f70d8a1 fix: update activity item colors and backgrounds for improved visual distinction 2026-05-10 11:59:54 +02:00
Dries Augustyns f22da4add1 feat: implement caching for recent activity count to optimize performance and reduce database load 2026-05-10 11:37:31 +02:00
Dries Augustyns de6335e999 feat: implement bulk contact action selector for improved flexibility in bulk operations 2026-05-10 10:40:40 +02:00
Dries Augustyns 7658a59b5d feat: enhance campaign scheduling and audience settings UI for better clarity and usability 2026-05-10 09:52:25 +02:00
Dries Augustyns aaf5ac6530 feat: refactor template editing layout for improved usability and clarity 2026-05-10 09:24:21 +02:00
Dries Augustyns c6340a1dc7 feat: add workflow duplication functionality with API endpoint and UI button 2026-05-09 16:51:36 +02:00
Dries Augustyns 4ba43dd3b6 feat: add external link to edit email templates in SendEmailStepDialog and WorkflowBuilder 2026-05-09 16:36:47 +02:00
Dries Augustyns d11495061d feat: add search functionality to campaigns list with debounce effect 2026-05-09 09:10:17 +02:00
Dries Augustyns fadc19d139 feat: Ability to change subscription status in workflows 2026-05-09 08:43:05 +02:00
30 changed files with 2176 additions and 973 deletions
+2
View File
@@ -64,6 +64,7 @@ export class Campaigns {
private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
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 pageSize = parseInt(req.query.pageSize as string) || 20;
@@ -74,6 +75,7 @@ export class Campaigns {
const result = await CampaignService.list(auth.projectId, {
status,
search,
page,
pageSize,
});
+38 -73
View File
@@ -1,6 +1,8 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import {ContactSchemas} from '@plunk/shared';
import type {BulkContactActionSelector} from '@plunk/types';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
@@ -424,31 +426,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
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',
});
}
return queueBulkAction(req, res, 'subscribe');
}
/**
@@ -459,30 +437,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
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',
});
}
return queueBulkAction(req, res, 'unsubscribe');
}
/**
@@ -493,30 +448,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
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',
});
}
return queueBulkAction(req, res, '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}`,
});
}
}
+20
View File
@@ -149,6 +149,26 @@ export class Workflows {
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
* Add a step to a workflow
+122 -49
View File
@@ -3,73 +3,149 @@
* 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 signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
const BATCH_SIZE = 100;
interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
/** Contacts whose state was actually changed by this run. */
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;
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() {
const worker = new Worker<BulkContactActionJobData>(
bulkContactQueue.name,
async (job: Job<BulkContactActionJobData>) => {
const {projectId, contactIds, operation} = job.data;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
);
const {projectId, operation, selector} = job.data;
const result: BulkActionResult = {
operation,
totalRequested: contactIds.length,
totalRequested: 0,
successCount: 0,
unchangedCount: 0,
failureCount: 0,
errors: [],
};
try {
// Process contacts in batches
if (selector.mode === 'ids') {
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) {
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 {
let batchResult: {updated?: number; deleted?: number};
switch (operation) {
case 'subscribe':
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
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
}
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);
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
result.failureCount += batchIds.length;
result.errors.push({
contactId: 'batch',
@@ -78,24 +154,21 @@ export function createBulkContactWorker() {
});
}
// Update progress
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
processedRows += batchIds.length;
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100)));
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,
concurrency: 3, // Process max 3 bulk operations concurrently
concurrency: 3,
},
);
+32 -1
View File
@@ -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)
* 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> {
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 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;
}
/**
+11 -1
View File
@@ -186,16 +186,26 @@ export class CampaignService {
projectId: string,
options: {
status?: CampaignStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<PaginatedResponse<Campaign>> {
const {status, page = 1, pageSize = 20} = options;
const {status, search, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize;
const where: Prisma.CampaignWhereInput = {
projectId,
...(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([
+30 -48
View File
@@ -721,99 +721,81 @@ export class ContactService {
/**
* 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}> {
// Verify all contacts belong to this project
public static async bulkSubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number; unchanged: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
where: {id: {in: contactIds}, projectId},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
if (contacts.length === 0) {
return {updated: 0, unchanged: 0};
}
// Only update contacts that are currently unsubscribed
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
const unchanged = contacts.length - unsubscribedIds.length;
if (unsubscribedIds.length === 0) {
return {updated: 0};
return {updated: 0, unchanged};
}
// Update in a single query for performance
const result = await prisma.contact.updateMany({
where: {
id: {in: unsubscribedIds},
projectId,
},
data: {
subscribed: true,
},
where: {id: {in: unsubscribedIds}, 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 => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
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({
where: {
id: {in: contactIds},
projectId,
},
where: {id: {in: contactIds}, projectId},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
if (contacts.length === 0) {
return {updated: 0, unchanged: 0};
}
// Only update contacts that are currently subscribed
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
const unchanged = contacts.length - subscribedIds.length;
if (subscribedIds.length === 0) {
return {updated: 0};
return {updated: 0, unchanged};
}
const result = await prisma.contact.updateMany({
where: {
id: {in: subscribedIds},
projectId,
},
data: {
subscribed: false,
},
where: {id: {in: subscribedIds}, 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 => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
}
});
return {updated: result.count};
return {updated: result.count, unchanged};
}
/**
+3 -2
View File
@@ -5,6 +5,7 @@ import signale from 'signale';
import type {
ApiRequestCleanupJobData,
BulkContactActionJobData,
BulkContactActionSelector,
CampaignBatchJobData,
ContactImportJobData,
DomainVerificationJobData,
@@ -350,12 +351,12 @@ export class QueueService {
*/
public static async queueBulkContactAction(
projectId: string,
contactIds: string[],
selector: BulkContactActionSelector,
operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add(
'bulk-contact-action',
{projectId, contactIds, operation},
{projectId, operation, selector},
{
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
},
@@ -979,7 +979,7 @@ export class WorkflowExecutionService {
_stepExecution: WorkflowStepExecution,
config: StepConfig,
): Promise<StepResult> {
const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config);
const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config);
const contact = execution.contact;
const currentData =
@@ -987,24 +987,43 @@ export class WorkflowExecutionService {
? (contact.data as Record<string, unknown>)
: {};
// Merge updates with current data
const newData = {
...currentData,
...updates,
};
const hasDataUpdates = updates && Object.keys(updates).length > 0;
const newData = hasDataUpdates ? {...currentData, ...updates} : currentData;
// Update contact in database
await prisma.contact.update({
where: {id: contact.id},
data: {
data: newData ? toPrismaJson(newData) : undefined,
},
});
const desiredSubscribed =
subscriptionAction === 'subscribe' ? true : subscriptionAction === 'unsubscribe' ? false : undefined;
const subscriptionChanging = desiredSubscribed !== undefined && desiredSubscribed !== contact.subscribed;
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 {
updated: true,
updated: hasDataUpdates || subscriptionChanging,
updates,
newData,
subscriptionAction,
subscribed: desiredSubscribed ?? contact.subscribed,
};
}
+66
View File
@@ -310,6 +310,72 @@ export class WorkflowService {
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
*/
+3
View File
@@ -53,6 +53,9 @@ export const Keys = {
stats(projectId: string, startTime: number | string, endTime: number | string): string {
return `activity:stats:${projectId}:${startTime}:${endTime}`;
},
recentCount(projectId: string, minutes: number): string {
return `activity:recent-count:${projectId}:${minutes}`;
},
},
Analytics: {
timeseries(projectId: string, startDate: string, endDate: string): string {
+1 -1
View File
@@ -27,7 +27,7 @@
"@tiptap/starter-kit": "^3.11.0",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.2.3",
"next": "^16.2.6",
"next-seo": "^6.6.0",
"react": "19.2.3",
"react-dom": "19.2.3",
+1 -1
View File
@@ -43,7 +43,7 @@
"framer-motion": "^12.23.24",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.2.3",
"next": "^16.2.6",
"next-seo": "^6.6.0",
"nuqs": "^2.7.3",
"react": "19.2.3",
+18 -18
View File
@@ -133,8 +133,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'event.triggered':
return {
icon: Zap,
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
color: 'text-amber-700',
bgColor: 'bg-amber-50',
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
description: undefined,
badge: {
@@ -150,8 +150,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.sent':
return {
icon: Send,
color: 'text-green-700',
bgColor: 'bg-green-50',
color: 'text-neutral-700',
bgColor: 'bg-neutral-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
@@ -169,8 +169,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.delivered':
return {
icon: CheckCircle,
color: 'text-green-700',
bgColor: 'bg-green-50',
color: 'text-emerald-700',
bgColor: 'bg-emerald-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
@@ -199,8 +199,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.opened':
return {
icon: Eye,
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
color: 'text-emerald-700',
bgColor: 'bg-emerald-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
description:
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
@@ -219,8 +219,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.clicked':
return {
icon: MousePointerClick,
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
color: 'text-sky-700',
bgColor: 'bg-sky-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
description:
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
@@ -269,8 +269,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.started':
return {
icon: Workflow,
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
color: 'text-amber-700',
bgColor: 'bg-amber-50',
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
description: `Status: ${String(metadata.status || 'unknown')}`,
badge: {
@@ -282,8 +282,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.completed':
return {
icon: CheckCheck,
color: 'text-green-700',
bgColor: 'bg-green-50',
color: 'text-amber-700',
bgColor: 'bg-amber-50',
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
description: metadata.exitReason
? `Exit: ${String(metadata.exitReason)}`
@@ -297,8 +297,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'campaign.scheduled':
return {
icon: Calendar,
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
color: 'text-sky-700',
bgColor: 'bg-sky-50',
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
description: metadata.subject
? `${String(metadata.subject)}${metadata.totalRecipients ? `${metadata.totalRecipients} recipients` : ''}`
@@ -314,8 +314,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.email.scheduled':
return {
icon: Calendar,
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
color: 'text-amber-700',
bgColor: 'bg-amber-50',
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
description: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? `${String(metadata.subject)}` : ''}`
+10 -10
View File
@@ -33,7 +33,7 @@ function HelpResources() {
};
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>
<div className="flex flex-col sm:flex-row gap-2">
<Button asChild variant="outline" size="sm" className="flex-1">
@@ -101,12 +101,12 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
if (isLoading || !setupState) {
return (
<Card>
<Card className="flex flex-col h-full">
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>Get started with Plunk in minutes</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-3">
{[1, 2, 3].map(i => (
<div
@@ -122,8 +122,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
</div>
))}
</div>
<HelpResources />
</CardContent>
<HelpResources />
</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 (setupState.hasVerifiedDomain && hasContacts && hasSentCampaign && hasRecentCampaign) {
return (
<Card>
<Card className="flex flex-col h-full">
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>Your project is fully set up</CardDescription>
</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="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" />
@@ -234,8 +234,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
</p>
</div>
</div>
<HelpResources />
</CardContent>
<HelpResources />
</Card>
);
}
@@ -244,14 +244,14 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
const visibleSteps = allSteps.slice(0, 3);
return (
<Card>
<Card className="flex flex-col h-full">
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>
{visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-3">
{visibleSteps.map(step => {
const Icon = step.icon;
@@ -281,8 +281,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
);
})}
</div>
<HelpResources />
</CardContent>
<HelpResources />
</Card>
);
}
+15 -5
View File
@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
import {
Clock,
ExternalLink,
GitBranch,
Hourglass,
Lightbulb,
@@ -243,7 +244,7 @@ function CustomNode({
bgColor?: string;
onEdit?: () => void;
onDelete?: () => void;
template?: {name: string};
template?: {id: string; name: string};
config?: any;
};
}) {
@@ -341,10 +342,19 @@ function CustomNode({
{/* Details */}
{data.template && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<Mail className="h-3 w-3" />
<span className="truncate">{data.template.name}</span>
</div>
<a
href={`/templates/${data.template.id}`}
target="_blank"
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>
)}
{data.type === 'DELAY' && data.config?.amount && (
@@ -1,4 +1,5 @@
import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui';
import {ExternalLink} from 'lucide-react';
import {useState} from 'react';
import {toast} from 'sonner';
@@ -68,7 +69,20 @@ export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuc
>
<div className="space-y-4">
<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} />
</div>
@@ -1,3 +1,4 @@
import {Label, RadioGroup, RadioGroupItem} from '@plunk/ui';
import {useState} from 'react';
import {toast} from 'sonner';
@@ -5,31 +6,50 @@ import {KeyValueEditor} from '../KeyValueEditor';
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) {
const config = getStepConfig(step);
const initialUpdates =
config.updates && typeof config.updates === 'object'
? (config.updates as Record<string, string | number | boolean>)
: null;
const initialSubscriptionAction: SubscriptionAction =
config.subscriptionAction === 'subscribe' || config.subscriptionAction === 'unsubscribe'
? config.subscriptionAction
: 'none';
const [name, setName] = useState(step.name);
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>(
initialUpdates,
);
const [subscriptionAction, setSubscriptionAction] = useState<SubscriptionAction>(initialSubscriptionAction);
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) {
toast.error('At least one field to update is required');
const hasUpdates = contactUpdateData && Object.keys(contactUpdateData).length > 0;
const hasSubscriptionAction = subscriptionAction !== 'none';
if (!hasUpdates && !hasSubscriptionAction) {
toast.error('Add at least one field to update or choose a subscription action');
return;
}
const ok = await update({
name,
config: {updates: contactUpdateData},
config: {
updates: hasUpdates ? contactUpdateData : {},
subscriptionAction,
},
});
if (ok) {
@@ -48,7 +68,32 @@ export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, o
onSubmit={handleSubmit}
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>
);
}
+357 -246
View File
@@ -42,6 +42,7 @@ import {
ArrowLeft,
Calendar,
ChevronDown,
Info,
Mail,
MousePointer,
Save,
@@ -107,6 +108,7 @@ export default function CampaignDetailsPage() {
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
const [scheduledDateTime, setScheduledDateTime] = useState('');
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [testEmailAddress, setTestEmailAddress] = useState('');
type CampaignDialog =
@@ -178,6 +180,7 @@ export default function CampaignDetailsPage() {
toast.success(`Campaign scheduled for ${localTimeString}`);
setDialog({type: 'none'});
setScheduledDateTime('');
setSelectedPreset(null);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
@@ -436,126 +439,35 @@ export default function CampaignDetailsPage() {
</div>
</div>
{/* Campaign Settings - Horizontal Layout */}
<div className="grid gap-6 md:grid-cols-2">
{/* Campaign Settings */}
<Card>
<CardHeader>
<CardTitle>Campaign Settings</CardTitle>
<CardDescription>Basic information about your campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Campaign Name *</Label>
<Input
id="name"
type="text"
value={editedCampaign.name || ''}
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
required
placeholder="Spring Sale Campaign"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={editedCampaign.description || ''}
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
placeholder="Optional description for internal use"
/>
</div>
<div>
<Label>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>
{/* Audience — surfaced first because Send lives in the header.
Users need to see who/how many before pressing Send. */}
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div>
<CardTitle>Audience</CardTitle>
<CardDescription>Who will receive this campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="audienceType">Audience Type *</Label>
<CardDescription>Who will receive this campaign when you send</CardDescription>
</div>
{draftRecipientCount > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 shrink-0">
<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
value={editedCampaign.audienceType ?? c.audienceType}
onValueChange={(value: CampaignAudienceType) => {
setEditedCampaign({
...editedCampaign,
audienceType: value,
// Clear segmentId if changing away from SEGMENT
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
});
}}
@@ -571,7 +483,7 @@ export default function CampaignDetailsPage() {
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
title="Segment"
title="Specific Segment"
description="Target a defined group of contacts"
/>
</SelectContent>
@@ -579,8 +491,10 @@ export default function CampaignDetailsPage() {
</div>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
<div>
<Label htmlFor="segment">Select Segment *</Label>
<div className="space-y-2">
<Label htmlFor="segment">
Select Segment <span className="text-red-500">*</span>
</Label>
<Select
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
onValueChange={(value: string) => {
@@ -610,44 +524,160 @@ export default function CampaignDetailsPage() {
</SelectContent>
</Select>
{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>
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
<p className="text-sm text-neutral-500">
Filtered audiences are configured with advanced filter conditions
</p>
)}
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
<p className="text-sm text-neutral-500">
Filtered audiences are configured with advanced filter conditions
</p>
)}
{/* Show recipient count */}
{draftRecipientCount > 0 && (
<div className="mt-4 p-3 bg-neutral-50 border border-neutral-200 rounded-lg space-y-1.5">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-900">
{draftRecipientCount.toLocaleString()} recipients
</span>
{draftRecipientCount > 0 && (
<p className="text-xs text-neutral-500">
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>
)}
</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>
<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>
)}
</CardContent>
</Card>
</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">
<CardHeader>
<CardTitle>Email Content</CardTitle>
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
<CardDescription>Design your email message</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
@@ -662,37 +692,51 @@ export default function CampaignDetailsPage() {
{/* Test Email Dialog */}
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send Test Email</DialogTitle>
<DialogTitle>Send a preview</DialogTitle>
<DialogDescription>
Send a test version of this campaign to a project member to verify how it looks. The test email will
be prefixed with [TEST] in the subject line.
Get a copy of this campaign in your inbox before sending it for real.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="testEmail">Project Member</Label>
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
<SelectTrigger id="testEmail" className="mt-2">
<SelectValue placeholder="Select a project member..." />
</SelectTrigger>
<SelectContent>
{projectMembers?.data.map(member => (
<SelectItem key={member.userId} value={member.email}>
{member.email}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-2">
For security reasons, test emails can only be sent to project members.
</p>
<p className="text-xs text-neutral-500 mt-1">
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
</p>
<div className="space-y-2">
<Label htmlFor="testEmail">Send to</Label>
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
<SelectTrigger id="testEmail">
<SelectValue placeholder="Choose a teammate" />
</SelectTrigger>
<SelectContent>
{projectMembers?.data.map(member => (
<SelectItem key={member.userId} value={member.email}>
{member.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Preview of how the email will arrive */}
<div className="space-y-2">
<Label className="text-neutral-500">Will arrive as</Label>
<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>
<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>
<Button
type="button"
@@ -709,7 +753,8 @@ export default function CampaignDetailsPage() {
onClick={handleSendTestEmail}
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>
</DialogFooter>
</DialogContent>
@@ -717,92 +762,105 @@ export default function CampaignDetailsPage() {
{/* Schedule Dialog */}
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Schedule Campaign</DialogTitle>
<DialogTitle>Schedule for later</DialogTitle>
<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>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Quick Presets */}
<div>
<Label>Quick Schedule</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())}
>
In 1 hour
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())}
>
In 3 hours
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())}
>
Tomorrow at 9 AM
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt2PM())}
>
Tomorrow at 2 PM
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.nextMonday())}
>
Next Monday
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScheduledDateTime(schedulePresets.inOneWeek())}
>
In 1 week
</Button>
<div className="space-y-5 py-2">
{/* Quick presets */}
<div className="space-y-2">
<Label>Quick options</Label>
<div className="grid grid-cols-2 gap-2">
{[
{key: 'in1h', label: 'In 1 hour', getValue: schedulePresets.inOneHour},
{key: 'in3h', label: 'In 3 hours', getValue: schedulePresets.inThreeHours},
{key: 'tom9', label: 'Tomorrow, 9 AM', getValue: schedulePresets.tomorrowAt9AM},
{key: 'tom2', label: 'Tomorrow, 2 PM', getValue: schedulePresets.tomorrowAt2PM},
{key: 'nextMon', label: 'Next Monday', getValue: schedulePresets.nextMonday},
{key: 'in1w', label: 'In 1 week', getValue: schedulePresets.inOneWeek},
].map(({key, label, getValue}) => {
const isActive = selectedPreset === key;
return (
<button
key={key}
type="button"
onClick={() => {
setScheduledDateTime(getValue());
setSelectedPreset(key);
}}
className={`min-h-[40px] px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
isActive
? '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'
}`}
>
{label}
</button>
);
})}
</div>
</div>
{/* Custom Date/Time */}
<div>
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
<div className="space-y-2">
<Label htmlFor="scheduledDateTime">Or pick an exact time</Label>
<Input
id="scheduledDateTime"
type="datetime-local"
value={scheduledDateTime}
onChange={e => setScheduledDateTime(e.target.value)}
onChange={e => {
setScheduledDateTime(e.target.value);
setSelectedPreset(null);
}}
min={new Date().toISOString().slice(0, 16)}
className="mt-2"
/>
{scheduledDateTime && (
<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>
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p>
<p className="text-xs text-neutral-500 mt-1">
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
</div>
{/* Confirmation preview — date + audience together */}
{scheduledDateTime && (
<div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200">
<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>
</div>
)}
</div>
{draftRecipientCount > 0 && (
<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>
<p className="text-xs text-neutral-500 leading-relaxed">
You can edit or cancel this campaign anytime before it sends.
</p>
<DialogFooter>
<Button
type="button"
@@ -810,12 +868,14 @@ export default function CampaignDetailsPage() {
onClick={() => {
setDialog({type: 'none'});
setScheduledDateTime('');
setSelectedPreset(null);
}}
>
Cancel
Not yet
</Button>
<Button type="button" onClick={handleSchedule}>
Schedule Campaign
<Button type="button" onClick={handleSchedule} disabled={!scheduledDateTime}>
<Calendar className="h-4 w-4" />
Schedule send
</Button>
</DialogFooter>
</DialogContent>
@@ -825,15 +885,66 @@ export default function CampaignDetailsPage() {
{/* Sticky Save Bar */}
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
<ConfirmDialog
open={dialog.type === 'send'}
onOpenChange={open => !open && setDialog({type: 'none'})}
onConfirm={handleSend}
title="Send Campaign"
description="Are you sure you want to send this campaign now? This action cannot be undone."
confirmText="Send Now"
variant="default"
/>
<Dialog open={dialog.type === 'send'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ready to send?</DialogTitle>
<DialogDescription>Review the details below, then send when you{"'"}re ready.</DialogDescription>
</DialogHeader>
<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
open={dialog.type === 'delete'}
+102 -79
View File
@@ -8,11 +8,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Input,
} from '@plunk/ui';
import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
@@ -23,11 +19,11 @@ import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog'
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network';
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 Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
@@ -35,7 +31,9 @@ import dayjs from 'dayjs';
export default function CampaignsPage() {
const router = useRouter();
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 [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -44,10 +42,18 @@ export default function CampaignsPage() {
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
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},
);
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [searchInput]);
const getStatusBadge = (status: CampaignStatus) => {
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
DRAFT: {label: 'Draft', variant: 'neutral'},
@@ -245,21 +251,45 @@ export default function CampaignsPage() {
</DropdownMenu>
</div>
{/* Filters */}
<div className="w-56">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
<SelectItem value="SENDING">Sending</SelectItem>
<SelectItem value="SENT">Sent</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search campaigns..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
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>
{/* Campaigns List */}
@@ -275,66 +305,59 @@ export default function CampaignsPage() {
<CardContent>
<EmptyState
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={
statusFilter !== 'ALL'
? 'Adjust your filters or create a new campaign.'
: 'Send one-off emails to groups of contacts.'
search
? 'Try a different search term.'
: statusFilter !== 'ALL'
? 'Adjust your filters or create a new campaign.'
: 'Send one-off emails to groups of contacts.'
}
action={
statusFilter === 'ALL' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<Plus className="h-4 w-4" />
Create Campaign
</Link>
</Button>
)
<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>
}
/>
</CardContent>
+484 -179
View File
@@ -2,9 +2,6 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
ConfirmDialog,
Dialog,
@@ -25,14 +22,18 @@ import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {
AlertTriangle,
Check,
CheckCircle,
ChevronLeft,
ChevronRight,
Edit,
FileUp,
Loader2,
Mail,
MailCheck,
MailX,
Minus,
Plus,
Search,
Trash2,
@@ -61,6 +62,8 @@ export default function ContactsPage() {
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState<number>(0);
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 [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
const pageSize = 50;
@@ -87,6 +90,9 @@ export default function ContactsPage() {
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
setSelectedContacts(new Set());
setSelectAllMatching(false);
setExcludedContacts(new Set());
}, 350);
return () => clearTimeout(timer);
}, [searchInput, search]);
@@ -96,9 +102,12 @@ export default function ContactsPage() {
const newPage = currentPage + 1;
setCursor(data.cursor);
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) {
setCursorHistory(prev => [...prev, data.cursor]);
}
@@ -111,12 +120,38 @@ export default function ContactsPage() {
const previousCursor = cursorHistory[newPage];
setCursor(previousCursor);
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 = () => {
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());
} else {
setSelectedContacts(new Set(contacts.map(c => c.id)));
@@ -124,15 +159,30 @@ export default function ContactsPage() {
};
const handleSelectContact = (contactId: string) => {
const newSelected = new Set(selectedContacts);
if (newSelected.has(contactId)) {
newSelected.delete(contactId);
} else {
newSelected.add(contactId);
if (selectAllMatching) {
setExcludedContacts(prev => {
const next = new Set(prev);
if (next.has(contactId)) next.delete(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') => {
setBulkOperation(operation);
setShowBulkActionsDialog(true);
@@ -140,6 +190,14 @@ export default function ContactsPage() {
const clearSelection = () => {
setSelectedContacts(new Set());
setSelectAllMatching(false);
setExcludedContacts(new Set());
};
const handleSelectAllMatching = () => {
setSelectAllMatching(true);
setSelectedContacts(new Set());
setExcludedContacts(new Set());
};
const promptDelete = (contactId: string) => {
@@ -172,8 +230,7 @@ export default function ContactsPage() {
<div>
<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">
Manage your email subscribers and their data.{' '}
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
Manage your email subscribers and their data.
</p>
</div>
<div className="flex gap-2">
@@ -191,45 +248,68 @@ export default function ContactsPage() {
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
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>
{/* Bulk Actions Toolbar */}
{selectedContacts.size > 0 && (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-neutral-900">
{selectedContacts.size} contact{selectedContacts.size !== 1 ? 's' : ''} selected
{/* Contacts Table */}
<Card>
{/* Contextual header strip: idle = search + count, selecting = bulk actions.
Single fixed-min-height row prevents layout shift as state toggles.
The select-all-matching link is folded inline into the toolbar. */}
<div
key={effectiveSelectionCount === 0 ? 'idle' : 'selecting'}
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"
>
{effectiveSelectionCount === 0 ? (
<div className="flex items-center gap-4 w-full">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400 pointer-events-none" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-9 h-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
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"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{totalCount > 0 && (
<span className="hidden sm:inline text-sm text-neutral-500 tabular-nums whitespace-nowrap">
{totalCount.toLocaleString()} {search ? 'matching' : 'total'}
</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')}>
<MailCheck className="h-4 w-4 mr-1.5" />
Subscribe
@@ -238,48 +318,50 @@ export default function ContactsPage() {
<MailX className="h-4 w-4 mr-1.5" />
Unsubscribe
</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" />
Delete
</Button>
</div>
</div>
<Button variant="ghost" size="sm" onClick={clearSelection}>
Clear Selection
<Button
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>
</div>
</CardContent>
</Card>
)}
{/* Contacts Table */}
<Card>
<CardHeader>
<CardTitle>All Contacts</CardTitle>
<CardDescription>
View and manage your contact list.
{totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
</CardDescription>
</CardHeader>
<CardContent>
)}
</div>
<CardContent className="p-0">
{isLoading && contacts.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="flex items-center justify-center py-16">
<IconSpinner />
</div>
) : contacts.length === 0 ? (
<EmptyState
icon={Mail}
title={search ? 'No contacts match' : 'No contacts yet'}
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
action={
!search ? (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
) : undefined
}
/>
<div className="px-6 py-12">
<EmptyState
icon={Mail}
title={search ? 'No contacts match' : 'No contacts yet'}
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
action={
!search ? (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
) : undefined
}
/>
</div>
) : (
<>
{/* Desktop Table View - Hidden on mobile */}
@@ -289,7 +371,7 @@ export default function ContactsPage() {
<tr>
<th className="px-6 py-3 text-left w-12">
<Checkbox
checked={selectedContacts.size === contacts.length && contacts.length > 0}
checked={allOnPageSelected}
onCheckedChange={handleSelectAll}
/>
</th>
@@ -312,7 +394,7 @@ export default function ContactsPage() {
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<Checkbox
checked={selectedContacts.has(contact.id)}
checked={isContactSelected(contact.id)}
onCheckedChange={() => handleSelectContact(contact.id)}
/>
</td>
@@ -360,7 +442,7 @@ export default function ContactsPage() {
</div>
{/* 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 => (
<div
key={contact.id}
@@ -405,7 +487,7 @@ export default function ContactsPage() {
{/* Pagination Controls */}
{(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">
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>
@@ -455,7 +537,12 @@ export default function ContactsPage() {
open={showBulkActionsDialog}
onOpenChange={setShowBulkActionsDialog}
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={() => {
mutate();
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 {
open: boolean;
onOpenChange: (open: boolean) => void;
operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
contactIds: string[];
selector: BulkSelector;
targetCount: number;
onSuccess: () => void;
}
interface BulkActionResult {
operation: string;
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
/** Contacts whose state was actually changed by this run. */
successCount: number;
/** Subscribe/unsubscribe only: contacts that were already in the target state. */
unchangedCount: number;
/** Contacts that errored or weren't found. */
failureCount: number;
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 [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
@@ -949,8 +1045,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
}
if (response.result) {
const {successCount, failureCount} = response.result;
toast.success(`Completed: ${successCount} succeeded${failureCount > 0 ? `, ${failureCount} failed` : ''}`);
toast.success(buildToastSummary(response.result));
}
onSuccess();
@@ -988,7 +1083,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
'POST',
endpoint,
{contactIds},
selector,
);
setJobId(data.jobId);
@@ -1019,103 +1114,97 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
onOpenChange(false);
};
const getOperationLabel = () => {
switch (operation) {
case 'subscribe':
return 'Subscribe';
case 'unsubscribe':
return 'Unsubscribe';
case 'delete':
return 'Delete';
default:
return 'Process';
}
};
const copy = getOperationCopy(operation);
const getOperationColor = () => {
switch (operation) {
case 'subscribe':
return 'green';
case 'unsubscribe':
return 'yellow';
case 'delete':
return 'red';
default:
return 'blue';
}
const isQueueing = status === 'processing' && progress === 0;
const dialogTitle =
status === 'completed'
? copy.completedTitle
: status === 'processing'
? copy.progressTitle
: status === 'failed'
? copy.failedTitle
: copy.title;
const handleRetry = () => {
setErrorMessage(null);
setStatus('idle');
void handleConfirm();
};
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{getOperationLabel()} Contacts</DialogTitle>
<DialogTitle className="transition-colors">{dialogTitle}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{status === 'idle' && (
<div className="space-y-1">
<p className="text-sm text-neutral-700">
{operation === 'delete' ? 'Permanently delete' : operation === 'subscribe' ? 'Subscribe' : 'Unsubscribe'}{' '}
<span className="font-medium text-neutral-900">{contactIds.length} contact{contactIds.length !== 1 ? 's' : ''}</span>?
<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 leading-relaxed">
{copy.confirmVerb}{' '}
<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>
{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>
)}
{status === 'processing' && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">Processing contacts...</span>
<span className="text-neutral-900 font-medium">{progress}%</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-1.5">
<div
className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
style={{width: `${progress}%`}}
/>
</div>
</div>
)}
{status === 'completed' && result && (
<div className="space-y-3">
<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</>
)}
<div className="space-y-3 py-1 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
<div className="flex items-baseline justify-between text-sm">
<span className="flex items-center gap-2 text-neutral-600">
{isQueueing && <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />}
<span>
{isQueueing
? 'Queued — starting up…'
: `${copy.processingLabel} ${targetCount.toLocaleString()} contact${targetCount !== 1 ? 's' : ''}`}
</span>
</span>
<span
className={`tabular-nums font-medium transition-opacity ${
isQueueing ? 'text-neutral-400' : 'text-neutral-900'
}`}
>
{progress}%
</span>
</div>
{result.errors && result.errors.length > 0 && (
<div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
<div className="text-xs text-neutral-600">
{result.errors.slice(0, 10).map((error, idx) => (
<div key={idx} className="px-3 py-2 border-b border-neutral-100 last:border-0 text-red-600">
{error.error}
</div>
))}
{result.errors.length > 10 && (
<div className="px-3 py-2 text-neutral-500">
+{result.errors.length - 10} more errors
</div>
)}
</div>
</div>
)}
<div className="relative w-full bg-neutral-100 rounded-full h-1.5 overflow-hidden">
{isQueueing ? (
<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="bg-neutral-900 h-full rounded-full transition-[width] duration-500 ease-out"
style={{width: `${progress}%`}}
/>
)}
</div>
</div>
)}
{status === 'completed' && result && <BulkResultSummary result={result} />}
{status === 'failed' && (
<div className="flex items-start gap-2 text-sm">
<XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-red-600">{errorMessage || 'Please try again.'}</p>
<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">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" strokeWidth={2.25} />
<p className="leading-relaxed">{errorMessage || 'Something went wrong. Please try again.'}</p>
</div>
)}
</div>
@@ -1132,16 +1221,25 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
disabled={isProcessing}
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>
</>
) : status === 'completed' ? (
<Button type="button" onClick={handleClose}>
Close
</Button>
) : (
<Button type="button" variant="outline" onClick={handleClose}>
Close
<Button
type="button"
onClick={handleClose}
variant={status === 'completed' ? 'default' : 'outline'}
>
{status === 'completed' ? 'Done' : 'Hide'}
</Button>
)}
</DialogFooter>
@@ -1152,11 +1250,218 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
open={showCloseConfirmDialog}
onOpenChange={setShowCloseConfirmDialog}
onConfirm={confirmClose}
title="Close Operation"
description="Operation is still in progress. Are you sure you want to close?"
confirmText="Close Anyway"
variant="destructive"
title="Hide this dialog?"
description="The job will keep running in the background. You won't see the result here, but the contacts will still be updated."
confirmText="Hide"
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
View File
@@ -10,10 +10,30 @@ import {
CardTitle,
Skeleton,
} 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 Link from 'next/link';
import {useState} from 'react';
import {useEffect, useMemo, useState} from 'react';
import useSWR from 'swr';
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
import {DashboardLayout} from '../components/DashboardLayout';
import {QuickStart} from '../components/QuickStart';
@@ -28,6 +48,195 @@ import {useConfig} from '../lib/hooks/useConfig';
import {useUser} from '../lib/hooks/useUser';
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() {
const {activeProject} = useActiveProject();
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
@@ -41,29 +250,129 @@ export default function Index() {
const [isResending, setIsResending] = useState(false);
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 = [
{
name: 'Total Contacts',
value: totalContacts.toLocaleString(),
value: totalContacts,
icon: Users,
format: (n: number) => n.toLocaleString(),
},
{
name: 'Emails Sent',
value: totalEmailsSent.toLocaleString(),
value: totalEmailsSent,
icon: Mail,
},
{
name: 'Campaigns',
value: totalCampaigns.toLocaleString(),
icon: Send,
format: (n: number) => n.toLocaleString(),
},
{
name: 'Open Rate',
value: `${openRate.toFixed(1)}%`,
value: openRate,
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() {
setIsResending(true);
setResendMessage('');
@@ -82,6 +391,9 @@ export default function Index() {
}
}
const recentItems = recentActivity?.data ?? [];
const liveCount = recentCount?.count ?? 0;
return (
<>
<NextSeo title="Dashboard" />
@@ -162,64 +474,202 @@ export default function Index() {
)}
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
</div>
<motion.div
initial={{opacity: 0, y: 8}}
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 */}
<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 isEmails = stat.name === 'Emails Sent';
const isOpenRate = stat.name === 'Open Rate';
return (
<Card key={stat.name}>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription>
<Icon className="h-4 w-4 text-neutral-500" />
</div>
<CardTitle className="text-2xl tabular-nums">
{isLoading ? <Skeleton className="h-7 w-16" /> : stat.value}
</CardTitle>
</CardHeader>
</Card>
<motion.div
key={stat.name}
initial={{opacity: 0, y: 12}}
animate={{opacity: 1, y: 0}}
transition={{
duration: 0.5,
delay: 0.05 + index * 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>{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>
{/* Quick Actions & API Keys */}
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
{/* Quick Start + Recent Activity — 50/50 working area with a fixed
row height so the layout doesn't reflow as Quick Start steps are
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} />}
{/* API Keys */}
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activeProject ? (
<>
<ApiKeyDisplay
label="Public Key"
value={activeProject.public}
description="Use this key for client-side integrations"
/>
<ApiKeyDisplay
label="Secret Key"
value={activeProject.secret}
description="Keep this key secure and never expose it publicly"
isSecret
/>
</>
<div className={!bannerActive ? 'lg:relative' : ''}>
<Card
className={`flex flex-col h-full ${
!bannerActive ? 'lg:absolute lg:inset-0' : ''
}`}
>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent activity</CardTitle>
<CardDescription>Live feed of whats happening across your project</CardDescription>
</div>
<Button asChild variant="ghost" size="sm">
<Link href="/activity">View all</Link>
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto">
{!recentActivity ? (
<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 youll 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>
</Card>
</CardContent>
</Card>
</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>
</DashboardLayout>
</>
+136 -128
View File
@@ -121,57 +121,35 @@ export default function TemplateEditorPage() {
return (
<DashboardLayout>
<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 */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Button asChild variant="ghost" size="sm">
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="flex-1 min-w-0">
<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">Make changes to your email template</p>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex-1">
{!hasChanges && !isSubmitting && (
<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 className="flex items-center gap-3 sm:gap-4">
<Button asChild variant="ghost" size="sm">
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="flex-1 min-w-0">
<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">
{isSubmitting
? 'Saving...'
: hasChanges
? <span className="text-amber-600">Unsaved changes</span>
: 'All changes saved'}
</p>
</div>
</div>
{/* Template Editor */}
<div className="space-y-6">
{/* Template Settings */}
<Card>
<form onSubmit={handleSave} className="space-y-6">
{/* Row 1: Basic Info + Template Type */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Template Settings</CardTitle>
<CardDescription>Configure the basic settings for your template</CardDescription>
<CardTitle>Basic Information</CardTitle>
<CardDescription>Name and describe your template</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Template Name *</Label>
<div className="space-y-2">
<Label htmlFor="name">Template Name <span className="text-red-500">*</span></Label>
<Input
id="name"
type="text"
@@ -182,7 +160,7 @@ export default function TemplateEditorPage() {
/>
</div>
<div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
@@ -192,94 +170,124 @@ export default function TemplateEditorPage() {
placeholder="Sent to new subscribers"
/>
</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>
</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 */}
<Card className="overflow-visible">
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={editedTemplate.body || ''}
onChange={body => setEditedTemplate({...editedTemplate, body})}
/>
</CardContent>
</Card>
</div>
</form>
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={editedTemplate.body || ''}
onChange={body => setEditedTemplate({...editedTemplate, body})}
/>
</CardContent>
</Card>
{/* 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 */}
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
+10 -3
View File
@@ -206,11 +206,18 @@ export default function WorkflowEditorPage() {
}
break;
case 'UPDATE_CONTACT':
if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) {
errors.push(`"${step.name}" step is missing contact updates`);
case 'UPDATE_CONTACT': {
const hasUpdates =
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;
}
}
});
+19 -1
View File
@@ -23,7 +23,7 @@ import {EmptyState} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
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 Link from 'next/link';
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) => {
try {
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">
<Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow"><Edit className="h-4 w-4" /></Link>
</Button>
<Button
variant="ghost"
size="sm"
title="Duplicate workflow"
onClick={() => handleDuplicate(workflow.id)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
+9
View File
@@ -164,4 +164,13 @@
@apply bg-background text-neutral-800 overflow-hidden;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
"fumadocs-openapi": "^10.0.11",
"fumadocs-ui": "16.0.8",
"lucide-react": "^0.553.0",
"next": "^16.2.3",
"next": "^16.2.6",
"next-sitemap": "^4.2.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
+26 -6
View File
@@ -96,9 +96,21 @@ export const ContactSchemas = {
subscribed: z.boolean().default(true),
data: jsonSchema.optional(),
}),
bulkAction: z.object({
contactIds: z.array(uuid).min(1).max(1000),
}),
bulkAction: z.discriminatedUnion('mode', [
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({
emails: z.array(z.string().email()).min(1).max(500),
}),
@@ -334,9 +346,17 @@ export const WorkflowStepConfigSchemas = {
headers: z.record(z.string()).optional(),
body: jsonSchema.optional(),
}),
updateContact: z.object({
updates: z.record(z.any()),
}),
updateContact: z
.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 = {
+12 -1
View File
@@ -12,12 +12,23 @@ export interface ContactImportJobData {
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)
* Used by: bulkContactQueue worker
*/
export interface BulkContactActionJobData {
projectId: string;
contactIds: string[];
operation: 'subscribe' | 'unsubscribe' | 'delete';
selector: BulkContactActionSelector;
}
+47 -47
View File
@@ -3003,10 +3003,10 @@ __metadata:
languageName: node
linkType: hard
"@next/env@npm:16.2.3":
version: 16.2.3
resolution: "@next/env@npm:16.2.3"
checksum: 10c0/56c3fee8ea226efe59ef065e054380f872c00c45c9fe4475eaa45f80773c3c1adc3ead3ccdd77447d3c1aeb4b3004aaaa033dd4a100d3e572fd01b83f992dde8
"@next/env@npm:16.2.6":
version: 16.2.6
resolution: "@next/env@npm:16.2.6"
checksum: 10c0/466722ce30a9561d29c08a7ba78091a47fef3644f422d04751c7124a24457ffe11eb25bb6c59c1e1d57735d9c68c58d185cc19ea58befd7a9c5b81dc05ca550d
languageName: node
linkType: hard
@@ -3033,9 +3033,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-darwin-arm64@npm:16.2.3":
version: 16.2.3
resolution: "@next/swc-darwin-arm64@npm:16.2.3"
"@next/swc-darwin-arm64@npm:16.2.6":
version: 16.2.6
resolution: "@next/swc-darwin-arm64@npm:16.2.6"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
@@ -3047,9 +3047,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-darwin-x64@npm:16.2.3":
version: 16.2.3
resolution: "@next/swc-darwin-x64@npm:16.2.3"
"@next/swc-darwin-x64@npm:16.2.6":
version: 16.2.6
resolution: "@next/swc-darwin-x64@npm:16.2.6"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
@@ -3061,9 +3061,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-arm64-gnu@npm:16.2.3":
version: 16.2.3
resolution: "@next/swc-linux-arm64-gnu@npm:16.2.3"
"@next/swc-linux-arm64-gnu@npm:16.2.6":
version: 16.2.6
resolution: "@next/swc-linux-arm64-gnu@npm:16.2.6"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
@@ -3075,9 +3075,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-arm64-musl@npm:16.2.3":
version: 16.2.3
resolution: "@next/swc-linux-arm64-musl@npm:16.2.3"
"@next/swc-linux-arm64-musl@npm:16.2.6":
version: 16.2.6
resolution: "@next/swc-linux-arm64-musl@npm:16.2.6"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
@@ -3089,9 +3089,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-x64-gnu@npm:16.2.3":
version: 16.2.3
resolution: "@next/swc-linux-x64-gnu@npm:16.2.3"
"@next/swc-linux-x64-gnu@npm:16.2.6":
version: 16.2.6
resolution: "@next/swc-linux-x64-gnu@npm:16.2.6"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
@@ -3103,9 +3103,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-x64-musl@npm:16.2.3":
version: 16.2.3
resolution: "@next/swc-linux-x64-musl@npm:16.2.3"
"@next/swc-linux-x64-musl@npm:16.2.6":
version: 16.2.6
resolution: "@next/swc-linux-x64-musl@npm:16.2.6"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
@@ -3117,9 +3117,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-win32-arm64-msvc@npm:16.2.3":
version: 16.2.3
resolution: "@next/swc-win32-arm64-msvc@npm:16.2.3"
"@next/swc-win32-arm64-msvc@npm:16.2.6":
version: 16.2.6
resolution: "@next/swc-win32-arm64-msvc@npm:16.2.6"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
@@ -3131,9 +3131,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-win32-x64-msvc@npm:16.2.3":
version: 16.2.3
resolution: "@next/swc-win32-x64-msvc@npm:16.2.3"
"@next/swc-win32-x64-msvc@npm:16.2.6":
version: 16.2.6
resolution: "@next/swc-win32-x64-msvc@npm:16.2.6"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -11342,9 +11342,9 @@ __metadata:
linkType: hard
"fast-uri@npm:^3.0.1":
version: 3.1.2
resolution: "fast-uri@npm:3.1.2"
checksum: 10c0/5b35641895959f3f7ab7a7b1b5542bded159346f25ec9f256817b206d50b64eda5828e90d605a2e2fc645c90519a7259c2bab2c942ee728c88b88e5be21b090d
version: 3.1.0
resolution: "fast-uri@npm:3.1.0"
checksum: 10c0/44364adca566f70f40d1e9b772c923138d47efeac2ae9732a872baafd77061f26b097ba2f68f0892885ad177becd065520412b8ffeec34b16c99433c5b9e2de7
languageName: node
linkType: hard
@@ -13263,7 +13263,7 @@ __metadata:
eslint-config-next: "npm:^16.0.1"
juice: "npm:^11.0.3"
lucide-react: "npm:^0.553.0"
next: "npm:^16.2.3"
next: "npm:^16.2.6"
next-seo: "npm:^6.6.0"
next-sitemap: "npm:^4.2.3"
postcss: "npm:^8.4.33"
@@ -15042,19 +15042,19 @@ __metadata:
languageName: node
linkType: hard
"next@npm:^16.2.3":
version: 16.2.3
resolution: "next@npm:16.2.3"
"next@npm:^16.2.6":
version: 16.2.6
resolution: "next@npm:16.2.6"
dependencies:
"@next/env": "npm:16.2.3"
"@next/swc-darwin-arm64": "npm:16.2.3"
"@next/swc-darwin-x64": "npm:16.2.3"
"@next/swc-linux-arm64-gnu": "npm:16.2.3"
"@next/swc-linux-arm64-musl": "npm:16.2.3"
"@next/swc-linux-x64-gnu": "npm:16.2.3"
"@next/swc-linux-x64-musl": "npm:16.2.3"
"@next/swc-win32-arm64-msvc": "npm:16.2.3"
"@next/swc-win32-x64-msvc": "npm:16.2.3"
"@next/env": "npm:16.2.6"
"@next/swc-darwin-arm64": "npm:16.2.6"
"@next/swc-darwin-x64": "npm:16.2.6"
"@next/swc-linux-arm64-gnu": "npm:16.2.6"
"@next/swc-linux-arm64-musl": "npm:16.2.6"
"@next/swc-linux-x64-gnu": "npm:16.2.6"
"@next/swc-linux-x64-musl": "npm:16.2.6"
"@next/swc-win32-arm64-msvc": "npm:16.2.6"
"@next/swc-win32-x64-msvc": "npm:16.2.6"
"@swc/helpers": "npm:0.5.15"
baseline-browser-mapping: "npm:^2.9.19"
caniuse-lite: "npm:^1.0.30001579"
@@ -15098,7 +15098,7 @@ __metadata:
optional: true
bin:
next: dist/bin/next
checksum: 10c0/8a9d27fc773d69f7f471cf1a23bde2ab2950e0411ef3e0d5c1664ed9654e94c3304eae1c4283ec0fa4e70e7b3f4416913350e118e0c18e8b055693dc5d021883
checksum: 10c0/3572071eb0e8051c3b007224dcf642037ce27a2f4b75c45f7e0fe7f2343e98e66604ce8696dde56ecd72963900d330d3a3af0f068f34cfc67520180263effa5f
languageName: node
linkType: hard
@@ -19539,7 +19539,7 @@ __metadata:
framer-motion: "npm:^12.23.24"
juice: "npm:^11.0.3"
lucide-react: "npm:^0.553.0"
next: "npm:^16.2.3"
next: "npm:^16.2.6"
next-seo: "npm:^6.6.0"
next-sitemap: "npm:^4.2.3"
nuqs: "npm:^2.7.3"
@@ -19736,7 +19736,7 @@ __metadata:
fumadocs-openapi: "npm:^10.0.11"
fumadocs-ui: "npm:16.0.8"
lucide-react: "npm:^0.553.0"
next: "npm:^16.2.3"
next: "npm:^16.2.6"
next-sitemap: "npm:^4.2.3"
postcss: "npm:^8.4.33"
react: "npm:^19.2.3"